mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64631a31e6 | ||
|
|
9fef6d6c91 | ||
|
|
f98bc330d4 | ||
|
|
86694dd926 | ||
|
|
f8da17719e | ||
|
|
a72e6087d8 | ||
|
|
3a64e322ad | ||
|
|
9e2853a5f8 | ||
|
|
feecb60442 | ||
|
|
e53e238e81 | ||
|
|
991aea85ee | ||
|
|
bfbf0bb892 | ||
|
|
b6cb4cc2d9 | ||
|
|
827481633c | ||
|
|
ad780b9cb4 | ||
|
|
4582ed16b0 | ||
|
|
f56505258d | ||
|
|
ddd1536bcb | ||
|
|
9fa5234061 | ||
|
|
e21efa61ef | ||
|
|
3f72c5d918 | ||
|
|
c286b88d54 | ||
|
|
22c08c2325 | ||
|
|
f6493e8843 | ||
|
|
1d2bfff8ea | ||
|
|
b967852891 | ||
|
|
d95d9583b0 | ||
|
|
8c0cce3ac5 | ||
|
|
f036bd54e3 | ||
|
|
77da90f3f8 | ||
|
|
b0b6f84492 | ||
|
|
0da8e32a51 | ||
|
|
7410faa96d | ||
|
|
e880dfa3e7 | ||
|
|
7791b3f170 | ||
|
|
e58039c83e | ||
|
|
9b0a7d2f61 | ||
|
|
86fe4929e9 | ||
|
|
9993718d9c | ||
|
|
0bf2f61ab1 | ||
|
|
51eff0d75f | ||
|
|
a30cce129e | ||
|
|
54885d8a1c | ||
|
|
617b4f81d8 | ||
|
|
a0f829383c | ||
|
|
6083c24484 | ||
|
|
1964545f35 | ||
|
|
fc7e971d7e | ||
|
|
86309c8e40 | ||
|
|
282ad3a960 | ||
|
|
d6f857eee2 | ||
|
|
751b420b39 | ||
|
|
5b2be80399 | ||
|
|
6ae8dd4cfd | ||
|
|
25a66df41b | ||
|
|
5212730773 | ||
|
|
9b7496947b | ||
|
|
895e94ccac | ||
|
|
da9d8a84f1 | ||
|
|
e7b9143423 | ||
|
|
604e4ea7e3 | ||
|
|
c926372d81 | ||
|
|
da12f47369 | ||
|
|
b7c81965a1 | ||
|
|
f65dc775eb | ||
|
|
c59041a09d | ||
|
|
715f37effc | ||
|
|
570e1941b2 | ||
|
|
4c700c8d2d | ||
|
|
5ae554ed51 | ||
|
|
62b697c90e | ||
|
|
4b3b602457 | ||
|
|
d7bd5c6636 | ||
|
|
66e6eb2509 | ||
|
|
1365fc9635 | ||
|
|
78669863b7 | ||
|
|
316bbe11c3 | ||
|
|
ff00499e8e | ||
|
|
1a5ae4e3ce | ||
|
|
bf9066a602 | ||
|
|
acf51e02a8 | ||
|
|
1c9d53df11 | ||
|
|
b298c4c273 | ||
|
|
f407a41a98 | ||
|
|
173c843107 | ||
|
|
b3787e07ea | ||
|
|
935c8cad88 | ||
|
|
d95270da5a | ||
|
|
7a2211d0f4 | ||
|
|
e14885fa80 | ||
|
|
ccbaf90a24 | ||
|
|
f66c2525cb | ||
|
|
842d932ae6 | ||
|
|
37d1c822f2 | ||
|
|
865fd28704 | ||
|
|
43a92a764f | ||
|
|
17f783073e | ||
|
|
db86ff4ce3 | ||
|
|
ee042769e2 | ||
|
|
41ffb8537a | ||
|
|
80658c94e3 | ||
|
|
c35301e155 | ||
|
|
14f0421e7b | ||
|
|
3f772b5b27 | ||
|
|
a12547081a | ||
|
|
63f4befe80 | ||
|
|
0f25386e2e | ||
|
|
7f752dde99 | ||
|
|
93ef98a429 | ||
|
|
b71bd801fb | ||
|
|
9badeb243e | ||
|
|
9a683ec511 | ||
|
|
eaa89defd1 | ||
|
|
d245059a01 | ||
|
|
c30bf3e557 | ||
|
|
dd95f24980 | ||
|
|
1cf36241e4 | ||
|
|
2ab31f3d3e | ||
|
|
2915cb2113 | ||
|
|
7b1057df0c | ||
|
|
0b99ac4071 | ||
|
|
19260945bd | ||
|
|
93ea832394 | ||
|
|
38d42c4d9b | ||
|
|
2d02ba22fb | ||
|
|
91d8734198 | ||
|
|
b676b2975a | ||
|
|
44f37711cb | ||
|
|
cea4c3f452 | ||
|
|
12ab5f1a93 | ||
|
|
a5bc9f916a | ||
|
|
ad472d657e | ||
|
|
b4383b1626 | ||
|
|
ea287093c4 | ||
|
|
3688d86ef8 | ||
|
|
9f9903797e | ||
|
|
3cd8847a95 | ||
|
|
88bb55c8f2 | ||
|
|
935f3fac8e | ||
|
|
ad90d417cc | ||
|
|
4eae7502d2 | ||
|
|
d6326d8967 | ||
|
|
8ec3766da3 | ||
|
|
90fc171e26 | ||
|
|
972a52ff9c | ||
|
|
6b78838416 | ||
|
|
13051b2f06 | ||
|
|
aed4bf62ae | ||
|
|
a039e395f5 | ||
|
|
67b30c1549 | ||
|
|
40bdf4316b | ||
|
|
e3c05a9d47 | ||
|
|
6ab77612f5 | ||
|
|
a3073007a1 | ||
|
|
a95fb4a96b | ||
|
|
f1ca52714d | ||
|
|
77ed954d18 | ||
|
|
4fa2f9474b | ||
|
|
75c9c80679 | ||
|
|
3eaa9e02c9 | ||
|
|
f165263296 | ||
|
|
c122e1ddd7 | ||
|
|
d2ea030f03 | ||
|
|
eb87e3fd42 | ||
|
|
6d742e9081 | ||
|
|
317b947608 | ||
|
|
1cb8b989d1 | ||
|
|
bde1f5414c | ||
|
|
d15207fa51 | ||
|
|
e808ca0d08 | ||
|
|
c554adae0d | ||
|
|
590121d5f3 | ||
|
|
58a7a07942 | ||
|
|
72b87dfaa9 | ||
|
|
b8fb6987e0 | ||
|
|
dec081a326 | ||
|
|
52647b2996 | ||
|
|
905cfd2c21 | ||
|
|
7ad7489860 | ||
|
|
77c4c9add8 | ||
|
|
07019f764e | ||
|
|
80eed53305 | ||
|
|
14c4c1d1f5 | ||
|
|
fa7dd277e6 | ||
|
|
0e589e3f9a | ||
|
|
59703d50d6 | ||
|
|
5413269943 | ||
|
|
c5582ceb1e | ||
|
|
36dc62745c | ||
|
|
cb6e6909d4 | ||
|
|
b3be3f4940 | ||
|
|
7505007d8c | ||
|
|
fdd6c1a1f7 | ||
|
|
135c37fbf7 | ||
|
|
a7a96a7b0f | ||
|
|
52bacf0946 | ||
|
|
d55e1b8545 | ||
|
|
0c059d1dc1 | ||
|
|
b6b8b4ebe1 | ||
|
|
14f4845170 | ||
|
|
be8a589986 | ||
|
|
951e8d0feb | ||
|
|
98f8d1ca79 | ||
|
|
24f5717ae9 | ||
|
|
f10eefdc0e | ||
|
|
edd0fed518 | ||
|
|
86b50070e8 | ||
|
|
c6aab08faa | ||
|
|
62ec09d0ae | ||
|
|
f6f9cf7e9f | ||
|
|
56aeb499c9 | ||
|
|
b4e6a693f5 | ||
|
|
48d5418c91 | ||
|
|
cc93ef4571 | ||
|
|
3e069798f9 | ||
|
|
ad10aa5e80 | ||
|
|
385aee8e56 | ||
|
|
a00327abe9 | ||
|
|
487555a5e5 | ||
|
|
9d51426cb4 | ||
|
|
6fab720843 | ||
|
|
4667f497ef | ||
|
|
eba9198827 | ||
|
|
f2413949bc | ||
|
|
9e230f423e | ||
|
|
8138b7e0e8 | ||
|
|
cfae867908 | ||
|
|
6857e3cf06 | ||
|
|
772cbdbe38 | ||
|
|
b2e2b1a038 | ||
|
|
d237ecf161 | ||
|
|
4a3d570155 | ||
|
|
ebcdc17c13 | ||
|
|
45e06b9bad | ||
|
|
c6b46ccbe1 | ||
|
|
9fc0fe71bf | ||
|
|
06df0795c8 | ||
|
|
eafeb6a012 | ||
|
|
05431509be | ||
|
|
9d3136e512 | ||
|
|
60566f22fb | ||
|
|
50440e4047 | ||
|
|
f45268aedc | ||
|
|
cc54e14114 | ||
|
|
1037ef768d | ||
|
|
c3dcf02e3c | ||
|
|
b157e3c7e6 | ||
|
|
74ac992420 | ||
|
|
53e1b31777 | ||
|
|
603a94e560 | ||
|
|
fac4ad33e5 | ||
|
|
fe96cd1004 | ||
|
|
7c85308cf6 | ||
|
|
a4a83b1699 | ||
|
|
8942d8c2b6 | ||
|
|
260dd7a125 | ||
|
|
294f0e7af8 | ||
|
|
444d82e4d6 | ||
|
|
62e6e0f088 | ||
|
|
d3385f2cdc | ||
|
|
516f163dfb | ||
|
|
a54bb1cd4f | ||
|
|
6f3c4e7778 | ||
|
|
73ad49a3a1 | ||
|
|
7ec0e024c7 | ||
|
|
7c81c187c4 | ||
|
|
7ae80b1b9f | ||
|
|
e58ae3e304 | ||
|
|
74f5a91fdd | ||
|
|
6de0447e8c | ||
|
|
45dbf617a7 | ||
|
|
b416bf417f |
@@ -0,0 +1,34 @@
|
||||
# Ignored advisories — all are transitive dependencies we cannot upgrade directly.
|
||||
#
|
||||
# time 0.3.45: pinned by mac-notification-sys (tauri dependency), awaiting upstream fix
|
||||
# GTK3/glib/pango/etc: tauri uses gtk-rs GTK3 bindings which are unmaintained
|
||||
# paste, proc-macro-error, fxhash: unmaintained transitive deps
|
||||
# lexical-core: unmaintained, pulled by tauri dep chain
|
||||
# serde_cbor: unmaintained, pulled by tao (tauri)
|
||||
# cocoa/cocoa-foundation: unmaintained, pulled by tauri/tao
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
"RUSTSEC-2026-0009", # time DoS — pinned by mac-notification-sys
|
||||
"RUSTSEC-2024-0370", # proc-macro-error unmaintained
|
||||
"RUSTSEC-2024-0411", # gtk-rs GTK3 unmaintained (gdk-pixbuf)
|
||||
"RUSTSEC-2024-0412", # gtk-rs GTK3 unmaintained (gdk)
|
||||
"RUSTSEC-2024-0413", # gtk-rs GTK3 unmaintained (atk)
|
||||
"RUSTSEC-2024-0414", # gtk-rs GTK3 unmaintained (pango)
|
||||
"RUSTSEC-2024-0415", # gtk-rs GTK3 unmaintained (gio)
|
||||
"RUSTSEC-2024-0416", # gtk-rs GTK3 unmaintained (atk-sys)
|
||||
"RUSTSEC-2024-0417", # gtk-rs GTK3 unmaintained (gdk-pixbuf-sys)
|
||||
"RUSTSEC-2024-0418", # gtk-rs GTK3 unmaintained (gdk-sys)
|
||||
"RUSTSEC-2024-0419", # gtk-rs GTK3 unmaintained (gtk3-macros)
|
||||
"RUSTSEC-2024-0420", # gtk-rs GTK3 unmaintained (pango-sys)
|
||||
"RUSTSEC-2024-0429", # gtk-rs GTK3 unmaintained (gtk-sys)
|
||||
"RUSTSEC-2024-0436", # paste unmaintained
|
||||
"RUSTSEC-2025-0057", # fxhash unmaintained
|
||||
"RUSTSEC-2025-0075", # glib unmaintained
|
||||
"RUSTSEC-2025-0080", # cocoa unmaintained
|
||||
"RUSTSEC-2025-0081", # cocoa-foundation unmaintained
|
||||
"RUSTSEC-2025-0098", # lexical-core unmaintained
|
||||
"RUSTSEC-2025-0100", # gio-sys unmaintained
|
||||
"RUSTSEC-2026-0002", # serde_cbor unmaintained
|
||||
"RUSTSEC-2023-0086", # lexopt unmaintained (if present)
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What happened?
|
||||
placeholder: Describe the bug clearly and concisely.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this?
|
||||
placeholder: |
|
||||
1. Run `openfang start`
|
||||
2. Open dashboard at http://localhost:4200
|
||||
3. Click ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OpenFang Version
|
||||
description: Output of `openfang -V`
|
||||
placeholder: "0.3.23"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- Linux (x86_64)
|
||||
- Linux (aarch64/ARM64)
|
||||
- macOS (Apple Silicon)
|
||||
- macOS (Intel)
|
||||
- Windows
|
||||
- Android (Termux)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Screenshots
|
||||
description: Paste relevant logs or attach screenshots.
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What feature would you like?
|
||||
placeholder: Describe the feature and why it would be useful.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you tried any workarounds?
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context, screenshots, or references.
|
||||
@@ -0,0 +1,17 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "ci"
|
||||
@@ -0,0 +1,19 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? Link related issues with "Fixes #123". -->
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- Brief list of what changed. -->
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes
|
||||
- [ ] `cargo test --workspace` passes
|
||||
- [ ] Live integration tested (if applicable)
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] No new unsafe code
|
||||
- [ ] No secrets or API keys in diff
|
||||
- [ ] User input validated at boundaries
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install cargo-audit
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
name: Secrets Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install trufflehog
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install system deps (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
archive: zip
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -181,6 +181,11 @@ jobs:
|
||||
- name: Build CLI
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: cargo build --release --target ${{ matrix.target }} --bin openfang
|
||||
- name: Ad-hoc codesign CLI binary (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
xattr -cr target/${{ matrix.target }}/release/openfang || true
|
||||
codesign --force --sign - target/${{ matrix.target }}/release/openfang
|
||||
- name: Package (Unix)
|
||||
if: matrix.archive == 'tar.gz'
|
||||
run: |
|
||||
@@ -207,22 +212,22 @@ jobs:
|
||||
name: Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU (for arm64 emulation)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -34,6 +34,7 @@ BUILD_LOG.md
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
._*
|
||||
Thumbs.db
|
||||
|
||||
# IDE & tools
|
||||
@@ -43,3 +44,4 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.serena/
|
||||
|
||||
@@ -56,6 +56,16 @@ Tests that require a real LLM key will skip gracefully if the env var is absent.
|
||||
cargo build --workspace
|
||||
```
|
||||
|
||||
### Fast Release Build (for development)
|
||||
|
||||
The default `--release` profile uses full LTO and single-codegen-unit, which produces the smallest/fastest binary but is slow to compile. For iterating locally, use the `release-fast` profile instead:
|
||||
|
||||
```bash
|
||||
cargo build --profile release-fast -p openfang-cli
|
||||
```
|
||||
|
||||
This cuts link time significantly (thin LTO, 8 codegen units, `opt-level=2`) while still producing a binary fast enough to run integration tests against. Use `--release` only for final binaries or CI.
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+1037
-481
File diff suppressed because it is too large
Load Diff
+38
-8
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.9"
|
||||
version = "0.5.1"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -32,7 +32,7 @@ tokio-stream = "0.1"
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
toml = "0.9"
|
||||
rmp-serde = "1"
|
||||
|
||||
# Error handling
|
||||
@@ -49,9 +49,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
|
||||
# IDs
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||
|
||||
# Database
|
||||
rusqlite = { version = "0.31", features = ["bundled", "serde_json"] }
|
||||
@@ -61,7 +62,7 @@ clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
|
||||
# HTTP client (for LLM drivers)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
|
||||
|
||||
# Async trait
|
||||
async-trait = "0.1"
|
||||
@@ -74,6 +75,7 @@ bytes = "1"
|
||||
|
||||
# Futures
|
||||
futures = "0.3"
|
||||
prost = "0.13"
|
||||
|
||||
# WebSocket client (for Discord/Slack gateway)
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
@@ -83,7 +85,7 @@ url = "2"
|
||||
wasmtime = "41"
|
||||
|
||||
# HTTP server (for API daemon)
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "compression-br"] }
|
||||
|
||||
@@ -101,6 +103,9 @@ walkdir = "2"
|
||||
|
||||
# Security
|
||||
sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
subtle = "2"
|
||||
@@ -109,7 +114,7 @@ rand = "0.8"
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
# Rate limiting
|
||||
governor = "0.8"
|
||||
governor = "0.10"
|
||||
|
||||
# Interactive CLI
|
||||
ratatui = "0.29"
|
||||
@@ -119,14 +124,32 @@ colored = "3"
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
|
||||
# HTML entity decoding
|
||||
html-escape = "0.2"
|
||||
|
||||
# Lightweight regex
|
||||
regex-lite = "0.1"
|
||||
|
||||
# MCP SDK (official Rust implementation)
|
||||
rmcp = { version = "1.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "reqwest"] }
|
||||
|
||||
# Socket options (SO_REUSEADDR)
|
||||
socket2 = "0.5"
|
||||
|
||||
# Zip archive extraction
|
||||
zip = { version = "4", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Email (SMTP + IMAP)
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
|
||||
imap = "2"
|
||||
native-tls = "0.2"
|
||||
mailparse = "0.15"
|
||||
native-tls = { version = "0.2", features = ["vendored"] }
|
||||
mailparse = "0.16"
|
||||
|
||||
# MQTT client
|
||||
rumqttc = "0.24"
|
||||
|
||||
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
# Testing
|
||||
tokio-test = "0.4"
|
||||
@@ -137,3 +160,10 @@ lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
opt-level = 3
|
||||
|
||||
[profile.release-fast]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
codegen-units = 8
|
||||
opt-level = 2
|
||||
strip = false
|
||||
|
||||
+16
-2
@@ -7,10 +7,24 @@ COPY crates ./crates
|
||||
COPY xtask ./xtask
|
||||
COPY agents ./agents
|
||||
COPY packages ./packages
|
||||
# Optional build args for dev environments to speed up compilation
|
||||
# Example: docker build --build-arg LTO=false --build-arg CODEGEN_UNITS=16 .
|
||||
ARG LTO=true
|
||||
ARG CODEGEN_UNITS=1
|
||||
ENV CARGO_PROFILE_RELEASE_LTO=${LTO} \
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=${CODEGEN_UNITS}
|
||||
RUN cargo build --release --bin openfang
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
FROM rust:1-slim-bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /build/target/release/openfang /usr/local/bin/
|
||||
COPY --from=builder /build/agents /opt/openfang/agents
|
||||
EXPOSE 4200
|
||||
|
||||
@@ -19,16 +19,17 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
|
||||
<img src="https://img.shields.io/badge/version-0.1.0-green?style=flat-square" alt="v0.1.0" />
|
||||
<img src="https://img.shields.io/badge/version-0.3.30-green?style=flat-square" alt="v0.3.30" />
|
||||
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> **v0.1.0 — First Release (February 2026)**
|
||||
> **v0.3.30 — Security Hardening Release (March 2026)**
|
||||
>
|
||||
> OpenFang is feature-complete but this is the first public release. You may encounter instability, rough edges, or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
> OpenFang is feature-complete but still pre-1.0. You may encounter rough edges or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
|
||||
---
|
||||
|
||||
@@ -263,6 +264,97 @@ Connect your agents to every platform your users are on.
|
||||
|
||||
Each adapter supports per-channel model overrides, DM/group policies, rate limiting, and output formatting.
|
||||
|
||||
---
|
||||
|
||||
## WhatsApp Web Gateway (QR Code)
|
||||
|
||||
Connect your personal WhatsApp account to OpenFang via QR code — just like WhatsApp Web. No Meta Business account required.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js >= 18** installed ([download](https://nodejs.org/))
|
||||
- OpenFang installed and initialized
|
||||
|
||||
### Setup
|
||||
|
||||
**1. Install the gateway dependencies:**
|
||||
|
||||
```bash
|
||||
cd packages/whatsapp-gateway
|
||||
npm install
|
||||
```
|
||||
|
||||
**2. Configure `config.toml`:**
|
||||
|
||||
```toml
|
||||
[channels.whatsapp]
|
||||
mode = "web"
|
||||
default_agent = "assistant"
|
||||
```
|
||||
|
||||
**3. Set the gateway URL (choose one):**
|
||||
|
||||
Add to your shell profile for persistence:
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
echo 'export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
Or set it inline when starting the gateway:
|
||||
|
||||
```bash
|
||||
export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"
|
||||
```
|
||||
|
||||
**4. Start the gateway:**
|
||||
|
||||
```bash
|
||||
node packages/whatsapp-gateway/index.js
|
||||
```
|
||||
|
||||
The gateway listens on port `3009` by default. Override with `WHATSAPP_GATEWAY_PORT`.
|
||||
|
||||
**5. Start OpenFang:**
|
||||
|
||||
```bash
|
||||
openfang start
|
||||
# Dashboard at http://localhost:4200
|
||||
```
|
||||
|
||||
**6. Scan the QR code:**
|
||||
|
||||
Open the dashboard → **Channels** → **WhatsApp**. A QR code will appear. Scan it with your phone:
|
||||
|
||||
> **WhatsApp** → **Settings** → **Linked Devices** → **Link a Device**
|
||||
|
||||
Once scanned, the status changes to `connected` and incoming messages are routed to your configured agent.
|
||||
|
||||
### Gateway Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `WHATSAPP_WEB_GATEWAY_URL` | Gateway URL for OpenFang to connect to | _(empty = disabled)_ |
|
||||
| `WHATSAPP_GATEWAY_PORT` | Port the gateway listens on | `3009` |
|
||||
| `OPENFANG_URL` | OpenFang API URL the gateway reports to | `http://127.0.0.1:4200` |
|
||||
| `OPENFANG_DEFAULT_AGENT` | Agent that handles incoming messages | `assistant` |
|
||||
|
||||
### Gateway API Endpoints
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| `POST` | `/login/start` | Generate QR code (returns base64 PNG) |
|
||||
| `GET` | `/login/status` | Connection status (`disconnected`, `qr_ready`, `connected`) |
|
||||
| `POST` | `/message/send` | Send a message (`{ "to": "5511999999999", "text": "Hello" }`) |
|
||||
| `GET` | `/health` | Health check |
|
||||
|
||||
### Alternative: WhatsApp Cloud API
|
||||
|
||||
For production workloads, use the [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api) with a Meta Business account. See the [Cloud API configuration docs](https://openfang.sh/docs/channels/whatsapp).
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 27 LLM Providers — 123+ Models
|
||||
@@ -370,7 +462,7 @@ cargo fmt --all -- --check
|
||||
|
||||
## Stability Notice
|
||||
|
||||
OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
OpenFang v0.3.30 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
|
||||
- **Breaking changes** may occur between minor versions until v1.0
|
||||
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
|
||||
@@ -381,6 +473,12 @@ We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
To report a security vulnerability, email **jaber@rightnowai.co**. We take all reports seriously and will respond within 48 hours.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — use it however you want.
|
||||
@@ -412,7 +510,7 @@ MIT — use it however you want.
|
||||
<p align="center">
|
||||
<a href="https://www.rightnowai.co/">Website</a> •
|
||||
<a href="https://x.com/Akashi203">Twitter / X</a> •
|
||||
<a href="https://github.com/sponsors/RightNow-AI">Sponsor</a>
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| 0.1.x | :white_check_mark: |
|
||||
| 0.3.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -14,7 +14,7 @@ If you discover a security vulnerability in OpenFang, please report it responsib
|
||||
|
||||
### How to Report
|
||||
|
||||
1. Email: **security@openfang.ai**
|
||||
1. Email: **jaber@rightnowai.co**
|
||||
2. Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
|
||||
@@ -5,8 +5,8 @@ author = "openfang"
|
||||
module = "builtin:chat"
|
||||
|
||||
[model]
|
||||
provider = "gemini"
|
||||
model = "gemini-2.5-flash"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
max_tokens = 4096
|
||||
temperature = 0.4
|
||||
@@ -34,8 +34,8 @@ OUTPUT FORMAT:
|
||||
- Caveats and limitations"""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
|
||||
[resources]
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["architecture", "design", "planning"]
|
||||
|
||||
[model]
|
||||
provider = "deepseek"
|
||||
model = "deepseek-chat"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
max_tokens = 8192
|
||||
temperature = 0.3
|
||||
@@ -31,8 +31,8 @@ Output format: Use clear headings, diagrams (ASCII), and structured reasoning.
|
||||
When asked to review, be honest about weaknesses."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
|
||||
[resources]
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["general", "assistant", "default", "multipurpose", "conversation", "productivity"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 8192
|
||||
temperature = 0.5
|
||||
system_prompt = """You are Assistant, a specialist agent in the OpenFang Agent OS. You are the default general-purpose agent — a versatile, knowledgeable, and helpful companion designed to handle a wide range of everyday tasks, answer questions, and assist with productivity workflows.
|
||||
@@ -61,7 +61,7 @@ TOOLS AVAILABLE:
|
||||
You are reliable, adaptable, and genuinely helpful. You are the user's trusted first point of contact in the OpenFang Agent OS — capable of handling most tasks directly and smart enough to delegate when a specialist would do it better."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "gemini"
|
||||
provider = "default"
|
||||
model = "gemini-2.0-flash"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
|
||||
@@ -76,3 +76,6 @@ memory_read = ["*"]
|
||||
memory_write = ["self.*", "shared.*"]
|
||||
agent_message = ["*"]
|
||||
shell = ["python *", "cargo *", "git *", "npm *"]
|
||||
|
||||
[autonomous]
|
||||
max_iterations = 100
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["review", "code-quality", "best-practices"]
|
||||
|
||||
[model]
|
||||
provider = "gemini"
|
||||
model = "gemini-2.5-flash"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
max_tokens = 4096
|
||||
temperature = 0.3
|
||||
@@ -34,8 +34,8 @@ Rules:
|
||||
- Focus on things that matter for production"""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
|
||||
[resources]
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["coding", "implementation", "rust", "python"]
|
||||
|
||||
[model]
|
||||
provider = "gemini"
|
||||
model = "gemini-2.5-flash"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
max_tokens = 8192
|
||||
temperature = 0.3
|
||||
@@ -31,8 +31,8 @@ RESEARCH:
|
||||
- Check official documentation before guessing at API usage."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
|
||||
[resources]
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["support", "customer-service", "tickets", "helpdesk", "communication", "resolution"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 4096
|
||||
temperature = 0.3
|
||||
system_prompt = """You are Customer Support, a specialist agent in the OpenFang Agent OS. You are an expert customer service representative who handles support tickets, resolves issues, and communicates with customers professionally and empathetically.
|
||||
@@ -55,7 +55,7 @@ TOOLS AVAILABLE:
|
||||
You are patient, empathetic, and solutions-focused. You turn frustrated customers into satisfied advocates."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "gemini"
|
||||
provider = "default"
|
||||
model = "gemini-2.0-flash"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ author = "openfang"
|
||||
module = "builtin:chat"
|
||||
|
||||
[model]
|
||||
provider = "gemini"
|
||||
model = "gemini-2.5-flash"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
max_tokens = 4096
|
||||
temperature = 0.3
|
||||
@@ -36,8 +36,8 @@ Output format:
|
||||
- Caveats and limitations"""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
|
||||
[resources]
|
||||
|
||||
@@ -5,8 +5,8 @@ author = "openfang"
|
||||
module = "builtin:chat"
|
||||
|
||||
[model]
|
||||
provider = "gemini"
|
||||
model = "gemini-2.5-flash"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
max_tokens = 4096
|
||||
temperature = 0.2
|
||||
@@ -37,8 +37,8 @@ OUTPUT FORMAT:
|
||||
- Prevention: Test or pattern to prevent recurrence"""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
|
||||
[resources]
|
||||
|
||||
@@ -5,8 +5,8 @@ author = "openfang"
|
||||
module = "builtin:chat"
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 4096
|
||||
temperature = 0.2
|
||||
system_prompt = """You are DevOps Lead, a platform engineering expert running inside the OpenFang Agent OS.
|
||||
@@ -35,7 +35,7 @@ When designing pipelines:
|
||||
5. Automated rollback on failure"""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "gemini"
|
||||
provider = "default"
|
||||
model = "gemini-2.0-flash"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ author = "openfang"
|
||||
module = "builtin:chat"
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 8192
|
||||
temperature = 0.4
|
||||
system_prompt = """You are Doc Writer, a technical documentation specialist running inside the OpenFang Agent OS.
|
||||
@@ -33,7 +33,7 @@ Style guide:
|
||||
- Consistent formatting and structure"""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "gemini"
|
||||
provider = "default"
|
||||
model = "gemini-2.0-flash"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["email", "communication", "triage", "drafting", "scheduling", "productivity"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 8192
|
||||
temperature = 0.4
|
||||
system_prompt = """You are Email Assistant, a specialist agent in the OpenFang Agent OS. Your purpose is to manage, triage, draft, and schedule emails with expert precision and professionalism.
|
||||
@@ -47,7 +47,7 @@ TOOLS AVAILABLE:
|
||||
You are thorough, discreet, and efficient. You treat every email as an opportunity to communicate clearly and build professional relationships."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "gemini"
|
||||
provider = "default"
|
||||
model = "gemini-2.0-flash"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["health", "wellness", "fitness", "medication", "habits", "tracking"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 4096
|
||||
temperature = 0.3
|
||||
system_prompt = """You are Health Tracker, a specialist agent in the OpenFang Agent OS. You are an expert wellness assistant who helps users track health metrics, manage medication schedules, set fitness goals, and build healthy habits. You are NOT a medical professional and you always make this clear.
|
||||
|
||||
@@ -5,8 +5,8 @@ author = "openfang"
|
||||
module = "builtin:chat"
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 4096
|
||||
temperature = 0.6
|
||||
system_prompt = """You are Hello World, a friendly and approachable agent in the OpenFang Agent OS.
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["smart-home", "iot", "automation", "devices", "monitoring", "home"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 4096
|
||||
temperature = 0.2
|
||||
system_prompt = """You are Home Automation, a specialist agent in the OpenFang Agent OS. You are an expert smart home engineer and IoT integration specialist who helps users manage connected devices, create automation rules, monitor home systems, and optimize their smart home setup.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
LangChain Code Review Agent — core review logic.
|
||||
|
||||
Supports OpenAI, Ollama, and any LangChain-compatible LLM.
|
||||
"""
|
||||
|
||||
import os
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You are a principal-level code reviewer with 15+ years of production experience \
|
||||
across multiple languages (Python, Rust, TypeScript, Java, Go, C/C++).
|
||||
You receive code snippets, diffs, or pull request descriptions and produce a \
|
||||
structured, actionable review report.
|
||||
|
||||
You MUST respond in **中文**, but keep code snippets, variable names, and \
|
||||
technical terms in their original language.
|
||||
|
||||
# ── 审核维度(按优先级排序) ──────────────────────────────
|
||||
|
||||
## 1. 正确性 (Correctness)
|
||||
- 逻辑错误、off-by-one、边界条件
|
||||
- 空指针 / None / undefined 未处理
|
||||
- 错误处理不完整(吞异常、漏 catch、panic 路径)
|
||||
- 并发问题:竞态条件、死锁、数据竞争
|
||||
- 类型安全:隐式转换、溢出、精度丢失
|
||||
- 资源泄漏:未关闭的文件/连接/锁
|
||||
|
||||
## 2. 安全性 (Security)
|
||||
- SQL / NoSQL / OS 命令注入
|
||||
- XSS、CSRF、SSRF
|
||||
- 硬编码密钥、token、密码
|
||||
- 不安全的反序列化
|
||||
- 路径穿越(Path Traversal)
|
||||
- 缺少输入校验 / 输出编码
|
||||
- 权限检查缺失或绕过
|
||||
- 敏感数据明文日志
|
||||
|
||||
## 3. 性能 (Performance)
|
||||
- 算法复杂度不合理(O(n²) 可优化为 O(n))
|
||||
- 不必要的内存分配 / 拷贝
|
||||
- N+1 查询、缺少批量操作
|
||||
- 阻塞 I/O 在异步上下文中
|
||||
- 缺少缓存 / 索引
|
||||
- 热路径上的正则编译 / 反射
|
||||
|
||||
## 4. 可维护性 (Maintainability)
|
||||
- 命名不清晰、缩写歧义
|
||||
- 函数过长(>50行建议拆分)
|
||||
- 重复代码(DRY 违反)
|
||||
- 职责不单一(SRP 违反)
|
||||
- 缺少必要注释(复杂业务逻辑、非显而易见的决策)
|
||||
- 魔法数字 / 字符串
|
||||
- 耦合过紧、依赖方向不合理
|
||||
|
||||
## 5. 测试 (Testing)
|
||||
- 关键路径缺少单元测试
|
||||
- 测试覆盖了 happy path 但遗漏了 edge case
|
||||
- 测试中有硬编码依赖(时间、文件路径、网络)
|
||||
- Mock 过度导致测试失去意义
|
||||
|
||||
## 6. 风格 (Style)
|
||||
- 不符合语言惯例(Pythonic、Rust idiom 等)
|
||||
- 格式不一致(应由 formatter 处理的除外)
|
||||
- 不必要的复杂写法
|
||||
|
||||
# ── 严重级别 ──────────────────────────────────────────
|
||||
|
||||
| 级别 | 含义 | 是否阻塞合并 |
|
||||
|------|------|-------------|
|
||||
| 🔴 **[必须修复]** | 存在 bug、安全漏洞或数据丢失风险 | 是 |
|
||||
| 🟡 **[建议修复]** | 不影响功能但会影响可维护性或性能 | 否,但强烈建议 |
|
||||
| 🔵 **[小建议]** | 风格、命名等微小改进 | 否 |
|
||||
| 🟢 **[亮点]** | 写得好的地方,值得肯定 | — |
|
||||
|
||||
# ── 输出格式 ──────────────────────────────────────────
|
||||
|
||||
严格按以下 Markdown 格式输出:
|
||||
|
||||
```
|
||||
## 📋 总结
|
||||
**结论**: [✅ 通过 / ⚠️ 需要修改 / 💬 仅评论]
|
||||
**概述**: [1-2 句话总体评价]
|
||||
**发现统计**: 🔴 X 个必须修复 | 🟡 X 个建议修复 | 🔵 X 个小建议 | 🟢 X 个亮点
|
||||
|
||||
---
|
||||
|
||||
## 🔍 详细发现
|
||||
|
||||
### 🔴 [必须修复] 问题标题
|
||||
- **位置**: `文件名` 第 X-Y 行
|
||||
- **问题**: 具体描述
|
||||
- **原因**: 为什么这是个问题,可能造成什么后果
|
||||
- **修复建议**:
|
||||
(给出修复后的代码)
|
||||
|
||||
### 🟡 [建议修复] 问题标题
|
||||
...
|
||||
|
||||
### 🔵 [小建议] 问题标题
|
||||
...
|
||||
|
||||
### 🟢 [亮点] 优点标题
|
||||
- **位置**: `文件名` 第 X-Y 行
|
||||
- **说明**: 为什么这段代码写得好
|
||||
|
||||
---
|
||||
|
||||
## 📊 评分
|
||||
| 维度 | 分数 | 说明 |
|
||||
|------|------|------|
|
||||
| 正确性 | X/10 | 一句话说明 |
|
||||
| 安全性 | X/10 | 一句话说明 |
|
||||
| 性能 | X/10 | 一句话说明 |
|
||||
| 可维护性 | X/10 | 一句话说明 |
|
||||
| 测试 | X/10 | 一句话说明 |
|
||||
| **综合** | **X/10** | 一句话总结 |
|
||||
```
|
||||
|
||||
# ── 审核原则 ──────────────────────────────────────────
|
||||
|
||||
1. **先肯定,再指出问题** — 不要只挑毛病,好的代码也要指出来
|
||||
2. **解释 WHY,不仅是 WHAT** — 每个问题都要说清楚「为什么不好」和「可能导致什么后果」
|
||||
3. **给出具体修复代码** — 不要只说"这里有问题",要给出改好后的写法
|
||||
4. **区分严重级别** — 不要把小问题标成必须修复,也不要把严重 bug 标成小建议
|
||||
5. **尊重作者** — 用建设性的语气,避免 "这是错的" 这种措辞,用 "这里可以改进为..."
|
||||
6. **不纠结格式** — 如果项目有 formatter/linter,格式问题跳过
|
||||
7. **关注变更本身** — 如果是 diff,只审核变更的部分,不要评论未修改的代码
|
||||
8. **没有代码时** — 直接要求提交代码,不要编造审核结果"""
|
||||
|
||||
|
||||
def _build_llm():
|
||||
"""Build the LLM based on environment configuration."""
|
||||
use_ollama = os.getenv("USE_OLLAMA", "").lower() in ("1", "true", "yes")
|
||||
|
||||
if use_ollama:
|
||||
from langchain_ollama import ChatOllama
|
||||
model = os.getenv("OLLAMA_MODEL", "qwen2.5")
|
||||
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
return ChatOllama(model=model, base_url=base_url, temperature=0.2)
|
||||
|
||||
provider = os.getenv("LLM_PROVIDER", "openai").lower()
|
||||
|
||||
if provider == "deepseek":
|
||||
from langchain_openai import ChatOpenAI
|
||||
return ChatOpenAI(
|
||||
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
|
||||
api_key=os.getenv("DEEPSEEK_API_KEY"),
|
||||
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
|
||||
temperature=0.2,
|
||||
max_tokens=4096,
|
||||
)
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
return ChatOpenAI(
|
||||
model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
|
||||
temperature=0.2,
|
||||
max_tokens=4096,
|
||||
)
|
||||
|
||||
|
||||
class CodeReviewAgent:
|
||||
"""LangChain-based code review agent."""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = _build_llm()
|
||||
self.prompt = ChatPromptTemplate.from_messages([
|
||||
("system", SYSTEM_PROMPT),
|
||||
("human", "{input}"),
|
||||
])
|
||||
self.chain = self.prompt | self.llm | StrOutputParser()
|
||||
|
||||
def review(self, code_or_diff: str) -> str:
|
||||
"""
|
||||
Review the given code or diff.
|
||||
|
||||
Args:
|
||||
code_or_diff: Source code, git diff, or PR description to review.
|
||||
|
||||
Returns:
|
||||
Structured review report as markdown text.
|
||||
"""
|
||||
if not code_or_diff.strip():
|
||||
return "No code provided. Please submit code or a diff to review."
|
||||
|
||||
return self.chain.invoke({"input": code_or_diff})
|
||||
@@ -0,0 +1,10 @@
|
||||
# Add this section to your ~/.openfang/config.toml
|
||||
# to register the LangChain code review agent.
|
||||
|
||||
[a2a]
|
||||
enabled = true
|
||||
listen_path = "/a2a"
|
||||
|
||||
[[a2a.external_agents]]
|
||||
name = "langchain-code-reviewer"
|
||||
url = "http://127.0.0.1:9100"
|
||||
@@ -0,0 +1,6 @@
|
||||
langchain>=0.3
|
||||
langchain-openai>=0.3
|
||||
langchain-core>=0.3
|
||||
langchain-ollama>=0.3
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.34
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
LangChain Code Review Agent — A2A-compatible server.
|
||||
|
||||
Exposes a code review agent via Google's A2A protocol so that
|
||||
OpenFang workflows can call it as an external agent.
|
||||
|
||||
Start:
|
||||
OPENAI_API_KEY=sk-xxx python server.py
|
||||
# or with Ollama (no key needed):
|
||||
USE_OLLAMA=1 python server.py
|
||||
|
||||
Endpoints:
|
||||
GET /.well-known/agent.json — A2A Agent Card
|
||||
POST /a2a — JSON-RPC task endpoint
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
|
||||
from agent import CodeReviewAgent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HOST = os.getenv("HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("PORT", "9100"))
|
||||
BASE_URL = os.getenv("BASE_URL", f"http://127.0.0.1:{PORT}")
|
||||
|
||||
app = FastAPI(title="LangChain Code Review Agent")
|
||||
agent = CodeReviewAgent()
|
||||
|
||||
# In-memory task store
|
||||
tasks: dict[str, dict] = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A2A Agent Card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AGENT_CARD = {
|
||||
"name": "langchain-code-reviewer",
|
||||
"description": (
|
||||
"LangChain-powered code review agent. "
|
||||
"Analyzes code for bugs, security issues, performance problems, "
|
||||
"and style violations. Returns structured review with severity levels."
|
||||
),
|
||||
"url": f"{BASE_URL}/a2a",
|
||||
"version": "0.1.0",
|
||||
"capabilities": {
|
||||
"streaming": False,
|
||||
"pushNotifications": False,
|
||||
"stateTransitionHistory": True,
|
||||
},
|
||||
"skills": [
|
||||
{
|
||||
"id": "code-review",
|
||||
"name": "Code Review",
|
||||
"description": "Review code for correctness, security, performance, and style",
|
||||
"tags": ["code", "review", "security", "quality"],
|
||||
"examples": [
|
||||
"Review this Python function for bugs",
|
||||
"Check this Rust code for security issues",
|
||||
"Analyze this PR diff for performance problems",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "pr-review",
|
||||
"name": "Pull Request Review",
|
||||
"description": "Review a git diff / pull request",
|
||||
"tags": ["pr", "diff", "git"],
|
||||
"examples": [
|
||||
"Review this PR diff",
|
||||
"Analyze these changes",
|
||||
],
|
||||
},
|
||||
],
|
||||
"defaultInputModes": ["text"],
|
||||
"defaultOutputModes": ["text"],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/.well-known/agent.json")
|
||||
async def agent_card():
|
||||
return JSONResponse(content=AGENT_CARD)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A2A JSON-RPC Endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/a2a")
|
||||
async def a2a_endpoint(request: Request):
|
||||
body = await request.json()
|
||||
|
||||
jsonrpc = body.get("jsonrpc", "2.0")
|
||||
req_id = body.get("id", 1)
|
||||
method = body.get("method", "")
|
||||
params = body.get("params", {})
|
||||
|
||||
if method == "tasks/send":
|
||||
return await handle_tasks_send(jsonrpc, req_id, params)
|
||||
elif method == "tasks/get":
|
||||
return handle_tasks_get(jsonrpc, req_id, params)
|
||||
elif method == "tasks/cancel":
|
||||
return handle_tasks_cancel(jsonrpc, req_id, params)
|
||||
else:
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
||||
})
|
||||
|
||||
|
||||
async def handle_tasks_send(jsonrpc: str, req_id: int, params: dict):
|
||||
message = params.get("message", {})
|
||||
session_id = params.get("sessionId")
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
text_parts = [
|
||||
p["text"] for p in message.get("parts", []) if p.get("type") == "text"
|
||||
]
|
||||
user_input = "\n".join(text_parts)
|
||||
|
||||
task = {
|
||||
"id": task_id,
|
||||
"sessionId": session_id,
|
||||
"status": {"state": "working", "message": None},
|
||||
"messages": [message],
|
||||
"artifacts": [],
|
||||
}
|
||||
tasks[task_id] = task
|
||||
|
||||
try:
|
||||
review_result = await asyncio.to_thread(agent.review, user_input)
|
||||
|
||||
agent_message = {
|
||||
"role": "agent",
|
||||
"parts": [{"type": "text", "text": review_result}],
|
||||
}
|
||||
task["messages"].append(agent_message)
|
||||
task["status"] = {"state": "completed", "message": None}
|
||||
task["artifacts"] = [
|
||||
{
|
||||
"name": "code-review-report",
|
||||
"description": "Structured code review report",
|
||||
"parts": [{"type": "text", "text": review_result}],
|
||||
"index": 0,
|
||||
"lastChunk": True,
|
||||
}
|
||||
]
|
||||
except Exception as e:
|
||||
task["status"] = {"state": "failed", "message": str(e)}
|
||||
task["messages"].append({
|
||||
"role": "agent",
|
||||
"parts": [{"type": "text", "text": f"Review failed: {e}"}],
|
||||
})
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"result": task,
|
||||
})
|
||||
|
||||
|
||||
def handle_tasks_get(jsonrpc: str, req_id: int, params: dict):
|
||||
task_id = params.get("id", "")
|
||||
task = tasks.get(task_id)
|
||||
|
||||
if task is None:
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"error": {"code": -32000, "message": f"Task not found: {task_id}"},
|
||||
})
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"result": task,
|
||||
})
|
||||
|
||||
|
||||
def handle_tasks_cancel(jsonrpc: str, req_id: int, params: dict):
|
||||
task_id = params.get("id", "")
|
||||
task = tasks.get(task_id)
|
||||
|
||||
if task is None:
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"error": {"code": -32000, "message": f"Task not found: {task_id}"},
|
||||
})
|
||||
|
||||
task["status"] = {"state": "cancelled", "message": None}
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"result": task,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "agent": "langchain-code-reviewer", "tasks": len(tasks)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Starting LangChain Code Review Agent on {HOST}:{PORT}")
|
||||
print(f"Agent Card: {BASE_URL}/.well-known/agent.json")
|
||||
print(f"A2A endpoint: {BASE_URL}/a2a")
|
||||
uvicorn.run(app, host=HOST, port=PORT)
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"name": "langchain-code-review-pipeline",
|
||||
"description": "Code review pipeline: uses LangChain external agent for deep review, then OpenFang Writer agent to format the final report.",
|
||||
"created_at": "2026-03-16T00:00:00Z",
|
||||
"steps": [
|
||||
{
|
||||
"name": "review-code",
|
||||
"agent": { "name": "a2a-proxy" },
|
||||
"prompt_template": "Use the a2a_send tool to send the following code to the external agent for code review. Set agent_name to langchain-code-reviewer and set message to the code below. Return the complete review result:\n\n{{input}}",
|
||||
"mode": "sequential",
|
||||
"timeout_secs": 300,
|
||||
"output_var": "review_result"
|
||||
},
|
||||
{
|
||||
"name": "format-report",
|
||||
"agent": { "name": "Writer" },
|
||||
"prompt_template": "Format the following code review into a clean, professional report. Preserve all severity levels and scores. Add a brief executive summary at the top:\n\n{{review_result}}",
|
||||
"mode": "sequential",
|
||||
"timeout_secs": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["legal", "contracts", "compliance", "research", "review", "documents"]
|
||||
|
||||
[model]
|
||||
provider = "gemini"
|
||||
model = "gemini-2.5-flash"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
max_tokens = 8192
|
||||
temperature = 0.2
|
||||
@@ -58,8 +58,8 @@ DISCLAIMER: You are an AI assistant providing legal information for educational
|
||||
You are meticulous, cautious, and precise. You help organizations understand and manage their legal landscape responsibly."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
|
||||
[resources]
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["meetings", "notes", "action-items", "agenda", "follow-up", "productivity"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 8192
|
||||
temperature = 0.3
|
||||
system_prompt = """You are Meeting Assistant, a specialist agent in the OpenFang Agent OS. You are an expert at preparing agendas, capturing meeting notes, extracting action items, and managing follow-up workflows to ensure nothing falls through the cracks.
|
||||
@@ -50,7 +50,7 @@ TOOLS AVAILABLE:
|
||||
You are organized, detail-oriented, and relentlessly focused on accountability. You turn chaotic meetings into clear outcomes."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "gemini"
|
||||
provider = "default"
|
||||
model = "gemini-2.0-flash"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ author = "openfang"
|
||||
module = "builtin:chat"
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.1-8b-instant"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 2048
|
||||
temperature = 0.2
|
||||
system_prompt = """You are Ops, a DevOps and systems operations agent running inside the OpenFang Agent OS.
|
||||
|
||||
@@ -5,8 +5,8 @@ author = "openfang"
|
||||
module = "builtin:chat"
|
||||
|
||||
[model]
|
||||
provider = "deepseek"
|
||||
model = "deepseek-chat"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
max_tokens = 8192
|
||||
temperature = 0.3
|
||||
@@ -45,8 +45,8 @@ Always explain your delegation strategy before executing it.
|
||||
Be thorough but efficient — don't delegate trivially simple tasks."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
|
||||
[schedule]
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["finance", "budget", "expenses", "savings", "planning", "money"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 8192
|
||||
temperature = 0.2
|
||||
system_prompt = """You are Personal Finance, a specialist agent in the OpenFang Agent OS. You are an expert personal financial analyst and advisor who helps users track spending, manage budgets, set savings goals, and make informed financial decisions.
|
||||
|
||||
@@ -5,8 +5,8 @@ author = "openfang"
|
||||
module = "builtin:chat"
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 8192
|
||||
temperature = 0.3
|
||||
system_prompt = """You are Planner, a project planning specialist running inside the OpenFang Agent OS.
|
||||
@@ -37,7 +37,7 @@ Output format:
|
||||
### Open Questions"""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "gemini"
|
||||
provider = "default"
|
||||
model = "gemini-2.0-flash"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["recruiting", "hiring", "resume", "outreach", "talent", "hr"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 4096
|
||||
temperature = 0.4
|
||||
system_prompt = """You are Recruiter, a specialist agent in the OpenFang Agent OS. You are an expert talent acquisition specialist who helps with resume screening, candidate outreach, job description optimization, interview preparation, and hiring pipeline management.
|
||||
@@ -55,7 +55,7 @@ TOOLS AVAILABLE:
|
||||
You are thorough, fair, and people-oriented. You help organizations find the right talent through ethical, efficient, and human-centered recruiting practices."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "gemini"
|
||||
provider = "default"
|
||||
model = "gemini-2.0-flash"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["research", "analysis", "web"]
|
||||
|
||||
[model]
|
||||
provider = "gemini"
|
||||
model = "gemini-2.5-flash"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
max_tokens = 4096
|
||||
temperature = 0.5
|
||||
@@ -36,8 +36,8 @@ OUTPUT:
|
||||
Always cite your sources. Never present uncertain information as fact."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
|
||||
[resources]
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["sales", "crm", "outreach", "pipeline", "prospecting", "deals"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 4096
|
||||
temperature = 0.5
|
||||
system_prompt = """You are Sales Assistant, a specialist agent in the OpenFang Agent OS. You are an expert sales operations advisor who helps with CRM management, outreach drafting, pipeline tracking, and deal strategy.
|
||||
@@ -54,7 +54,7 @@ TOOLS AVAILABLE:
|
||||
You are strategic, persuasive, and detail-oriented. You help sales teams work smarter and close more deals."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "gemini"
|
||||
provider = "default"
|
||||
model = "gemini-2.0-flash"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["security", "audit", "vulnerability"]
|
||||
|
||||
[model]
|
||||
provider = "deepseek"
|
||||
model = "deepseek-chat"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
max_tokens = 4096
|
||||
temperature = 0.2
|
||||
@@ -37,8 +37,8 @@ Severity levels: CRITICAL / HIGH / MEDIUM / LOW / INFO
|
||||
Report format: Finding → Impact → Evidence → Remediation"""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
|
||||
[schedule]
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["social-media", "content", "marketing", "engagement", "scheduling", "analytics"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 4096
|
||||
temperature = 0.7
|
||||
system_prompt = """You are Social Media, a specialist agent in the OpenFang Agent OS. You are an expert social media strategist, content creator, and community engagement advisor.
|
||||
@@ -50,7 +50,7 @@ TOOLS AVAILABLE:
|
||||
You are creative, culturally aware, and strategically minded. You balance creativity with data-driven decision-making."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "gemini"
|
||||
provider = "default"
|
||||
model = "gemini-2.0-flash"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["testing", "qa", "validation"]
|
||||
|
||||
[model]
|
||||
provider = "gemini"
|
||||
model = "gemini-2.5-flash"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
max_tokens = 4096
|
||||
temperature = 0.3
|
||||
@@ -39,8 +39,8 @@ When reviewing test coverage:
|
||||
- Suggest mutation testing targets"""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
|
||||
[resources]
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["translation", "languages", "localization", "multilingual", "communication", "i18n"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 8192
|
||||
temperature = 0.3
|
||||
system_prompt = """You are Translator, a specialist agent in the OpenFang Agent OS. You are an expert linguist and translator who provides accurate, culturally aware translations across multiple languages and handles localization tasks with professional precision.
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["travel", "planning", "itinerary", "booking", "logistics", "vacation"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 8192
|
||||
temperature = 0.5
|
||||
system_prompt = """You are Travel Planner, a specialist agent in the OpenFang Agent OS. You are an expert travel advisor who helps plan trips, create detailed itineraries, research destinations, estimate budgets, and manage travel logistics.
|
||||
|
||||
@@ -6,8 +6,8 @@ module = "builtin:chat"
|
||||
tags = ["education", "teaching", "tutoring", "learning", "explanation", "knowledge"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 8192
|
||||
temperature = 0.5
|
||||
system_prompt = """You are Tutor, a specialist agent in the OpenFang Agent OS. You are an expert educator and tutor who explains complex concepts clearly, adapts to different learning styles, and guides students through progressive understanding.
|
||||
|
||||
@@ -5,8 +5,8 @@ author = "openfang"
|
||||
module = "builtin:chat"
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 4096
|
||||
temperature = 0.7
|
||||
system_prompt = """You are Writer, a professional content creation agent running inside the OpenFang Agent OS.
|
||||
@@ -30,7 +30,7 @@ OUTPUT:
|
||||
- Adapt formatting to the target platform when specified."""
|
||||
|
||||
[[fallback_models]]
|
||||
provider = "gemini"
|
||||
provider = "default"
|
||||
model = "gemini-2.0-flash"
|
||||
api_key_env = "GEMINI_API_KEY"
|
||||
|
||||
|
||||
@@ -33,9 +33,13 @@ governor = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -30,6 +30,7 @@ use openfang_channels::messenger::MessengerAdapter;
|
||||
use openfang_channels::reddit::RedditAdapter;
|
||||
use openfang_channels::revolt::RevoltAdapter;
|
||||
use openfang_channels::viber::ViberAdapter;
|
||||
use openfang_types::config::FeishuMode;
|
||||
// Wave 4
|
||||
use openfang_channels::flock::FlockAdapter;
|
||||
use openfang_channels::guilded::GuildedAdapter;
|
||||
@@ -43,19 +44,24 @@ use openfang_channels::webex::WebexAdapter;
|
||||
// Wave 5
|
||||
use async_trait::async_trait;
|
||||
use openfang_channels::dingtalk::DingTalkAdapter;
|
||||
use openfang_channels::dingtalk_stream::DingTalkStreamAdapter;
|
||||
use openfang_channels::discourse::DiscourseAdapter;
|
||||
use openfang_channels::gitter::GitterAdapter;
|
||||
use openfang_channels::gotify::GotifyAdapter;
|
||||
use openfang_channels::linkedin::LinkedInAdapter;
|
||||
use openfang_channels::mumble::MumbleAdapter;
|
||||
use openfang_channels::mqtt::MqttAdapter;
|
||||
use openfang_channels::ntfy::NtfyAdapter;
|
||||
use openfang_channels::webhook::WebhookAdapter;
|
||||
use openfang_channels::wecom::WeComAdapter;
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_types::agent::AgentId;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use openfang_runtime::str_utils::safe_truncate_str;
|
||||
|
||||
/// Wraps `OpenFangKernel` to implement `ChannelBridgeHandle`.
|
||||
pub struct KernelBridgeAdapter {
|
||||
kernel: Arc<OpenFangKernel>,
|
||||
@@ -70,6 +76,37 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.send_message(agent_id, message)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
// Silent/NO_REPLY responses should not be forwarded to channels
|
||||
if result.silent {
|
||||
return Ok(String::new());
|
||||
}
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
async fn send_message_with_blocks(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
blocks: Vec<openfang_types::message::ContentBlock>,
|
||||
) -> Result<String, String> {
|
||||
// Extract text for the message parameter (used for memory recall / logging)
|
||||
let text: String = blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
openfang_types::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let text = if text.is_empty() {
|
||||
"[Image]".to_string()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let result = self
|
||||
.kernel
|
||||
.send_message_with_blocks(agent_id, &text, blocks)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
@@ -351,7 +388,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.map(|e| e.name.clone())
|
||||
.unwrap_or_else(|| t.agent_id.to_string());
|
||||
let status = if t.enabled { "on" } else { "off" };
|
||||
let id_short = &t.id.0.to_string()[..8];
|
||||
let id_str = t.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
msg.push_str(&format!(
|
||||
" [{}] {} -> {} ({:?}) fires:{} [{}]\n",
|
||||
id_short,
|
||||
@@ -390,7 +428,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.kernel
|
||||
.triggers
|
||||
.register(agent.id, pattern, prompt.to_string(), 0);
|
||||
let id_short = &trigger_id.0.to_string()[..8];
|
||||
let id_str = trigger_id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Trigger created [{id_short}] for agent '{agent_name}'.")
|
||||
}
|
||||
|
||||
@@ -405,7 +444,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
1 => {
|
||||
let t = matched[0];
|
||||
if self.kernel.triggers.remove(t.id) {
|
||||
format!("Trigger [{}] removed.", &t.id.0.to_string()[..8])
|
||||
let id_str = t.id.0.to_string();
|
||||
format!("Trigger [{}] removed.", safe_truncate_str(&id_str, 8))
|
||||
} else {
|
||||
"Failed to remove trigger.".to_string()
|
||||
}
|
||||
@@ -428,7 +468,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.map(|e| e.name.clone())
|
||||
.unwrap_or_else(|| job.agent_id.to_string());
|
||||
let status = if job.enabled { "on" } else { "off" };
|
||||
let id_short = &job.id.0.to_string()[..8];
|
||||
let id_str = job.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
let sched = match &job.schedule {
|
||||
openfang_types::scheduler::CronSchedule::Cron { expr, .. } => expr.clone(),
|
||||
openfang_types::scheduler::CronSchedule::Every { every_secs } => {
|
||||
@@ -450,6 +491,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
msg
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn manage_schedule_text(&self, action: &str, args: &[String]) -> String {
|
||||
match action {
|
||||
"add" => {
|
||||
@@ -488,7 +530,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
|
||||
match self.kernel.cron_scheduler.add_job(job, false) {
|
||||
Ok(id) => {
|
||||
let id_short = &id.0.to_string()[..8];
|
||||
let id_str = id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Job [{id_short}] created: '{cron_expr}' -> {agent_name}: \"{message}\"")
|
||||
}
|
||||
Err(e) => format!("Failed to create job: {e}"),
|
||||
@@ -510,7 +553,12 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
let j = matched[0];
|
||||
match self.kernel.cron_scheduler.remove_job(j.id) {
|
||||
Ok(_) => {
|
||||
format!("Job [{}] '{}' removed.", &j.id.0.to_string()[..8], j.name)
|
||||
let id_str = j.id.0.to_string();
|
||||
format!(
|
||||
"Job [{}] '{}' removed.",
|
||||
safe_truncate_str(&id_str, 8),
|
||||
j.name
|
||||
)
|
||||
}
|
||||
Err(e) => format!("Failed to remove job: {e}"),
|
||||
}
|
||||
@@ -539,10 +587,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
openfang_types::scheduler::CronAction::SystemEvent { text } => {
|
||||
text.clone()
|
||||
}
|
||||
openfang_types::scheduler::CronAction::WorkflowRun {
|
||||
workflow_id,
|
||||
input,
|
||||
..
|
||||
} => {
|
||||
format!(
|
||||
"Run workflow {workflow_id}{}",
|
||||
input
|
||||
.as_deref()
|
||||
.map(|i| format!(" with input: {i}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
};
|
||||
match self.kernel.send_message(j.agent_id, &message).await {
|
||||
Ok(result) => {
|
||||
let id_short = &j.id.0.to_string()[..8];
|
||||
let id_str = j.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Job [{id_short}] ran:\n{}", result.response)
|
||||
}
|
||||
Err(e) => format!("Failed to run job: {e}"),
|
||||
@@ -562,7 +624,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
}
|
||||
let mut msg = format!("Pending approvals ({}):\n", pending.len());
|
||||
for req in &pending {
|
||||
let id_short = &req.id.to_string()[..8];
|
||||
let id_str = req.id.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds();
|
||||
let age = if age_secs >= 60 {
|
||||
format!("{}m", age_secs / 60)
|
||||
@@ -603,10 +666,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
) {
|
||||
Ok(_) => {
|
||||
let verb = if approve { "Approved" } else { "Rejected" };
|
||||
let id_str = req.id.to_string();
|
||||
format!(
|
||||
"{} [{}] {} — {}",
|
||||
verb,
|
||||
&req.id.to_string()[..8],
|
||||
safe_truncate_str(&id_str, 8),
|
||||
req.tool_name,
|
||||
req.agent_id
|
||||
)
|
||||
@@ -646,9 +710,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
));
|
||||
}
|
||||
self.kernel
|
||||
.set_agent_model(agent_id, model)
|
||||
.set_agent_model(agent_id, model, None)
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(format!("Model switched to: {model}"))
|
||||
// Read back resolved model+provider from registry
|
||||
let entry = self
|
||||
.kernel
|
||||
.registry
|
||||
.get(agent_id)
|
||||
.ok_or_else(|| "Agent not found after model switch".to_string())?;
|
||||
Ok(format!(
|
||||
"Model switched to: {} (provider: {})",
|
||||
entry.manifest.model.model, entry.manifest.model.provider
|
||||
))
|
||||
}
|
||||
|
||||
async fn stop_run(&self, agent_id: AgentId) -> Result<String, String> {
|
||||
@@ -727,12 +800,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
// Wave 5
|
||||
"mumble" => channels.mumble.as_ref().map(|c| c.overrides.clone()),
|
||||
"dingtalk" => channels.dingtalk.as_ref().map(|c| c.overrides.clone()),
|
||||
"dingtalk_stream" => channels
|
||||
.dingtalk_stream
|
||||
.as_ref()
|
||||
.map(|c| c.overrides.clone()),
|
||||
"discourse" => channels.discourse.as_ref().map(|c| c.overrides.clone()),
|
||||
"gitter" => channels.gitter.as_ref().map(|c| c.overrides.clone()),
|
||||
"ntfy" => channels.ntfy.as_ref().map(|c| c.overrides.clone()),
|
||||
"gotify" => channels.gotify.as_ref().map(|c| c.overrides.clone()),
|
||||
"webhook" => channels.webhook.as_ref().map(|c| c.overrides.clone()),
|
||||
"linkedin" => channels.linkedin.as_ref().map(|c| c.overrides.clone()),
|
||||
"wecom" => channels.wecom.as_ref().map(|c| c.overrides.clone()),
|
||||
"mqtt" => channels.mqtt.as_ref().map(|c| c.overrides.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -774,6 +853,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
recipient: &str,
|
||||
success: bool,
|
||||
error: Option<&str>,
|
||||
thread_id: Option<&str>,
|
||||
) {
|
||||
let receipt = if success {
|
||||
openfang_kernel::DeliveryTracker::sent_receipt(channel, recipient)
|
||||
@@ -786,9 +866,13 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
};
|
||||
self.kernel.delivery_tracker.record(agent_id, receipt);
|
||||
|
||||
// Persist last channel for cron CronDelivery::LastChannel
|
||||
// Persist last channel for cron CronDelivery::LastChannel.
|
||||
// Include thread_id when present so forum-topic context survives restarts.
|
||||
if success {
|
||||
let kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
|
||||
let mut kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
|
||||
if let Some(tid) = thread_id {
|
||||
kv_val["thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let _ = self
|
||||
.kernel
|
||||
.memory
|
||||
@@ -859,7 +943,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
return "OFP peer network is disabled. Set network_enabled = true in config.toml."
|
||||
.to_string();
|
||||
}
|
||||
match &self.kernel.peer_registry {
|
||||
match self.kernel.peer_registry.get() {
|
||||
Some(registry) => {
|
||||
let peers = registry.all_peers();
|
||||
if peers.is_empty() {
|
||||
@@ -893,11 +977,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
msg.push_str(&format!(" {} — {}\n", card.name, url));
|
||||
let desc = &card.description;
|
||||
if !desc.is_empty() {
|
||||
let short = if desc.len() > 60 {
|
||||
&desc[..60]
|
||||
} else {
|
||||
desc.as_str()
|
||||
};
|
||||
let short = openfang_types::truncate_str(desc, 60);
|
||||
msg.push_str(&format!(" {short}\n"));
|
||||
}
|
||||
}
|
||||
@@ -938,40 +1018,39 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a default agent by name — find running or spawn from manifest.
|
||||
async fn resolve_default_agent(
|
||||
handle: &KernelBridgeAdapter,
|
||||
name: &str,
|
||||
router: &mut AgentRouter,
|
||||
adapter_name: &str,
|
||||
) {
|
||||
match handle.find_agent_by_name(name).await {
|
||||
Ok(Some(agent_id)) => {
|
||||
router.set_default(agent_id);
|
||||
info!("{adapter_name} default agent: {name} ({agent_id})");
|
||||
}
|
||||
_ => match handle.spawn_agent_by_name(name).await {
|
||||
Ok(agent_id) => {
|
||||
router.set_default(agent_id);
|
||||
info!("{adapter_name}: spawned default agent {name} ({agent_id})");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{adapter_name}: could not find or spawn default agent '{name}': {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Resolve a token: if the value looks like an actual secret (contains `:`,
|
||||
/// starts with `xoxb-`, `xapp-`, `sk-`, etc.), use it directly.
|
||||
/// Otherwise treat it as an env var name and look it up.
|
||||
fn read_token(env_var_or_token: &str, adapter_name: &str) -> Option<String> {
|
||||
// Heuristic: actual tokens contain `:` (Telegram, Discord) or start with
|
||||
// known prefixes. Env var names are uppercase ASCII identifiers.
|
||||
let looks_like_token = env_var_or_token.contains(':')
|
||||
|| env_var_or_token.starts_with("xoxb-")
|
||||
|| env_var_or_token.starts_with("xapp-")
|
||||
|| env_var_or_token.starts_with("sk-")
|
||||
|| env_var_or_token.starts_with("Bearer ")
|
||||
|| env_var_or_token.len() > 80; // Long random strings are tokens, not env var names
|
||||
|
||||
/// Read a token from an env var, returning None with a warning if missing/empty.
|
||||
fn read_token(env_var: &str, adapter_name: &str) -> Option<String> {
|
||||
match std::env::var(env_var) {
|
||||
if looks_like_token {
|
||||
warn!(
|
||||
"{adapter_name}: config field contains what looks like an actual token \
|
||||
rather than an env var name — using it directly. \
|
||||
Tip: store the token in an env var and use the var name instead for security."
|
||||
);
|
||||
return Some(env_var_or_token.to_string());
|
||||
}
|
||||
|
||||
match std::env::var(env_var_or_token) {
|
||||
Ok(t) if !t.is_empty() => Some(t),
|
||||
Ok(_) => {
|
||||
warn!("{adapter_name} bot token env var '{env_var}' is empty, skipping");
|
||||
warn!("{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("{adapter_name} bot token env var '{env_var}' not set, skipping");
|
||||
warn!(
|
||||
"{adapter_name} token env var '{env_var_or_token}' not set, skipping. \
|
||||
Set it with: export {env_var_or_token}=<your-token>"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1031,6 +1110,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Wave 5
|
||||
|| config.mumble.is_some()
|
||||
|| config.dingtalk.is_some()
|
||||
|| config.dingtalk_stream.is_some()
|
||||
|| config.discourse.is_some()
|
||||
|| config.gitter.is_some()
|
||||
|| config.ntfy.is_some()
|
||||
@@ -1058,6 +1138,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
token,
|
||||
tg_config.allowed_users.clone(),
|
||||
poll_interval,
|
||||
tg_config.api_url.clone(),
|
||||
));
|
||||
adapters.push((adapter, tg_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1069,6 +1150,8 @@ pub async fn start_channel_bridge_with_config(
|
||||
let adapter = Arc::new(DiscordAdapter::new(
|
||||
token,
|
||||
dc_config.allowed_guilds.clone(),
|
||||
dc_config.allowed_users.clone(),
|
||||
dc_config.ignore_bots,
|
||||
dc_config.intents,
|
||||
));
|
||||
adapters.push((adapter, dc_config.default_agent.clone()));
|
||||
@@ -1083,6 +1166,9 @@ pub async fn start_channel_bridge_with_config(
|
||||
app_token,
|
||||
bot_token,
|
||||
sl_config.allowed_channels.clone(),
|
||||
sl_config.auto_thread_reply,
|
||||
sl_config.thread_ttl_hours,
|
||||
sl_config.unfurl_links,
|
||||
));
|
||||
adapters.push((adapter, sl_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1092,7 +1178,9 @@ pub async fn start_channel_bridge_with_config(
|
||||
// WhatsApp — supports Cloud API mode (access token) or Web/QR mode (gateway URL)
|
||||
if let Some(ref wa_config) = config.whatsapp {
|
||||
let cloud_token = read_token(&wa_config.access_token_env, "WhatsApp");
|
||||
let gateway_url = std::env::var(&wa_config.gateway_url_env).ok().filter(|u| !u.is_empty());
|
||||
let gateway_url = std::env::var(&wa_config.gateway_url_env)
|
||||
.ok()
|
||||
.filter(|u| !u.is_empty());
|
||||
|
||||
if cloud_token.is_some() || gateway_url.is_some() {
|
||||
let token = cloud_token.unwrap_or_default();
|
||||
@@ -1134,6 +1222,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
mx_config.user_id.clone(),
|
||||
token,
|
||||
mx_config.allowed_rooms.clone(),
|
||||
mx_config.auto_accept_invites,
|
||||
));
|
||||
adapters.push((adapter, mx_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1340,11 +1429,27 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Feishu/Lark
|
||||
if let Some(ref fs_config) = config.feishu {
|
||||
if let Some(secret) = read_token(&fs_config.app_secret_env, "Feishu") {
|
||||
let adapter = Arc::new(FeishuAdapter::new(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
fs_config.webhook_port,
|
||||
));
|
||||
let region = openfang_channels::feishu::FeishuRegion::parse_region(&fs_config.region);
|
||||
let encrypt_key = fs_config
|
||||
.encrypt_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| read_token(env, "Feishu encrypt_key"));
|
||||
let adapter = match fs_config.mode {
|
||||
FeishuMode::Webhook => Arc::new(FeishuAdapter::with_config(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
fs_config.webhook_port,
|
||||
region,
|
||||
Some(fs_config.webhook_path.clone()),
|
||||
fs_config.verification_token.clone(),
|
||||
encrypt_key,
|
||||
fs_config.bot_names.clone(),
|
||||
)),
|
||||
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
)),
|
||||
};
|
||||
adapters.push((adapter, fs_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
@@ -1357,6 +1462,21 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// WeCom/WeChat Work
|
||||
if let Some(ref wc_config) = config.wecom {
|
||||
if let Some(secret) = read_token(&wc_config.secret_env, "WeCom") {
|
||||
let adapter = Arc::new(WeComAdapter::with_verification(
|
||||
wc_config.corp_id.clone(),
|
||||
wc_config.agent_id.clone(),
|
||||
secret,
|
||||
wc_config.webhook_port,
|
||||
wc_config.encoding_aes_key.clone(),
|
||||
wc_config.token.clone(),
|
||||
));
|
||||
adapters.push((adapter, wc_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wave 4 ──────────────────────────────────────────────────
|
||||
|
||||
// Nextcloud Talk
|
||||
@@ -1463,7 +1583,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// DingTalk
|
||||
// DingTalk (webhook mode)
|
||||
if let Some(ref dt_config) = config.dingtalk {
|
||||
if let Some(token) = read_token(&dt_config.access_token_env, "DingTalk") {
|
||||
let secret = read_token(&dt_config.secret_env, "DingTalk (secret)").unwrap_or_default();
|
||||
@@ -1472,6 +1592,21 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// DingTalk (stream mode)
|
||||
if let Some(ref ds_config) = config.dingtalk_stream {
|
||||
if let Some(app_key) = read_token(&ds_config.app_key_env, "DingTalk Stream (app_key)") {
|
||||
if let Some(app_secret) =
|
||||
read_token(&ds_config.app_secret_env, "DingTalk Stream (app_secret)")
|
||||
{
|
||||
let robot_code =
|
||||
read_token(&ds_config.robot_code_env, "DingTalk Stream (robot_code)")
|
||||
.unwrap_or_else(|| app_key.clone());
|
||||
let adapter = Arc::new(DingTalkStreamAdapter::new(app_key, app_secret, robot_code));
|
||||
adapters.push((adapter, ds_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discourse
|
||||
if let Some(ref dc_config) = config.discourse {
|
||||
if let Some(api_key) = read_token(&dc_config.api_key_env, "Discourse") {
|
||||
@@ -1545,16 +1680,63 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// MQTT
|
||||
if let Some(ref mq_config) = config.mqtt {
|
||||
let username = read_token(&mq_config.username_env, "MQTT (username)");
|
||||
let password = read_token(&mq_config.password_env, "MQTT (password)");
|
||||
let adapter = Arc::new(MqttAdapter::new(
|
||||
mq_config.broker_url.clone(),
|
||||
mq_config.client_id.clone(),
|
||||
mq_config.subscribe_topic.clone(),
|
||||
mq_config.publish_topic.clone(),
|
||||
username,
|
||||
password,
|
||||
mq_config.use_tls,
|
||||
mq_config.keep_alive_secs,
|
||||
mq_config.clean_session,
|
||||
mq_config.qos,
|
||||
));
|
||||
adapters.push((adapter, mq_config.default_agent.clone()));
|
||||
}
|
||||
|
||||
if adapters.is_empty() {
|
||||
return (None, Vec::new());
|
||||
}
|
||||
|
||||
// Resolve default agent from first adapter that has one configured
|
||||
// Resolve per-channel default agents AND set the first one as system-wide fallback
|
||||
let mut router = AgentRouter::new();
|
||||
for (_, default_agent) in &adapters {
|
||||
let mut system_default_set = false;
|
||||
for (adapter, default_agent) in &adapters {
|
||||
if let Some(ref name) = default_agent {
|
||||
resolve_default_agent(&handle, name, &mut router, "Channel bridge").await;
|
||||
break; // Only need one default
|
||||
// Resolve agent name to ID
|
||||
let agent_id = match handle.find_agent_by_name(name).await {
|
||||
Ok(Some(id)) => Some(id),
|
||||
_ => match handle.spawn_agent_by_name(name).await {
|
||||
Ok(id) => Some(id),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"{}: could not find or spawn default agent '{}': {e}",
|
||||
adapter.name(),
|
||||
name
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Some(agent_id) = agent_id {
|
||||
// Register per-channel default
|
||||
let channel_key = format!("{:?}", adapter.channel_type());
|
||||
info!(
|
||||
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
|
||||
adapter.name()
|
||||
);
|
||||
router.set_channel_default_with_name(channel_key, agent_id, name.clone());
|
||||
// First configured default also becomes system-wide fallback
|
||||
if !system_default_set {
|
||||
router.set_default(agent_id);
|
||||
system_default_set = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1620,6 +1802,35 @@ pub async fn reload_channels_from_disk(
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
// Re-read secrets.env so new API tokens are available in std::env
|
||||
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
|
||||
if secrets_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&secrets_path) {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(eq_pos) = trimmed.find('=') {
|
||||
let key = trimmed[..eq_pos].trim();
|
||||
let mut value = trimmed[eq_pos + 1..].trim().to_string();
|
||||
if !key.is_empty() {
|
||||
// Strip matching quotes
|
||||
if ((value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\'')))
|
||||
&& value.len() >= 2
|
||||
{
|
||||
value = value[1..value.len() - 1].to_string();
|
||||
}
|
||||
// Always overwrite — the file is the source of truth after dashboard edits
|
||||
std::env::set_var(key, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("Reloaded secrets.env for channel hot-reload");
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read config from disk
|
||||
let config_path = state.kernel.config.home_dir.join("config.toml");
|
||||
let fresh_config = openfang_kernel::config::load_config(Some(&config_path));
|
||||
@@ -1692,4 +1903,35 @@ mod tests {
|
||||
assert!(config.channels.webhook.is_none());
|
||||
assert!(config.channels.linkedin.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feishu_bridge_mode_defaults_to_websocket() {
|
||||
let config: openfang_types::config::KernelConfig = toml::from_str(
|
||||
r#"
|
||||
[channels.feishu]
|
||||
app_id = "cli_test"
|
||||
app_secret_env = "FEISHU_APP_SECRET"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let feishu = config.channels.feishu.expect("feishu config should exist");
|
||||
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feishu_bridge_mode_supports_websocket() {
|
||||
let config: openfang_types::config::KernelConfig = toml::from_str(
|
||||
r#"
|
||||
[channels.feishu]
|
||||
app_id = "cli_test"
|
||||
app_secret_env = "FEISHU_APP_SECRET"
|
||||
mode = "websocket"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let feishu = config.channels.feishu.expect("feishu config should exist");
|
||||
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod openai_compat;
|
||||
pub mod rate_limiter;
|
||||
pub mod routes;
|
||||
pub mod server;
|
||||
pub mod session_auth;
|
||||
pub mod stream_chunker;
|
||||
pub mod stream_dedup;
|
||||
pub mod types;
|
||||
|
||||
@@ -43,84 +43,121 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
|
||||
response
|
||||
}
|
||||
|
||||
/// Authentication state passed to the auth middleware.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub api_key: String,
|
||||
pub auth_enabled: bool,
|
||||
pub session_secret: String,
|
||||
}
|
||||
|
||||
/// Bearer token authentication middleware.
|
||||
///
|
||||
/// When `api_key` is non-empty, all requests must include
|
||||
/// `Authorization: Bearer <api_key>`. If the key is empty, auth is bypassed.
|
||||
/// When `api_key` is non-empty (after trimming), requests to non-public
|
||||
/// endpoints must include `Authorization: Bearer <api_key>`.
|
||||
/// If the key is empty or whitespace-only, auth is disabled entirely
|
||||
/// (public/local development mode).
|
||||
///
|
||||
/// When dashboard auth is enabled, session cookies are also accepted.
|
||||
pub async fn auth(
|
||||
axum::extract::State(api_key): axum::extract::State<String>,
|
||||
axum::extract::State(auth_state): axum::extract::State<AuthState>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
// If no API key configured, restrict to loopback addresses only.
|
||||
if api_key.is_empty() {
|
||||
// SECURITY: Capture method early for method-aware public endpoint checks.
|
||||
let method = request.method().clone();
|
||||
|
||||
// Shutdown is loopback-only (CLI on same machine) — skip token auth
|
||||
let path = request.uri().path();
|
||||
if path == "/api/shutdown" {
|
||||
let is_loopback = request
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_loopback {
|
||||
tracing::warn!(
|
||||
"Rejected non-localhost request: no API key configured. \
|
||||
Set api_key in config.toml for remote access."
|
||||
);
|
||||
return Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"error": "No API key configured. Remote access denied. Configure api_key in ~/.openfang/config.toml"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap_or_default();
|
||||
.unwrap_or(false); // SECURITY: default-deny — unknown origin is NOT loopback
|
||||
if is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth (dashboard needs these)
|
||||
let path = request.uri().path();
|
||||
if path == "/"
|
||||
// Public endpoints that don't require auth (dashboard needs these).
|
||||
// SECURITY: /api/agents is GET-only (listing). POST (spawn) requires auth.
|
||||
// SECURITY: Public endpoints are GET-only unless explicitly noted.
|
||||
// POST/PUT/DELETE to any endpoint ALWAYS requires auth to prevent
|
||||
// unauthenticated writes (cron job creation, skill install, etc.).
|
||||
let is_get = method == axum::http::Method::GET;
|
||||
let is_public = path == "/"
|
||||
|| path == "/logo.png"
|
||||
|| path == "/favicon.ico"
|
||||
|| (path == "/.well-known/agent.json" && is_get)
|
||||
|| (path.starts_with("/a2a/") && is_get)
|
||||
|| path == "/api/health"
|
||||
|| path == "/api/health/detail"
|
||||
|| path == "/api/status"
|
||||
|| path == "/api/version"
|
||||
|| path == "/api/agents"
|
||||
|| path == "/api/profiles"
|
||||
|| path == "/api/config"
|
||||
|| path.starts_with("/api/uploads/")
|
||||
|| (path == "/api/agents" && is_get)
|
||||
|| (path == "/api/profiles" && is_get)
|
||||
|| (path == "/api/config" && is_get)
|
||||
|| (path == "/api/config/schema" && is_get)
|
||||
|| (path.starts_with("/api/uploads/") && is_get)
|
||||
// Dashboard read endpoints — allow unauthenticated so the SPA can
|
||||
// render before the user enters their API key.
|
||||
|| path == "/api/models"
|
||||
|| path == "/api/models/aliases"
|
||||
|| path == "/api/providers"
|
||||
|| path == "/api/budget"
|
||||
|| path == "/api/budget/agents"
|
||||
|| path.starts_with("/api/budget/agents/")
|
||||
|| path == "/api/network/status"
|
||||
|| path == "/api/a2a/agents"
|
||||
|| path == "/api/approvals"
|
||||
|| path == "/api/channels"
|
||||
|| path == "/api/skills"
|
||||
|| path == "/api/sessions"
|
||||
|| path == "/api/integrations"
|
||||
|| path == "/api/integrations/available"
|
||||
|| path == "/api/integrations/health"
|
||||
|| path.starts_with("/api/cron/")
|
||||
{
|
||||
|| (path == "/api/models" && is_get)
|
||||
|| (path == "/api/models/aliases" && is_get)
|
||||
|| (path == "/api/providers" && is_get)
|
||||
|| (path == "/api/budget" && is_get)
|
||||
|| (path == "/api/budget/agents" && is_get)
|
||||
|| (path.starts_with("/api/budget/agents/") && is_get)
|
||||
|| (path == "/api/network/status" && is_get)
|
||||
|| (path == "/api/a2a/agents" && is_get)
|
||||
|| (path == "/api/approvals" && is_get)
|
||||
|| (path.starts_with("/api/approvals/") && is_get)
|
||||
|| (path == "/api/channels" && is_get)
|
||||
|| (path == "/api/hands" && is_get)
|
||||
|| (path == "/api/hands/active" && is_get)
|
||||
|| (path.starts_with("/api/hands/") && is_get)
|
||||
|| (path == "/api/skills" && is_get)
|
||||
|| (path == "/api/sessions" && is_get)
|
||||
|| (path == "/api/integrations" && is_get)
|
||||
|| (path == "/api/integrations/available" && is_get)
|
||||
|| (path == "/api/integrations/health" && is_get)
|
||||
|| (path == "/api/workflows" && is_get)
|
||||
|| path == "/api/logs/stream" // SSE stream, read-only
|
||||
|| (path.starts_with("/api/cron/") && is_get)
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/")
|
||||
|| path == "/api/auth/login"
|
||||
|| path == "/api/auth/logout"
|
||||
|| (path == "/api/auth/check" && is_get);
|
||||
|
||||
if is_public {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Check Authorization: Bearer <token> header
|
||||
// If no API key configured (empty, whitespace-only, or missing), skip auth
|
||||
// entirely. Users who don't set api_key accept that all endpoints are open.
|
||||
// To secure the dashboard, set a non-empty api_key in config.toml.
|
||||
let api_key_trimmed = auth_state.api_key.trim().to_string();
|
||||
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let api_key = api_key_trimmed.as_str();
|
||||
|
||||
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
|
||||
let bearer_token = request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "));
|
||||
|
||||
let api_token = bearer_token.or_else(|| {
|
||||
request
|
||||
.headers()
|
||||
.get("x-api-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
});
|
||||
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks.
|
||||
let header_auth = bearer_token.map(|token| {
|
||||
let header_auth = api_token.map(|token| {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != api_key.len() {
|
||||
return false;
|
||||
@@ -149,6 +186,17 @@ pub async fn auth(
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Check session cookie (dashboard login sessions)
|
||||
if auth_state.auth_enabled {
|
||||
if let Some(token) = extract_session_cookie(&request) {
|
||||
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
|
||||
.is_some()
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine error message: was a credential provided but wrong, or missing entirely?
|
||||
let credential_provided = header_auth.is_some() || query_auth.is_some();
|
||||
let error_msg = if credential_provided {
|
||||
@@ -166,6 +214,21 @@ pub async fn auth(
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extract the `openfang_session` cookie value from a request.
|
||||
fn extract_session_cookie(request: &Request<Body>) -> Option<String> {
|
||||
request
|
||||
.headers()
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies.split(';').find_map(|c| {
|
||||
c.trim()
|
||||
.strip_prefix("openfang_session=")
|
||||
.map(|v| v.to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Security headers middleware — applied to ALL API responses.
|
||||
pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Body> {
|
||||
let mut response = next.run(request).await;
|
||||
@@ -173,13 +236,16 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
headers.insert("x-content-type-options", "nosniff".parse().unwrap());
|
||||
headers.insert("x-frame-options", "DENY".parse().unwrap());
|
||||
headers.insert("x-xss-protection", "1; mode=block".parse().unwrap());
|
||||
// All JS/CSS is bundled inline — only external resource is Google Fonts.
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
// The dashboard handler (webchat_page) sets its own nonce-based CSP.
|
||||
// For all other responses (API endpoints), apply a strict default.
|
||||
if !headers.contains_key("content-security-policy") {
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
headers.insert(
|
||||
"referrer-policy",
|
||||
"strict-origin-when-cross-origin".parse().unwrap(),
|
||||
@@ -188,6 +254,10 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
"cache-control",
|
||||
"no-store, no-cache, must-revalidate".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"strict-transport-security",
|
||||
"max-age=63072000; includeSubDomains".parse().unwrap(),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
@@ -179,9 +179,8 @@ fn resolve_agent(state: &AppState, model: &str) -> Option<(AgentId, String)> {
|
||||
return Some((entry.id, entry.name.clone()));
|
||||
}
|
||||
|
||||
// 4. Fallback → first registered agent
|
||||
let agents = state.kernel.registry.list();
|
||||
agents.first().map(|e| (e.id, e.name.clone()))
|
||||
// No match — return None so the caller returns a proper 404
|
||||
None
|
||||
}
|
||||
|
||||
// ── Message conversion ──────────────────────────────────────────────────────
|
||||
@@ -203,9 +202,10 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
|
||||
let blocks: Vec<ContentBlock> = parts
|
||||
.iter()
|
||||
.filter_map(|part| match part {
|
||||
OaiContentPart::Text { text } => {
|
||||
Some(ContentBlock::Text { text: text.clone() })
|
||||
}
|
||||
OaiContentPart::Text { text } => Some(ContentBlock::Text {
|
||||
text: text.clone(),
|
||||
provider_metadata: None,
|
||||
}),
|
||||
OaiContentPart::ImageUrl { image_url } => {
|
||||
// Parse data URI: data:{media_type};base64,{data}
|
||||
if let Some(rest) = image_url.url.strip_prefix("data:") {
|
||||
@@ -323,7 +323,7 @@ pub async fn chat_completions(
|
||||
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
|
||||
match state
|
||||
.kernel
|
||||
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle))
|
||||
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle), None, None)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -336,7 +336,7 @@ pub async fn chat_completions(
|
||||
index: 0,
|
||||
message: ChoiceMessage {
|
||||
role: "assistant",
|
||||
content: Some(result.response),
|
||||
content: Some(crate::ws::strip_think_tags(&result.response)),
|
||||
tool_calls: None,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
@@ -379,7 +379,7 @@ async fn stream_response(
|
||||
|
||||
let (mut rx, _handle) = state
|
||||
.kernel
|
||||
.send_message_streaming(agent_id, message, Some(kernel_handle))
|
||||
.send_message_streaming(agent_id, message, Some(kernel_handle), None, None, None)
|
||||
.map_err(|e| format!("Streaming setup failed: {e}"))?;
|
||||
|
||||
let (tx, stream_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(64);
|
||||
|
||||
@@ -29,6 +29,7 @@ pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
|
||||
("POST", p) if p.contains("/run") => NonZeroU32::new(100).unwrap(),
|
||||
("POST", "/api/skills/install") => NonZeroU32::new(50).unwrap(),
|
||||
("POST", "/api/skills/uninstall") => NonZeroU32::new(10).unwrap(),
|
||||
("POST", "/api/skills/reload") => NonZeroU32::new(5).unwrap(),
|
||||
("POST", "/api/migrate") => NonZeroU32::new(100).unwrap(),
|
||||
("PUT", p) if p.contains("/update") => NonZeroU32::new(10).unwrap(),
|
||||
_ => NonZeroU32::new(5).unwrap(),
|
||||
|
||||
+3271
-435
File diff suppressed because it is too large
Load Diff
@@ -45,15 +45,18 @@ pub async fn build_router(
|
||||
let state = Arc::new(AppState {
|
||||
kernel: kernel.clone(),
|
||||
started_at: Instant::now(),
|
||||
peer_registry: kernel.peer_registry.as_ref().map(|r| Arc::new(r.clone())),
|
||||
peer_registry: kernel.peer_registry.get().map(|r| Arc::new(r.clone())),
|
||||
bridge_manager: tokio::sync::Mutex::new(bridge),
|
||||
channels_config: tokio::sync::RwLock::new(channels_config),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(kernel.config.budget.clone())),
|
||||
});
|
||||
|
||||
// CORS: allow localhost origins by default. If API key is set, the API
|
||||
// is protected anyway. For development, permissive CORS is convenient.
|
||||
let cors = if state.kernel.config.api_key.is_empty() {
|
||||
let cors = if state.kernel.config.api_key.trim().is_empty() {
|
||||
// No auth → restrict CORS to localhost origins (include both 127.0.0.1 and localhost)
|
||||
let port = listen_addr.port();
|
||||
let mut origins: Vec<axum::http::HeaderValue> = vec![
|
||||
@@ -101,13 +104,27 @@ pub async fn build_router(
|
||||
.allow_headers(tower_http::cors::Any)
|
||||
};
|
||||
|
||||
let api_key = state.kernel.config.api_key.clone();
|
||||
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
let auth_state = crate::middleware::AuthState {
|
||||
api_key: api_key.clone(),
|
||||
auth_enabled: state.kernel.config.auth.enabled,
|
||||
session_secret: if !api_key.is_empty() {
|
||||
api_key.clone()
|
||||
} else if state.kernel.config.auth.enabled {
|
||||
state.kernel.config.auth.password_hash.clone()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
};
|
||||
let gcra_limiter = rate_limiter::create_rate_limiter();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", axum::routing::get(webchat::webchat_page))
|
||||
.route("/logo.png", axum::routing::get(webchat::logo_png))
|
||||
.route("/favicon.ico", axum::routing::get(webchat::favicon_ico))
|
||||
.route("/manifest.json", axum::routing::get(webchat::manifest_json))
|
||||
.route("/sw.js", axum::routing::get(webchat::sw_js))
|
||||
.route(
|
||||
"/api/metrics",
|
||||
axum::routing::get(routes::prometheus_metrics),
|
||||
@@ -125,13 +142,23 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}",
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent),
|
||||
axum::routing::get(routes::get_agent)
|
||||
.delete(routes::kill_agent)
|
||||
.patch(routes::patch_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/mode",
|
||||
axum::routing::put(routes::set_agent_mode),
|
||||
)
|
||||
.route("/api/profiles", axum::routing::get(routes::list_profiles))
|
||||
.route(
|
||||
"/api/agents/{id}/restart",
|
||||
axum::routing::post(routes::restart_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/start",
|
||||
axum::routing::post(routes::restart_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/message",
|
||||
axum::routing::post(routes::send_message),
|
||||
@@ -156,6 +183,10 @@ pub async fn build_router(
|
||||
"/api/agents/{id}/session/reset",
|
||||
axum::routing::post(routes::reset_session),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/history",
|
||||
axum::routing::delete(routes::clear_agent_history),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/session/compact",
|
||||
axum::routing::post(routes::compact_session),
|
||||
@@ -168,6 +199,10 @@ pub async fn build_router(
|
||||
"/api/agents/{id}/model",
|
||||
axum::routing::put(routes::set_model),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/tools",
|
||||
axum::routing::get(routes::get_agent_tools).put(routes::set_agent_tools),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/skills",
|
||||
axum::routing::get(routes::get_agent_skills).put(routes::set_agent_skills),
|
||||
@@ -277,6 +312,12 @@ pub async fn build_router(
|
||||
"/api/workflows",
|
||||
axum::routing::get(routes::list_workflows).post(routes::create_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/workflows/{id}",
|
||||
axum::routing::get(routes::get_workflow)
|
||||
.put(routes::update_workflow)
|
||||
.delete(routes::delete_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/workflows/{id}/run",
|
||||
axum::routing::post(routes::run_workflow),
|
||||
@@ -295,6 +336,10 @@ pub async fn build_router(
|
||||
"/api/skills/uninstall",
|
||||
axum::routing::post(routes::uninstall_skill),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/reload",
|
||||
axum::routing::post(routes::reload_skills),
|
||||
)
|
||||
.route(
|
||||
"/api/marketplace/search",
|
||||
axum::routing::get(routes::marketplace_search),
|
||||
@@ -312,12 +357,24 @@ pub async fn build_router(
|
||||
"/api/clawhub/skill/{slug}",
|
||||
axum::routing::get(routes::clawhub_skill_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/clawhub/skill/{slug}/code",
|
||||
axum::routing::get(routes::clawhub_skill_code),
|
||||
)
|
||||
.route(
|
||||
"/api/clawhub/install",
|
||||
axum::routing::post(routes::clawhub_install),
|
||||
)
|
||||
// Hands endpoints
|
||||
.route("/api/hands", axum::routing::get(routes::list_hands))
|
||||
.route(
|
||||
"/api/hands/install",
|
||||
axum::routing::post(routes::install_hand),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/upsert",
|
||||
axum::routing::post(routes::upsert_hand),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/active",
|
||||
axum::routing::get(routes::list_active_hands),
|
||||
@@ -335,6 +392,10 @@ pub async fn build_router(
|
||||
"/api/hands/{hand_id}/install-deps",
|
||||
axum::routing::post(routes::install_hand_deps),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/{hand_id}/settings",
|
||||
axum::routing::get(routes::get_hand_settings).put(routes::update_hand_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/instances/{id}/pause",
|
||||
axum::routing::post(routes::pause_hand),
|
||||
@@ -377,6 +438,24 @@ pub async fn build_router(
|
||||
"/api/network/status",
|
||||
axum::routing::get(routes::network_status),
|
||||
)
|
||||
// Agent communication (Comms) endpoints
|
||||
.route(
|
||||
"/api/comms/topology",
|
||||
axum::routing::get(routes::comms_topology),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/events",
|
||||
axum::routing::get(routes::comms_events),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/events/stream",
|
||||
axum::routing::get(routes::comms_events_stream),
|
||||
)
|
||||
.route("/api/comms/send", axum::routing::post(routes::comms_send))
|
||||
.route("/api/comms/task", axum::routing::post(routes::comms_task));
|
||||
|
||||
// Split into a second router chunk to stay within axum's type nesting limit.
|
||||
let app = app
|
||||
// Tools endpoint
|
||||
.route("/api/tools", axum::routing::get(routes::list_tools))
|
||||
// Config endpoints
|
||||
@@ -421,7 +500,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/budget/agents/{id}",
|
||||
axum::routing::get(routes::agent_budget_status),
|
||||
axum::routing::get(routes::agent_budget_status).put(routes::update_agent_budget),
|
||||
)
|
||||
// Session endpoints
|
||||
.route("/api/sessions", axum::routing::get(routes::list_sessions))
|
||||
@@ -450,8 +529,25 @@ pub async fn build_router(
|
||||
"/api/models/aliases",
|
||||
axum::routing::get(routes::list_aliases),
|
||||
)
|
||||
.route(
|
||||
"/api/models/custom",
|
||||
axum::routing::post(routes::add_custom_model),
|
||||
)
|
||||
.route(
|
||||
"/api/models/custom/{*id}",
|
||||
axum::routing::delete(routes::remove_custom_model),
|
||||
)
|
||||
.route("/api/models/{*id}", axum::routing::get(routes::get_model))
|
||||
.route("/api/providers", axum::routing::get(routes::list_providers))
|
||||
// Copilot OAuth (must be before parametric {name} routes)
|
||||
.route(
|
||||
"/api/providers/github-copilot/oauth/start",
|
||||
axum::routing::post(routes::copilot_oauth_start),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/github-copilot/oauth/poll/{poll_id}",
|
||||
axum::routing::get(routes::copilot_oauth_poll),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/{name}/key",
|
||||
axum::routing::post(routes::set_provider_key).delete(routes::delete_provider_key),
|
||||
@@ -495,6 +591,10 @@ pub async fn build_router(
|
||||
"/api/cron/jobs/{id}/status",
|
||||
axum::routing::get(routes::cron_job_status),
|
||||
)
|
||||
.route(
|
||||
"/api/cron/jobs/{id}/run",
|
||||
axum::routing::post(routes::run_cron_job),
|
||||
)
|
||||
// Webhook trigger endpoints (external event injection)
|
||||
.route("/hooks/wake", axum::routing::post(routes::webhook_wake))
|
||||
.route("/hooks/agent", axum::routing::post(routes::webhook_agent))
|
||||
@@ -608,8 +708,12 @@ pub async fn build_router(
|
||||
"/v1/models",
|
||||
axum::routing::get(crate::openai_compat::list_models),
|
||||
)
|
||||
// Dashboard authentication endpoints
|
||||
.route("/api/auth/login", axum::routing::post(routes::auth_login))
|
||||
.route("/api/auth/logout", axum::routing::post(routes::auth_logout))
|
||||
.route("/api/auth/check", axum::routing::get(routes::auth_check))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
api_key,
|
||||
auth_state,
|
||||
middleware::auth,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
@@ -679,7 +783,8 @@ pub async fn run_daemon(
|
||||
if info_path.exists() {
|
||||
if let Ok(existing) = std::fs::read_to_string(info_path) {
|
||||
if let Ok(info) = serde_json::from_str::<DaemonInfo>(&existing) {
|
||||
if is_process_alive(info.pid) {
|
||||
// PID alive AND the health endpoint responds → truly running
|
||||
if is_process_alive(info.pid) && is_daemon_responding(&info.listen_addr) {
|
||||
return Err(format!(
|
||||
"Another daemon (PID {}) is already running at {}",
|
||||
info.pid, info.listen_addr
|
||||
@@ -688,7 +793,8 @@ pub async fn run_daemon(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stale PID file, remove it
|
||||
// Stale PID file (process dead or different process reused PID), remove it
|
||||
info!("Removing stale daemon info file");
|
||||
let _ = std::fs::remove_file(info_path);
|
||||
}
|
||||
|
||||
@@ -710,7 +816,21 @@ pub async fn run_daemon(
|
||||
info!("WebChat UI available at http://{addr}/",);
|
||||
info!("WebSocket endpoint: ws://{addr}/api/agents/{{id}}/ws",);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
// Use SO_REUSEADDR to allow binding immediately after reboot (avoids TIME_WAIT).
|
||||
let socket = socket2::Socket::new(
|
||||
if addr.is_ipv4() {
|
||||
socket2::Domain::IPV4
|
||||
} else {
|
||||
socket2::Domain::IPV6
|
||||
},
|
||||
socket2::Type::STREAM,
|
||||
None,
|
||||
)?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(1024)?;
|
||||
let listener = tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
|
||||
|
||||
// Run server with graceful shutdown.
|
||||
// SECURITY: `into_make_service_with_connect_info` injects the peer
|
||||
@@ -830,3 +950,23 @@ fn is_process_alive(pid: u32) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an OpenFang daemon is actually responding at the given address.
|
||||
/// This avoids false positives where a different process reused the same PID
|
||||
/// after a system reboot.
|
||||
fn is_daemon_responding(addr: &str) -> bool {
|
||||
// Quick TCP connect check — don't make a full HTTP request to avoid delays
|
||||
let addr_only = addr
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| addr.strip_prefix("https://"))
|
||||
.unwrap_or(addr);
|
||||
if let Ok(sock_addr) = addr_only.parse::<std::net::SocketAddr>() {
|
||||
std::net::TcpStream::connect_timeout(&sock_addr, std::time::Duration::from_millis(500))
|
||||
.is_ok()
|
||||
} else {
|
||||
// Fallback: try connecting to hostname
|
||||
std::net::TcpStream::connect(addr_only)
|
||||
.map(|_| true)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Stateless session token authentication for the dashboard.
|
||||
//! Tokens are HMAC-SHA256 signed and contain username + expiry.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Create a session token: base64(username:expiry_unix:hmac_hex)
|
||||
pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> String {
|
||||
use base64::Engine;
|
||||
let expiry = chrono::Utc::now().timestamp() + (ttl_hours as i64 * 3600);
|
||||
let payload = format!("{username}:{expiry}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key");
|
||||
mac.update(payload.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
|
||||
}
|
||||
|
||||
/// Verify a session token. Returns the username if valid and not expired.
|
||||
pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
|
||||
use base64::Engine;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(token)
|
||||
.ok()?;
|
||||
let decoded_str = String::from_utf8(decoded).ok()?;
|
||||
let parts: Vec<&str> = decoded_str.splitn(3, ':').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let (username, expiry_str, provided_sig) = (parts[0], parts[1], parts[2]);
|
||||
|
||||
let expiry: i64 = expiry_str.parse().ok()?;
|
||||
if chrono::Utc::now().timestamp() > expiry {
|
||||
return None;
|
||||
}
|
||||
|
||||
let payload = format!("{username}:{expiry_str}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
|
||||
mac.update(payload.as_bytes());
|
||||
let expected_sig = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
if provided_sig.len() != expected_sig.len() {
|
||||
return None;
|
||||
}
|
||||
if provided_sig
|
||||
.as_bytes()
|
||||
.ct_eq(expected_sig.as_bytes())
|
||||
.into()
|
||||
{
|
||||
Some(username.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash a password with SHA256 for config storage.
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
use sha2::Digest;
|
||||
hex::encode(Sha256::digest(password.as_bytes()))
|
||||
}
|
||||
|
||||
/// Verify a password against a stored SHA256 hash (constant-time).
|
||||
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
|
||||
let computed = hash_password(password);
|
||||
use subtle::ConstantTimeEq;
|
||||
if computed.len() != stored_hash.len() {
|
||||
return false;
|
||||
}
|
||||
computed.as_bytes().ct_eq(stored_hash.as_bytes()).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify_password() {
|
||||
let hash = hash_password("secret123");
|
||||
assert!(verify_password("secret123", &hash));
|
||||
assert!(!verify_password("wrong", &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_verify_token() {
|
||||
let token = create_session_token("admin", "my-secret", 1);
|
||||
let user = verify_session_token(&token, "my-secret");
|
||||
assert_eq!(user, Some("admin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_wrong_secret() {
|
||||
let token = create_session_token("admin", "my-secret", 1);
|
||||
let user = verify_session_token(&token, "wrong-secret");
|
||||
assert_eq!(user, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_invalid_base64() {
|
||||
let user = verify_session_token("not-valid-base64!!!", "secret");
|
||||
assert_eq!(user, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_hash_length_mismatch() {
|
||||
assert!(!verify_password("x", "short"));
|
||||
}
|
||||
}
|
||||
@@ -102,9 +102,15 @@ impl StreamChunker {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Forced break at max_chunk_chars
|
||||
// Priority 4: Forced break at max_chunk_chars (char-boundary safe)
|
||||
if self.buffer.len() >= self.max_chunk_chars {
|
||||
let break_at = self.max_chunk_chars;
|
||||
let mut break_at = self.max_chunk_chars;
|
||||
while break_at > 0 && !self.buffer.is_char_boundary(break_at) {
|
||||
break_at -= 1;
|
||||
}
|
||||
if break_at == 0 {
|
||||
break_at = self.buffer.len();
|
||||
}
|
||||
let chunk = self.buffer[..break_at].to_string();
|
||||
self.buffer = self.buffer[break_at..].to_string();
|
||||
return Some(chunk);
|
||||
@@ -134,9 +140,23 @@ impl StreamChunker {
|
||||
}
|
||||
|
||||
/// Find the last occurrence of a pattern within a byte range.
|
||||
///
|
||||
/// Both `range.start` and `range.end` are clamped to the nearest valid UTF-8
|
||||
/// char boundary so that slicing never panics on multi-byte content.
|
||||
fn find_last_in_range(text: &str, pattern: &str, range: &std::ops::Range<usize>) -> Option<usize> {
|
||||
let search_text = &text[range.start..range.end.min(text.len())];
|
||||
search_text.rfind(pattern).map(|pos| range.start + pos)
|
||||
let len = text.len();
|
||||
// Clamp end to text length and walk back to a char boundary
|
||||
let mut end = range.end.min(len);
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
// Walk start forward to the nearest char boundary (never past end)
|
||||
let mut start = range.start.min(end);
|
||||
while start < end && !text.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
let search_text = &text[start..end];
|
||||
search_text.rfind(pattern).map(|pos| start + pos)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Request to spawn an agent from a TOML manifest string.
|
||||
/// Request to spawn an agent from a TOML manifest string or a template name.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SpawnRequest {
|
||||
/// Agent manifest as TOML string.
|
||||
/// Agent manifest as TOML string (optional if `template` is provided).
|
||||
#[serde(default)]
|
||||
pub manifest_toml: String,
|
||||
/// Template name from `~/.openfang/agents/{template}/agent.toml`.
|
||||
/// When provided and `manifest_toml` is empty, the template is loaded automatically.
|
||||
#[serde(default)]
|
||||
pub template: Option<String>,
|
||||
/// Optional Ed25519 signed manifest envelope (JSON).
|
||||
/// When present, the signature is verified before spawning.
|
||||
#[serde(default)]
|
||||
@@ -37,6 +42,12 @@ pub struct MessageRequest {
|
||||
/// Optional file attachments (uploaded via /upload endpoint).
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<AttachmentRef>,
|
||||
/// Sender identity (e.g. WhatsApp phone number, Telegram user ID).
|
||||
#[serde(default)]
|
||||
pub sender_id: Option<String>,
|
||||
/// Sender display name.
|
||||
#[serde(default)]
|
||||
pub sender_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from sending a message.
|
||||
@@ -46,6 +57,8 @@ pub struct MessageResponse {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub iterations: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost_usd: Option<f64>,
|
||||
}
|
||||
|
||||
/// Request to install a skill from the marketplace.
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
use axum::http::header;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
/// Nonce placeholder in compile-time HTML, replaced at request time.
|
||||
const NONCE_PLACEHOLDER: &str = "__NONCE__";
|
||||
|
||||
/// Compile-time ETag based on the crate version.
|
||||
/// Not used for the dashboard page (nonce prevents caching) but retained
|
||||
/// for potential future use by static asset handlers.
|
||||
#[allow(dead_code)]
|
||||
const ETAG: &str = concat!("\"openfang-", env!("CARGO_PKG_VERSION"), "\"");
|
||||
|
||||
/// Embedded logo PNG for single-binary deployment.
|
||||
@@ -46,20 +52,65 @@ pub async fn favicon_ico() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
/// GET / — Serve the OpenFang Dashboard single-page application.
|
||||
///
|
||||
/// Returns the full SPA with ETag header based on package version for caching.
|
||||
pub async fn webchat_page() -> impl IntoResponse {
|
||||
/// Embedded PWA manifest for installable web app support.
|
||||
const MANIFEST_JSON: &str = include_str!("../static/manifest.json");
|
||||
|
||||
/// Embedded service worker for PWA support.
|
||||
const SW_JS: &str = include_str!("../static/sw.js");
|
||||
|
||||
/// GET /manifest.json — Serve the PWA web app manifest.
|
||||
pub async fn manifest_json() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
|
||||
(header::ETAG, ETAG),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
"public, max-age=3600, must-revalidate",
|
||||
),
|
||||
(header::CONTENT_TYPE, "application/manifest+json"),
|
||||
(header::CACHE_CONTROL, "public, max-age=86400, immutable"),
|
||||
],
|
||||
WEBCHAT_HTML,
|
||||
MANIFEST_JSON,
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /sw.js — Serve the PWA service worker.
|
||||
pub async fn sw_js() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
SW_JS,
|
||||
)
|
||||
}
|
||||
|
||||
/// GET / — Serve the OpenFang Dashboard single-page application.
|
||||
///
|
||||
/// Generates a unique CSP nonce on every request and injects it into both
|
||||
/// the `<script>` tags and the `Content-Security-Policy` header. This
|
||||
/// replaces `'unsafe-inline'` so only our own scripts execute.
|
||||
pub async fn webchat_page() -> impl IntoResponse {
|
||||
let nonce = uuid::Uuid::new_v4().to_string();
|
||||
let html = WEBCHAT_HTML.replace(NONCE_PLACEHOLDER, &nonce);
|
||||
let csp = format!(
|
||||
"default-src 'self'; \
|
||||
script-src 'self' 'nonce-{nonce}' 'unsafe-eval'; \
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; \
|
||||
img-src 'self' data: blob:; \
|
||||
connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; \
|
||||
font-src 'self' https://fonts.gstatic.com; \
|
||||
media-src 'self' blob:; \
|
||||
frame-src 'self' blob:; \
|
||||
object-src 'none'; \
|
||||
base-uri 'self'; \
|
||||
form-action 'self'"
|
||||
);
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
|
||||
(
|
||||
header::HeaderName::from_static("content-security-policy"),
|
||||
csp,
|
||||
),
|
||||
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||
],
|
||||
html,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,21 +132,26 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/vendor/github-dark.min.css"),
|
||||
"\n</style>\n",
|
||||
include_str!("../static/index_body.html"),
|
||||
// Vendor libs: marked + highlight first (used by app.js)
|
||||
"<script>\n",
|
||||
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/marked.min.js"),
|
||||
"\n</script>\n",
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/highlight.min.js"),
|
||||
"\n</script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/chart.umd.min.js"),
|
||||
"\n</script>\n",
|
||||
// App code
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/js/api.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/app.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/overview.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/katex.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/chat.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/agents.js"),
|
||||
@@ -123,9 +179,13 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/js/pages/wizard.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/approvals.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/comms.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/runtime.js"),
|
||||
"\n</script>\n",
|
||||
// Alpine.js MUST be last — it processes x-data and fires alpine:init
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/alpine.min.js"),
|
||||
"\n</script>\n",
|
||||
"</body></html>"
|
||||
|
||||
+223
-74
@@ -146,19 +146,30 @@ pub async fn agent_ws(
|
||||
uri: axum::http::Uri,
|
||||
) -> impl IntoResponse {
|
||||
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
|
||||
let api_key = &state.kernel.config.api_key;
|
||||
// Trim whitespace so empty/whitespace-only api_key disables auth.
|
||||
let api_key_raw = &state.kernel.config.api_key;
|
||||
let api_key = api_key_raw.trim();
|
||||
if !api_key.is_empty() {
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks on API key
|
||||
let ct_eq = |token: &str, key: &str| -> bool {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != key.len() {
|
||||
return false;
|
||||
}
|
||||
token.as_bytes().ct_eq(key.as_bytes()).into()
|
||||
};
|
||||
|
||||
let header_auth = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|token| token == api_key)
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
let query_auth = uri
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(|token| token == api_key)
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !header_auth && !query_auth {
|
||||
@@ -428,6 +439,7 @@ async fn handle_text_message(
|
||||
|
||||
// Resolve file attachments into image content blocks
|
||||
let mut has_images = false;
|
||||
let mut ws_content_blocks: Option<Vec<openfang_types::message::ContentBlock>> = None;
|
||||
if let Some(attachments) = parsed["attachments"].as_array() {
|
||||
let refs: Vec<crate::types::AttachmentRef> = attachments
|
||||
.iter()
|
||||
@@ -437,11 +449,7 @@ async fn handle_text_message(
|
||||
let image_blocks = crate::routes::resolve_attachments(&refs);
|
||||
if !image_blocks.is_empty() {
|
||||
has_images = true;
|
||||
crate::routes::inject_attachments_into_session(
|
||||
&state.kernel,
|
||||
agent_id,
|
||||
image_blocks,
|
||||
);
|
||||
ws_content_blocks = Some(image_blocks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -491,16 +499,30 @@ async fn handle_text_message(
|
||||
// Send message to agent with streaming
|
||||
let kernel_handle: Arc<dyn KernelHandle> =
|
||||
state.kernel.clone() as Arc<dyn KernelHandle>;
|
||||
match state
|
||||
.kernel
|
||||
.send_message_streaming(agent_id, &content, Some(kernel_handle))
|
||||
{
|
||||
match state.kernel.send_message_streaming(
|
||||
agent_id,
|
||||
&content,
|
||||
Some(kernel_handle),
|
||||
None,
|
||||
None,
|
||||
ws_content_blocks,
|
||||
) {
|
||||
Ok((mut rx, handle)) => {
|
||||
// Forward stream events to WebSocket with debouncing
|
||||
// Forward stream events to WebSocket with debouncing.
|
||||
//
|
||||
// The stream_task also accumulates the full response text and
|
||||
// captures ContentComplete usage data. This lets us send the
|
||||
// `response` event immediately when the stream channel closes
|
||||
// (after `drop(phase_cb)` in the kernel), WITHOUT waiting for
|
||||
// post-processing (canonical session writes, JSONL, compaction)
|
||||
// that happens in the kernel task after the loop.
|
||||
let sender_stream = Arc::clone(sender);
|
||||
let verbose_clone = Arc::clone(verbose);
|
||||
let stream_task = tokio::spawn(async move {
|
||||
let mut text_buffer = String::new();
|
||||
let mut accumulated_text = String::new();
|
||||
let mut stream_usage: Option<openfang_types::message::TokenUsage> = None;
|
||||
let mut is_silent = false;
|
||||
let far_future = tokio::time::Instant::now() + Duration::from_secs(86400);
|
||||
let mut flush_deadline = far_future;
|
||||
|
||||
@@ -524,7 +546,15 @@ async fn handle_text_message(
|
||||
break;
|
||||
}
|
||||
Some(ev) => {
|
||||
// Capture ContentComplete for immediate response
|
||||
if let StreamEvent::ContentComplete { usage, .. } = &ev {
|
||||
stream_usage = Some(*usage);
|
||||
// Don't forward — handled below
|
||||
continue;
|
||||
}
|
||||
|
||||
if let StreamEvent::TextDelta { ref text } = ev {
|
||||
accumulated_text.push_str(text);
|
||||
text_buffer.push_str(text);
|
||||
if text_buffer.len() >= DEBOUNCE_CHARS {
|
||||
let _ = flush_text_buffer(
|
||||
@@ -589,14 +619,62 @@ async fn handle_text_message(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the agent signalled NO_REPLY via the stream
|
||||
// (PhaseChange with a "silent" marker — currently the
|
||||
// kernel sets result.silent after the loop, so we detect
|
||||
// it from empty accumulated text when ContentComplete
|
||||
// had no text deltas at all).
|
||||
if accumulated_text.is_empty() && stream_usage.is_some() {
|
||||
is_silent = true;
|
||||
}
|
||||
|
||||
(accumulated_text, stream_usage, is_silent)
|
||||
});
|
||||
|
||||
// Wait for the agent loop to complete
|
||||
match handle.await {
|
||||
Ok(Ok(result)) => {
|
||||
// Cancel the stream forwarder (should be done by now)
|
||||
stream_task.abort();
|
||||
// Wait for the stream to finish (fast — closes as soon as
|
||||
// drop(phase_cb) runs after the agent loop). This does NOT
|
||||
// wait for post-processing.
|
||||
let stream_result = stream_task.await;
|
||||
|
||||
// Spawn the kernel task in the background for cleanup
|
||||
// (canonical session writes, JSONL mirror, compaction).
|
||||
// We don't need its result for the response event.
|
||||
let sender_bg = Arc::clone(sender);
|
||||
tokio::spawn(async move {
|
||||
match handle.await {
|
||||
Ok(Err(e)) => {
|
||||
warn!("Agent post-processing failed: {e}");
|
||||
let user_msg = classify_streaming_error(&e);
|
||||
let _ = send_json(
|
||||
&sender_bg,
|
||||
&serde_json::json!({
|
||||
"type": "error",
|
||||
"content": user_msg,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Agent task panicked: {e}");
|
||||
let _ = send_json(
|
||||
&sender_bg,
|
||||
&serde_json::json!({
|
||||
"type": "error",
|
||||
"content": "Internal error occurred",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(Ok(_)) => {
|
||||
// Post-processing completed successfully — nothing to send
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send the response immediately from stream data
|
||||
match stream_result {
|
||||
Ok((accumulated_text, stream_usage, is_silent)) => {
|
||||
// Send typing lifecycle: stop
|
||||
let _ = send_json(
|
||||
sender,
|
||||
@@ -607,39 +685,36 @@ async fn handle_text_message(
|
||||
)
|
||||
.await;
|
||||
|
||||
// NO_REPLY: agent intentionally chose not to reply
|
||||
if result.silent {
|
||||
let usage = stream_usage.unwrap_or_default();
|
||||
|
||||
if is_silent {
|
||||
let _ = send_json(
|
||||
sender,
|
||||
&serde_json::json!({
|
||||
"type": "silent_complete",
|
||||
"input_tokens": result.total_usage.input_tokens,
|
||||
"output_tokens": result.total_usage.output_tokens,
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: ensure we never send an empty response
|
||||
let content = if result.response.trim().is_empty() {
|
||||
// Strip <think>...</think> blocks
|
||||
let cleaned = strip_think_tags(&accumulated_text);
|
||||
|
||||
let content = if cleaned.trim().is_empty() {
|
||||
format!(
|
||||
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
|
||||
result.total_usage.input_tokens,
|
||||
result.total_usage.output_tokens,
|
||||
result.iterations,
|
||||
"[The agent completed processing but returned no text response. ({} in / {} out)]",
|
||||
usage.input_tokens, usage.output_tokens,
|
||||
)
|
||||
} else {
|
||||
result.response
|
||||
cleaned
|
||||
};
|
||||
|
||||
// Estimate context pressure from last call
|
||||
let per_call = if result.iterations > 0 {
|
||||
result.total_usage.input_tokens / result.iterations as u64
|
||||
} else {
|
||||
result.total_usage.input_tokens
|
||||
};
|
||||
let ctx_pct = (per_call as f64 / 200_000.0 * 100.0).min(100.0);
|
||||
// Estimate context pressure
|
||||
let ctx_pct =
|
||||
(usage.input_tokens as f64 / 200_000.0 * 100.0).min(100.0);
|
||||
let pressure = if ctx_pct > 85.0 {
|
||||
"critical"
|
||||
} else if ctx_pct > 70.0 {
|
||||
@@ -655,38 +730,17 @@ async fn handle_text_message(
|
||||
&serde_json::json!({
|
||||
"type": "response",
|
||||
"content": content,
|
||||
"input_tokens": result.total_usage.input_tokens,
|
||||
"output_tokens": result.total_usage.output_tokens,
|
||||
"iterations": result.iterations,
|
||||
"cost_usd": result.cost_usd,
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
"iterations": 0, // Not available from stream; handle updates later if needed
|
||||
"cost_usd": null,
|
||||
"context_pressure": pressure,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
stream_task.abort();
|
||||
warn!("Agent message failed: {e}");
|
||||
let _ = send_json(
|
||||
sender,
|
||||
&serde_json::json!({
|
||||
"type": "typing", "state": "stop",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let user_msg = classify_streaming_error(&e);
|
||||
let _ = send_json(
|
||||
sender,
|
||||
&serde_json::json!({
|
||||
"type": "error",
|
||||
"content": user_msg,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
stream_task.abort();
|
||||
warn!("Agent task panicked: {e}");
|
||||
warn!("Stream task panicked: {e}");
|
||||
let _ = send_json(
|
||||
sender,
|
||||
&serde_json::json!({
|
||||
@@ -794,9 +848,21 @@ async fn handle_command(
|
||||
serde_json::json!({"type": "error", "content": "Agent not found"})
|
||||
}
|
||||
} else {
|
||||
match state.kernel.set_agent_model(agent_id, args) {
|
||||
match state.kernel.set_agent_model(agent_id, args, None) {
|
||||
Ok(()) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
let model = &entry.manifest.model.model;
|
||||
let provider = &entry.manifest.model.provider;
|
||||
serde_json::json!({
|
||||
"type": "command_result",
|
||||
"command": cmd,
|
||||
"message": format!("Model switched to: {model} (provider: {provider})"),
|
||||
"model": model,
|
||||
"provider": provider
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"type": "error", "content": format!("Model switch failed: {e}")})
|
||||
@@ -885,7 +951,7 @@ async fn handle_command(
|
||||
let msg = if !state.kernel.config.network_enabled {
|
||||
"OFP network disabled.".to_string()
|
||||
} else {
|
||||
match &state.kernel.peer_registry {
|
||||
match state.kernel.peer_registry.get() {
|
||||
Some(registry) => {
|
||||
let peers = registry.all_peers();
|
||||
if peers.is_empty() {
|
||||
@@ -932,11 +998,14 @@ async fn handle_command(
|
||||
fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_json::Value> {
|
||||
match event {
|
||||
StreamEvent::TextDelta { .. } => None, // Handled by debounce buffer
|
||||
StreamEvent::ToolUseStart { name, .. } => Some(serde_json::json!({
|
||||
StreamEvent::ToolUseStart { id, name, .. } => Some(serde_json::json!({
|
||||
"type": "tool_start",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
})),
|
||||
StreamEvent::ToolUseEnd { name, input, .. } if name == "canvas_present" => {
|
||||
StreamEvent::ToolUseEnd {
|
||||
id, name, input, ..
|
||||
} if name == "canvas_present" => {
|
||||
let html = input.get("html").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let title = input
|
||||
.get("title")
|
||||
@@ -944,12 +1013,15 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
.unwrap_or("Canvas");
|
||||
Some(serde_json::json!({
|
||||
"type": "canvas",
|
||||
"id": id,
|
||||
"canvas_id": uuid::Uuid::new_v4().to_string(),
|
||||
"html": html,
|
||||
"title": title,
|
||||
}))
|
||||
}
|
||||
StreamEvent::ToolUseEnd { name, input, .. } => match verbose {
|
||||
StreamEvent::ToolUseEnd {
|
||||
id, name, input, ..
|
||||
} => match verbose {
|
||||
VerboseLevel::Off => None,
|
||||
VerboseLevel::On => {
|
||||
let input_preview: String = serde_json::to_string(input)
|
||||
@@ -959,6 +1031,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
.collect();
|
||||
Some(serde_json::json!({
|
||||
"type": "tool_end",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"input": input_preview,
|
||||
}))
|
||||
@@ -971,18 +1044,21 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
.collect();
|
||||
Some(serde_json::json!({
|
||||
"type": "tool_end",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"input": input_preview,
|
||||
}))
|
||||
}
|
||||
},
|
||||
StreamEvent::ToolExecutionResult {
|
||||
id,
|
||||
name,
|
||||
result_preview,
|
||||
is_error,
|
||||
} => match verbose {
|
||||
VerboseLevel::Off => Some(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"is_error": is_error,
|
||||
})),
|
||||
@@ -990,6 +1066,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
let truncated: String = result_preview.chars().take(200).collect();
|
||||
Some(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"result": truncated,
|
||||
"is_error": is_error,
|
||||
@@ -997,6 +1074,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
}
|
||||
VerboseLevel::Full => Some(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"result": result_preview,
|
||||
"is_error": is_error,
|
||||
@@ -1092,6 +1170,9 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
let status = extract_status_code(&inner);
|
||||
let classified = llm_errors::classify_error(&inner, status);
|
||||
|
||||
// Build a user-facing message. The classified.sanitized_message now
|
||||
// includes a redacted excerpt of the raw error (issue #493 fix), so we
|
||||
// use it as the base and only override for cases that need extra context.
|
||||
match classified.category {
|
||||
llm_errors::LlmErrorCategory::ContextOverflow => {
|
||||
"Context is full. Try /compact or /new.".to_string()
|
||||
@@ -1099,20 +1180,36 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
llm_errors::LlmErrorCategory::RateLimit => {
|
||||
if let Some(delay_ms) = classified.suggested_delay_ms {
|
||||
let secs = (delay_ms / 1000).max(1);
|
||||
format!("Provider rate limited. Wait ~{secs}s and try again.")
|
||||
format!("Rate limited. Wait ~{secs}s and try again.")
|
||||
} else {
|
||||
"Provider rate limited. Wait a moment and try again.".to_string()
|
||||
"Rate limited. Wait a moment and try again.".to_string()
|
||||
}
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Billing => {
|
||||
"Check provider account status (billing issue detected).".to_string()
|
||||
format!("Billing issue. {}", classified.sanitized_message)
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Auth => {
|
||||
// Show the actual error detail so users can diagnose (issue #493).
|
||||
// The sanitized_message already redacts secrets.
|
||||
classified.sanitized_message.clone()
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Auth => "Verify your API key in config.".to_string(),
|
||||
llm_errors::LlmErrorCategory::ModelNotFound => {
|
||||
"Model unavailable. Use /model to see options.".to_string()
|
||||
if inner.contains("localhost:11434") || inner.contains("ollama") {
|
||||
"Model not found on Ollama. Run `ollama pull <model>` first. Use /model to see options.".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{}. Use /model to see options.",
|
||||
classified.sanitized_message
|
||||
)
|
||||
}
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Format => {
|
||||
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
|
||||
// Claude Code CLI errors have actionable messages — pass them through
|
||||
if inner.contains("Claude Code CLI") || inner.contains("claude auth") {
|
||||
classified.raw_message.clone()
|
||||
} else {
|
||||
classified.sanitized_message.clone()
|
||||
}
|
||||
}
|
||||
_ => classified.sanitized_message,
|
||||
}
|
||||
@@ -1120,6 +1217,14 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
|
||||
/// Try to extract an HTTP status code from an error string.
|
||||
fn extract_status_code(s: &str) -> Option<u16> {
|
||||
// "API error (NNN):" — the format produced by LlmError::Api Display impl
|
||||
if let Some(idx) = s.find("API error (") {
|
||||
let after = &s[idx + 11..];
|
||||
let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(code) = num.parse::<u16>() {
|
||||
return Some(code);
|
||||
}
|
||||
}
|
||||
// "status: NNN"
|
||||
if let Some(idx) = s.find("status: ") {
|
||||
let after = &s[idx + 8..];
|
||||
@@ -1147,6 +1252,27 @@ fn extract_status_code(s: &str) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip `<think>...</think>` blocks from model output.
|
||||
///
|
||||
/// Some models (MiniMax, DeepSeek, etc.) wrap their reasoning in `<think>` tags.
|
||||
/// These are internal chain-of-thought and shouldn't be shown to the user.
|
||||
pub fn strip_think_tags(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut remaining = text;
|
||||
while let Some(start) = remaining.find("<think>") {
|
||||
result.push_str(&remaining[..start]);
|
||||
if let Some(end) = remaining[start..].find("</think>") {
|
||||
remaining = &remaining[(start + end + 8)..]; // 8 = "</think>".len()
|
||||
} else {
|
||||
// Unclosed <think> tag — strip to end
|
||||
remaining = "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1217,10 +1343,33 @@ mod tests {
|
||||
);
|
||||
assert_eq!(extract_status_code("StatusCode(401)"), Some(401));
|
||||
assert_eq!(extract_status_code("some random error"), None);
|
||||
// LlmError::Api Display format (issue #493 fix)
|
||||
assert_eq!(
|
||||
extract_status_code("LLM driver error: API error (403): quota exceeded"),
|
||||
Some(403)
|
||||
);
|
||||
assert_eq!(
|
||||
extract_status_code("API error (401): invalid api key"),
|
||||
Some(401)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_trims_whitespace() {
|
||||
assert_eq!(sanitize_user_input(" hello "), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_think_tags() {
|
||||
assert_eq!(
|
||||
strip_think_tags("<think>reasoning here</think>The answer is 42."),
|
||||
"The answer is 42."
|
||||
);
|
||||
assert_eq!(
|
||||
strip_think_tags("Hello <think>\nsome thinking\n</think> world"),
|
||||
"Hello world"
|
||||
);
|
||||
assert_eq!(strip_think_tags("No thinking here"), "No thinking here");
|
||||
assert_eq!(strip_think_tags("<think>all thinking</think>"), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,32 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Card-based flex containers for agent chips and similar inline layouts */
|
||||
.card-flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Nested list indentation inside cards, detail panels, and modals */
|
||||
.card ul, .card ol,
|
||||
.detail-grid ul, .detail-grid ol,
|
||||
.modal ul, .modal ol,
|
||||
.info-card ul, .info-card ol {
|
||||
padding-left: 18px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.card ul ul, .card ol ol,
|
||||
.modal ul ul, .modal ol ol {
|
||||
padding-left: 16px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.card li, .modal li, .info-card li {
|
||||
margin-bottom: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Glow effect on card hover */
|
||||
.card-glow {
|
||||
overflow: hidden;
|
||||
@@ -90,13 +116,17 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge + .badge { margin-left: 4px; }
|
||||
|
||||
.badge-running { background: rgba(74,222,128,0.12); color: var(--success); }
|
||||
.badge-suspended { background: rgba(245,158,11,0.12); color: var(--warning); }
|
||||
@@ -110,7 +140,7 @@
|
||||
.badge-error { background: rgba(239,68,68,0.12); color: var(--error); }
|
||||
.badge-muted { background: rgba(148,163,184,0.12); color: var(--text-dim); }
|
||||
.badge-info { background: rgba(59,130,246,0.12); color: var(--info); }
|
||||
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; }
|
||||
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; padding: 2px 6px; }
|
||||
.text-danger { color: var(--error); }
|
||||
|
||||
/* Tables */
|
||||
@@ -617,6 +647,11 @@ mark.search-highlight {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.message-bubble.markdown-body ul,
|
||||
.message-bubble.markdown-body ol {
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -898,6 +933,129 @@ mark.search-highlight {
|
||||
.slash-menu-item:last-child { border-bottom: none; }
|
||||
.slash-menu-item:hover, .slash-menu-item.slash-active { background: var(--surface2); }
|
||||
|
||||
/* Model switcher dropdown */
|
||||
.model-switcher-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
max-width: 200px;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-switcher-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
.model-switcher-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.model-switcher-btn:disabled:hover { border-color: var(--border); color: var(--text-dim); }
|
||||
.model-switcher-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
.model-switcher-chevron {
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.model-switcher-chevron.open { transform: rotate(180deg); }
|
||||
.model-switcher-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
width: 340px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
.model-switcher-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-switcher-search select {
|
||||
max-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.model-switcher-search select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.model-switcher-search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.model-switcher-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.model-switcher-group-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 6px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-switcher-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.model-switcher-item:hover { background: var(--surface2); }
|
||||
.model-switcher-item.active {
|
||||
background: var(--accent-subtle, rgba(255,92,0,0.06));
|
||||
cursor: default;
|
||||
}
|
||||
.model-switcher-item-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-switcher-tier {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.model-switcher-tier.tier-frontier { background: rgba(168,85,247,0.15); color: #a855f7; }
|
||||
.model-switcher-tier.tier-smart { background: rgba(59,130,246,0.15); color: #3b82f6; }
|
||||
.model-switcher-tier.tier-balanced { background: rgba(34,197,94,0.15); color: #22c55e; }
|
||||
.model-switcher-tier.tier-fast { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
||||
.model-switcher-tier.tier-local { background: rgba(148,163,184,0.12); color: var(--text-dim); }
|
||||
|
||||
/* Sidebar footer */
|
||||
.sidebar-footer {
|
||||
padding: 8px 0;
|
||||
@@ -1121,8 +1279,11 @@ mark.search-highlight {
|
||||
/* Utility */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.grid { display: grid; }
|
||||
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
@@ -1130,6 +1291,7 @@ mark.search-highlight {
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mb-2 { margin-bottom: 8px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.text-sm { font-size: 11px; }
|
||||
.text-xs { font-size: 10px; }
|
||||
@@ -3072,4 +3234,219 @@ mark.search-highlight {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.flex-col { flex-direction: column; }
|
||||
/* Comms page */
|
||||
.comms-topo-tree { padding: 4px 0 4px 8px; }
|
||||
.comms-topo-child { padding: 0 0 0 20px; display: flex; align-items: center; gap: 4px; }
|
||||
.comms-topo-branch { color: var(--text-dim); font-family: var(--font-mono); white-space: pre; }
|
||||
.comms-topo-node { display: flex; align-items: center; gap: 4px; padding: 2px 0; }
|
||||
.comms-event-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; transition: background var(--transition-fast);
|
||||
}
|
||||
.comms-event-row:hover { background: var(--bg-hover); }
|
||||
.comms-event-time { min-width: 50px; text-align: right; }
|
||||
.comms-event-detail { margin-left: auto; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Trader Dashboard
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.trader-dashboard {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 96vw;
|
||||
max-width: 1200px;
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.trader-dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-card);
|
||||
z-index: 10;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
.trader-dashboard-body {
|
||||
padding: 16px 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* KPI Cards */
|
||||
.trader-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.trader-kpi-row { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.trader-kpi-row { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
.trader-kpi-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.trader-kpi-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.trader-kpi-value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.kpi-positive { color: var(--success) !important; }
|
||||
.kpi-negative { color: var(--error) !important; }
|
||||
|
||||
/* Chart Rows */
|
||||
.trader-chart-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.trader-chart-row { flex-direction: column; }
|
||||
}
|
||||
.trader-chart-panel {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.trader-chart-title {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.trader-chart-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
}
|
||||
.trader-chart-wrap canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
.trader-chart-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Heatmap Table */
|
||||
.trader-heatmap-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.trader-heatmap-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.trader-heatmap-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.trader-heatmap-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.heatmap-positive { color: var(--success); font-weight: 600; }
|
||||
.heatmap-negative { color: var(--error); font-weight: 600; }
|
||||
|
||||
/* Signal Badges */
|
||||
.signal-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.signal-strong_buy, .signal-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.signal-sell, .signal-strong_sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
.signal-hold { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
|
||||
|
||||
/* Confidence Bar */
|
||||
.confidence-bar-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 100px;
|
||||
}
|
||||
.confidence-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.conf-high { background: var(--success); }
|
||||
.conf-mid { background: var(--warning); }
|
||||
.conf-low { background: var(--error); }
|
||||
.confidence-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
min-width: 32px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Trades Table */
|
||||
.trader-trades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.trader-trades-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.trader-trades-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.trade-side-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.trade-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.trade-sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
[data-theme="light"] .sidebar-logo img,
|
||||
[data-theme="light"] .message-avatar img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
@@ -238,6 +243,14 @@
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
position: fixed !important;
|
||||
top: 12px;
|
||||
left: 16px;
|
||||
z-index: 98;
|
||||
padding: 6px 10px !important;
|
||||
}
|
||||
|
||||
/* Wide desktop — larger card grids */
|
||||
@media (min-width: 1400px) {
|
||||
.card-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||
@@ -272,6 +285,8 @@
|
||||
left: -300px;
|
||||
}
|
||||
.mobile-menu-btn { display: flex !important; }
|
||||
/* Offset header content so it does not overlap the fixed mobile menu button. */
|
||||
.page-header > :first-child { margin-left: 52px; }
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
@@ -280,7 +295,14 @@
|
||||
|
||||
/* Mobile small screen */
|
||||
@media (max-width: 480px) {
|
||||
.page-header { flex-direction: column; gap: 8px; align-items: flex-start; padding: 12px 16px; }
|
||||
.page-header {
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-header h2 {
|
||||
line-height: 44px;
|
||||
}
|
||||
.page-body { padding: 12px; }
|
||||
.stats-row { flex-wrap: wrap; }
|
||||
.stat-card { min-width: 80px; flex: 1 1 40%; }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@
|
||||
<title>OpenFang Dashboard</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/png" href="/logo.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -161,6 +161,17 @@ var OpenFangAPI = (function() {
|
||||
return fetch(BASE + path, opts).then(function(r) {
|
||||
if (_connectionState !== 'connected') setConnectionState('connected');
|
||||
if (!r.ok) {
|
||||
// On 401, auto-show auth prompt so the user can re-enter their key
|
||||
if (r.status === 401 && typeof Alpine !== 'undefined') {
|
||||
try {
|
||||
var store = Alpine.store('app');
|
||||
if (store && !store.showAuthPrompt) {
|
||||
_authToken = '';
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
store.showAuthPrompt = true;
|
||||
}
|
||||
} catch(e2) { /* ignore Alpine errors */ }
|
||||
}
|
||||
return r.text().then(function(text) {
|
||||
var msg = '';
|
||||
try {
|
||||
@@ -213,9 +224,12 @@ var OpenFangAPI = (function() {
|
||||
try {
|
||||
var url = WS_BASE + '/api/agents/' + agentId + '/ws';
|
||||
if (_authToken) url += '?token=' + encodeURIComponent(_authToken);
|
||||
_ws = new WebSocket(url);
|
||||
var socket = new WebSocket(url);
|
||||
_ws = socket;
|
||||
|
||||
_ws.onopen = function() {
|
||||
socket.onopen = function() {
|
||||
// Guard: ignore if this socket was superseded by a newer connection
|
||||
if (_ws !== socket) return;
|
||||
_wsConnected = true;
|
||||
_reconnectAttempts = 0;
|
||||
setConnectionState('connected');
|
||||
@@ -226,14 +240,20 @@ var OpenFangAPI = (function() {
|
||||
if (_wsCallbacks.onOpen) _wsCallbacks.onOpen();
|
||||
};
|
||||
|
||||
_ws.onmessage = function(e) {
|
||||
socket.onmessage = function(e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
|
||||
} catch(err) { /* ignore parse errors */ }
|
||||
} catch(parseErr) {
|
||||
return; // Ignore malformed JSON frames
|
||||
}
|
||||
// Dispatch outside try/catch so handler errors are not swallowed
|
||||
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
|
||||
};
|
||||
|
||||
_ws.onclose = function(e) {
|
||||
socket.onclose = function(e) {
|
||||
// Guard: only update state if this is still the active socket.
|
||||
// A superseded socket closing must not null-out the new connection.
|
||||
if (_ws !== socket) return;
|
||||
_wsConnected = false;
|
||||
_ws = null;
|
||||
if (_wsAgentId && _reconnectAttempts < MAX_RECONNECT && e.code !== 1000) {
|
||||
@@ -254,7 +274,9 @@ var OpenFangAPI = (function() {
|
||||
if (_wsCallbacks.onClose) _wsCallbacks.onClose();
|
||||
};
|
||||
|
||||
_ws.onerror = function() {
|
||||
socket.onerror = function() {
|
||||
// Guard: ignore errors from superseded sockets
|
||||
if (_ws !== socket) return;
|
||||
_wsConnected = false;
|
||||
if (_wsCallbacks.onError) _wsCallbacks.onError();
|
||||
};
|
||||
@@ -286,15 +308,15 @@ var OpenFangAPI = (function() {
|
||||
function getToken() { return _authToken; }
|
||||
|
||||
function upload(agentId, file) {
|
||||
var hdrs = {
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
'X-Filename': file.name
|
||||
};
|
||||
var hdrs = {};
|
||||
if (_authToken) hdrs['Authorization'] = 'Bearer ' + _authToken;
|
||||
var form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('filename', file.name);
|
||||
return fetch(BASE + '/api/agents/' + agentId + '/upload', {
|
||||
method: 'POST',
|
||||
headers: hdrs,
|
||||
body: file
|
||||
body: form
|
||||
}).then(function(r) {
|
||||
if (!r.ok) throw new Error('Upload failed');
|
||||
return r.json();
|
||||
|
||||
@@ -24,9 +24,43 @@ function escapeHtml(text) {
|
||||
function renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
if (typeof marked !== 'undefined') {
|
||||
var html = marked.parse(text);
|
||||
// Protect LaTeX blocks from marked.js mangling (underscores, backslashes, etc.)
|
||||
var latexBlocks = [];
|
||||
var protected_ = text;
|
||||
// Protect display math $$...$$ first (greedy across lines)
|
||||
protected_ = protected_.replace(/\$\$([\s\S]+?)\$\$/g, function(match) {
|
||||
var idx = latexBlocks.length;
|
||||
latexBlocks.push(match);
|
||||
return '\x00LATEX' + idx + '\x00';
|
||||
});
|
||||
// Protect inline math $...$ (single line, not empty, not starting/ending with space)
|
||||
protected_ = protected_.replace(/\$([^\s$](?:[^$]*[^\s$])?)\$/g, function(match) {
|
||||
var idx = latexBlocks.length;
|
||||
latexBlocks.push(match);
|
||||
return '\x00LATEX' + idx + '\x00';
|
||||
});
|
||||
// Protect \[...\] display math
|
||||
protected_ = protected_.replace(/\\\[([\s\S]+?)\\\]/g, function(match) {
|
||||
var idx = latexBlocks.length;
|
||||
latexBlocks.push(match);
|
||||
return '\x00LATEX' + idx + '\x00';
|
||||
});
|
||||
// Protect \(...\) inline math
|
||||
protected_ = protected_.replace(/\\\(([\s\S]+?)\\\)/g, function(match) {
|
||||
var idx = latexBlocks.length;
|
||||
latexBlocks.push(match);
|
||||
return '\x00LATEX' + idx + '\x00';
|
||||
});
|
||||
|
||||
var html = marked.parse(protected_);
|
||||
// Restore LaTeX blocks
|
||||
for (var i = 0; i < latexBlocks.length; i++) {
|
||||
html = html.replace('\x00LATEX' + i + '\x00', latexBlocks[i]);
|
||||
}
|
||||
// Add copy buttons to code blocks
|
||||
html = html.replace(/<pre><code/g, '<pre><button class="copy-btn" onclick="copyCode(this)">Copy</button><code');
|
||||
// Open external links in new tab
|
||||
html = html.replace(/<a\s+href="(https?:\/\/[^"]*)"(?![^>]*target=)([^>]*)>/gi, '<a href="$1" target="_blank" rel="noopener"$2>');
|
||||
return html;
|
||||
}
|
||||
return escapeHtml(text);
|
||||
@@ -100,10 +134,14 @@ document.addEventListener('alpine:init', function() {
|
||||
lastError: '',
|
||||
version: '0.1.0',
|
||||
agentCount: 0,
|
||||
pendingApprovalCount: 0,
|
||||
lastPendingApprovalSignature: '',
|
||||
pendingAgent: null,
|
||||
focusMode: localStorage.getItem('openfang-focus') === 'true',
|
||||
showOnboarding: false,
|
||||
showAuthPrompt: false,
|
||||
authMode: 'apikey',
|
||||
sessionUser: null,
|
||||
|
||||
toggleFocusMode() {
|
||||
this.focusMode = !this.focusMode;
|
||||
@@ -118,6 +156,23 @@ document.addEventListener('alpine:init', function() {
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
async refreshApprovals() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/approvals');
|
||||
var approvals = Array.isArray(data) ? data : (data.approvals || []);
|
||||
var pending = approvals.filter(function(a) { return a.status === 'pending'; });
|
||||
var signature = pending
|
||||
.map(function(a) { return a.id; })
|
||||
.sort()
|
||||
.join(',');
|
||||
if (pending.length > 0 && signature !== this.lastPendingApprovalSignature && typeof OpenFangToast !== 'undefined') {
|
||||
OpenFangToast.warn('An agent is waiting for approval. Open Approvals to review.');
|
||||
}
|
||||
this.pendingApprovalCount = pending.length;
|
||||
this.lastPendingApprovalSignature = signature;
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
async checkStatus() {
|
||||
try {
|
||||
var s = await OpenFangAPI.get('/api/status');
|
||||
@@ -155,10 +210,36 @@ document.addEventListener('alpine:init', function() {
|
||||
|
||||
async checkAuth() {
|
||||
try {
|
||||
await OpenFangAPI.get('/api/providers');
|
||||
// First check if session-based auth is configured
|
||||
var authInfo = await OpenFangAPI.get('/api/auth/check');
|
||||
if (authInfo.mode === 'none') {
|
||||
// No session auth — fall back to API key detection
|
||||
this.authMode = 'apikey';
|
||||
this.sessionUser = null;
|
||||
} else if (authInfo.mode === 'session') {
|
||||
this.authMode = 'session';
|
||||
if (authInfo.authenticated) {
|
||||
this.sessionUser = authInfo.username;
|
||||
this.showAuthPrompt = false;
|
||||
return;
|
||||
}
|
||||
// Session auth enabled but not authenticated — show login prompt
|
||||
this.showAuthPrompt = true;
|
||||
return;
|
||||
}
|
||||
} catch(e) { /* ignore — fall through to API key check */ }
|
||||
|
||||
// API key mode detection
|
||||
try {
|
||||
await OpenFangAPI.get('/api/tools');
|
||||
this.showAuthPrompt = false;
|
||||
} catch(e) {
|
||||
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0)) {
|
||||
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
|
||||
var saved = localStorage.getItem('openfang-api-key');
|
||||
if (saved) {
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
}
|
||||
this.showAuthPrompt = true;
|
||||
}
|
||||
}
|
||||
@@ -172,6 +253,29 @@ document.addEventListener('alpine:init', function() {
|
||||
this.refreshAgents();
|
||||
},
|
||||
|
||||
async sessionLogin(username, password) {
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/auth/login', { username: username, password: password });
|
||||
if (result.status === 'ok') {
|
||||
this.sessionUser = result.username;
|
||||
this.showAuthPrompt = false;
|
||||
this.refreshAgents();
|
||||
} else {
|
||||
OpenFangToast.error(result.error || 'Login failed');
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Login failed');
|
||||
}
|
||||
},
|
||||
|
||||
async sessionLogout() {
|
||||
try {
|
||||
await OpenFangAPI.post('/api/auth/logout');
|
||||
} catch(e) { /* ignore */ }
|
||||
this.sessionUser = null;
|
||||
this.showAuthPrompt = true;
|
||||
},
|
||||
|
||||
clearApiKey() {
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
@@ -209,7 +313,7 @@ function app() {
|
||||
});
|
||||
|
||||
// Hash routing
|
||||
var validPages = ['overview','agents','sessions','approvals','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
|
||||
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','runtime','settings','wizard'];
|
||||
var pageRedirects = {
|
||||
'chat': 'agents',
|
||||
'templates': 'agents',
|
||||
@@ -265,9 +369,13 @@ function app() {
|
||||
|
||||
// Initial data load
|
||||
this.pollStatus();
|
||||
Alpine.store('app').refreshApprovals();
|
||||
Alpine.store('app').checkOnboarding();
|
||||
Alpine.store('app').checkAuth();
|
||||
setInterval(function() { self.pollStatus(); }, 5000);
|
||||
setInterval(function() {
|
||||
self.pollStatus();
|
||||
Alpine.store('app').refreshApprovals();
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
navigate(p) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// On-demand KaTeX loader and renderer for chat messages.
|
||||
|
||||
var KATEX_VERSION = '0.16.21';
|
||||
var KATEX_CSS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.css';
|
||||
var KATEX_JS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.js';
|
||||
var KATEX_AUTORENDER_URL =
|
||||
'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/contrib/auto-render.min.js';
|
||||
var katexLoadPromise = null;
|
||||
|
||||
function hasLatexDelimiters(text) {
|
||||
if (!text) return false;
|
||||
return /\$\$|\\\[|\\\(|\$(?=\S)[^$\n]+\$/.test(text);
|
||||
}
|
||||
|
||||
function loadScript(url) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.onload = function () {
|
||||
resolve();
|
||||
};
|
||||
script.onerror = function () {
|
||||
reject(new Error('Failed to load script: ' + url));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureKatexLoaded() {
|
||||
if (typeof renderMathInElement === 'function') return Promise.resolve(true);
|
||||
if (katexLoadPromise) return katexLoadPromise;
|
||||
|
||||
katexLoadPromise = new Promise(function (resolve) {
|
||||
var cssId = 'openfang-katex-css';
|
||||
if (!document.getElementById(cssId)) {
|
||||
var link = document.createElement('link');
|
||||
link.id = cssId;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = KATEX_CSS_URL;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
loadScript(KATEX_JS_URL)
|
||||
.then(function () {
|
||||
return loadScript(KATEX_AUTORENDER_URL);
|
||||
})
|
||||
.then(function () {
|
||||
resolve(typeof renderMathInElement === 'function');
|
||||
})
|
||||
.catch(function () {
|
||||
katexLoadPromise = null;
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
return katexLoadPromise;
|
||||
}
|
||||
|
||||
// Render LaTeX math in the chat message container using KaTeX auto-render.
|
||||
// Call this after new messages are inserted into the DOM.
|
||||
function renderLatex(el) {
|
||||
var target = el || document.getElementById('messages');
|
||||
if (!target) return;
|
||||
if (!hasLatexDelimiters(target.textContent || '')) return;
|
||||
|
||||
ensureKatexLoaded().then(function (ok) {
|
||||
if (!ok || typeof renderMathInElement !== 'function') return;
|
||||
try {
|
||||
renderMathInElement(target, {
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
],
|
||||
throwOnError: false,
|
||||
trust: false,
|
||||
});
|
||||
} catch (e) {
|
||||
/* KaTeX render error — ignore gracefully */
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
// OpenFang Agents Page — Multi-step spawn wizard, detail view with tabs, file editor, personality presets
|
||||
'use strict';
|
||||
|
||||
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n""").
|
||||
* Backslashes are escaped, and runs of 3+ consecutive double-quotes are
|
||||
* broken up so the TOML parser never sees an unintended closing delimiter.
|
||||
*/
|
||||
function tomlMultilineEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
|
||||
}
|
||||
|
||||
/** Escape a string for use inside a TOML basic (single-line) string ("...").
|
||||
* Backslashes, double-quotes, and common control chars are escaped.
|
||||
*/
|
||||
function tomlBasicEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
function agentsPage() {
|
||||
return {
|
||||
tab: 'agents',
|
||||
@@ -25,6 +40,8 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// -- Multi-step wizard state --
|
||||
spawnProviders: [], // populated from /api/providers on wizard open
|
||||
spawnProvidersLoading: false,
|
||||
spawnStep: 1,
|
||||
spawnIdentity: { emoji: '', color: '#FF5C00', archetype: '' },
|
||||
selectedPreset: '',
|
||||
@@ -54,6 +71,20 @@ function agentsPage() {
|
||||
filesLoading: false,
|
||||
configForm: {},
|
||||
configSaving: false,
|
||||
// -- Tool filters --
|
||||
toolFilters: { tool_allowlist: [], tool_blocklist: [] },
|
||||
toolFiltersLoading: false,
|
||||
newAllowTool: '',
|
||||
newBlockTool: '',
|
||||
// -- Model switch --
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
editingProvider: false,
|
||||
newProviderValue: '',
|
||||
modelSaving: false,
|
||||
// -- Fallback chain --
|
||||
editingFallback: false,
|
||||
newFallbackValue: '',
|
||||
|
||||
// -- Templates state --
|
||||
tplTemplates: [],
|
||||
@@ -63,98 +94,12 @@ function agentsPage() {
|
||||
selectedCategory: 'All',
|
||||
searchQuery: '',
|
||||
|
||||
builtinTemplates: [
|
||||
{
|
||||
name: 'General Assistant',
|
||||
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
|
||||
category: 'General',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.'
|
||||
},
|
||||
{
|
||||
name: 'Code Helper',
|
||||
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.'
|
||||
},
|
||||
{
|
||||
name: 'Researcher',
|
||||
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
|
||||
category: 'Research',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'research',
|
||||
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.'
|
||||
},
|
||||
{
|
||||
name: 'Writer',
|
||||
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
|
||||
category: 'Writing',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.'
|
||||
},
|
||||
{
|
||||
name: 'Data Analyst',
|
||||
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.'
|
||||
},
|
||||
{
|
||||
name: 'DevOps Engineer',
|
||||
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'automation',
|
||||
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.'
|
||||
},
|
||||
{
|
||||
name: 'Customer Support',
|
||||
description: 'A professional, empathetic agent for handling customer inquiries and resolving issues.',
|
||||
category: 'Business',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'messaging',
|
||||
system_prompt: 'You are a professional customer support representative. Be empathetic, patient, and solution-oriented. Acknowledge concerns before offering solutions. Escalate complex issues appropriately.'
|
||||
},
|
||||
{
|
||||
name: 'Tutor',
|
||||
description: 'A patient educational agent that explains concepts step-by-step and adapts to the learner\'s level.',
|
||||
category: 'General',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a patient and encouraging tutor. Explain concepts step by step, starting from fundamentals. Use analogies and examples. Check understanding before moving on. Adapt to the learner\'s pace.'
|
||||
},
|
||||
{
|
||||
name: 'API Designer',
|
||||
description: 'An agent specialized in RESTful API design, OpenAPI specs, and integration architecture.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an API design expert. Help users design clean, consistent RESTful APIs following best practices. Cover endpoint naming, request/response schemas, error handling, and versioning.'
|
||||
},
|
||||
{
|
||||
name: 'Meeting Notes',
|
||||
description: 'Summarizes meeting transcripts into structured notes with action items and key decisions.',
|
||||
category: 'Business',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'minimal',
|
||||
system_prompt: 'You are a meeting summarizer. When given a meeting transcript or notes, produce a structured summary with: key decisions, action items (with owners), discussion highlights, and follow-up questions.'
|
||||
}
|
||||
],
|
||||
builtinTemplates: [],
|
||||
|
||||
// Load templates from API
|
||||
async init() {
|
||||
await this.loadTemplates();
|
||||
},
|
||||
|
||||
// ── Profile Descriptions ──
|
||||
profileDescriptions: {
|
||||
@@ -251,6 +196,7 @@ function agentsPage() {
|
||||
this.loadError = '';
|
||||
try {
|
||||
await Alpine.store('app').refreshAgents();
|
||||
await this.loadTemplates();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load agents. Is the daemon running?';
|
||||
}
|
||||
@@ -288,10 +234,73 @@ function agentsPage() {
|
||||
OpenFangAPI.get('/api/templates'),
|
||||
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
|
||||
]);
|
||||
this.tplTemplates = results[0].templates || [];
|
||||
// Combine static and dynamic templates
|
||||
this.builtinTemplates = [
|
||||
{
|
||||
name: 'General Assistant',
|
||||
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
|
||||
category: 'General',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.',
|
||||
manifest_toml: 'name = "General Assistant"\ndescription = "A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Code Helper',
|
||||
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.',
|
||||
manifest_toml: 'name = "Code Helper"\ndescription = "A programming-focused agent that writes, reviews, and debugs code across multiple languages."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Researcher',
|
||||
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
|
||||
category: 'Research',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'research',
|
||||
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.',
|
||||
manifest_toml: 'name = "Researcher"\ndescription = "An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries."\nmodule = "builtin:chat"\nprofile = "research"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Writer',
|
||||
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
|
||||
category: 'Writing',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.',
|
||||
manifest_toml: 'name = "Writer"\ndescription = "A creative writing agent that helps with drafting, editing, and improving written content of all kinds."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Data Analyst',
|
||||
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.',
|
||||
manifest_toml: 'name = "Data Analyst"\ndescription = "A data-focused agent that helps analyze datasets, create queries, and interpret statistical results."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'DevOps Engineer',
|
||||
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'automation',
|
||||
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.',
|
||||
manifest_toml: 'name = "DevOps Engineer"\ndescription = "A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting."\nmodule = "builtin:chat"\nprofile = "automation"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.\n"""'
|
||||
},
|
||||
...results[0].templates || []
|
||||
];
|
||||
this.tplProviders = results[1].providers || [];
|
||||
} catch(e) {
|
||||
this.tplTemplates = [];
|
||||
this.builtinTemplates = [];
|
||||
this.tplLoadError = e.message || 'Could not load templates.';
|
||||
}
|
||||
this.tplLoading = false;
|
||||
@@ -307,12 +316,15 @@ function agentsPage() {
|
||||
OpenFangAPI.wsDisconnect();
|
||||
},
|
||||
|
||||
showDetail(agent) {
|
||||
async showDetail(agent) {
|
||||
this.detailAgent = agent;
|
||||
this.detailAgent._fallbacks = [];
|
||||
this.detailTab = 'info';
|
||||
this.agentFiles = [];
|
||||
this.editingFile = null;
|
||||
this.fileContent = '';
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
this.configForm = {
|
||||
name: agent.name || '',
|
||||
system_prompt: agent.system_prompt || '',
|
||||
@@ -322,6 +334,11 @@ function agentsPage() {
|
||||
vibe: (agent.identity && agent.identity.vibe) || ''
|
||||
};
|
||||
this.showDetailModal = true;
|
||||
// Fetch full agent detail to get fallback_models
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
|
||||
this.detailAgent._fallbacks = full.fallback_models || [];
|
||||
} catch(e) { /* ignore */ }
|
||||
},
|
||||
|
||||
killAgent(agent) {
|
||||
@@ -358,7 +375,7 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// ── Multi-step wizard navigation ──
|
||||
openSpawnWizard() {
|
||||
async openSpawnWizard() {
|
||||
this.showSpawnModal = true;
|
||||
this.spawnStep = 1;
|
||||
this.spawnMode = 'wizard';
|
||||
@@ -366,8 +383,26 @@ function agentsPage() {
|
||||
this.selectedPreset = '';
|
||||
this.soulContent = '';
|
||||
this.spawnForm.name = '';
|
||||
this.spawnForm.provider = 'groq';
|
||||
this.spawnForm.model = 'llama-3.3-70b-versatile';
|
||||
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
|
||||
this.spawnForm.profile = 'full';
|
||||
// Fetch status defaults and dynamic provider list concurrently
|
||||
this.spawnProvidersLoading = true;
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/status').catch(function() { return {}; }),
|
||||
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
|
||||
]);
|
||||
var status = results[0];
|
||||
var provData = results[1];
|
||||
if (status.default_provider) this.spawnForm.provider = status.default_provider;
|
||||
if (status.default_model) this.spawnForm.model = status.default_model;
|
||||
this.spawnProviders = provData.providers || [];
|
||||
} catch(e) {
|
||||
this.spawnProviders = [];
|
||||
}
|
||||
this.spawnProvidersLoading = false;
|
||||
},
|
||||
|
||||
nextStep() {
|
||||
@@ -391,7 +426,7 @@ function agentsPage() {
|
||||
var f = this.spawnForm;
|
||||
var si = this.spawnIdentity;
|
||||
var lines = [
|
||||
'name = "' + f.name + '"',
|
||||
'name = "' + tomlBasicEscape(f.name) + '"',
|
||||
'module = "builtin:chat"'
|
||||
];
|
||||
if (f.profile && f.profile !== 'custom') {
|
||||
@@ -400,7 +435,7 @@ function agentsPage() {
|
||||
lines.push('', '[model]');
|
||||
lines.push('provider = "' + f.provider + '"');
|
||||
lines.push('model = "' + f.model + '"');
|
||||
lines.push('system_prompt = "' + f.systemPrompt.replace(/"/g, '\\"') + '"');
|
||||
lines.push('system_prompt = """\n' + tomlMultilineEscape(f.systemPrompt) + '\n"""');
|
||||
if (f.profile === 'custom') {
|
||||
lines.push('', '[capabilities]');
|
||||
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
|
||||
@@ -543,15 +578,20 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// -- Template methods --
|
||||
async spawnFromTemplate(name) {
|
||||
async spawnFromTemplate(template) {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(name));
|
||||
if (data.manifest_toml) {
|
||||
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: data.manifest_toml });
|
||||
var manifestToml = template.manifest_toml;
|
||||
if (!manifestToml) {
|
||||
// If template doesn't have manifest_toml, fetch it from the API
|
||||
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(template.name));
|
||||
manifestToml = data.manifest_toml;
|
||||
}
|
||||
if (manifestToml) {
|
||||
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: manifestToml });
|
||||
if (res.agent_id) {
|
||||
OpenFangToast.success('Agent "' + (res.name || name) + '" spawned from template');
|
||||
OpenFangToast.success('Agent "' + (res.name || template.name) + '" spawned from template');
|
||||
await Alpine.store('app').refreshAgents();
|
||||
this.chatWithAgent({ id: res.agent_id, name: res.name || name, model_provider: '?', model_name: '?' });
|
||||
this.chatWithAgent({ id: res.agent_id, name: res.name || template.name, model_provider: '?', model_name: '?' });
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
@@ -559,13 +599,151 @@ function agentsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Clear agent history ──
|
||||
async clearHistory(agent) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Clear History', 'Clear all conversation history for "' + agent.name + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/agents/' + agent.id + '/history');
|
||||
OpenFangToast.success('History cleared for "' + agent.name + '"');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to clear history: ' + e.message);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ── Model switch ──
|
||||
async changeModel() {
|
||||
if (!this.detailAgent || !this.newModelValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
var providerInfo = (resp && resp.provider) ? ' (provider: ' + resp.provider + ')' : '';
|
||||
OpenFangToast.success('Model changed' + providerInfo + ' (memory reset)');
|
||||
this.editingModel = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
// Refresh detailAgent
|
||||
var agents = Alpine.store('app').agents;
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to change model: ' + e.message);
|
||||
}
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Provider switch ──
|
||||
async changeProvider() {
|
||||
if (!this.detailAgent || !this.newProviderValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
var combined = this.newProviderValue.trim() + '/' + this.detailAgent.model_name;
|
||||
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: combined });
|
||||
OpenFangToast.success('Provider changed to ' + (resp && resp.provider ? resp.provider : this.newProviderValue.trim()));
|
||||
this.editingProvider = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
var agents = Alpine.store('app').agents;
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to change provider: ' + e.message);
|
||||
}
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Fallback model chain ──
|
||||
async addFallback() {
|
||||
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
|
||||
var parts = this.newFallbackValue.trim().split('/');
|
||||
var provider = parts.length > 1 ? parts[0] : this.detailAgent.model_provider;
|
||||
var model = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
|
||||
if (!this.detailAgent._fallbacks) this.detailAgent._fallbacks = [];
|
||||
this.detailAgent._fallbacks.push({ provider: provider, model: model });
|
||||
try {
|
||||
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
|
||||
fallback_models: this.detailAgent._fallbacks
|
||||
});
|
||||
OpenFangToast.success('Fallback added: ' + provider + '/' + model);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
|
||||
this.detailAgent._fallbacks.pop();
|
||||
}
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
},
|
||||
|
||||
async removeFallback(idx) {
|
||||
if (!this.detailAgent || !this.detailAgent._fallbacks) return;
|
||||
var removed = this.detailAgent._fallbacks.splice(idx, 1);
|
||||
try {
|
||||
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
|
||||
fallback_models: this.detailAgent._fallbacks
|
||||
});
|
||||
OpenFangToast.success('Fallback removed');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
|
||||
this.detailAgent._fallbacks.splice(idx, 0, removed[0]);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tool filters ──
|
||||
async loadToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
this.toolFiltersLoading = true;
|
||||
try {
|
||||
this.toolFilters = await OpenFangAPI.get('/api/agents/' + this.detailAgent.id + '/tools');
|
||||
} catch(e) {
|
||||
this.toolFilters = { tool_allowlist: [], tool_blocklist: [] };
|
||||
}
|
||||
this.toolFiltersLoading = false;
|
||||
},
|
||||
|
||||
addAllowTool() {
|
||||
var t = this.newAllowTool.trim();
|
||||
if (t && this.toolFilters.tool_allowlist.indexOf(t) === -1) {
|
||||
this.toolFilters.tool_allowlist.push(t);
|
||||
this.newAllowTool = '';
|
||||
this.saveToolFilters();
|
||||
}
|
||||
},
|
||||
|
||||
removeAllowTool(tool) {
|
||||
this.toolFilters.tool_allowlist = this.toolFilters.tool_allowlist.filter(function(t) { return t !== tool; });
|
||||
this.saveToolFilters();
|
||||
},
|
||||
|
||||
addBlockTool() {
|
||||
var t = this.newBlockTool.trim();
|
||||
if (t && this.toolFilters.tool_blocklist.indexOf(t) === -1) {
|
||||
this.toolFilters.tool_blocklist.push(t);
|
||||
this.newBlockTool = '';
|
||||
this.saveToolFilters();
|
||||
}
|
||||
},
|
||||
|
||||
removeBlockTool(tool) {
|
||||
this.toolFilters.tool_blocklist = this.toolFilters.tool_blocklist.filter(function(t) { return t !== tool; });
|
||||
this.saveToolFilters();
|
||||
},
|
||||
|
||||
async saveToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/tools', this.toolFilters);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update tool filters: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async spawnBuiltin(t) {
|
||||
var toml = 'name = "' + t.name + '"\n';
|
||||
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
|
||||
var toml = 'name = "' + tomlBasicEscape(t.name) + '"\n';
|
||||
toml += 'description = "' + tomlBasicEscape(t.description) + '"\n';
|
||||
toml += 'module = "builtin:chat"\n';
|
||||
toml += 'profile = "' + t.profile + '"\n\n';
|
||||
toml += '[model]\nprovider = "' + t.provider + '"\nmodel = "' + t.model + '"\n';
|
||||
toml += 'system_prompt = """\n' + t.system_prompt + '\n"""\n';
|
||||
toml += 'system_prompt = """\n' + tomlMultilineEscape(t.system_prompt) + '\n"""\n';
|
||||
|
||||
try {
|
||||
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
|
||||
|
||||
@@ -7,6 +7,22 @@ function approvalsPage() {
|
||||
filterStatus: 'all',
|
||||
loading: true,
|
||||
loadError: '',
|
||||
refreshTimer: null,
|
||||
|
||||
init() {
|
||||
var self = this;
|
||||
this.loadData();
|
||||
this.refreshTimer = setInterval(function() {
|
||||
self.loadData();
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
get filtered() {
|
||||
var f = this.filterStatus;
|
||||
|
||||
@@ -141,7 +141,16 @@ function channelsPage() {
|
||||
|
||||
openSetup(ch) {
|
||||
this.setupModal = ch;
|
||||
this.formValues = {};
|
||||
// Pre-populate form values from saved config (non-secret fields).
|
||||
var vals = {};
|
||||
if (ch.fields) {
|
||||
ch.fields.forEach(function(f) {
|
||||
if (f.value !== undefined && f.value !== null && f.type !== 'secret') {
|
||||
vals[f.key] = String(f.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.formValues = vals;
|
||||
this.showAdvanced = false;
|
||||
this.showBusinessApi = false;
|
||||
this.setupStep = ch.configured ? 3 : 1;
|
||||
|
||||
@@ -29,6 +29,19 @@ function chatPage() {
|
||||
_audioChunks: [],
|
||||
recordingTime: 0,
|
||||
_recordingTimer: null,
|
||||
// Model autocomplete state
|
||||
showModelPicker: false,
|
||||
modelPickerList: [],
|
||||
modelPickerFilter: '',
|
||||
modelPickerIdx: 0,
|
||||
// Model switcher dropdown
|
||||
showModelSwitcher: false,
|
||||
modelSwitcherFilter: '',
|
||||
modelSwitcherProviderFilter: '',
|
||||
modelSwitcherIdx: 0,
|
||||
modelSwitching: false,
|
||||
_modelCache: null,
|
||||
_modelCacheTime: 0,
|
||||
slashCommands: [
|
||||
{ cmd: '/help', desc: 'Show available commands' },
|
||||
{ cmd: '/agents', desc: 'Switch to Agents page' },
|
||||
@@ -80,6 +93,47 @@ function chatPage() {
|
||||
}
|
||||
},
|
||||
|
||||
get modelDisplayName() {
|
||||
if (!this.currentAgent) return '';
|
||||
var name = this.currentAgent.model_name || '';
|
||||
var short = name.replace(/-\d{8}$/, '');
|
||||
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
|
||||
},
|
||||
|
||||
get switcherProviders() {
|
||||
var seen = {};
|
||||
(this._modelCache || []).forEach(function(m) { seen[m.provider] = true; });
|
||||
return Object.keys(seen).sort();
|
||||
},
|
||||
|
||||
get filteredSwitcherModels() {
|
||||
var models = this._modelCache || [];
|
||||
var provFilter = this.modelSwitcherProviderFilter;
|
||||
var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : '';
|
||||
if (!provFilter && !textFilter) return models;
|
||||
return models.filter(function(m) {
|
||||
if (provFilter && m.provider !== provFilter) return false;
|
||||
if (textFilter) {
|
||||
return m.id.toLowerCase().indexOf(textFilter) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(textFilter) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(textFilter) !== -1;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
get groupedSwitcherModels() {
|
||||
var filtered = this.filteredSwitcherModels;
|
||||
var groups = {}, order = [];
|
||||
filtered.forEach(function(m) {
|
||||
if (!groups[m.provider]) { groups[m.provider] = []; order.push(m.provider); }
|
||||
groups[m.provider].push(m);
|
||||
});
|
||||
return order.map(function(p) {
|
||||
return { provider: p.charAt(0).toUpperCase() + p.slice(1), models: groups[p] };
|
||||
});
|
||||
},
|
||||
|
||||
init() {
|
||||
var self = this;
|
||||
|
||||
@@ -96,6 +150,11 @@ function chatPage() {
|
||||
var input = document.getElementById('msg-input');
|
||||
if (input) { input.focus(); self.inputText = '/'; }
|
||||
}
|
||||
// Ctrl+M for model switcher
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'm' && self.currentAgent) {
|
||||
e.preventDefault();
|
||||
self.toggleModelSwitcher();
|
||||
}
|
||||
// Ctrl+F for chat search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && self.currentAgent) {
|
||||
e.preventDefault();
|
||||
@@ -126,18 +185,98 @@ function chatPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for slash commands
|
||||
// Watch for slash commands + model autocomplete
|
||||
this.$watch('inputText', function(val) {
|
||||
if (val.startsWith('/')) {
|
||||
var modelMatch = val.match(/^\/model\s+(.*)$/i);
|
||||
if (modelMatch) {
|
||||
self.showSlashMenu = false;
|
||||
self.modelPickerFilter = modelMatch[1].toLowerCase();
|
||||
if (!self.modelPickerList.length) {
|
||||
OpenFangAPI.get('/api/models').then(function(data) {
|
||||
self.modelPickerList = (data.models || []).filter(function(m) { return m.available; });
|
||||
self.showModelPicker = true;
|
||||
self.modelPickerIdx = 0;
|
||||
}).catch(function() {});
|
||||
} else {
|
||||
self.showModelPicker = true;
|
||||
}
|
||||
} else if (val.startsWith('/')) {
|
||||
self.showModelPicker = false;
|
||||
self.slashFilter = val.slice(1).toLowerCase();
|
||||
self.showSlashMenu = true;
|
||||
self.slashIdx = 0;
|
||||
} else {
|
||||
self.showSlashMenu = false;
|
||||
self.showModelPicker = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
get filteredModelPicker() {
|
||||
if (!this.modelPickerFilter) return this.modelPickerList.slice(0, 15);
|
||||
var f = this.modelPickerFilter;
|
||||
return this.modelPickerList.filter(function(m) {
|
||||
return m.id.toLowerCase().indexOf(f) !== -1 || (m.display_name || '').toLowerCase().indexOf(f) !== -1 || m.provider.toLowerCase().indexOf(f) !== -1;
|
||||
}).slice(0, 15);
|
||||
},
|
||||
|
||||
pickModel(modelId) {
|
||||
this.showModelPicker = false;
|
||||
this.inputText = '/model ' + modelId;
|
||||
this.sendMessage();
|
||||
},
|
||||
|
||||
toggleModelSwitcher() {
|
||||
if (this.showModelSwitcher) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
var now = Date.now();
|
||||
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
|
||||
this.modelSwitcherFilter = '';
|
||||
this.modelSwitcherProviderFilter = '';
|
||||
this.modelSwitcherIdx = 0;
|
||||
this.showModelSwitcher = true;
|
||||
this.$nextTick(function() {
|
||||
var el = document.getElementById('model-switcher-search');
|
||||
if (el) el.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
OpenFangAPI.get('/api/models').then(function(data) {
|
||||
var models = (data.models || []).filter(function(m) { return m.available; });
|
||||
self._modelCache = models;
|
||||
self._modelCacheTime = Date.now();
|
||||
self.modelPickerList = models;
|
||||
self.modelSwitcherFilter = '';
|
||||
self.modelSwitcherProviderFilter = '';
|
||||
self.modelSwitcherIdx = 0;
|
||||
self.showModelSwitcher = true;
|
||||
self.$nextTick(function() {
|
||||
var el = document.getElementById('model-switcher-search');
|
||||
if (el) el.focus();
|
||||
});
|
||||
}).catch(function(e) {
|
||||
OpenFangToast.error('Failed to load models: ' + e.message);
|
||||
});
|
||||
},
|
||||
|
||||
switchModel(model) {
|
||||
if (!this.currentAgent) return;
|
||||
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
this.modelSwitching = true;
|
||||
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function(resp) {
|
||||
// Use server-resolved model/provider to stay in sync (fixes #387/#466)
|
||||
self.currentAgent.model_name = (resp && resp.model) || model.id;
|
||||
self.currentAgent.model_provider = (resp && resp.provider) || model.provider;
|
||||
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
|
||||
self.showModelSwitcher = false;
|
||||
self.modelSwitching = false;
|
||||
}).catch(function(e) {
|
||||
OpenFangToast.error('Switch failed: ' + e.message);
|
||||
self.modelSwitching = false;
|
||||
});
|
||||
},
|
||||
|
||||
// Fetch dynamic slash commands from server
|
||||
fetchCommands: function() {
|
||||
var self = this;
|
||||
@@ -282,9 +421,13 @@ function chatPage() {
|
||||
case '/model':
|
||||
if (self.currentAgent) {
|
||||
if (cmdArgs) {
|
||||
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function() {
|
||||
self.currentAgent.model_name = cmdArgs;
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`', meta: '', tools: [] });
|
||||
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) {
|
||||
// Use server-resolved model/provider (fixes #387/#466)
|
||||
var resolvedModel = (resp && resp.model) || cmdArgs;
|
||||
var resolvedProvider = (resp && resp.provider) || '';
|
||||
self.currentAgent.model_name = resolvedModel;
|
||||
if (resolvedProvider) { self.currentAgent.model_provider = resolvedProvider; }
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + resolvedModel + '`' + (resolvedProvider ? ' (provider: `' + resolvedProvider + '`)' : ''), meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); });
|
||||
} else {
|
||||
@@ -387,13 +530,16 @@ function chatPage() {
|
||||
id: (t.name || 'tool') + '-hist-' + idx,
|
||||
name: t.name || 'unknown',
|
||||
running: false,
|
||||
expanded: false,
|
||||
expanded: true,
|
||||
input: t.input || '',
|
||||
result: t.result || '',
|
||||
is_error: !!t.is_error
|
||||
};
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools };
|
||||
var images = (m.images || []).map(function(img) {
|
||||
return { file_id: img.file_id, filename: img.filename || 'image' };
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools, images: images };
|
||||
});
|
||||
self.$nextTick(function() { self.scrollToBottom(); });
|
||||
}
|
||||
@@ -476,8 +622,12 @@ function chatPage() {
|
||||
this.scrollToBottom();
|
||||
this._resetTypingTimeout();
|
||||
} else if (data.level) {
|
||||
var lastThink = this.messages[this.messages.length - 1];
|
||||
if (lastThink && lastThink.thinking) lastThink.text = 'Thinking (' + data.level + ')...';
|
||||
var thinkIdx = this.messages.length - 1;
|
||||
var lastThink = thinkIdx >= 0 ? this.messages[thinkIdx] : null;
|
||||
if (lastThink && lastThink.thinking) {
|
||||
lastThink.text = 'Thinking (' + data.level + ')...';
|
||||
this.messages.splice(thinkIdx, 1, lastThink);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -490,9 +640,11 @@ function chatPage() {
|
||||
}
|
||||
this._resetTypingTimeout();
|
||||
} else if (data.state === 'tool') {
|
||||
var typingMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var toolTypIdx = this.messages.length - 1;
|
||||
var typingMsg = toolTypIdx >= 0 ? this.messages[toolTypIdx] : null;
|
||||
if (typingMsg && (typingMsg.thinking || typingMsg.streaming)) {
|
||||
typingMsg.text = 'Using ' + (data.tool || 'tool') + '...';
|
||||
this.messages.splice(toolTypIdx, 1, typingMsg);
|
||||
}
|
||||
this._resetTypingTimeout();
|
||||
} else if (data.state === 'stop') {
|
||||
@@ -502,26 +654,45 @@ function chatPage() {
|
||||
|
||||
case 'phase':
|
||||
// Show tool/phase progress so the user sees the agent is working
|
||||
var phaseMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var phaseIdx = this.messages.length - 1;
|
||||
var phaseMsg = phaseIdx >= 0 ? this.messages[phaseIdx] : null;
|
||||
if (phaseMsg && (phaseMsg.thinking || phaseMsg.streaming)) {
|
||||
var detail = data.detail || data.phase || 'Working...';
|
||||
// Context warning: show prominently
|
||||
// Skip phases that have no user-meaningful display text — "streaming"
|
||||
// and "done" are lifecycle signals, not status to show in the chat bubble.
|
||||
if (data.phase === 'streaming' || data.phase === 'done') {
|
||||
break;
|
||||
}
|
||||
// Context warning: show prominently as a separate system message
|
||||
if (data.phase === 'context_warning') {
|
||||
this.messages.push({ id: ++msgId, role: 'system', text: detail, meta: '', tools: [] });
|
||||
var cwDetail = data.detail || 'Context limit reached.';
|
||||
this.messages.push({ id: ++msgId, role: 'system', text: cwDetail, meta: '', tools: [] });
|
||||
} else if (data.phase === 'thinking' && this.thinkingMode === 'stream') {
|
||||
// Stream reasoning tokens to a collapsible panel
|
||||
if (!phaseMsg._reasoning) phaseMsg._reasoning = '';
|
||||
phaseMsg._reasoning += (detail || '') + '\n';
|
||||
phaseMsg._reasoning += (data.detail || '') + '\n';
|
||||
phaseMsg.text = '<details><summary>Reasoning...</summary>\n\n' + phaseMsg._reasoning + '</details>';
|
||||
} else {
|
||||
phaseMsg.text = detail;
|
||||
this.messages.splice(phaseIdx, 1, phaseMsg);
|
||||
} else if (phaseMsg.thinking) {
|
||||
// Only update text on messages still in thinking state (not yet
|
||||
// receiving streamed content) to avoid overwriting accumulated text.
|
||||
var phaseDetail;
|
||||
if (data.phase === 'tool_use') {
|
||||
phaseDetail = 'Using ' + (data.detail || 'tool') + '...';
|
||||
} else if (data.phase === 'thinking') {
|
||||
phaseDetail = 'Thinking...';
|
||||
} else {
|
||||
phaseDetail = data.detail || 'Working...';
|
||||
}
|
||||
phaseMsg.text = phaseDetail;
|
||||
this.messages.splice(phaseIdx, 1, phaseMsg);
|
||||
}
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
|
||||
case 'text_delta':
|
||||
var last = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var lastIdx = this.messages.length - 1;
|
||||
var last = lastIdx >= 0 ? this.messages[lastIdx] : null;
|
||||
if (last && last.streaming) {
|
||||
if (last.thinking) { last.text = ''; last.thinking = false; }
|
||||
// If we already detected a text-based tool call, skip further text
|
||||
@@ -542,7 +713,7 @@ function chatPage() {
|
||||
id: toolMatch[1] + '-txt-' + Date.now(),
|
||||
name: toolMatch[1],
|
||||
running: true,
|
||||
expanded: false,
|
||||
expanded: true,
|
||||
input: inputMatch ? inputMatch[1].replace(/<\/function>?\s*$/, '').trim() : '',
|
||||
result: '',
|
||||
is_error: false
|
||||
@@ -550,6 +721,10 @@ function chatPage() {
|
||||
}
|
||||
}
|
||||
this.tokenCount = Math.round(last.text.length / 4);
|
||||
// Force Alpine reactivity: splice-in-place so x-for re-renders
|
||||
// this item. Direct property mutation on array elements may not
|
||||
// trigger DOM updates from async WebSocket callbacks.
|
||||
this.messages.splice(lastIdx, 1, last);
|
||||
} else {
|
||||
this.messages.push({ id: ++msgId, role: 'agent', text: data.content, meta: '', streaming: true, tools: [] });
|
||||
}
|
||||
@@ -557,17 +732,20 @@ function chatPage() {
|
||||
break;
|
||||
|
||||
case 'tool_start':
|
||||
var lastMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var tsIdx = this.messages.length - 1;
|
||||
var lastMsg = tsIdx >= 0 ? this.messages[tsIdx] : null;
|
||||
if (lastMsg && lastMsg.streaming) {
|
||||
if (!lastMsg.tools) lastMsg.tools = [];
|
||||
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: false, input: '', result: '', is_error: false });
|
||||
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: true, input: '', result: '', is_error: false });
|
||||
this.messages.splice(tsIdx, 1, lastMsg);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
|
||||
case 'tool_end':
|
||||
// Tool call parsed by LLM — update tool card with input params
|
||||
var lastMsg2 = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var teIdx = this.messages.length - 1;
|
||||
var lastMsg2 = teIdx >= 0 ? this.messages[teIdx] : null;
|
||||
if (lastMsg2 && lastMsg2.tools) {
|
||||
for (var ti = lastMsg2.tools.length - 1; ti >= 0; ti--) {
|
||||
if (lastMsg2.tools[ti].name === data.tool && lastMsg2.tools[ti].running) {
|
||||
@@ -575,12 +753,14 @@ function chatPage() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.messages.splice(teIdx, 1, lastMsg2);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
// Tool execution completed — update tool card with result
|
||||
var lastMsg3 = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var trIdx = this.messages.length - 1;
|
||||
var lastMsg3 = trIdx >= 0 ? this.messages[trIdx] : null;
|
||||
if (lastMsg3 && lastMsg3.tools) {
|
||||
for (var ri = lastMsg3.tools.length - 1; ri >= 0; ri--) {
|
||||
if (lastMsg3.tools[ri].name === data.tool && lastMsg3.tools[ri].running) {
|
||||
@@ -609,6 +789,7 @@ function chatPage() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.messages.splice(trIdx, 1, lastMsg3);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
@@ -885,10 +1066,16 @@ function chatPage() {
|
||||
});
|
||||
},
|
||||
|
||||
_latexTimer: null,
|
||||
scrollToBottom() {
|
||||
var self = this;
|
||||
var el = document.getElementById('messages');
|
||||
if (el) self.$nextTick(function() { el.scrollTop = el.scrollHeight; });
|
||||
if (el) self.$nextTick(function() {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
// Debounce LaTeX rendering to avoid running on every streaming token
|
||||
if (self._latexTimer) clearTimeout(self._latexTimer);
|
||||
self._latexTimer = setTimeout(function() { renderLatex(el); }, 150);
|
||||
});
|
||||
},
|
||||
|
||||
addFiles(files) {
|
||||
@@ -958,6 +1145,9 @@ function chatPage() {
|
||||
|
||||
formatToolJson: function(text) {
|
||||
if (!text) return '';
|
||||
if (typeof text === 'object') {
|
||||
return JSON.stringify(text, null, 2);
|
||||
}
|
||||
try { return JSON.stringify(JSON.parse(text), null, 2); }
|
||||
catch(e) { return text; }
|
||||
},
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// OpenFang Comms Page — Agent topology & inter-agent communication feed
|
||||
'use strict';
|
||||
|
||||
function commsPage() {
|
||||
return {
|
||||
topology: { nodes: [], edges: [] },
|
||||
events: [],
|
||||
loading: true,
|
||||
loadError: '',
|
||||
sseSource: null,
|
||||
showSendModal: false,
|
||||
showTaskModal: false,
|
||||
sendFrom: '',
|
||||
sendTo: '',
|
||||
sendMsg: '',
|
||||
sendLoading: false,
|
||||
taskTitle: '',
|
||||
taskDesc: '',
|
||||
taskAssign: '',
|
||||
taskLoading: false,
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/comms/topology'),
|
||||
OpenFangAPI.get('/api/comms/events?limit=200')
|
||||
]);
|
||||
this.topology = results[0] || { nodes: [], edges: [] };
|
||||
this.events = results[1] || [];
|
||||
this.startSSE();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load comms data.';
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
startSSE() {
|
||||
if (this.sseSource) this.sseSource.close();
|
||||
var self = this;
|
||||
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
|
||||
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
|
||||
this.sseSource = new EventSource(url);
|
||||
this.sseSource.onmessage = function(ev) {
|
||||
if (ev.data === 'ping') return;
|
||||
try {
|
||||
var event = JSON.parse(ev.data);
|
||||
self.events.unshift(event);
|
||||
if (self.events.length > 200) self.events.length = 200;
|
||||
// Refresh topology on spawn/terminate events
|
||||
if (event.kind === 'agent_spawned' || event.kind === 'agent_terminated') {
|
||||
self.refreshTopology();
|
||||
}
|
||||
} catch(e) { /* ignore parse errors */ }
|
||||
};
|
||||
},
|
||||
|
||||
stopSSE() {
|
||||
if (this.sseSource) {
|
||||
this.sseSource.close();
|
||||
this.sseSource = null;
|
||||
}
|
||||
},
|
||||
|
||||
async refreshTopology() {
|
||||
try {
|
||||
this.topology = await OpenFangAPI.get('/api/comms/topology');
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
rootNodes() {
|
||||
var childIds = {};
|
||||
var self = this;
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'parent_child') childIds[e.to] = true;
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return !childIds[n.id]; });
|
||||
},
|
||||
|
||||
childrenOf(id) {
|
||||
var childIds = {};
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'parent_child' && e.from === id) childIds[e.to] = true;
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return childIds[n.id]; });
|
||||
},
|
||||
|
||||
peersOf(id) {
|
||||
var peerIds = {};
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'peer') {
|
||||
if (e.from === id) peerIds[e.to] = true;
|
||||
if (e.to === id) peerIds[e.from] = true;
|
||||
}
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return peerIds[n.id]; });
|
||||
},
|
||||
|
||||
stateBadgeClass(state) {
|
||||
switch(state) {
|
||||
case 'Running': return 'badge badge-success';
|
||||
case 'Suspended': return 'badge badge-warning';
|
||||
case 'Terminated': case 'Crashed': return 'badge badge-danger';
|
||||
default: return 'badge badge-dim';
|
||||
}
|
||||
},
|
||||
|
||||
eventBadgeClass(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return 'badge badge-info';
|
||||
case 'agent_spawned': return 'badge badge-success';
|
||||
case 'agent_terminated': return 'badge badge-danger';
|
||||
case 'task_posted': return 'badge badge-warning';
|
||||
case 'task_claimed': return 'badge badge-info';
|
||||
case 'task_completed': return 'badge badge-success';
|
||||
default: return 'badge badge-dim';
|
||||
}
|
||||
},
|
||||
|
||||
eventIcon(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return '\u2709';
|
||||
case 'agent_spawned': return '+';
|
||||
case 'agent_terminated': return '\u2715';
|
||||
case 'task_posted': return '\u2691';
|
||||
case 'task_claimed': return '\u2690';
|
||||
case 'task_completed': return '\u2713';
|
||||
default: return '\u2022';
|
||||
}
|
||||
},
|
||||
|
||||
eventLabel(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return 'Message';
|
||||
case 'agent_spawned': return 'Spawned';
|
||||
case 'agent_terminated': return 'Terminated';
|
||||
case 'task_posted': return 'Task Posted';
|
||||
case 'task_claimed': return 'Task Claimed';
|
||||
case 'task_completed': return 'Task Done';
|
||||
default: return kind;
|
||||
}
|
||||
},
|
||||
|
||||
timeAgo(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
var d = new Date(dateStr);
|
||||
var secs = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (secs < 60) return secs + 's ago';
|
||||
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
|
||||
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
|
||||
return Math.floor(secs / 86400) + 'd ago';
|
||||
},
|
||||
|
||||
openSendModal() {
|
||||
this.sendFrom = '';
|
||||
this.sendTo = '';
|
||||
this.sendMsg = '';
|
||||
this.showSendModal = true;
|
||||
},
|
||||
|
||||
async submitSend() {
|
||||
if (!this.sendFrom || !this.sendTo || !this.sendMsg.trim()) return;
|
||||
this.sendLoading = true;
|
||||
try {
|
||||
await OpenFangAPI.post('/api/comms/send', {
|
||||
from_agent_id: this.sendFrom,
|
||||
to_agent_id: this.sendTo,
|
||||
message: this.sendMsg
|
||||
});
|
||||
OpenFangToast.success('Message sent');
|
||||
this.showSendModal = false;
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Send failed');
|
||||
}
|
||||
this.sendLoading = false;
|
||||
},
|
||||
|
||||
openTaskModal() {
|
||||
this.taskTitle = '';
|
||||
this.taskDesc = '';
|
||||
this.taskAssign = '';
|
||||
this.showTaskModal = true;
|
||||
},
|
||||
|
||||
async submitTask() {
|
||||
if (!this.taskTitle.trim()) return;
|
||||
this.taskLoading = true;
|
||||
try {
|
||||
var body = { title: this.taskTitle, description: this.taskDesc };
|
||||
if (this.taskAssign) body.assigned_to = this.taskAssign;
|
||||
await OpenFangAPI.post('/api/comms/task', body);
|
||||
OpenFangToast.success('Task posted');
|
||||
this.showTaskModal = false;
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Task failed');
|
||||
}
|
||||
this.taskLoading = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,15 @@ function handsPage() {
|
||||
browserViewerOpen: false,
|
||||
_browserPollTimer: null,
|
||||
|
||||
// ── Trader Dashboard State ────────────────────────────────────────────
|
||||
dashboardOpen: false,
|
||||
dashboardLoading: false,
|
||||
dashboardData: null,
|
||||
_dashboardInst: null,
|
||||
_chartEquity: null,
|
||||
_chartPnl: null,
|
||||
_chartRadar: null,
|
||||
|
||||
// ── Setup Wizard State ──────────────────────────────────────────────
|
||||
setupWizard: null,
|
||||
setupStep: 1,
|
||||
@@ -27,6 +36,7 @@ function handsPage() {
|
||||
_clipboardTimer: null,
|
||||
detectedPlatform: 'linux',
|
||||
installPlatforms: {},
|
||||
apiKeyInputs: {},
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
@@ -101,11 +111,15 @@ function handsPage() {
|
||||
} else {
|
||||
this._detectClientPlatform();
|
||||
}
|
||||
// Initialize per-requirement platform selections
|
||||
// Initialize per-requirement platform selections and API key inputs
|
||||
this.installPlatforms = {};
|
||||
this.apiKeyInputs = {};
|
||||
if (data.requirements) {
|
||||
for (var j = 0; j < data.requirements.length; j++) {
|
||||
this.installPlatforms[data.requirements[j].key] = this.detectedPlatform;
|
||||
if (data.requirements[j].type === 'ApiKey') {
|
||||
this.apiKeyInputs[data.requirements[j].key] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
this.setupWizard = data;
|
||||
@@ -274,7 +288,10 @@ function handsPage() {
|
||||
if (!this.setupWizard || !this.setupWizard.requirements) return 0;
|
||||
var count = 0;
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
if (this.setupWizard.requirements[i].satisfied) count++;
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.satisfied) { count++; continue; }
|
||||
// Count API key reqs as met if user entered a value
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') count++;
|
||||
}
|
||||
return count;
|
||||
},
|
||||
@@ -285,7 +302,34 @@ function handsPage() {
|
||||
},
|
||||
|
||||
get setupAllReqsMet() {
|
||||
return this.setupReqsTotal > 0 && this.setupReqsMet === this.setupReqsTotal;
|
||||
if (!this.setupWizard || !this.setupWizard.requirements) return false;
|
||||
if (this.setupReqsTotal === 0) return false;
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.satisfied) continue;
|
||||
// API key reqs are satisfied if the user entered a value in the input
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') continue;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
getSettingKeyForReq(req) {
|
||||
// Find the matching setting key for an API key requirement.
|
||||
// Convention: setting key is the lowercase version of the requirement key.
|
||||
if (!this.setupWizard || !this.setupWizard.settings) return null;
|
||||
var lowerKey = req.key.toLowerCase();
|
||||
for (var i = 0; i < this.setupWizard.settings.length; i++) {
|
||||
if (this.setupWizard.settings[i].key === lowerKey) return lowerKey;
|
||||
}
|
||||
// Fallback: try matching by check_value lowercased
|
||||
if (req.check_value) {
|
||||
var lowerCheck = req.check_value.toLowerCase();
|
||||
for (var j = 0; j < this.setupWizard.settings.length; j++) {
|
||||
if (this.setupWizard.settings[j].key === lowerCheck) return lowerCheck;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
get setupHasReqs() {
|
||||
@@ -297,6 +341,10 @@ function handsPage() {
|
||||
},
|
||||
|
||||
setupNextStep() {
|
||||
// When leaving step 1, sync API key inputs into settings values
|
||||
if (this.setupStep === 1) {
|
||||
this._syncApiKeysToSettings();
|
||||
}
|
||||
if (this.setupStep === 1 && this.setupHasSettings) {
|
||||
this.setupStep = 2;
|
||||
} else if (this.setupStep === 1) {
|
||||
@@ -306,6 +354,19 @@ function handsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
_syncApiKeysToSettings() {
|
||||
if (!this.setupWizard || !this.setupWizard.requirements) return;
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
|
||||
var settingKey = this.getSettingKeyForReq(req);
|
||||
if (settingKey) {
|
||||
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setupPrevStep() {
|
||||
if (this.setupStep === 3 && this.setupHasSettings) {
|
||||
this.setupStep = 2;
|
||||
@@ -323,11 +384,24 @@ function handsPage() {
|
||||
this.setupChecking = false;
|
||||
this.clipboardMsg = null;
|
||||
this.installPlatforms = {};
|
||||
this.apiKeyInputs = {};
|
||||
},
|
||||
|
||||
async launchHand() {
|
||||
if (!this.setupWizard) return;
|
||||
var handId = this.setupWizard.id;
|
||||
// Sync API key inputs from step 1 into settings values
|
||||
if (this.setupWizard.requirements) {
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
|
||||
var settingKey = this.getSettingKeyForReq(req);
|
||||
if (settingKey) {
|
||||
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var config = {};
|
||||
for (var key in this.settingsValues) {
|
||||
config[key] = this.settingsValues[key];
|
||||
@@ -499,6 +573,382 @@ function handsPage() {
|
||||
this.stopBrowserPolling();
|
||||
this.browserViewerOpen = false;
|
||||
this.browserViewer = null;
|
||||
},
|
||||
|
||||
// ── Trader Dashboard ──────────────────────────────────────────────────
|
||||
|
||||
isTraderHand(inst) {
|
||||
return inst.hand_id === 'trader';
|
||||
},
|
||||
|
||||
async openDashboard(inst) {
|
||||
this._dashboardInst = inst;
|
||||
this.dashboardOpen = true;
|
||||
this.dashboardLoading = true;
|
||||
this.dashboardData = null;
|
||||
await this._fetchDashboardData(inst);
|
||||
this.dashboardLoading = false;
|
||||
// Render charts after DOM update
|
||||
var self = this;
|
||||
setTimeout(function() { self._renderCharts(); }, 60);
|
||||
},
|
||||
|
||||
async refreshDashboard() {
|
||||
if (!this._dashboardInst) return;
|
||||
this.dashboardLoading = true;
|
||||
await this._fetchDashboardData(this._dashboardInst);
|
||||
this.dashboardLoading = false;
|
||||
var self = this;
|
||||
setTimeout(function() { self._renderCharts(); }, 60);
|
||||
},
|
||||
|
||||
closeDashboard() {
|
||||
this.dashboardOpen = false;
|
||||
this._destroyCharts();
|
||||
this.dashboardData = null;
|
||||
this._dashboardInst = null;
|
||||
},
|
||||
|
||||
async _fetchDashboardData(inst) {
|
||||
var data = {
|
||||
agent_name: inst.agent_name || inst.hand_id,
|
||||
portfolio_value: null,
|
||||
total_pnl: null,
|
||||
win_rate: null,
|
||||
sharpe_ratio: null,
|
||||
max_drawdown: null,
|
||||
trades_count: null,
|
||||
equity_curve: [],
|
||||
daily_pnl: [],
|
||||
watchlist_heatmap: [],
|
||||
signal_radar: null,
|
||||
recent_trades: []
|
||||
};
|
||||
|
||||
// Fetch basic stats from the hand stats endpoint
|
||||
try {
|
||||
var stats = await OpenFangAPI.get('/api/hands/instances/' + inst.instance_id + '/stats');
|
||||
var m = stats.metrics || {};
|
||||
if (m['Portfolio Value']) data.portfolio_value = this._metricVal(m['Portfolio Value']);
|
||||
if (m['Total P&L']) data.total_pnl = this._metricVal(m['Total P&L']);
|
||||
if (m['Win Rate']) data.win_rate = this._metricVal(m['Win Rate']);
|
||||
if (m['Sharpe Ratio']) data.sharpe_ratio = this._metricVal(m['Sharpe Ratio']);
|
||||
if (m['Max Drawdown']) data.max_drawdown = this._metricVal(m['Max Drawdown']);
|
||||
if (m['Trades Executed']) data.trades_count = this._metricVal(m['Trades Executed']);
|
||||
} catch(e) {
|
||||
// Stats endpoint might fail — continue with KV data
|
||||
}
|
||||
|
||||
// Fetch rich chart data from agent memory KV
|
||||
var agentId = inst.agent_id || 'shared';
|
||||
var kvKeys = [
|
||||
'trader_hand_equity_curve',
|
||||
'trader_hand_daily_pnl',
|
||||
'trader_hand_watchlist_heatmap',
|
||||
'trader_hand_signal_radar',
|
||||
'trader_hand_recent_trades',
|
||||
'trader_hand_portfolio_value',
|
||||
'trader_hand_total_pnl',
|
||||
'trader_hand_win_rate',
|
||||
'trader_hand_sharpe_ratio',
|
||||
'trader_hand_max_drawdown',
|
||||
'trader_hand_trades_count'
|
||||
];
|
||||
|
||||
for (var i = 0; i < kvKeys.length; i++) {
|
||||
try {
|
||||
var resp = await OpenFangAPI.get('/api/memory/agents/' + agentId + '/kv/' + kvKeys[i]);
|
||||
if (resp && resp.value !== null && resp.value !== undefined) {
|
||||
var val = resp.value;
|
||||
this._applyKvToData(data, kvKeys[i], val);
|
||||
}
|
||||
} catch(e) {
|
||||
// Key might not exist yet — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
this.dashboardData = data;
|
||||
},
|
||||
|
||||
_metricVal(metric) {
|
||||
if (!metric) return null;
|
||||
var v = metric.value;
|
||||
if (v === null || v === undefined) return null;
|
||||
// Values come as JSON values — could be string, number, etc.
|
||||
if (typeof v === 'string') return v;
|
||||
return String(v);
|
||||
},
|
||||
|
||||
_applyKvToData(data, key, val) {
|
||||
// Values from KV can be strings (JSON-encoded) or already parsed
|
||||
var parsed = val;
|
||||
if (typeof val === 'string') {
|
||||
try { parsed = JSON.parse(val); } catch(e) { parsed = val; }
|
||||
}
|
||||
|
||||
switch(key) {
|
||||
case 'trader_hand_portfolio_value':
|
||||
if (!data.portfolio_value) data.portfolio_value = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_total_pnl':
|
||||
if (!data.total_pnl) data.total_pnl = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_win_rate':
|
||||
if (!data.win_rate) data.win_rate = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_sharpe_ratio':
|
||||
if (!data.sharpe_ratio) data.sharpe_ratio = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_max_drawdown':
|
||||
if (!data.max_drawdown) data.max_drawdown = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_trades_count':
|
||||
if (!data.trades_count) data.trades_count = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_equity_curve':
|
||||
if (Array.isArray(parsed)) data.equity_curve = parsed;
|
||||
break;
|
||||
case 'trader_hand_daily_pnl':
|
||||
if (Array.isArray(parsed)) data.daily_pnl = parsed;
|
||||
break;
|
||||
case 'trader_hand_watchlist_heatmap':
|
||||
if (Array.isArray(parsed)) data.watchlist_heatmap = parsed;
|
||||
break;
|
||||
case 'trader_hand_signal_radar':
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data.signal_radar = parsed;
|
||||
break;
|
||||
case 'trader_hand_recent_trades':
|
||||
if (Array.isArray(parsed)) data.recent_trades = parsed;
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_destroyCharts() {
|
||||
if (this._chartEquity) { this._chartEquity.destroy(); this._chartEquity = null; }
|
||||
if (this._chartPnl) { this._chartPnl.destroy(); this._chartPnl = null; }
|
||||
if (this._chartRadar) { this._chartRadar.destroy(); this._chartRadar = null; }
|
||||
},
|
||||
|
||||
_renderCharts() {
|
||||
if (typeof Chart === 'undefined') return;
|
||||
this._destroyCharts();
|
||||
if (!this.dashboardData) return;
|
||||
|
||||
var d = this.dashboardData;
|
||||
|
||||
// Detect theme
|
||||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
var gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
||||
var textColor = isDark ? '#8A8380' : '#6B6560';
|
||||
var accentColor = '#FF5C00';
|
||||
var successColor = isDark ? '#4ADE80' : '#22C55E';
|
||||
var errorColor = '#EF4444';
|
||||
|
||||
// ── Equity Curve ──
|
||||
if (d.equity_curve && d.equity_curve.length > 0) {
|
||||
var eqCanvas = document.getElementById('traderEquityChart');
|
||||
if (eqCanvas) {
|
||||
var labels = [];
|
||||
var values = [];
|
||||
for (var i = 0; i < d.equity_curve.length; i++) {
|
||||
labels.push(d.equity_curve[i].date || '');
|
||||
values.push(parseFloat(d.equity_curve[i].value) || 0);
|
||||
}
|
||||
// Determine gradient
|
||||
var eqCtx = eqCanvas.getContext('2d');
|
||||
var gradient = eqCtx.createLinearGradient(0, 0, 0, eqCanvas.parentElement.clientHeight || 180);
|
||||
gradient.addColorStop(0, isDark ? 'rgba(255, 92, 0, 0.25)' : 'rgba(255, 92, 0, 0.15)');
|
||||
gradient.addColorStop(1, 'rgba(255, 92, 0, 0)');
|
||||
|
||||
this._chartEquity = new Chart(eqCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
borderColor: accentColor,
|
||||
backgroundColor: gradient,
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: d.equity_curve.length > 20 ? 0 : 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: accentColor
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
||||
titleColor: textColor,
|
||||
bodyColor: isDark ? '#e0e0e0' : '#333',
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
return '$' + ctx.parsed.y.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: gridColor },
|
||||
ticks: { color: textColor, maxTicksLimit: 8, font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 10 },
|
||||
callback: function(v) { return '$' + v.toLocaleString(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Daily P&L Bar Chart ──
|
||||
if (d.daily_pnl && d.daily_pnl.length > 0) {
|
||||
var pnlCanvas = document.getElementById('traderPnlChart');
|
||||
if (pnlCanvas) {
|
||||
var pnlLabels = [];
|
||||
var pnlValues = [];
|
||||
var pnlColors = [];
|
||||
for (var j = 0; j < d.daily_pnl.length; j++) {
|
||||
pnlLabels.push(d.daily_pnl[j].date || '');
|
||||
var pnlVal = parseFloat(d.daily_pnl[j].pnl) || 0;
|
||||
pnlValues.push(pnlVal);
|
||||
pnlColors.push(pnlVal >= 0 ? successColor : errorColor);
|
||||
}
|
||||
|
||||
this._chartPnl = new Chart(pnlCanvas.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: pnlLabels,
|
||||
datasets: [{
|
||||
data: pnlValues,
|
||||
backgroundColor: pnlColors,
|
||||
borderRadius: 3,
|
||||
borderSkipped: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
||||
titleColor: textColor,
|
||||
bodyColor: isDark ? '#e0e0e0' : '#333',
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
var v = ctx.parsed.y;
|
||||
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: textColor, maxTicksLimit: 7, font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 10 },
|
||||
callback: function(v) {
|
||||
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signal Radar Chart ──
|
||||
if (d.signal_radar) {
|
||||
var radarCanvas = document.getElementById('traderRadarChart');
|
||||
if (radarCanvas) {
|
||||
var radarLabels = [];
|
||||
var radarValues = [];
|
||||
var keys = ['technical', 'fundamental', 'sentiment', 'macro'];
|
||||
var displayLabels = ['Technical', 'Fundamental', 'Sentiment', 'Macro'];
|
||||
for (var k = 0; k < keys.length; k++) {
|
||||
radarLabels.push(displayLabels[k]);
|
||||
radarValues.push(parseFloat(d.signal_radar[keys[k]]) || 0);
|
||||
}
|
||||
|
||||
this._chartRadar = new Chart(radarCanvas.getContext('2d'), {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: radarLabels,
|
||||
datasets: [{
|
||||
data: radarValues,
|
||||
borderColor: accentColor,
|
||||
backgroundColor: isDark ? 'rgba(255, 92, 0, 0.2)' : 'rgba(255, 92, 0, 0.12)',
|
||||
borderWidth: 2,
|
||||
pointBackgroundColor: accentColor,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
||||
titleColor: textColor,
|
||||
bodyColor: isDark ? '#e0e0e0' : '#333',
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function(ctx) { return ctx.parsed.r + '/100'; }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
beginAtZero: true,
|
||||
grid: { color: gridColor },
|
||||
angleLines: { color: gridColor },
|
||||
pointLabels: {
|
||||
color: textColor,
|
||||
font: { size: 11, weight: '600' }
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
backdropColor: 'transparent',
|
||||
stepSize: 25,
|
||||
font: { size: 9 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Runtime page — system overview and provider status
|
||||
document.addEventListener('alpine:init', function() {
|
||||
Alpine.data('runtimePage', function() {
|
||||
return {
|
||||
loading: true,
|
||||
uptime: '-',
|
||||
agentCount: 0,
|
||||
version: '-',
|
||||
defaultModel: '-',
|
||||
platform: '-',
|
||||
arch: '-',
|
||||
apiListen: '-',
|
||||
homeDir: '-',
|
||||
logLevel: '-',
|
||||
networkEnabled: false,
|
||||
providers: [],
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/status'),
|
||||
OpenFangAPI.get('/api/version'),
|
||||
OpenFangAPI.get('/api/providers'),
|
||||
OpenFangAPI.get('/api/agents')
|
||||
]);
|
||||
var status = results[0];
|
||||
var ver = results[1];
|
||||
var prov = results[2];
|
||||
var agents = results[3];
|
||||
|
||||
this.version = ver.version || '-';
|
||||
this.platform = ver.platform || '-';
|
||||
this.arch = ver.arch || '-';
|
||||
this.agentCount = Array.isArray(agents) ? agents.length : 0;
|
||||
this.defaultModel = status.default_model || '-';
|
||||
this.apiListen = status.api_listen || status.listen || '-';
|
||||
this.homeDir = status.home_dir || '-';
|
||||
this.logLevel = status.log_level || '-';
|
||||
this.networkEnabled = !!status.network_enabled;
|
||||
|
||||
// Compute uptime from uptime_seconds
|
||||
var diff = status.uptime_seconds || 0;
|
||||
if (diff < 60) this.uptime = diff + 's';
|
||||
else if (diff < 3600) this.uptime = Math.floor(diff / 60) + 'm ' + (diff % 60) + 's';
|
||||
else if (diff < 86400) this.uptime = Math.floor(diff / 3600) + 'h ' + Math.floor((diff % 3600) / 60) + 'm';
|
||||
else this.uptime = Math.floor(diff / 86400) + 'd ' + Math.floor((diff % 86400) / 3600) + 'h';
|
||||
|
||||
this.providers = (prov.providers || []).filter(function(p) {
|
||||
return p.auth_status === 'Configured' || p.reachable || p.is_local;
|
||||
});
|
||||
} catch(e) {
|
||||
console.error('Runtime load error:', e);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -201,15 +201,17 @@ function schedulerPage() {
|
||||
async runNow(job) {
|
||||
this.runningJobId = job.id;
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
|
||||
if (result.status === 'completed') {
|
||||
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
|
||||
job.last_run = new Date().toISOString();
|
||||
var result = await OpenFangAPI.post('/api/cron/jobs/' + job.id + '/run', {});
|
||||
if (result.status === 'triggered' || result.status === 'completed') {
|
||||
OpenFangToast.success('Job "' + (job.name || 'job') + '" triggered');
|
||||
// Don't update job.last_run here — the job runs asynchronously in the
|
||||
// background. The real last_run is set by the server on completion and
|
||||
// will appear on the next data refresh.
|
||||
} else {
|
||||
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
|
||||
OpenFangToast.error('Run failed: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Run Now is not yet available for cron jobs');
|
||||
OpenFangToast.error('Run failed: ' + (e.message || e));
|
||||
}
|
||||
this.runningJobId = '';
|
||||
},
|
||||
|
||||
@@ -14,11 +14,23 @@ function settingsPage() {
|
||||
modelSearch: '',
|
||||
modelProviderFilter: '',
|
||||
modelTierFilter: '',
|
||||
showCustomModelForm: false,
|
||||
customModelId: '',
|
||||
customModelProvider: 'openrouter',
|
||||
customModelContext: 128000,
|
||||
customModelMaxOutput: 8192,
|
||||
customModelStatus: '',
|
||||
providerKeyInputs: {},
|
||||
providerUrlInputs: {},
|
||||
providerUrlSaving: {},
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
customProviderName: '',
|
||||
customProviderUrl: '',
|
||||
customProviderKey: '',
|
||||
customProviderStatus: '',
|
||||
addingCustomProvider: false,
|
||||
loading: true,
|
||||
loadError: '',
|
||||
|
||||
@@ -212,8 +224,13 @@ function settingsPage() {
|
||||
this.providers = data.providers || [];
|
||||
for (var i = 0; i < this.providers.length; i++) {
|
||||
var p = this.providers[i];
|
||||
if (p.is_local && p.base_url && !this.providerUrlInputs[p.id]) {
|
||||
this.providerUrlInputs[p.id] = p.base_url;
|
||||
if (p.is_local) {
|
||||
if (!this.providerUrlInputs[p.id]) {
|
||||
this.providerUrlInputs[p.id] = p.base_url || '';
|
||||
}
|
||||
if (this.providerUrlSaving[p.id] === undefined) {
|
||||
this.providerUrlSaving[p.id] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) { this.providers = []; }
|
||||
@@ -226,6 +243,37 @@ function settingsPage() {
|
||||
} catch(e) { this.models = []; }
|
||||
},
|
||||
|
||||
async addCustomModel() {
|
||||
var id = this.customModelId.trim();
|
||||
if (!id) return;
|
||||
this.customModelStatus = 'Adding...';
|
||||
try {
|
||||
await OpenFangAPI.post('/api/models/custom', {
|
||||
id: id,
|
||||
provider: this.customModelProvider || 'openrouter',
|
||||
context_window: this.customModelContext || 128000,
|
||||
max_output_tokens: this.customModelMaxOutput || 8192,
|
||||
});
|
||||
this.customModelStatus = 'Added!';
|
||||
this.customModelId = '';
|
||||
this.showCustomModelForm = false;
|
||||
await this.loadModels();
|
||||
} catch(e) {
|
||||
this.customModelStatus = 'Error: ' + (e.message || 'Failed');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCustomModel(modelId) {
|
||||
if (!confirm('Delete custom model "' + modelId + '"?')) return;
|
||||
try {
|
||||
await OpenFangAPI.del('/api/models/custom/' + encodeURIComponent(modelId));
|
||||
OpenFangToast.success('Model deleted');
|
||||
await this.loadModels();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete: ' + (e.message || 'Unknown error'));
|
||||
}
|
||||
},
|
||||
|
||||
async loadConfigSchema() {
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
@@ -247,11 +295,14 @@ function settingsPage() {
|
||||
|
||||
async saveConfigField(section, field, value) {
|
||||
var key = section + '.' + field;
|
||||
// Root-level fields (api_key, api_listen, log_level) use just the field name
|
||||
var sectionMeta = this.configSchema && this.configSchema[section];
|
||||
var path = (sectionMeta && sectionMeta.root_level) ? field : key;
|
||||
this.configSaving[key] = true;
|
||||
try {
|
||||
await OpenFangAPI.post('/api/config/set', { path: key, value: value });
|
||||
await OpenFangAPI.post('/api/config/set', { path: path, value: value });
|
||||
this.configDirty[key] = false;
|
||||
OpenFangToast.success('Saved ' + key);
|
||||
OpenFangToast.success('Saved ' + field);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save: ' + e.message);
|
||||
}
|
||||
@@ -301,7 +352,10 @@ function settingsPage() {
|
||||
|
||||
providerAuthText(p) {
|
||||
if (p.auth_status === 'configured') return 'Configured';
|
||||
if (p.auth_status === 'not_set' || p.auth_status === 'missing') return 'Not Set';
|
||||
if (p.auth_status === 'not_set' || p.auth_status === 'missing') {
|
||||
if (p.id === 'claude-code') return 'Not Installed';
|
||||
return 'Not Set';
|
||||
}
|
||||
return 'No Key Needed';
|
||||
},
|
||||
|
||||
@@ -347,8 +401,12 @@ function settingsPage() {
|
||||
var key = this.providerKeyInputs[provider.id];
|
||||
if (!key || !key.trim()) { OpenFangToast.error('Please enter an API key'); return; }
|
||||
try {
|
||||
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
|
||||
OpenFangToast.success('API key saved for ' + provider.display_name);
|
||||
var resp = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
|
||||
if (resp && resp.switched_default) {
|
||||
OpenFangToast.warning(resp.message || 'Default provider was switched to ' + provider.display_name);
|
||||
} else {
|
||||
OpenFangToast.success('API key saved for ' + provider.display_name);
|
||||
}
|
||||
this.providerKeyInputs[provider.id] = '';
|
||||
await this.loadProviders();
|
||||
await this.loadModels();
|
||||
@@ -368,6 +426,54 @@ function settingsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
async startCopilotOAuth() {
|
||||
this.copilotOAuth.polling = true;
|
||||
this.copilotOAuth.userCode = '';
|
||||
try {
|
||||
var resp = await OpenFangAPI.post('/api/providers/github-copilot/oauth/start', {});
|
||||
this.copilotOAuth.userCode = resp.user_code;
|
||||
this.copilotOAuth.verificationUri = resp.verification_uri;
|
||||
this.copilotOAuth.pollId = resp.poll_id;
|
||||
this.copilotOAuth.interval = resp.interval || 5;
|
||||
window.open(resp.verification_uri, '_blank');
|
||||
this.pollCopilotOAuth();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to start Copilot login: ' + e.message);
|
||||
this.copilotOAuth.polling = false;
|
||||
}
|
||||
},
|
||||
|
||||
pollCopilotOAuth() {
|
||||
var self = this;
|
||||
setTimeout(async function() {
|
||||
if (!self.copilotOAuth.pollId) return;
|
||||
try {
|
||||
var resp = await OpenFangAPI.get('/api/providers/github-copilot/oauth/poll/' + self.copilotOAuth.pollId);
|
||||
if (resp.status === 'complete') {
|
||||
OpenFangToast.success('GitHub Copilot authenticated successfully!');
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
await self.loadProviders();
|
||||
await self.loadModels();
|
||||
} else if (resp.status === 'pending') {
|
||||
if (resp.interval) self.copilotOAuth.interval = resp.interval;
|
||||
self.pollCopilotOAuth();
|
||||
} else if (resp.status === 'expired') {
|
||||
OpenFangToast.error('Device code expired. Please try again.');
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
} else if (resp.status === 'denied') {
|
||||
OpenFangToast.error('Access denied by user.');
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
} else {
|
||||
OpenFangToast.error('OAuth error: ' + (resp.error || resp.status));
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Poll error: ' + e.message);
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
}
|
||||
}, self.copilotOAuth.interval * 1000);
|
||||
},
|
||||
|
||||
async testProvider(provider) {
|
||||
this.providerTesting[provider.id] = true;
|
||||
this.providerTestResults[provider.id] = null;
|
||||
@@ -408,6 +514,34 @@ function settingsPage() {
|
||||
this.providerUrlSaving[provider.id] = false;
|
||||
},
|
||||
|
||||
async addCustomProvider() {
|
||||
var name = this.customProviderName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
||||
if (!name) { OpenFangToast.error('Please enter a provider name'); return; }
|
||||
var url = this.customProviderUrl.trim();
|
||||
if (!url) { OpenFangToast.error('Please enter a base URL'); return; }
|
||||
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
||||
OpenFangToast.error('URL must start with http:// or https://'); return;
|
||||
}
|
||||
this.addingCustomProvider = true;
|
||||
this.customProviderStatus = '';
|
||||
try {
|
||||
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(name) + '/url', { base_url: url });
|
||||
if (this.customProviderKey.trim()) {
|
||||
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(name) + '/key', { key: this.customProviderKey.trim() });
|
||||
}
|
||||
this.customProviderName = '';
|
||||
this.customProviderUrl = '';
|
||||
this.customProviderKey = '';
|
||||
this.customProviderStatus = '';
|
||||
OpenFangToast.success('Provider "' + name + '" added' + (result.reachable ? ' (reachable)' : ' (not reachable yet)'));
|
||||
await this.loadProviders();
|
||||
} catch(e) {
|
||||
this.customProviderStatus = 'Error: ' + (e.message || 'Failed');
|
||||
OpenFangToast.error('Failed to add provider: ' + e.message);
|
||||
}
|
||||
this.addingCustomProvider = false;
|
||||
},
|
||||
|
||||
// -- Security methods --
|
||||
async loadSecurity() {
|
||||
this.secLoading = true;
|
||||
|
||||
@@ -19,10 +19,16 @@ function skillsPage() {
|
||||
installingSlug: null,
|
||||
installResult: null,
|
||||
_searchTimer: null,
|
||||
_browseCache: {}, // { key: { ts, data } } client-side 60s cache
|
||||
_searchCache: {},
|
||||
|
||||
// Skill detail modal
|
||||
skillDetail: null,
|
||||
detailLoading: false,
|
||||
showSkillCode: false,
|
||||
skillCode: '',
|
||||
skillCodeFilename: '',
|
||||
skillCodeLoading: false,
|
||||
|
||||
// MCP servers
|
||||
mcpServers: [],
|
||||
@@ -146,9 +152,16 @@ function skillsPage() {
|
||||
if (this._searchTimer) clearTimeout(this._searchTimer);
|
||||
},
|
||||
|
||||
// ClawHub browse by sort
|
||||
// ClawHub browse by sort (with 60s client-side cache)
|
||||
async browseClawHub(sort) {
|
||||
this.clawhubSort = sort || 'trending';
|
||||
var ckey = 'browse:' + this.clawhubSort;
|
||||
var cached = this._browseCache[ckey];
|
||||
if (cached && (Date.now() - cached.ts) < 60000) {
|
||||
this.clawhubBrowseResults = cached.data.items || [];
|
||||
this.clawhubNextCursor = cached.data.next_cursor || null;
|
||||
return;
|
||||
}
|
||||
this.clawhubLoading = true;
|
||||
this.clawhubError = '';
|
||||
this.clawhubNextCursor = null;
|
||||
@@ -157,6 +170,7 @@ function skillsPage() {
|
||||
this.clawhubBrowseResults = data.items || [];
|
||||
this.clawhubNextCursor = data.next_cursor || null;
|
||||
if (data.error) this.clawhubError = data.error;
|
||||
this._browseCache[ckey] = { ts: Date.now(), data: data };
|
||||
} catch(e) {
|
||||
this.clawhubBrowseResults = [];
|
||||
this.clawhubError = e.message || 'Browse failed';
|
||||
@@ -195,6 +209,26 @@ function skillsPage() {
|
||||
closeDetail() {
|
||||
this.skillDetail = null;
|
||||
this.installResult = null;
|
||||
this.showSkillCode = false;
|
||||
this.skillCode = '';
|
||||
this.skillCodeFilename = '';
|
||||
},
|
||||
|
||||
async viewSkillCode(slug) {
|
||||
if (this.showSkillCode) {
|
||||
this.showSkillCode = false;
|
||||
return;
|
||||
}
|
||||
this.skillCodeLoading = true;
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/clawhub/skill/' + encodeURIComponent(slug) + '/code');
|
||||
this.skillCode = data.code || '';
|
||||
this.skillCodeFilename = data.filename || 'source';
|
||||
this.showSkillCode = true;
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Could not load skill source code');
|
||||
}
|
||||
this.skillCodeLoading = false;
|
||||
},
|
||||
|
||||
// Install from ClawHub
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
// OpenFang Setup Wizard — First-run guided setup (Provider + Agent + Channel)
|
||||
'use strict';
|
||||
|
||||
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n"""). */
|
||||
function wizardTomlMultilineEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
|
||||
}
|
||||
|
||||
/** Escape a string for use inside a TOML basic (single-line) string ("..."). */
|
||||
function wizardTomlBasicEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
function wizardPage() {
|
||||
return {
|
||||
step: 1,
|
||||
@@ -244,6 +254,15 @@ function wizardPage() {
|
||||
this.error = '';
|
||||
try {
|
||||
await this.loadProviders();
|
||||
// Pre-select first unconfigured provider, or first one
|
||||
var unconfigured = this.providers.filter(function(p) {
|
||||
return p.auth_status !== 'configured' && p.api_key_env;
|
||||
});
|
||||
if (unconfigured.length > 0) {
|
||||
this.selectedProvider = unconfigured[0].id;
|
||||
} else if (this.providers.length > 0) {
|
||||
this.selectedProvider = this.providers[0].id;
|
||||
}
|
||||
} catch(e) {
|
||||
this.error = e.message || 'Could not load setup data.';
|
||||
}
|
||||
@@ -283,11 +302,13 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get canGoNext() {
|
||||
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider;
|
||||
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider || this.claudeCodeDetected;
|
||||
if (this.step === 3) return this.agentName.trim().length > 0;
|
||||
return true;
|
||||
},
|
||||
|
||||
claudeCodeDetected: false,
|
||||
|
||||
get hasConfiguredProvider() {
|
||||
var self = this;
|
||||
return this.providers.some(function(p) {
|
||||
@@ -301,15 +322,6 @@ function wizardPage() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/providers');
|
||||
this.providers = data.providers || [];
|
||||
// Pre-select first unconfigured provider, or first one
|
||||
var unconfigured = this.providers.filter(function(p) {
|
||||
return p.auth_status !== 'configured' && p.api_key_env;
|
||||
});
|
||||
if (unconfigured.length > 0) {
|
||||
this.selectedProvider = unconfigured[0].id;
|
||||
} else if (this.providers.length > 0) {
|
||||
this.selectedProvider = this.providers[0].id;
|
||||
}
|
||||
} catch(e) { this.providers = []; }
|
||||
},
|
||||
|
||||
@@ -320,7 +332,7 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get popularProviders() {
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
|
||||
return this.providers.filter(function(p) {
|
||||
return popular.indexOf(p.id) >= 0;
|
||||
}).sort(function(a, b) {
|
||||
@@ -329,7 +341,7 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get otherProviders() {
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
|
||||
return this.providers.filter(function(p) {
|
||||
return popular.indexOf(p.id) < 0;
|
||||
});
|
||||
@@ -355,7 +367,8 @@ function wizardPage() {
|
||||
fireworks: { url: 'https://fireworks.ai/account/api-keys', text: 'Get your key from Fireworks AI' },
|
||||
perplexity: { url: 'https://www.perplexity.ai/settings/api', text: 'Get your key from Perplexity Settings' },
|
||||
cohere: { url: 'https://dashboard.cohere.com/api-keys', text: 'Get your key from the Cohere Dashboard' },
|
||||
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' }
|
||||
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' },
|
||||
'claude-code': { url: 'https://docs.anthropic.com/en/docs/claude-code', text: 'Install: npm install -g @anthropic-ai/claude-code && claude auth (no API key needed)' }
|
||||
};
|
||||
return help[id] || null;
|
||||
},
|
||||
@@ -408,6 +421,28 @@ function wizardPage() {
|
||||
this.testingProvider = false;
|
||||
},
|
||||
|
||||
async detectClaudeCode() {
|
||||
this.testingProvider = true;
|
||||
this.testResult = null;
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/providers/claude-code/test', {});
|
||||
this.testResult = result;
|
||||
if (result.status === 'ok') {
|
||||
this.claudeCodeDetected = true;
|
||||
this.keySaved = true;
|
||||
this.setupSummary.provider = 'Claude Code';
|
||||
OpenFangToast.success('Claude Code detected (' + (result.latency_ms || '?') + 'ms)');
|
||||
} else {
|
||||
this.testResult = { status: 'error', error: 'Claude Code CLI not detected' };
|
||||
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
|
||||
}
|
||||
} catch(e) {
|
||||
this.testResult = { status: 'error', error: e.message };
|
||||
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
|
||||
}
|
||||
this.testingProvider = false;
|
||||
},
|
||||
|
||||
// ── Step 3: Agent creation ──
|
||||
|
||||
selectTemplate(index) {
|
||||
@@ -437,12 +472,12 @@ function wizardPage() {
|
||||
}
|
||||
|
||||
var toml = '[agent]\n';
|
||||
toml += 'name = "' + name.replace(/"/g, '\\"') + '"\n';
|
||||
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
|
||||
toml += 'name = "' + wizardTomlBasicEscape(name) + '"\n';
|
||||
toml += 'description = "' + wizardTomlBasicEscape(tpl.description) + '"\n';
|
||||
toml += 'profile = "' + tpl.profile + '"\n\n';
|
||||
toml += '[model]\nprovider = "' + provider + '"\n';
|
||||
toml += 'name = "' + model + '"\n\n';
|
||||
toml += '[prompt]\nsystem = """\n' + tpl.system_prompt + '\n"""\n';
|
||||
toml += 'model = "' + model + '"\n';
|
||||
toml += 'system_prompt = """\n' + wizardTomlMultilineEscape(tpl.system_prompt) + '\n"""\n';
|
||||
|
||||
this.creatingAgent = true;
|
||||
try {
|
||||
@@ -468,13 +503,14 @@ function wizardPage() {
|
||||
gemini: 'gemini-2.5-flash',
|
||||
groq: 'llama-3.3-70b-versatile',
|
||||
deepseek: 'deepseek-chat',
|
||||
openrouter: 'openrouter/auto',
|
||||
openrouter: 'openrouter/google/gemini-2.5-flash',
|
||||
mistral: 'mistral-large-latest',
|
||||
together: 'meta-llama/Llama-3-70b-chat-hf',
|
||||
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
||||
perplexity: 'llama-3.1-sonar-large-128k-online',
|
||||
cohere: 'command-r-plus',
|
||||
xai: 'grok-2'
|
||||
xai: 'grok-2',
|
||||
'claude-code': 'claude-code/sonnet'
|
||||
};
|
||||
return defaults[providerId] || '';
|
||||
},
|
||||
|
||||
@@ -37,6 +37,13 @@ function workflowBuilder() {
|
||||
{ type: 'end', label: 'End', color: '#ef4444', icon: 'E', ports: { in: 1, out: 0 } }
|
||||
],
|
||||
|
||||
_renderScheduled: false,
|
||||
_lastClickNodeId: null,
|
||||
_lastClickTime: 0,
|
||||
_didDrag: false,
|
||||
_didConnect: false,
|
||||
_didPan: false,
|
||||
|
||||
async init() {
|
||||
var self = this;
|
||||
// Load agents for the agent step dropdown
|
||||
@@ -50,6 +57,157 @@ function workflowBuilder() {
|
||||
self.addNode('start', 60, 200);
|
||||
},
|
||||
|
||||
// ── SVG Manual Rendering ────────────────────────────
|
||||
// Alpine.js x-for inside <svg> breaks because document.importNode
|
||||
// doesn't handle SVG namespace correctly. We render nodes/connections
|
||||
// manually via createElementNS and schedule re-renders reactively.
|
||||
|
||||
scheduleRender: function() {
|
||||
if (this._renderScheduled) return;
|
||||
this._renderScheduled = true;
|
||||
var self = this;
|
||||
requestAnimationFrame(function() {
|
||||
self._renderScheduled = false;
|
||||
self.renderCanvas();
|
||||
});
|
||||
},
|
||||
|
||||
renderCanvas: function() {
|
||||
var container = document.getElementById('wf-render-group');
|
||||
if (!container) return;
|
||||
var SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
var self = this;
|
||||
|
||||
// Clear previous rendered content
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
// ── Connections ──
|
||||
for (var ci = 0; ci < this.connections.length; ci++) {
|
||||
var conn = this.connections[ci];
|
||||
var d = this.getConnectionPath(conn);
|
||||
if (!d) continue;
|
||||
var path = document.createElementNS(SVG_NS, 'path');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('fill', 'none');
|
||||
path.setAttribute('stroke', (this.selectedConnection && this.selectedConnection.id === conn.id) ? 'var(--accent)' : 'var(--text-dim)');
|
||||
path.setAttribute('stroke-width', (this.selectedConnection && this.selectedConnection.id === conn.id) ? '3' : '2');
|
||||
path.style.cursor = 'pointer';
|
||||
(function(c) {
|
||||
path.addEventListener('click', function(e) { e.stopPropagation(); self.selectedConnection = c; self.scheduleRender(); });
|
||||
})(conn);
|
||||
container.appendChild(path);
|
||||
}
|
||||
|
||||
// ── Connection preview ──
|
||||
if (this.connecting && this.connectPreview) {
|
||||
var pd = this.getPreviewPath();
|
||||
if (pd) {
|
||||
var preview = document.createElementNS(SVG_NS, 'path');
|
||||
preview.setAttribute('d', pd);
|
||||
preview.setAttribute('fill', 'none');
|
||||
preview.setAttribute('stroke', 'var(--accent)');
|
||||
preview.setAttribute('stroke-width', '2');
|
||||
preview.setAttribute('stroke-dasharray', '6,3');
|
||||
container.appendChild(preview);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nodes ──
|
||||
for (var ni = 0; ni < this.nodes.length; ni++) {
|
||||
var node = this.nodes[ni];
|
||||
var g = document.createElementNS(SVG_NS, 'g');
|
||||
g.classList.add('wf-node');
|
||||
g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')');
|
||||
(function(n) {
|
||||
g.addEventListener('mousedown', function(e) { self.onNodeMouseDown(n, e); });
|
||||
g.addEventListener('dblclick', function() { self.editNode(n); });
|
||||
})(node);
|
||||
|
||||
// Node body rect
|
||||
var rect = document.createElementNS(SVG_NS, 'rect');
|
||||
rect.setAttribute('x', '0'); rect.setAttribute('y', '0');
|
||||
rect.setAttribute('width', node.width); rect.setAttribute('height', node.height);
|
||||
rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8');
|
||||
rect.setAttribute('fill', (self.selectedNode && self.selectedNode.id === node.id) ? 'var(--card-bg)' : 'var(--bg-secondary)');
|
||||
rect.setAttribute('stroke', (self.selectedNode && self.selectedNode.id === node.id) ? node.color : 'var(--border)');
|
||||
rect.setAttribute('stroke-width', '2');
|
||||
rect.style.cursor = 'grab';
|
||||
g.appendChild(rect);
|
||||
|
||||
// Color accent bar
|
||||
var bar = document.createElementNS(SVG_NS, 'rect');
|
||||
bar.setAttribute('x', '0'); bar.setAttribute('y', '0');
|
||||
bar.setAttribute('width', '6'); bar.setAttribute('height', node.height);
|
||||
bar.setAttribute('rx', '3'); bar.setAttribute('ry', '0');
|
||||
bar.setAttribute('fill', node.color);
|
||||
g.appendChild(bar);
|
||||
|
||||
// Icon circle + text
|
||||
var circle = document.createElementNS(SVG_NS, 'circle');
|
||||
circle.setAttribute('cx', '28'); circle.setAttribute('cy', node.height / 2);
|
||||
circle.setAttribute('r', '14'); circle.setAttribute('fill', node.color);
|
||||
circle.setAttribute('opacity', '0.15');
|
||||
g.appendChild(circle);
|
||||
|
||||
var iconText = document.createElementNS(SVG_NS, 'text');
|
||||
iconText.setAttribute('x', '28'); iconText.setAttribute('y', node.height / 2 + 4);
|
||||
iconText.setAttribute('text-anchor', 'middle'); iconText.setAttribute('fill', node.color);
|
||||
iconText.setAttribute('style', 'font-size:12px;font-weight:700;pointer-events:none');
|
||||
iconText.textContent = node.icon;
|
||||
g.appendChild(iconText);
|
||||
|
||||
// Label
|
||||
var label = document.createElementNS(SVG_NS, 'text');
|
||||
label.setAttribute('x', '50'); label.setAttribute('y', node.height / 2 - 4);
|
||||
label.setAttribute('fill', 'var(--text)');
|
||||
label.setAttribute('style', 'font-size:12px;font-weight:600;pointer-events:none');
|
||||
label.textContent = node.label;
|
||||
g.appendChild(label);
|
||||
|
||||
// Sub-label
|
||||
var subLabel = document.createElementNS(SVG_NS, 'text');
|
||||
subLabel.setAttribute('x', '50'); subLabel.setAttribute('y', node.height / 2 + 12);
|
||||
subLabel.setAttribute('fill', 'var(--text-dim)');
|
||||
subLabel.setAttribute('style', 'font-size:10px;pointer-events:none');
|
||||
if (node.type === 'agent') subLabel.textContent = node.config.agent_name || 'No agent';
|
||||
else if (node.type === 'condition') subLabel.textContent = node.config.expression || 'No condition';
|
||||
else if (node.type === 'loop') subLabel.textContent = 'max ' + (node.config.max_iterations || 5) + ' iters';
|
||||
else if (node.type === 'parallel') subLabel.textContent = (node.config.fan_count || 3) + ' branches';
|
||||
else if (node.type === 'collect') subLabel.textContent = node.config.strategy || 'all';
|
||||
g.appendChild(subLabel);
|
||||
|
||||
// Input ports
|
||||
for (var pi = 0; pi < node.ports.in; pi++) {
|
||||
var inp = document.createElementNS(SVG_NS, 'circle');
|
||||
inp.classList.add('wf-port', 'wf-port-in');
|
||||
inp.setAttribute('cx', node.width / (node.ports.in + 1) * (pi + 1));
|
||||
inp.setAttribute('cy', '0'); inp.setAttribute('r', '6');
|
||||
inp.setAttribute('fill', 'var(--bg-secondary)');
|
||||
inp.setAttribute('stroke', 'var(--text-dim)'); inp.setAttribute('stroke-width', '2');
|
||||
(function(nid, idx) {
|
||||
inp.addEventListener('mouseup', function(e) { e.stopPropagation(); self.endConnect(nid, idx, e); });
|
||||
})(node.id, pi);
|
||||
g.appendChild(inp);
|
||||
}
|
||||
|
||||
// Output ports
|
||||
for (var po = 0; po < node.ports.out; po++) {
|
||||
var outp = document.createElementNS(SVG_NS, 'circle');
|
||||
outp.classList.add('wf-port', 'wf-port-out');
|
||||
outp.setAttribute('cx', node.width / (node.ports.out + 1) * (po + 1));
|
||||
outp.setAttribute('cy', node.height); outp.setAttribute('r', '6');
|
||||
outp.setAttribute('fill', 'var(--bg-secondary)');
|
||||
outp.setAttribute('stroke', node.color); outp.setAttribute('stroke-width', '2');
|
||||
(function(nid, idx) {
|
||||
outp.addEventListener('mousedown', function(e) { e.stopPropagation(); self.startConnect(nid, idx, e); });
|
||||
})(node.id, po);
|
||||
g.appendChild(outp);
|
||||
}
|
||||
|
||||
container.appendChild(g);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Node Management ──────────────────────────────────
|
||||
|
||||
addNode: function(type, x, y) {
|
||||
@@ -83,6 +241,7 @@ function workflowBuilder() {
|
||||
node.config = { strategy: 'all' };
|
||||
}
|
||||
this.nodes.push(node);
|
||||
this.scheduleRender();
|
||||
return node;
|
||||
},
|
||||
|
||||
@@ -95,6 +254,7 @@ function workflowBuilder() {
|
||||
this.selectedNode = null;
|
||||
this.showNodeEditor = false;
|
||||
}
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
duplicateNode: function(node) {
|
||||
@@ -166,19 +326,36 @@ function workflowBuilder() {
|
||||
}
|
||||
this.connecting = null;
|
||||
this.connectPreview = null;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
deleteConnection: function(connId) {
|
||||
this.connections = this.connections.filter(function(c) { return c.id !== connId; });
|
||||
this.selectedConnection = null;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── Drag Handling ────────────────────────────────────
|
||||
|
||||
onNodeMouseDown: function(node, e) {
|
||||
e.stopPropagation();
|
||||
// Detect double-click manually — the native dblclick event never fires
|
||||
// because scheduleRender() destroys and recreates all SVG elements between
|
||||
// the first and second click, so the browser loses the DOM target for dblclick.
|
||||
var now = Date.now();
|
||||
if (this._lastClickNodeId === node.id && (now - this._lastClickTime) < 350) {
|
||||
// Double-click detected — open editor instead of starting drag
|
||||
this._lastClickNodeId = null;
|
||||
this._lastClickTime = 0;
|
||||
this.editNode(node);
|
||||
return;
|
||||
}
|
||||
this._lastClickNodeId = node.id;
|
||||
this._lastClickTime = now;
|
||||
|
||||
this.selectedNode = node;
|
||||
this.selectedConnection = null;
|
||||
this._didDrag = false;
|
||||
this.dragging = node.id;
|
||||
var rect = this._getCanvasRect();
|
||||
this.dragOffset = {
|
||||
@@ -193,6 +370,7 @@ function workflowBuilder() {
|
||||
this.selectedConnection = null;
|
||||
this.showNodeEditor = false;
|
||||
// Start canvas pan
|
||||
this._didPan = false;
|
||||
this.canvasDragging = true;
|
||||
this.canvasDragStart = { x: e.clientX - this.canvasOffset.x * this.zoom, y: e.clientY - this.canvasOffset.y * this.zoom };
|
||||
},
|
||||
@@ -200,17 +378,22 @@ function workflowBuilder() {
|
||||
onCanvasMouseMove: function(e) {
|
||||
var rect = this._getCanvasRect();
|
||||
if (this.dragging) {
|
||||
this._didDrag = true;
|
||||
var node = this.getNode(this.dragging);
|
||||
if (node) {
|
||||
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
|
||||
node.y = Math.max(0, (e.clientY - rect.top) / this.zoom - this.canvasOffset.y - this.dragOffset.y);
|
||||
}
|
||||
this.scheduleRender();
|
||||
} else if (this.connecting) {
|
||||
this._didConnect = true;
|
||||
this.connectPreview = {
|
||||
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
|
||||
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
|
||||
};
|
||||
this.scheduleRender();
|
||||
} else if (this.canvasDragging) {
|
||||
this._didPan = true;
|
||||
this.canvasOffset = {
|
||||
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
|
||||
y: (e.clientY - this.canvasDragStart.y) / this.zoom
|
||||
@@ -219,10 +402,19 @@ function workflowBuilder() {
|
||||
},
|
||||
|
||||
onCanvasMouseUp: function() {
|
||||
// Only re-render if something actually moved. Rendering on every mouseup
|
||||
// destroys SVG elements between clicks, which prevents dblclick detection.
|
||||
var needsRender = this._didDrag || this._didConnect || this._didPan;
|
||||
this.dragging = null;
|
||||
this.connecting = null;
|
||||
this.connectPreview = null;
|
||||
this.canvasDragging = false;
|
||||
this._didDrag = false;
|
||||
this._didConnect = false;
|
||||
this._didPan = false;
|
||||
if (needsRender) {
|
||||
this.scheduleRender();
|
||||
}
|
||||
},
|
||||
|
||||
onCanvasWheel: function(e) {
|
||||
@@ -267,6 +459,12 @@ function workflowBuilder() {
|
||||
editNode: function(node) {
|
||||
this.selectedNode = node;
|
||||
this.showNodeEditor = true;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// Called from editor panel inputs to reflect changes on the canvas SVG
|
||||
applyNodeEdit: function() {
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── TOML Generation ──────────────────────────────────
|
||||
@@ -386,7 +584,7 @@ function workflowBuilder() {
|
||||
var rect = this._getCanvasRect();
|
||||
var x = (e.clientX - rect.left) / this.zoom - this.canvasOffset.x;
|
||||
var y = (e.clientY - rect.top) / this.zoom - this.canvasOffset.y;
|
||||
this.addNode(type, x - 90, y - 35);
|
||||
this.addNode(type, x - 90, y - 35); // addNode already calls scheduleRender
|
||||
},
|
||||
|
||||
onCanvasDragOver: function(e) {
|
||||
@@ -405,6 +603,7 @@ function workflowBuilder() {
|
||||
this.nodes[i].y = y;
|
||||
y += 120;
|
||||
}
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── Clear ────────────────────────────────────────────
|
||||
@@ -414,7 +613,7 @@ function workflowBuilder() {
|
||||
this.connections = [];
|
||||
this.selectedNode = null;
|
||||
this.nextId = 1;
|
||||
this.addNode('start', 60, 200);
|
||||
this.addNode('start', 60, 200); // addNode already calls scheduleRender
|
||||
},
|
||||
|
||||
// ── Zoom controls ────────────────────────────────────
|
||||
|
||||
@@ -13,6 +13,8 @@ function workflowsPage() {
|
||||
loading: true,
|
||||
loadError: '',
|
||||
newWf: { name: '', description: '', steps: [{ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' }] },
|
||||
editModal: null,
|
||||
editWf: { name: '', description: '', steps: [] },
|
||||
|
||||
// -- Workflows methods --
|
||||
async loadWorkflows() {
|
||||
@@ -74,6 +76,57 @@ function workflowsPage() {
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to load run history: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWorkflow(wf) {
|
||||
if (!confirm('Delete workflow "' + wf.name + '"? This cannot be undone.')) return;
|
||||
try {
|
||||
await OpenFangAPI.delete('/api/workflows/' + wf.id);
|
||||
OpenFangToast.success('Workflow "' + wf.name + '" deleted');
|
||||
await this.loadWorkflows();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete workflow: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async showEditModal(wf) {
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/workflows/' + wf.id);
|
||||
this.editWf = {
|
||||
name: full.name || '',
|
||||
description: full.description || '',
|
||||
steps: (full.steps || []).map(function(s) {
|
||||
return {
|
||||
name: s.name || '',
|
||||
agent_name: (s.agent && s.agent.name) || '',
|
||||
mode: s.mode || 'sequential',
|
||||
prompt: s.prompt_template || '{{input}}'
|
||||
};
|
||||
})
|
||||
};
|
||||
if (this.editWf.steps.length === 0) {
|
||||
this.editWf.steps.push({ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' });
|
||||
}
|
||||
this.editModal = wf;
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to load workflow: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async saveWorkflow() {
|
||||
if (!this.editModal) return;
|
||||
var steps = this.editWf.steps.map(function(s) {
|
||||
return { name: s.name || 'step', agent_name: s.agent_name, mode: s.mode, prompt: s.prompt || '{{input}}' };
|
||||
});
|
||||
try {
|
||||
var wfName = this.editWf.name;
|
||||
await OpenFangAPI.put('/api/workflows/' + this.editModal.id, { name: wfName, description: this.editWf.description, steps: steps });
|
||||
this.editModal = null;
|
||||
OpenFangToast.success('Workflow "' + wfName + '" updated');
|
||||
await this.loadWorkflows();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update workflow: ' + e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "OpenFang Agent OS",
|
||||
"short_name": "OpenFang",
|
||||
"description": "Open-source Agent Operating System",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#6366f1",
|
||||
"icons": [
|
||||
{"src": "/logo.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/logo.png", "sizes": "512x512", "type": "image/png"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -76,6 +76,9 @@ async fn start_test_server_with_provider(
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -221,10 +224,10 @@ async fn test_status_endpoint() {
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["status"], "running");
|
||||
assert_eq!(body["agent_count"], 0);
|
||||
assert_eq!(body["agent_count"], 1); // default assistant auto-spawned
|
||||
assert!(body["uptime_seconds"].is_number());
|
||||
assert_eq!(body["default_provider"], "ollama");
|
||||
assert_eq!(body["agents"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(body["agents"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -246,7 +249,7 @@ async fn test_spawn_list_kill_agent() {
|
||||
let agent_id = body["agent_id"].as_str().unwrap().to_string();
|
||||
assert!(!agent_id.is_empty());
|
||||
|
||||
// --- List (1 agent) ---
|
||||
// --- List (2 agents: default assistant + test-agent) ---
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
@@ -254,10 +257,10 @@ async fn test_spawn_list_kill_agent() {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 1);
|
||||
assert_eq!(agents[0]["name"], "test-agent");
|
||||
assert_eq!(agents[0]["id"], agent_id);
|
||||
assert_eq!(agents[0]["model_provider"], "ollama");
|
||||
assert_eq!(agents.len(), 2);
|
||||
let test_agent = agents.iter().find(|a| a["name"] == "test-agent").unwrap();
|
||||
assert_eq!(test_agent["id"], agent_id);
|
||||
assert_eq!(test_agent["model_provider"], "ollama");
|
||||
|
||||
// --- Kill ---
|
||||
let resp = client
|
||||
@@ -269,7 +272,7 @@ async fn test_spawn_list_kill_agent() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["status"], "killed");
|
||||
|
||||
// --- List (empty) ---
|
||||
// --- List (only default assistant remains) ---
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
@@ -277,7 +280,8 @@ async fn test_spawn_list_kill_agent() {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 0);
|
||||
assert_eq!(agents.len(), 1);
|
||||
assert_eq!(agents[0]["name"], "assistant");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -616,14 +620,14 @@ memory_write = ["self.*"]
|
||||
ids.push(body["agent_id"].as_str().unwrap().to_string());
|
||||
}
|
||||
|
||||
// List should show 3
|
||||
// List should show 4 (3 spawned + default assistant)
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 3);
|
||||
assert_eq!(agents.len(), 4);
|
||||
|
||||
// Status should agree
|
||||
let resp = client
|
||||
@@ -632,7 +636,7 @@ memory_write = ["self.*"]
|
||||
.await
|
||||
.unwrap();
|
||||
let status: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(status["agent_count"], 3);
|
||||
assert_eq!(status["agent_count"], 4);
|
||||
|
||||
// Kill one
|
||||
let resp = client
|
||||
@@ -642,14 +646,14 @@ memory_write = ["self.*"]
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// List should show 2
|
||||
// List should show 3 (2 spawned + default assistant)
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 2);
|
||||
assert_eq!(agents.len(), 3);
|
||||
|
||||
// Kill the rest
|
||||
for id in [&ids[0], &ids[2]] {
|
||||
@@ -660,14 +664,14 @@ memory_write = ["self.*"]
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// List should be empty
|
||||
// List should have only default assistant
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 0);
|
||||
assert_eq!(agents.len(), 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -702,9 +706,23 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let api_key_state = state.kernel.config.api_key.clone();
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
let auth_state = middleware::AuthState {
|
||||
api_key: api_key.clone(),
|
||||
auth_enabled: state.kernel.config.auth.enabled,
|
||||
session_secret: if !api_key.is_empty() {
|
||||
api_key.clone()
|
||||
} else if state.kernel.config.auth.enabled {
|
||||
state.kernel.config.auth.password_hash.clone()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/health", axum::routing::get(routes::health))
|
||||
@@ -748,7 +766,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
)
|
||||
.route("/api/shutdown", axum::routing::post(routes::shutdown))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
api_key_state,
|
||||
auth_state,
|
||||
middleware::auth,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(middleware::request_logging))
|
||||
|
||||
@@ -113,6 +113,9 @@ async fn test_full_daemon_lifecycle() {
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -236,6 +239,9 @@ async fn test_server_immediate_responsiveness() {
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -57,6 +57,9 @@ async fn start_test_server() -> TestServer {
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -540,7 +543,7 @@ async fn load_spawn_kill_cycle() {
|
||||
.await
|
||||
.unwrap();
|
||||
let remaining = agents.as_array().map(|a| a.len()).unwrap_or(0);
|
||||
assert_eq!(remaining, 0, "All agents should be killed");
|
||||
assert_eq!(remaining, 1, "Only default assistant should remain");
|
||||
}
|
||||
|
||||
/// Test: Prometheus metrics endpoint under sustained load.
|
||||
|
||||
@@ -14,6 +14,7 @@ chrono = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -24,13 +25,20 @@ zeroize = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
base64 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
html-escape = { workspace = true }
|
||||
regex-lite = "0.1"
|
||||
roxmltree = "0.21"
|
||||
|
||||
lettre = { workspace = true }
|
||||
imap = { workspace = true }
|
||||
native-tls = { workspace = true }
|
||||
mailparse = { workspace = true }
|
||||
rumqttc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -215,7 +215,7 @@ impl BlueskyAdapter {
|
||||
let chunks = split_message(text, MAX_MESSAGE_LEN);
|
||||
|
||||
for chunk in chunks {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
|
||||
let mut record = serde_json::json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
@@ -435,7 +435,11 @@ impl ChannelAdapter for BlueskyAdapter {
|
||||
service_url
|
||||
);
|
||||
if let Some(ref seen) = last_seen_at {
|
||||
url.push_str(&format!("&seenAt={}", seen));
|
||||
let encoded: String = url::form_urlencoded::Serializer::new(String::new())
|
||||
.append_pair("seenAt", seen)
|
||||
.finish();
|
||||
url.push('&');
|
||||
url.push_str(&encoded);
|
||||
}
|
||||
|
||||
let resp = match client.get(&url).bearer_auth(&token).send().await {
|
||||
@@ -492,7 +496,7 @@ impl ChannelAdapter for BlueskyAdapter {
|
||||
if last_seen_at.is_some() {
|
||||
let mark_url = format!("{}/xrpc/app.bsky.notification.updateSeen", service_url);
|
||||
let mark_body = serde_json::json!({
|
||||
"seenAt": Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
|
||||
"seenAt": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
});
|
||||
let _ = client
|
||||
.post(&mark_url)
|
||||
|
||||
+1077
-119
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,600 @@
|
||||
//! DingTalk Stream channel adapter.
|
||||
//!
|
||||
//! Uses DingTalk Stream Mode (WebSocket long-connection) instead of the
|
||||
//! legacy webhook approach. The webhook adapter in `dingtalk.rs` is preserved
|
||||
//! for backwards compatibility.
|
||||
//!
|
||||
//! Protocol:
|
||||
//! 1. POST /v1.0/oauth2/accessToken → get access token
|
||||
//! 2. POST /v1.0/gateway/connections/open → get WebSocket URL
|
||||
//! 3. Connect via WebSocket, handle ping/pong and EVENT messages
|
||||
//! 4. Outbound: POST /v1.0/robot/oToMessages/batchSend
|
||||
|
||||
use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use futures::{SinkExt, Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const API_BASE: &str = "https://api.dingtalk.com";
|
||||
const MAX_MESSAGE_LEN: usize = 20000;
|
||||
|
||||
// ─── Adapter ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct DingTalkStreamAdapter {
|
||||
app_key: String,
|
||||
app_secret: String,
|
||||
robot_code: String,
|
||||
client: reqwest::Client,
|
||||
token_cache: Arc<Mutex<TokenCache>>,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
impl DingTalkStreamAdapter {
|
||||
pub fn new(app_key: String, app_secret: String, robot_code: String) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
app_key,
|
||||
app_secret,
|
||||
robot_code,
|
||||
client: reqwest::Client::new(),
|
||||
token_cache: Arc::new(Mutex::new(TokenCache::default())),
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_token(&self) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_secs();
|
||||
{
|
||||
let c = self.token_cache.lock().unwrap();
|
||||
if !c.token.is_empty() && c.expire_at > now + 300 {
|
||||
return Ok(c.token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let resp: serde_json::Value = self
|
||||
.client
|
||||
.post(format!("{API_BASE}/v1.0/oauth2/accessToken"))
|
||||
.json(&serde_json::json!({
|
||||
"appKey": self.app_key,
|
||||
"appSecret": self.app_secret,
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let token = resp["accessToken"]
|
||||
.as_str()
|
||||
.ok_or("missing accessToken")?
|
||||
.to_string();
|
||||
let expire_in = resp["expireIn"].as_u64().unwrap_or(7200);
|
||||
|
||||
{
|
||||
let mut c = self.token_cache.lock().unwrap();
|
||||
c.token = token.clone();
|
||||
c.expire_at = now + expire_in;
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
async fn send_to_ids(
|
||||
&self,
|
||||
user_ids: &[&str],
|
||||
content: ChannelContent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let token = self
|
||||
.get_token()
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error> { e })?;
|
||||
|
||||
let (msg_key, _msg_param) = match &content {
|
||||
ChannelContent::Text(t) => (
|
||||
"sampleText",
|
||||
serde_json::json!({ "content": t }).to_string(),
|
||||
),
|
||||
_ => (
|
||||
"sampleText",
|
||||
serde_json::json!({ "content": "(unsupported content type)" }).to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let text = match &content {
|
||||
ChannelContent::Text(t) => t.as_str(),
|
||||
_ => "(unsupported)",
|
||||
};
|
||||
let chunks = split_message(text, MAX_MESSAGE_LEN);
|
||||
|
||||
for chunk in &chunks {
|
||||
let param = serde_json::json!({ "content": chunk }).to_string();
|
||||
let body = serde_json::json!({
|
||||
"robotCode": self.robot_code,
|
||||
"userIds": user_ids,
|
||||
"msgKey": msg_key,
|
||||
"msgParam": param,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{API_BASE}/v1.0/robot/oToMessages/batchSend"))
|
||||
.header("x-acs-dingtalk-access-token", &token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let err_body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("DingTalk batchSend error {status}: {err_body}").into());
|
||||
}
|
||||
|
||||
if chunks.len() > 1 {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for DingTalkStreamAdapter {
|
||||
fn name(&self) -> &str {
|
||||
"dingtalk_stream"
|
||||
}
|
||||
|
||||
fn channel_type(&self) -> ChannelType {
|
||||
ChannelType::Custom("dingtalk_stream".to_string())
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let app_key = self.app_key.clone();
|
||||
let app_secret = self.app_secret.clone();
|
||||
let client = self.client.clone();
|
||||
let token_cache = Arc::clone(&self.token_cache);
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
|
||||
info!("DingTalk Stream adapter starting WebSocket connection");
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut attempt: u32 = 0;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("DingTalk Stream: shutdown requested");
|
||||
break;
|
||||
}
|
||||
|
||||
// 1. Get access token
|
||||
let token =
|
||||
match get_access_token(&client, &app_key, &app_secret, &token_cache).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
warn!("DingTalk Stream: token fetch failed: {e}");
|
||||
attempt += 1;
|
||||
tokio::time::sleep(backoff(attempt)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Get WebSocket endpoint
|
||||
let ws_url = match get_ws_endpoint(&client, &app_key, &app_secret, &token).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
warn!("DingTalk Stream: endpoint fetch failed: {e}");
|
||||
attempt += 1;
|
||||
tokio::time::sleep(backoff(attempt)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"DingTalk Stream: connecting to {}...",
|
||||
&ws_url[..ws_url.len().min(60)]
|
||||
);
|
||||
|
||||
// 3. Connect
|
||||
let ws_stream = match connect_async(&ws_url).await {
|
||||
Ok((ws, _)) => ws,
|
||||
Err(e) => {
|
||||
warn!("DingTalk Stream: WS connect failed: {e}");
|
||||
attempt += 1;
|
||||
tokio::time::sleep(backoff(attempt)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
info!("DingTalk Stream: connected");
|
||||
attempt = 0;
|
||||
let (mut sink, mut source) = ws_stream.split();
|
||||
|
||||
// 4. Message loop
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("DingTalk Stream: graceful shutdown");
|
||||
return;
|
||||
}
|
||||
}
|
||||
msg = source.next() => {
|
||||
match msg {
|
||||
None => { warn!("DingTalk Stream: connection closed"); break; }
|
||||
Some(Err(e)) => { warn!("DingTalk Stream: WS error: {e}"); break; }
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
handle_frame(&text, &mut sink, &tx).await;
|
||||
}
|
||||
Some(Ok(Message::Ping(d))) => { let _ = sink.send(Message::Pong(d)).await; }
|
||||
Some(Ok(Message::Close(_))) => { info!("DingTalk Stream: close frame"); break; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect
|
||||
attempt += 1;
|
||||
let delay = backoff(attempt);
|
||||
info!("DingTalk Stream: reconnecting in {delay:?}");
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let uid = &user.platform_id;
|
||||
if uid.is_empty() {
|
||||
return Err("DingTalk Stream: no platform_id to reply to".into());
|
||||
}
|
||||
self.send_to_ids(&[uid.as_str()], content).await
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Token helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Default)]
|
||||
struct TokenCache {
|
||||
token: String,
|
||||
expire_at: u64,
|
||||
}
|
||||
|
||||
async fn get_access_token(
|
||||
http: &reqwest::Client,
|
||||
app_key: &str,
|
||||
app_secret: &str,
|
||||
cache: &Arc<Mutex<TokenCache>>,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_secs();
|
||||
{
|
||||
let c = cache.lock().unwrap();
|
||||
if !c.token.is_empty() && c.expire_at > now + 300 {
|
||||
return Ok(c.token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let resp: serde_json::Value = http
|
||||
.post(format!("{API_BASE}/v1.0/oauth2/accessToken"))
|
||||
.json(&serde_json::json!({ "appKey": app_key, "appSecret": app_secret }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let token = resp["accessToken"]
|
||||
.as_str()
|
||||
.ok_or("missing accessToken")?
|
||||
.to_string();
|
||||
let expire_in = resp["expireIn"].as_u64().unwrap_or(7200);
|
||||
{
|
||||
let mut c = cache.lock().unwrap();
|
||||
c.token = token.clone();
|
||||
c.expire_at = now + expire_in;
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
// ─── Gateway / WebSocket helpers ─────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OpenConnectionRequest<'a> {
|
||||
#[serde(rename = "clientId")]
|
||||
client_id: &'a str,
|
||||
#[serde(rename = "clientSecret")]
|
||||
client_secret: &'a str,
|
||||
subscriptions: Vec<SubItem>,
|
||||
ua: &'a str,
|
||||
#[serde(rename = "localIp")]
|
||||
local_ip: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SubItem {
|
||||
#[serde(rename = "type")]
|
||||
sub_type: String,
|
||||
topic: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenConnectionResponse {
|
||||
endpoint: String,
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
async fn get_ws_endpoint(
|
||||
http: &reqwest::Client,
|
||||
app_key: &str,
|
||||
app_secret: &str,
|
||||
token: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let body = OpenConnectionRequest {
|
||||
client_id: app_key,
|
||||
client_secret: app_secret,
|
||||
subscriptions: vec![SubItem {
|
||||
sub_type: "CALLBACK".to_string(),
|
||||
topic: "/v1.0/im/bot/messages/get".to_string(),
|
||||
}],
|
||||
ua: "openfang/0.3",
|
||||
local_ip: "",
|
||||
};
|
||||
let resp: OpenConnectionResponse = http
|
||||
.post(format!("{API_BASE}/v1.0/gateway/connections/open"))
|
||||
.header("x-acs-dingtalk-access-token", token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let sep = if resp.endpoint.contains('?') {
|
||||
"&"
|
||||
} else {
|
||||
"?"
|
||||
};
|
||||
Ok(format!("{}{}ticket={}", resp.endpoint, sep, resp.ticket))
|
||||
}
|
||||
|
||||
// ─── Frame handling ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProtoFrame {
|
||||
#[serde(rename = "type")]
|
||||
msg_type: String,
|
||||
headers: ProtoHeaders,
|
||||
#[serde(default)]
|
||||
data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProtoHeaders {
|
||||
#[serde(rename = "messageId", default)]
|
||||
message_id: String,
|
||||
#[serde(default)]
|
||||
topic: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AckReply {
|
||||
code: u32,
|
||||
headers: AckHeaders,
|
||||
message: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AckHeaders {
|
||||
#[serde(rename = "contentType")]
|
||||
content_type: String,
|
||||
#[serde(rename = "messageId")]
|
||||
message_id: String,
|
||||
topic: String,
|
||||
}
|
||||
|
||||
fn make_ack(message_id: &str, topic: &str) -> String {
|
||||
serde_json::to_string(&AckReply {
|
||||
code: 200,
|
||||
headers: AckHeaders {
|
||||
content_type: "application/json".to_string(),
|
||||
message_id: message_id.to_string(),
|
||||
topic: topic.to_string(),
|
||||
},
|
||||
message: "OK".to_string(),
|
||||
data: String::new(),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CallbackPayload {
|
||||
#[serde(rename = "msgtype", default)]
|
||||
msg_type: String,
|
||||
#[serde(default)]
|
||||
text: Option<TextContent>,
|
||||
#[serde(rename = "senderStaffId", default)]
|
||||
sender_staff_id: String,
|
||||
#[serde(rename = "senderId", default)]
|
||||
sender_id: String,
|
||||
#[serde(rename = "senderNick", default)]
|
||||
sender_nick: String,
|
||||
#[serde(rename = "conversationId", default)]
|
||||
conversation_id: String,
|
||||
#[serde(rename = "conversationType", default)]
|
||||
conversation_type: String,
|
||||
#[serde(rename = "messageId", default)]
|
||||
message_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TextContent {
|
||||
content: String,
|
||||
}
|
||||
|
||||
async fn handle_frame<S>(text: &str, sink: &mut S, tx: &mpsc::Sender<ChannelMessage>)
|
||||
where
|
||||
S: SinkExt<Message> + Unpin,
|
||||
<S as futures::Sink<Message>>::Error: std::fmt::Display,
|
||||
{
|
||||
let frame: ProtoFrame = match serde_json::from_str(text) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!("DingTalk Stream: bad frame: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mid = &frame.headers.message_id;
|
||||
let topic = &frame.headers.topic;
|
||||
|
||||
match frame.msg_type.as_str() {
|
||||
"SYSTEM" if topic == "ping" => {
|
||||
let _ = sink.send(Message::Text(make_ack(mid, "pong"))).await;
|
||||
}
|
||||
"CALLBACK" | "EVENT" => {
|
||||
let data_str = frame.data.to_string();
|
||||
// Try direct parse, then try unwrapping double-encoded string
|
||||
let cb: Option<CallbackPayload> = serde_json::from_str(&data_str).ok().or_else(|| {
|
||||
serde_json::from_str::<String>(&data_str)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
});
|
||||
|
||||
if let Some(cb) = cb {
|
||||
if cb.msg_type == "text" {
|
||||
if let Some(ref tc) = cb.text {
|
||||
let trimmed = tc.content.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
let content = if trimmed.starts_with('/') {
|
||||
let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
|
||||
let cmd = parts[0].trim_start_matches('/');
|
||||
let args: Vec<String> = parts
|
||||
.get(1)
|
||||
.map(|a| a.split_whitespace().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
ChannelContent::Command {
|
||||
name: cmd.to_string(),
|
||||
args,
|
||||
}
|
||||
} else {
|
||||
ChannelContent::Text(trimmed)
|
||||
};
|
||||
|
||||
let mut meta = HashMap::new();
|
||||
meta.insert(
|
||||
"conversation_id".to_string(),
|
||||
serde_json::Value::String(cb.conversation_id),
|
||||
);
|
||||
|
||||
let uid = if cb.sender_staff_id.is_empty() {
|
||||
cb.sender_id
|
||||
} else {
|
||||
cb.sender_staff_id
|
||||
};
|
||||
|
||||
let msg = ChannelMessage {
|
||||
channel: ChannelType::Custom("dingtalk_stream".to_string()),
|
||||
platform_message_id: cb.message_id,
|
||||
sender: ChannelUser {
|
||||
platform_id: uid,
|
||||
display_name: cb.sender_nick,
|
||||
openfang_user: None,
|
||||
},
|
||||
content,
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: cb.conversation_type == "2",
|
||||
thread_id: None,
|
||||
metadata: meta,
|
||||
};
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
error!("DingTalk Stream: channel receiver dropped");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = sink.send(Message::Text(make_ack(mid, topic))).await;
|
||||
}
|
||||
_ => {
|
||||
let _ = sink.send(Message::Text(make_ack(mid, topic))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backoff(attempt: u32) -> Duration {
|
||||
let ms = (1000u64 * 2u64.saturating_pow(attempt.min(6))).min(60_000);
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adapter_creation() {
|
||||
let a = DingTalkStreamAdapter::new("k".into(), "s".into(), "r".into());
|
||||
assert_eq!(a.name(), "dingtalk_stream");
|
||||
assert_eq!(
|
||||
a.channel_type(),
|
||||
ChannelType::Custom("dingtalk_stream".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_doubles() {
|
||||
assert_eq!(backoff(0), Duration::from_millis(1000));
|
||||
assert_eq!(backoff(1), Duration::from_millis(2000));
|
||||
assert_eq!(backoff(2), Duration::from_millis(4000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_capped() {
|
||||
assert_eq!(backoff(10), Duration::from_millis(60_000));
|
||||
assert_eq!(backoff(20), Duration::from_millis(60_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_ack_valid_json() {
|
||||
let ack = make_ack("msg1", "topic1");
|
||||
let v: serde_json::Value = serde_json::from_str(&ack).unwrap();
|
||||
assert_eq!(v["code"], 200);
|
||||
assert_eq!(v["headers"]["messageId"], "msg1");
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,9 @@ pub struct DiscordAdapter {
|
||||
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
|
||||
token: Zeroizing<String>,
|
||||
client: reqwest::Client,
|
||||
allowed_guilds: Vec<u64>,
|
||||
allowed_guilds: Vec<String>,
|
||||
allowed_users: Vec<String>,
|
||||
ignore_bots: bool,
|
||||
intents: u64,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
@@ -51,12 +53,20 @@ pub struct DiscordAdapter {
|
||||
}
|
||||
|
||||
impl DiscordAdapter {
|
||||
pub fn new(token: String, allowed_guilds: Vec<u64>, intents: u64) -> Self {
|
||||
pub fn new(
|
||||
token: String,
|
||||
allowed_guilds: Vec<String>,
|
||||
allowed_users: Vec<String>,
|
||||
ignore_bots: bool,
|
||||
intents: u64,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
token: Zeroizing::new(token),
|
||||
client: reqwest::Client::new(),
|
||||
allowed_guilds,
|
||||
allowed_users,
|
||||
ignore_bots,
|
||||
intents,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
@@ -147,6 +157,8 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
let token = self.token.clone();
|
||||
let intents = self.intents;
|
||||
let allowed_guilds = self.allowed_guilds.clone();
|
||||
let allowed_users = self.allowed_users.clone();
|
||||
let ignore_bots = self.ignore_bots;
|
||||
let bot_user_id = self.bot_user_id.clone();
|
||||
let session_id_store = self.session_id.clone();
|
||||
let resume_url_store = self.resume_gateway_url.clone();
|
||||
@@ -306,9 +318,14 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
}
|
||||
|
||||
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
|
||||
if let Some(msg) =
|
||||
parse_discord_message(d, &bot_user_id, &allowed_guilds)
|
||||
.await
|
||||
if let Some(msg) = parse_discord_message(
|
||||
d,
|
||||
&bot_user_id,
|
||||
&allowed_guilds,
|
||||
&allowed_users,
|
||||
ignore_bots,
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
"Discord {event_name} from {}: {:?}",
|
||||
@@ -422,7 +439,9 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
async fn parse_discord_message(
|
||||
d: &serde_json::Value,
|
||||
bot_user_id: &Arc<RwLock<Option<String>>>,
|
||||
allowed_guilds: &[u64],
|
||||
allowed_guilds: &[String],
|
||||
allowed_users: &[String],
|
||||
ignore_bots: bool,
|
||||
) -> Option<ChannelMessage> {
|
||||
let author = d.get("author")?;
|
||||
let author_id = author["id"].as_str()?;
|
||||
@@ -434,16 +453,21 @@ async fn parse_discord_message(
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out other bots
|
||||
if author["bot"].as_bool() == Some(true) {
|
||||
// Filter out other bots (configurable via ignore_bots)
|
||||
if ignore_bots && author["bot"].as_bool() == Some(true) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Filter by allowed users
|
||||
if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == author_id) {
|
||||
debug!("Discord: ignoring message from unlisted user {author_id}");
|
||||
return None;
|
||||
}
|
||||
|
||||
// Filter by allowed guilds
|
||||
if !allowed_guilds.is_empty() {
|
||||
if let Some(guild_id) = d["guild_id"].as_str() {
|
||||
let gid: u64 = guild_id.parse().unwrap_or(0);
|
||||
if !allowed_guilds.contains(&gid) {
|
||||
if !allowed_guilds.iter().any(|g| g == guild_id) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -487,6 +511,29 @@ async fn parse_discord_message(
|
||||
ChannelContent::Text(content_text.to_string())
|
||||
};
|
||||
|
||||
// Determine if this is a group message (guild_id present = server channel)
|
||||
let is_group = d["guild_id"].as_str().is_some();
|
||||
|
||||
// Check if bot was @mentioned (for MentionOnly policy enforcement)
|
||||
let was_mentioned = if let Some(ref bid) = *bot_user_id.read().await {
|
||||
// Check Discord mentions array
|
||||
let mentioned_in_array = d["mentions"]
|
||||
.as_array()
|
||||
.map(|arr| arr.iter().any(|m| m["id"].as_str() == Some(bid.as_str())))
|
||||
.unwrap_or(false);
|
||||
// Also check content for <@bot_id> or <@!bot_id> patterns
|
||||
let mentioned_in_content = content_text.contains(&format!("<@{bid}>"))
|
||||
|| content_text.contains(&format!("<@!{bid}>"));
|
||||
mentioned_in_array || mentioned_in_content
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
if was_mentioned {
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
|
||||
}
|
||||
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Discord,
|
||||
platform_message_id: message_id.to_string(),
|
||||
@@ -498,9 +545,9 @@ async fn parse_discord_message(
|
||||
content,
|
||||
target_agent: None,
|
||||
timestamp,
|
||||
is_group: true,
|
||||
is_group,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -524,7 +571,9 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
assert_eq!(msg.sender.display_name, "alice");
|
||||
assert_eq!(msg.sender.platform_id, "ch1");
|
||||
@@ -546,7 +595,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -566,7 +615,52 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_ignore_bots_false_allows_other_bots() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "ch1",
|
||||
"content": "Bot message",
|
||||
"author": {
|
||||
"id": "other_bot",
|
||||
"username": "somebot",
|
||||
"discriminator": "0",
|
||||
"bot": true
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
// With ignore_bots=false, other bots' messages should be allowed
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
|
||||
assert!(msg.is_some());
|
||||
let msg = msg.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "somebot");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Bot message"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_ignore_bots_false_still_filters_self() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "ch1",
|
||||
"content": "My own message",
|
||||
"author": {
|
||||
"id": "bot123",
|
||||
"username": "openfang",
|
||||
"discriminator": "0",
|
||||
"bot": true
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
// Even with ignore_bots=false, the bot's own messages must still be filtered
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -587,11 +681,12 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &[111, 222]).await;
|
||||
let msg =
|
||||
parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &[999]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[], true).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -610,7 +705,9 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -635,7 +732,7 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -654,7 +751,9 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "alice#1234");
|
||||
}
|
||||
|
||||
@@ -676,16 +775,129 @@ mod tests {
|
||||
});
|
||||
|
||||
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
|
||||
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
assert!(
|
||||
matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_allowed_users_filter() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "ch1",
|
||||
"content": "Hello",
|
||||
"author": {
|
||||
"id": "user999",
|
||||
"username": "bob",
|
||||
"discriminator": "0"
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
// Not in allowed users
|
||||
let msg = parse_discord_message(
|
||||
&d,
|
||||
&bot_id,
|
||||
&[],
|
||||
&["user111".into(), "user222".into()],
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed users
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()], true).await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_mention_detection() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
|
||||
// Message with bot mentioned in mentions array
|
||||
let d = serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "ch1",
|
||||
"guild_id": "guild1",
|
||||
"content": "Hey <@bot123> help me",
|
||||
"mentions": [{"id": "bot123", "username": "openfang"}],
|
||||
"author": {
|
||||
"id": "user1",
|
||||
"username": "alice",
|
||||
"discriminator": "0"
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg.is_group);
|
||||
assert_eq!(
|
||||
msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
|
||||
// Message without mention in group
|
||||
let d2 = serde_json::json!({
|
||||
"id": "msg2",
|
||||
"channel_id": "ch1",
|
||||
"guild_id": "guild1",
|
||||
"content": "Just chatting",
|
||||
"author": {
|
||||
"id": "user1",
|
||||
"username": "alice",
|
||||
"discriminator": "0"
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg2.is_group);
|
||||
assert!(!msg2.metadata.contains_key("was_mentioned"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_dm_not_group() {
|
||||
let bot_id = Arc::new(RwLock::new(None));
|
||||
let d = serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "dm-ch1",
|
||||
"content": "Hello",
|
||||
"author": {
|
||||
"id": "user1",
|
||||
"username": "alice",
|
||||
"discriminator": "0"
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!msg.is_group);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_adapter_creation() {
|
||||
let adapter = DiscordAdapter::new("test-token".to_string(), vec![123, 456], 33280);
|
||||
let adapter = DiscordAdapter::new(
|
||||
"test-token".to_string(),
|
||||
vec!["123".to_string(), "456".to_string()],
|
||||
vec![],
|
||||
true,
|
||||
37376,
|
||||
);
|
||||
assert_eq!(adapter.name(), "discord");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Discord);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,21 @@ use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// SASL PLAIN authenticator for IMAP servers that reject LOGIN
|
||||
/// (e.g., Lark/Larksuite which only advertise AUTH=PLAIN).
|
||||
struct PlainAuthenticator {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl imap::Authenticator for PlainAuthenticator {
|
||||
type Response = String;
|
||||
fn process(&self, _data: &[u8]) -> Self::Response {
|
||||
// SASL PLAIN: \0<username>\0<password>
|
||||
format!("\x00{}\x00{}", self.username, self.password)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reply context for email threading (In-Reply-To / Subject continuity).
|
||||
#[derive(Debug, Clone)]
|
||||
struct ReplyCtx {
|
||||
@@ -124,8 +139,7 @@ impl EmailAdapter {
|
||||
async fn build_smtp_transport(
|
||||
&self,
|
||||
) -> Result<AsyncSmtpTransport<Tokio1Executor>, Box<dyn std::error::Error>> {
|
||||
let creds =
|
||||
Credentials::new(self.username.clone(), self.password.as_str().to_string());
|
||||
let creds = Credentials::new(self.username.clone(), self.password.as_str().to_string());
|
||||
|
||||
let transport = if self.smtp_port == 465 {
|
||||
// Implicit TLS (port 465)
|
||||
@@ -200,12 +214,25 @@ fn fetch_unseen_emails(
|
||||
.build()
|
||||
.map_err(|e| format!("TLS connector error: {e}"))?;
|
||||
|
||||
let client = imap::connect((host, port), host, &tls)
|
||||
.map_err(|e| format!("IMAP connect failed: {e}"))?;
|
||||
let client =
|
||||
imap::connect((host, port), host, &tls).map_err(|e| format!("IMAP connect failed: {e}"))?;
|
||||
|
||||
let mut session = client
|
||||
.login(username, password)
|
||||
.map_err(|(e, _)| format!("IMAP login failed: {e}"))?;
|
||||
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
|
||||
// that reject LOGIN and only support AUTH=PLAIN (SASL).
|
||||
let mut session = match client.login(username, password) {
|
||||
Ok(s) => s,
|
||||
Err((login_err, client)) => {
|
||||
let authenticator = PlainAuthenticator {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
client
|
||||
.authenticate("PLAIN", &authenticator)
|
||||
.map_err(|(e, _)| {
|
||||
format!("IMAP login failed: {login_err}; AUTH=PLAIN also failed: {e}")
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
@@ -362,8 +389,7 @@ impl ChannelAdapter for EmailAdapter {
|
||||
}
|
||||
|
||||
// Extract target agent from subject brackets (stored in metadata for router)
|
||||
let _target_agent =
|
||||
EmailAdapter::extract_agent_from_subject(&subject);
|
||||
let _target_agent = EmailAdapter::extract_agent_from_subject(&subject);
|
||||
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
|
||||
|
||||
// Build the message body: prepend subject context
|
||||
|
||||
+1430
-302
File diff suppressed because it is too large
Load Diff
@@ -17,16 +17,177 @@ pub fn format_for_channel(text: &str, format: OutputFormat) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a message for WeCom, using a stronger plain-text conversion to avoid
|
||||
/// leaking Markdown syntax into enterprise chat replies.
|
||||
pub fn format_for_wecom(text: &str, format: OutputFormat) -> String {
|
||||
match format {
|
||||
OutputFormat::PlainText => markdown_to_wecom_plain(text),
|
||||
_ => format_for_channel(text, format),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Markdown to Telegram HTML subset.
|
||||
///
|
||||
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`.
|
||||
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`, `<blockquote>`.
|
||||
fn markdown_to_telegram_html(text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let mut blocks = Vec::new();
|
||||
let lines: Vec<&str> = normalized.lines().collect();
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fenced code block
|
||||
if let Some(fence) = fence_delimiter(trimmed) {
|
||||
i += 1;
|
||||
let mut code_lines = Vec::new();
|
||||
while i < lines.len() {
|
||||
let candidate = lines[i].trim();
|
||||
if candidate.starts_with(fence) {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
code_lines.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
let code = escape_html(&code_lines.join("\n"));
|
||||
blocks.push(format!("<pre><code>{}</code></pre>", code));
|
||||
continue;
|
||||
}
|
||||
|
||||
// ATX heading (#, ##, ...)
|
||||
if let Some(content) = heading_text(trimmed) {
|
||||
blocks.push(format!("<b>{}</b>", render_inline_markdown(content.trim())));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if trimmed.starts_with('>') {
|
||||
let mut quote_lines = Vec::new();
|
||||
while i < lines.len() {
|
||||
let current = lines[i].trim();
|
||||
if current.is_empty() || !current.starts_with('>') {
|
||||
break;
|
||||
}
|
||||
let content = current.strip_prefix('>').unwrap_or(current).trim_start();
|
||||
quote_lines.push(render_inline_markdown(content));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push(format!(
|
||||
"<blockquote>{}</blockquote>",
|
||||
quote_lines.join("\n")
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if let Some(item) = unordered_list_item(trimmed) {
|
||||
let mut items = vec![format!("• {}", render_inline_markdown(item.trim()))];
|
||||
i += 1;
|
||||
while i < lines.len() {
|
||||
let current = lines[i].trim();
|
||||
if let Some(next_item) = unordered_list_item(current) {
|
||||
items.push(format!("• {}", render_inline_markdown(next_item.trim())));
|
||||
i += 1;
|
||||
} else if current.is_empty() {
|
||||
i += 1;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
blocks.push(items.join("\n"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if let Some(item) = ordered_list_item(trimmed) {
|
||||
let mut items = vec![format!("1. {}", render_inline_markdown(item.trim()))];
|
||||
let mut counter = 2;
|
||||
i += 1;
|
||||
while i < lines.len() {
|
||||
let current = lines[i].trim();
|
||||
if let Some(next_item) = ordered_list_item(current) {
|
||||
items.push(format!(
|
||||
"{}. {}",
|
||||
counter,
|
||||
render_inline_markdown(next_item.trim())
|
||||
));
|
||||
counter += 1;
|
||||
i += 1;
|
||||
} else if current.is_empty() {
|
||||
i += 1;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
blocks.push(items.join("\n"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph
|
||||
let mut paragraph_lines = vec![trimmed];
|
||||
i += 1;
|
||||
while i < lines.len() {
|
||||
let current = lines[i].trim();
|
||||
if current.is_empty()
|
||||
|| fence_delimiter(current).is_some()
|
||||
|| heading_text(current).is_some()
|
||||
|| current.starts_with('>')
|
||||
|| unordered_list_item(current).is_some()
|
||||
|| ordered_list_item(current).is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
paragraph_lines.push(current);
|
||||
i += 1;
|
||||
}
|
||||
let joined = paragraph_lines.join("\n");
|
||||
blocks.push(render_inline_markdown(&joined));
|
||||
}
|
||||
|
||||
blocks.join("\n\n")
|
||||
}
|
||||
|
||||
fn render_inline_markdown(text: &str) -> String {
|
||||
let mut result = escape_html(text);
|
||||
|
||||
// Links: [text](url) → <a href="url">text</a>
|
||||
while let Some(bracket_start) = result.find('[') {
|
||||
if let Some(bracket_end_rel) = result[bracket_start..].find("](") {
|
||||
let bracket_end = bracket_start + bracket_end_rel;
|
||||
if let Some(paren_end_rel) = result[bracket_end + 2..].find(')') {
|
||||
let paren_end = bracket_end + 2 + paren_end_rel;
|
||||
let link_text = result[bracket_start + 1..bracket_end].to_string();
|
||||
let url = result[bracket_end + 2..paren_end].to_string();
|
||||
result = format!(
|
||||
"{}<a href=\"{}\">{}</a>{}",
|
||||
&result[..bracket_start],
|
||||
url,
|
||||
link_text,
|
||||
&result[paren_end + 1..]
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Bold: **text** → <b>text</b>
|
||||
while let Some(start) = result.find("**") {
|
||||
if let Some(end) = result[start + 2..].find("**") {
|
||||
let end = start + 2 + end;
|
||||
if let Some(end_rel) = result[start + 2..].find("**") {
|
||||
let end = start + 2 + end_rel;
|
||||
let inner = result[start + 2..end].to_string();
|
||||
result = format!("{}<b>{}</b>{}", &result[..start], inner, &result[end + 2..]);
|
||||
} else {
|
||||
@@ -34,8 +195,23 @@ fn markdown_to_telegram_html(text: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Italic: *text* → <i>text</i> (but not inside bold tags)
|
||||
// Simple heuristic: match single * not preceded/followed by *
|
||||
// Inline code: `text` → <code>text</code>
|
||||
while let Some(start) = result.find('`') {
|
||||
if let Some(end_rel) = result[start + 1..].find('`') {
|
||||
let end = start + 1 + end_rel;
|
||||
let inner = result[start + 1..end].to_string();
|
||||
result = format!(
|
||||
"{}<code>{}</code>{}",
|
||||
&result[..start],
|
||||
inner,
|
||||
&result[end + 1..]
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Italic: *text* → <i>text</i> (single star only)
|
||||
let mut out = String::with_capacity(result.len());
|
||||
let chars: Vec<char> = result.chars().collect();
|
||||
let mut i = 0;
|
||||
@@ -56,48 +232,57 @@ fn markdown_to_telegram_html(text: &str) -> String {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
result = out;
|
||||
|
||||
// Inline code: `text` → <code>text</code>
|
||||
while let Some(start) = result.find('`') {
|
||||
if let Some(end) = result[start + 1..].find('`') {
|
||||
let end = start + 1 + end;
|
||||
let inner = result[start + 1..end].to_string();
|
||||
result = format!(
|
||||
"{}<code>{}</code>{}",
|
||||
&result[..start],
|
||||
inner,
|
||||
&result[end + 1..]
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
out
|
||||
}
|
||||
|
||||
fn escape_html(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn fence_delimiter(line: &str) -> Option<&'static str> {
|
||||
if line.starts_with("```") {
|
||||
Some("```")
|
||||
} else if line.starts_with("~~~") {
|
||||
Some("~~~")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn heading_text(line: &str) -> Option<&str> {
|
||||
let hashes = line.chars().take_while(|c| *c == '#').count();
|
||||
if (1..=6).contains(&hashes) && line.chars().nth(hashes) == Some(' ') {
|
||||
Some(&line[hashes + 1..])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn unordered_list_item(line: &str) -> Option<&str> {
|
||||
for prefix in ["- ", "* ", "+ "] {
|
||||
if let Some(rest) = line.strip_prefix(prefix) {
|
||||
return Some(rest);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Links: [text](url) → <a href="url">text</a>
|
||||
while let Some(bracket_start) = result.find('[') {
|
||||
if let Some(bracket_end) = result[bracket_start..].find("](") {
|
||||
let bracket_end = bracket_start + bracket_end;
|
||||
if let Some(paren_end) = result[bracket_end + 2..].find(')') {
|
||||
let paren_end = bracket_end + 2 + paren_end;
|
||||
let link_text = &result[bracket_start + 1..bracket_end];
|
||||
let url = &result[bracket_end + 2..paren_end];
|
||||
result = format!(
|
||||
"{}<a href=\"{}\">{}</a>{}",
|
||||
&result[..bracket_start],
|
||||
url,
|
||||
link_text,
|
||||
&result[paren_end + 1..]
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
fn ordered_list_item(line: &str) -> Option<&str> {
|
||||
let digit_count = line.chars().take_while(|c| c.is_ascii_digit()).count();
|
||||
if digit_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let rest = &line[digit_count..];
|
||||
if let Some(item) = rest.strip_prefix(". ") {
|
||||
Some(item)
|
||||
} else if let Some(item) = rest.strip_prefix(") ") {
|
||||
Some(item)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Convert Markdown to Slack mrkdwn format.
|
||||
@@ -141,6 +326,192 @@ fn markdown_to_slack_mrkdwn(text: &str) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
fn strip_atx_heading(line: &str) -> String {
|
||||
let trimmed = line.trim_start();
|
||||
let heading_level = trimmed.chars().take_while(|c| *c == '#').count();
|
||||
if !(1..=6).contains(&heading_level) {
|
||||
return line.to_string();
|
||||
}
|
||||
|
||||
if trimmed.chars().nth(heading_level) != Some(' ') {
|
||||
return line.to_string();
|
||||
}
|
||||
|
||||
trimmed[heading_level..]
|
||||
.trim()
|
||||
.trim_end_matches('#')
|
||||
.trim_end()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn strip_blockquote_prefix(line: &str) -> String {
|
||||
let mut trimmed = line.trim_start();
|
||||
while let Some(rest) = trimmed.strip_prefix('>') {
|
||||
trimmed = rest.trim_start();
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn strip_task_list_prefix(line: &str) -> String {
|
||||
let trimmed = line.trim_start();
|
||||
for prefix in [
|
||||
"- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] ", "+ [ ] ", "+ [x] ", "+ [X] ",
|
||||
] {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix) {
|
||||
return rest.to_string();
|
||||
}
|
||||
}
|
||||
line.to_string()
|
||||
}
|
||||
|
||||
fn is_fenced_code_marker(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
let mut chars = trimmed.chars();
|
||||
let Some(marker) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
if marker != '`' && marker != '~' {
|
||||
return false;
|
||||
}
|
||||
chars.all(|c| c == marker || c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn is_setext_heading_underline(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
trimmed.chars().all(|c| c == '=' || c == '-') && trimmed.contains(['=', '-'])
|
||||
}
|
||||
|
||||
fn is_table_divider(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.is_empty() && trimmed.chars().all(|c| matches!(c, '|' | ':' | '-' | ' '))
|
||||
}
|
||||
|
||||
fn strip_inline_markdown(mut text: String) -> String {
|
||||
while let Some(start) = text.find("![") {
|
||||
if let Some(mid) = text[start..].find("](") {
|
||||
let mid = start + mid;
|
||||
if let Some(end) = text[mid + 2..].find(')') {
|
||||
let end = mid + 2 + end;
|
||||
let alt = &text[start + 2..mid];
|
||||
let url = &text[mid + 2..end];
|
||||
let replacement = if alt.is_empty() {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("{alt} ({url})")
|
||||
};
|
||||
text = format!("{}{}{}", &text[..start], replacement, &text[end + 1..]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
while let Some(start) = text.find('[') {
|
||||
if let Some(mid) = text[start..].find("](") {
|
||||
let mid = start + mid;
|
||||
if let Some(end) = text[mid + 2..].find(')') {
|
||||
let end = mid + 2 + end;
|
||||
let label = &text[start + 1..mid];
|
||||
let url = &text[mid + 2..end];
|
||||
text = format!("{}{} ({}){}", &text[..start], label, url, &text[end + 1..]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
while let Some(start) = text.find('<') {
|
||||
if let Some(end) = text[start + 1..].find('>') {
|
||||
let end = start + 1 + end;
|
||||
let inner = &text[start + 1..end];
|
||||
if inner.starts_with("http://")
|
||||
|| inner.starts_with("https://")
|
||||
|| inner.starts_with("mailto:")
|
||||
{
|
||||
text = format!("{}{}{}", &text[..start], inner, &text[end + 1..]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
text = text.replace("**", "");
|
||||
text = text.replace("__", "");
|
||||
text = text.replace("~~", "");
|
||||
text = text.replace('`', "");
|
||||
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
for (i, &ch) in chars.iter().enumerate() {
|
||||
if ch == '*'
|
||||
&& (i == 0 || chars[i - 1] != '*')
|
||||
&& (i + 1 >= chars.len() || chars[i + 1] != '*')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Strip common Markdown blocks for WeCom plain-text replies.
|
||||
fn markdown_to_wecom_plain(text: &str) -> String {
|
||||
let mut result_lines = Vec::new();
|
||||
let mut in_fenced_code = false;
|
||||
|
||||
for raw_line in text.replace("\r\n", "\n").lines() {
|
||||
let trimmed = raw_line.trim();
|
||||
|
||||
if is_fenced_code_marker(trimmed) {
|
||||
in_fenced_code = !in_fenced_code;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_fenced_code {
|
||||
result_lines.push(raw_line.trim_end().to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_setext_heading_underline(trimmed) || is_table_divider(trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut line = strip_atx_heading(raw_line);
|
||||
line = strip_blockquote_prefix(&line);
|
||||
line = strip_task_list_prefix(&line);
|
||||
|
||||
let trimmed_line = line.trim();
|
||||
if trimmed_line.starts_with('|') && trimmed_line.ends_with('|') && trimmed_line.len() > 2 {
|
||||
line = trimmed_line
|
||||
.trim_matches('|')
|
||||
.split('|')
|
||||
.map(|cell| cell.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
line = strip_inline_markdown(line);
|
||||
result_lines.push(line.trim().to_string());
|
||||
}
|
||||
|
||||
let mut collapsed = Vec::new();
|
||||
for line in result_lines {
|
||||
if line.is_empty()
|
||||
&& collapsed
|
||||
.last()
|
||||
.is_some_and(|prev: &String| prev.is_empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
collapsed.push(line);
|
||||
}
|
||||
|
||||
collapsed.join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
/// Strip all Markdown formatting, producing plain text.
|
||||
fn markdown_to_plain(text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
@@ -226,6 +597,36 @@ mod tests {
|
||||
assert_eq!(result, "<a href=\"https://example.com\">click here</a>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_html_heading() {
|
||||
let result = markdown_to_telegram_html("## Result");
|
||||
assert_eq!(result, "<b>Result</b>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_html_unordered_list() {
|
||||
let result = markdown_to_telegram_html("- alpha\n- beta");
|
||||
assert_eq!(result, "• alpha\n• beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_html_ordered_list() {
|
||||
let result = markdown_to_telegram_html("1. alpha\n2. beta");
|
||||
assert_eq!(result, "1. alpha\n2. beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_html_fenced_code_block() {
|
||||
let result = markdown_to_telegram_html("```rust\nfn main() {}\n```");
|
||||
assert_eq!(result, "<pre><code>fn main() {}</code></pre>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_html_blockquote() {
|
||||
let result = markdown_to_telegram_html("> note\n> second line");
|
||||
assert_eq!(result, "<blockquote>note\nsecond line</blockquote>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slack_mrkdwn_bold() {
|
||||
let result = markdown_to_slack_mrkdwn("Hello **world**!");
|
||||
@@ -249,4 +650,26 @@ mod tests {
|
||||
let result = markdown_to_plain("[click](https://example.com)");
|
||||
assert_eq!(result, "click (https://example.com)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wecom_plain_text_strips_common_markdown_blocks() {
|
||||
let result = markdown_to_wecom_plain(
|
||||
"# Title\n\
|
||||
\n\
|
||||
> quoted text\n\
|
||||
\n\
|
||||
- [x] done item\n\
|
||||
- [ ] todo item\n\
|
||||
\n\
|
||||
```rust\n\
|
||||
let value = 1;\n\
|
||||
```\n\
|
||||
\n\
|
||||
[docs](https://example.com)\n",
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
"Title\n\nquoted text\n\ndone item\ntodo item\n\nlet value = 1;\n\ndocs (https://example.com)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,13 @@ pub mod twist;
|
||||
pub mod webex;
|
||||
// Wave 5 — Niche & differentiating channels
|
||||
pub mod dingtalk;
|
||||
pub mod dingtalk_stream;
|
||||
pub mod discourse;
|
||||
pub mod gitter;
|
||||
pub mod gotify;
|
||||
pub mod linkedin;
|
||||
pub mod mumble;
|
||||
pub mod mqtt;
|
||||
pub mod ntfy;
|
||||
pub mod webhook;
|
||||
pub mod wecom;
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// LINE push message API endpoint.
|
||||
@@ -62,8 +62,8 @@ impl LineAdapter {
|
||||
pub fn new(channel_secret: String, access_token: String, webhook_port: u16) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
channel_secret: Zeroizing::new(channel_secret),
|
||||
access_token: Zeroizing::new(access_token),
|
||||
channel_secret: Zeroizing::new(channel_secret.trim().to_string()),
|
||||
access_token: Zeroizing::new(access_token.trim().to_string()),
|
||||
webhook_port,
|
||||
client: reqwest::Client::new(),
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
@@ -96,12 +96,35 @@ impl LineAdapter {
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if result.len() != expected.len() {
|
||||
debug!(
|
||||
"LINE: signature length mismatch: computed={} received={}",
|
||||
result.len(),
|
||||
expected.len()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (a, b) in result.iter().zip(expected.iter()) {
|
||||
diff |= a ^ b;
|
||||
}
|
||||
if diff != 0 {
|
||||
let computed = base64::engine::general_purpose::STANDARD.encode(&result);
|
||||
// Log first/last 4 chars of each signature for debugging without leaking full HMAC
|
||||
let comp_redacted = format!(
|
||||
"{}...{}",
|
||||
&computed[..4.min(computed.len())],
|
||||
&computed[computed.len().saturating_sub(4)..]
|
||||
);
|
||||
let recv_redacted = format!(
|
||||
"{}...{}",
|
||||
&signature[..4.min(signature.len())],
|
||||
&signature[signature.len().saturating_sub(4)..]
|
||||
);
|
||||
debug!(
|
||||
"LINE: signature mismatch: computed={comp_redacted} received={recv_redacted} body_len={}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
@@ -359,18 +382,18 @@ impl ChannelAdapter for LineAdapter {
|
||||
let secret = Arc::clone(&channel_secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
move |headers: axum::http::HeaderMap,
|
||||
body: axum::extract::Json<serde_json::Value>| {
|
||||
body: axum::body::Bytes| {
|
||||
let secret = Arc::clone(&secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
async move {
|
||||
// Verify X-Line-Signature
|
||||
// Verify X-Line-Signature using the raw request
|
||||
// body bytes — NOT re-serialized JSON — because the
|
||||
// HMAC must be computed over the exact bytes LINE sent.
|
||||
let signature = headers
|
||||
.get("x-line-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let body_bytes = serde_json::to_vec(&body.0).unwrap_or_default();
|
||||
|
||||
// Create a temporary adapter-like verifier
|
||||
let adapter = LineAdapter {
|
||||
channel_secret: secret.as_ref().clone(),
|
||||
@@ -382,14 +405,23 @@ impl ChannelAdapter for LineAdapter {
|
||||
};
|
||||
|
||||
if !signature.is_empty()
|
||||
&& !adapter.verify_signature(&body_bytes, signature)
|
||||
&& !adapter.verify_signature(&body, signature)
|
||||
{
|
||||
warn!("LINE: invalid webhook signature");
|
||||
return axum::http::StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
|
||||
// Parse the raw bytes into JSON after signature verification
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("LINE: failed to parse webhook body as JSON: {e}");
|
||||
return axum::http::StatusCode::BAD_REQUEST;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse events array
|
||||
if let Some(events) = body.0["events"].as_array() {
|
||||
if let Some(events) = parsed["events"].as_array() {
|
||||
for event in events {
|
||||
if let Some(msg) = parse_line_event(event) {
|
||||
let _ = tx.send(msg).await;
|
||||
@@ -626,6 +658,71 @@ mod tests {
|
||||
assert!(parse_line_event(&event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_with_raw_body() {
|
||||
// Verify that HMAC-SHA256 signature validation works with raw body bytes
|
||||
let secret = "test-channel-secret";
|
||||
let adapter = LineAdapter::new(secret.to_string(), "token".to_string(), 9000);
|
||||
|
||||
// Compute the expected signature manually
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let body = br#"{"events":[{"type":"message"}]}"#;
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let expected_sig =
|
||||
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
assert!(adapter.verify_signature(body, &expected_sig));
|
||||
|
||||
// Re-serialized JSON should NOT match (this was the bug)
|
||||
let parsed: serde_json::Value = serde_json::from_slice(body).unwrap();
|
||||
let reserialized = serde_json::to_vec(&parsed).unwrap();
|
||||
// The re-serialized form may differ in whitespace/key order
|
||||
// If it happens to be identical for this input, the test still validates
|
||||
// the core mechanism works with raw bytes
|
||||
if reserialized != body.to_vec() {
|
||||
assert!(!adapter.verify_signature(&reserialized, &expected_sig));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_secret_trimmed() {
|
||||
// Environment variables often have trailing newlines or spaces
|
||||
let adapter = LineAdapter::new(
|
||||
" my-secret\n".to_string(),
|
||||
" my-token\r\n".to_string(),
|
||||
9000,
|
||||
);
|
||||
assert_eq!(adapter.channel_secret.as_str(), "my-secret");
|
||||
assert_eq!(adapter.access_token.as_str(), "my-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_bad_base64() {
|
||||
let adapter = LineAdapter::new("secret".to_string(), "token".to_string(), 9000);
|
||||
assert!(!adapter.verify_signature(b"body", "not-valid-base64!!!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_wrong_secret() {
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let body = b"test body";
|
||||
let mut mac = HmacSha256::new_from_slice(b"wrong-secret").unwrap();
|
||||
mac.update(body);
|
||||
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
let adapter = LineAdapter::new("correct-secret".to_string(), "token".to_string(), 9000);
|
||||
assert!(!adapter.verify_signature(body, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_line_event_room_source() {
|
||||
let event = serde_json::json!({
|
||||
|
||||
@@ -298,17 +298,8 @@ fn strip_html_tags(html: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Decode HTML entities
|
||||
let decoded = result
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ");
|
||||
|
||||
// Decode HTML entities (handles named, decimal, and hex entities)
|
||||
let decoded = html_escape::decode_html_entities(&result);
|
||||
decoded.trim().to_string()
|
||||
}
|
||||
|
||||
@@ -465,10 +456,18 @@ impl ChannelAdapter for MastodonAdapter {
|
||||
let notifications: Vec<serde_json::Value> =
|
||||
poll_resp.json().await.unwrap_or_default();
|
||||
|
||||
for notif in ¬ifications {
|
||||
if let Some(nid) = notif["id"].as_str() {
|
||||
// Mastodon returns notifications newest-first. Record the first
|
||||
// (highest) ID before processing so we never re-fetch these on
|
||||
// the next poll. Updating inside the loop would leave us with
|
||||
// the oldest ID, causing every previously seen notification to
|
||||
// be re-delivered and re-processed.
|
||||
if let Some(newest) = notifications.first() {
|
||||
if let Some(nid) = newest["id"].as_str() {
|
||||
last_notification_id = Some(nid.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
for notif in ¬ifications {
|
||||
if let Some(msg) = parse_mastodon_notification(notif, &own_account_id) {
|
||||
if tx.send(msg).await.is_err() {
|
||||
return;
|
||||
@@ -527,6 +526,10 @@ impl ChannelAdapter for MastodonAdapter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn suppress_error_responses(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
Ok(())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user