mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
+1025
-500
File diff suppressed because it is too large
Load Diff
+36
-9
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.4"
|
||||
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,17 +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 = "2", default-features = false, features = ["deflate"] }
|
||||
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"
|
||||
@@ -140,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,7 +19,7 @@
|
||||
<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>
|
||||
@@ -27,9 +27,9 @@
|
||||
|
||||
---
|
||||
|
||||
> **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)
|
||||
|
||||
---
|
||||
|
||||
@@ -264,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
|
||||
@@ -371,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)
|
||||
@@ -382,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.
|
||||
|
||||
+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() {
|
||||
@@ -934,16 +1018,39 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// 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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1003,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()
|
||||
@@ -1030,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()));
|
||||
}
|
||||
@@ -1041,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()));
|
||||
@@ -1055,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()));
|
||||
}
|
||||
@@ -1064,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();
|
||||
@@ -1106,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()));
|
||||
}
|
||||
@@ -1312,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()));
|
||||
}
|
||||
}
|
||||
@@ -1329,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
|
||||
@@ -1435,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();
|
||||
@@ -1444,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") {
|
||||
@@ -1517,6 +1680,25 @@ 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());
|
||||
}
|
||||
@@ -1548,7 +1730,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
|
||||
adapter.name()
|
||||
);
|
||||
router.set_channel_default(channel_key, agent_id);
|
||||
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);
|
||||
@@ -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,86 +43,105 @@ 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"
|
||||
|| path.starts_with("/a2a/")
|
||||
|| (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.starts_with("/api/approvals/")
|
||||
|| path == "/api/channels"
|
||||
|| path == "/api/hands"
|
||||
|| path == "/api/hands/active"
|
||||
|| path.starts_with("/api/hands/")
|
||||
|| path == "/api/skills"
|
||||
|| path == "/api/sessions"
|
||||
|| path == "/api/integrations"
|
||||
|| path == "/api/integrations/available"
|
||||
|| path == "/api/integrations/health"
|
||||
|| path == "/api/workflows"
|
||||
|| path == "/api/logs/stream"
|
||||
|| 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;
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -167,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 {
|
||||
@@ -184,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;
|
||||
@@ -191,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(),
|
||||
@@ -206,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);
|
||||
|
||||
+2129
-499
File diff suppressed because it is too large
Load Diff
@@ -45,16 +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![
|
||||
@@ -102,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),
|
||||
@@ -126,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),
|
||||
@@ -286,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),
|
||||
@@ -331,6 +363,14 @@ pub async fn build_router(
|
||||
)
|
||||
// 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),
|
||||
@@ -350,8 +390,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/hands/{hand_id}/settings",
|
||||
axum::routing::get(routes::get_hand_settings)
|
||||
.put(routes::update_hand_settings),
|
||||
axum::routing::get(routes::get_hand_settings).put(routes::update_hand_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/instances/{id}/pause",
|
||||
@@ -408,14 +447,11 @@ pub async fn build_router(
|
||||
"/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),
|
||||
)
|
||||
.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
|
||||
@@ -460,8 +496,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/budget/agents/{id}",
|
||||
axum::routing::get(routes::agent_budget_status)
|
||||
.put(routes::update_agent_budget),
|
||||
axum::routing::get(routes::agent_budget_status).put(routes::update_agent_budget),
|
||||
)
|
||||
// Session endpoints
|
||||
.route("/api/sessions", axum::routing::get(routes::list_sessions))
|
||||
@@ -552,6 +587,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))
|
||||
@@ -665,8 +704,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(
|
||||
@@ -736,7 +779,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
|
||||
@@ -745,7 +789,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);
|
||||
}
|
||||
|
||||
@@ -767,7 +812,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
|
||||
@@ -887,3 +946,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"));
|
||||
}
|
||||
}
|
||||
@@ -140,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.
|
||||
|
||||
@@ -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"),
|
||||
@@ -129,7 +185,7 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
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;
|
||||
@@ -949,6 +984,14 @@ mark.search-highlight {
|
||||
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;
|
||||
@@ -1236,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; }
|
||||
@@ -1245,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; }
|
||||
@@ -3200,3 +3247,206 @@ mark.search-highlight {
|
||||
.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%; }
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
<body x-data="app" :data-theme="theme">
|
||||
|
||||
<!-- API Key Auth Prompt -->
|
||||
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '' }">
|
||||
<!-- Auth Prompt (API Key or Username/Password) -->
|
||||
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '', loginUser: '', loginPass: '' }">
|
||||
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
|
||||
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
|
||||
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
|
||||
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
|
||||
<!-- Session login mode -->
|
||||
<template x-if="$store.app.authMode === 'session'">
|
||||
<div>
|
||||
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">Sign In</h3>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">Enter your dashboard credentials.</p>
|
||||
<input type="text" x-model="loginUser" placeholder="Username" autocomplete="username" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.5rem">
|
||||
<input type="password" x-model="loginPass" placeholder="Password" autocomplete="current-password" @keydown.enter="$store.app.sessionLogin(loginUser, loginPass)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
|
||||
<button @click="$store.app.sessionLogin(loginUser, loginPass)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Sign In</button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- API key mode -->
|
||||
<template x-if="$store.app.authMode === 'apikey'">
|
||||
<div>
|
||||
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
|
||||
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
|
||||
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
|
||||
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,6 +111,7 @@
|
||||
<a class="nav-item" :class="{ active: page === 'approvals' }" @click="navigate('approvals')" :aria-current="page === 'approvals' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
|
||||
<span class="nav-label">Approvals</span>
|
||||
<span class="badge badge-warn" x-show="$store.app.pendingApprovalCount > 0" x-text="$store.app.pendingApprovalCount"></span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'comms' }" @click="navigate('comms')" :aria-current="page === 'comms' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
|
||||
@@ -170,6 +187,10 @@
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div x-show="$store.app.sessionUser" style="padding:4px 16px;display:flex;align-items:center;justify-content:space-between">
|
||||
<span class="text-xs text-dim" x-text="$store.app.sessionUser" style="letter-spacing:0.5px"></span>
|
||||
<button @click="$store.app.sessionLogout()" class="btn btn-ghost btn-sm" style="font-size:11px;padding:2px 8px;opacity:0.7" title="Sign out">Logout</button>
|
||||
</div>
|
||||
<div class="sidebar-label text-xs text-dim" style="padding:0 16px 4px;letter-spacing:0.5px">Ctrl+K agents | Ctrl+N new</div>
|
||||
</div>
|
||||
<div class="sidebar-toggle" @click="toggleSidebar()" x-text="sidebarCollapsed ? '\u276F' : '\u276E'"></div>
|
||||
@@ -180,7 +201,7 @@
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Mobile menu button -->
|
||||
<button class="mobile-menu-btn btn btn-ghost" @click="mobileMenuOpen = !mobileMenuOpen" style="position:fixed;top:8px;left:8px;z-index:98;padding:6px 10px">
|
||||
<button class="mobile-menu-btn btn btn-ghost" @click="mobileMenuOpen = !mobileMenuOpen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -589,6 +610,14 @@
|
||||
|
||||
<!-- Messages area -->
|
||||
<div class="messages" id="messages" @dragover.prevent="dragOver = true" @dragleave="dragOver = false" @drop.prevent="handleDrop($event); dragOver = false">
|
||||
<!-- Empty state: no agent selected -->
|
||||
<template x-if="!currentAgent">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center;padding:32px;opacity:0.8">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:16px;opacity:0.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<h3 style="margin:0 0 8px;font-size:16px;font-weight:600">Select an agent to start chatting</h3>
|
||||
<p class="text-dim" style="font-size:13px;max-width:320px">Choose an agent from the sidebar or go to the Agents tab to create a new one.</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Message list -->
|
||||
<template x-if="currentAgent">
|
||||
<div>
|
||||
@@ -727,7 +756,7 @@
|
||||
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
|
||||
</div>
|
||||
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
|
||||
@keydown.enter.prevent="if(!$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.enter.prevent="if(!$event.isComposing && $event.keyCode !== 229 && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false; showModelPicker = false"
|
||||
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
@@ -760,7 +789,13 @@
|
||||
<div class="model-switcher-dropdown" x-show="showModelSwitcher" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform translate-y-1" x-transition:enter-end="opacity-100 transform translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
||||
<div class="model-switcher-search">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
|
||||
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="!$event.isComposing && $event.keyCode !== 229 && filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
|
||||
<select x-model="modelSwitcherProviderFilter" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;padding:2px 6px;cursor:pointer;font-family:var(--font-mono);flex-shrink:0">
|
||||
<option value="">All</option>
|
||||
<template x-for="pn in switcherProviders" :key="pn">
|
||||
<option :value="pn" x-text="pn"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="modelSwitching" style="display:flex;align-items:center;justify-content:center;padding:12px;gap:8px">
|
||||
<div class="tool-card-spinner"></div>
|
||||
@@ -777,7 +812,7 @@
|
||||
<div class="model-switcher-item" :class="{'active': currentAgent && m.id === currentAgent.model_name}" @click="switchModel(m)" @mouseenter="modelSwitcherIdx = filteredSwitcherModels.indexOf(m)">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span class="model-switcher-item-name" x-text="m.display_name || m.id"></span>
|
||||
<span class="model-switcher-item-name" x-text="m.provider + ':' + (m.display_name || m.id)"></span>
|
||||
<span class="model-switcher-tier" :class="'tier-' + (m.tier || 'balanced').toLowerCase()" x-text="m.tier || 'Balanced'"></span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
|
||||
@@ -833,6 +868,9 @@
|
||||
<div class="text-xs text-dim font-mono" style="font-size:11px" x-text="agent.model_name"></div>
|
||||
</div>
|
||||
<span class="badge" :class="'badge-' + agent.state.toLowerCase()" x-text="agent.state" style="font-size:10px"></span>
|
||||
<button class="agent-chip-config-btn" @click.stop="showDetail(agent)" title="Agent settings" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--text-dim);transition:all 0.15s;flex-shrink:0" @mouseenter="$el.style.borderColor='var(--accent)';$el.style.color='var(--accent)';$el.style.background='var(--surface2)'" @mouseleave="$el.style.borderColor='var(--border)';$el.style.color='var(--text-dim)';$el.style.background='transparent'">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -843,7 +881,7 @@
|
||||
<div class="text-sm font-bold mb-2" style="color:var(--text-dim);letter-spacing:0.5px;font-size:11px;text-transform:uppercase" x-text="agents.length ? 'Or Start a New Agent' : 'Start Chatting'"></div>
|
||||
<div class="card-grid">
|
||||
<template x-for="t in builtinTemplates" :key="t.name">
|
||||
<div class="card" style="cursor:pointer" @click="spawnBuiltin(t)">
|
||||
<div class="card" style="cursor:pointer" @click="t.manifest_toml ? spawnFromTemplate(t) : spawnBuiltin(t)">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<div class="card-header" style="margin:0;font-size:14px;font-weight:600" x-text="t.name"></div>
|
||||
<span class="badge badge-dim" x-text="t.category"></span>
|
||||
@@ -860,7 +898,7 @@
|
||||
<!-- Agent detail modal with tabs (Info / Files / Config) -->
|
||||
<template x-if="showDetailModal && detailAgent">
|
||||
<div class="modal-overlay" @click.self="showDetailModal = false" @keydown.escape.window="showDetailModal = false">
|
||||
<div class="modal" style="max-width:600px">
|
||||
<div class="modal" style="max-width:700px">
|
||||
<div class="modal-header">
|
||||
<h3>
|
||||
<span x-show="detailAgent.identity && detailAgent.identity.emoji" x-text="detailAgent.identity && detailAgent.identity.emoji" style="margin-right:6px"></span>
|
||||
@@ -885,7 +923,23 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="detail-row" x-show="detailAgent.profile"><span class="detail-label">Profile</span><span class="detail-value" style="text-transform:capitalize" x-text="detailAgent.profile || '-'"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Provider</span><span class="detail-value" x-text="detailAgent.model_provider"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Provider</span>
|
||||
<template x-if="!editingProvider">
|
||||
<span>
|
||||
<span class="detail-value" x-text="detailAgent.model_provider"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="margin-left:8px;padding:2px 8px;font-size:11px" @click="editingProvider = true; newProviderValue = detailAgent.model_provider">Change</button>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="editingProvider">
|
||||
<span class="flex gap-1" style="align-items:center">
|
||||
<input class="form-input" style="width:160px;font-size:12px" x-model="newProviderValue" placeholder="provider" @keydown.enter="changeProvider()" @keydown.escape="editingProvider = false">
|
||||
<button class="btn btn-primary btn-sm" @click="changeProvider()" :disabled="modelSaving" style="padding:2px 10px">
|
||||
<span x-show="!modelSaving">Save</span><span x-show="modelSaving">...</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingProvider = false" style="padding:2px 8px">Cancel</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="detail-row"><span class="detail-label">Model</span>
|
||||
<template x-if="!editingModel">
|
||||
<span>
|
||||
@@ -904,6 +958,38 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value" x-text="detailAgent.created_at ? new Date(detailAgent.created_at).toLocaleString() : '-'"></span></div>
|
||||
|
||||
<!-- Fallback Model Chain -->
|
||||
<div class="detail-row" style="align-items:flex-start">
|
||||
<span class="detail-label">Fallbacks</span>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;margin-left:16px">
|
||||
<template x-if="detailAgent._fallbacks && detailAgent._fallbacks.length > 0">
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
<template x-for="(fb, idx) in detailAgent._fallbacks" :key="idx">
|
||||
<div class="flex gap-1 items-center" style="min-width:0">
|
||||
<span class="badge" style="font-size:11px;font-family:var(--font-mono);word-break:break-all;white-space:normal" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger);flex-shrink:0" @click="removeFallback(idx)">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!detailAgent._fallbacks || detailAgent._fallbacks.length === 0">
|
||||
<span class="text-dim" style="font-size:12px">None — add a fallback chain</span>
|
||||
</template>
|
||||
<template x-if="!editingFallback">
|
||||
<div>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="editingFallback">
|
||||
<div class="flex gap-1" style="align-items:center">
|
||||
<input class="form-input" style="width:220px;font-size:12px" x-model="newFallbackValue" placeholder="provider/model" @keydown.enter="addFallback()" @keydown.escape="editingFallback = false">
|
||||
<button class="btn btn-primary btn-sm" @click="addFallback()" style="padding:2px 10px;font-size:11px">Add</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingFallback = false" style="padding:2px 8px;font-size:11px">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" @click="chatWithAgent(detailAgent); showDetailModal = false">Chat</button>
|
||||
@@ -1043,7 +1129,7 @@
|
||||
<div x-show="spawnStep === 1">
|
||||
<div class="form-group">
|
||||
<label>Agent Name</label>
|
||||
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="nextStep()">
|
||||
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) nextStep()">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Emoji</label>
|
||||
@@ -1071,12 +1157,26 @@
|
||||
<div class="form-group">
|
||||
<label>Provider</label>
|
||||
<select class="form-select" x-model="spawnForm.provider">
|
||||
<option value="anthropic">Anthropic</option><option value="openai">OpenAI</option>
|
||||
<option value="groq">Groq</option><option value="ollama">Ollama</option>
|
||||
<option value="google">Google</option><option value="mistral">Mistral</option>
|
||||
<option value="xai">xAI</option><option value="deepseek">DeepSeek</option>
|
||||
<option value="cerebras">Cerebras</option><option value="sambanova">SambaNova</option>
|
||||
<option value="together">Together</option>
|
||||
<template x-if="spawnProvidersLoading">
|
||||
<option disabled>Loading providers…</option>
|
||||
</template>
|
||||
<template x-if="!spawnProvidersLoading && spawnProviders.length === 0">
|
||||
<option disabled>No providers available</option>
|
||||
</template>
|
||||
<template x-if="!spawnProvidersLoading && spawnProviders.filter(p => !p.is_local).length > 0">
|
||||
<optgroup label="Cloud">
|
||||
<template x-for="p in spawnProviders.filter(p => !p.is_local)" :key="p.id">
|
||||
<option :value="p.id" x-text="p.display_name"></option>
|
||||
</template>
|
||||
</optgroup>
|
||||
</template>
|
||||
<template x-if="!spawnProvidersLoading && spawnProviders.filter(p => p.is_local).length > 0">
|
||||
<optgroup label="Local">
|
||||
<template x-for="p in spawnProviders.filter(p => p.is_local)" :key="p.id">
|
||||
<option :value="p.id" x-text="p.display_name"></option>
|
||||
</template>
|
||||
</optgroup>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -1177,7 +1277,7 @@
|
||||
|
||||
<!-- Page: Approvals -->
|
||||
<template x-if="page === 'approvals'">
|
||||
<div x-data="approvalsPage" x-init="loadData()">
|
||||
<div x-data="approvalsPage()" x-init="init()">
|
||||
<div class="page-header">
|
||||
<h2>Execution Approvals</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -1199,6 +1299,7 @@
|
||||
<button class="filter-pill" :class="{ active: filterStatus === 'pending' }" @click="filterStatus = 'pending'">Pending</button>
|
||||
<button class="filter-pill" :class="{ active: filterStatus === 'approved' }" @click="filterStatus = 'approved'">Approved</button>
|
||||
<button class="filter-pill" :class="{ active: filterStatus === 'rejected' }" @click="filterStatus = 'rejected'">Rejected</button>
|
||||
<button class="filter-pill" :class="{ active: filterStatus === 'expired' }" @click="filterStatus = 'expired'">Expired</button>
|
||||
</div>
|
||||
<div x-show="filtered.length === 0" class="empty-state">
|
||||
<h4>No approvals</h4>
|
||||
@@ -1241,7 +1342,7 @@
|
||||
|
||||
<!-- Tab: List -->
|
||||
<template x-if="wfTab === 'list'">
|
||||
<div x-data="workflowsPage">
|
||||
<div x-data="workflowsPage" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden">
|
||||
<div class="page-body" x-init="loadWorkflows()">
|
||||
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading workflows...</span></div>
|
||||
<div x-show="!loading && loadError" class="error-state">
|
||||
@@ -1272,7 +1373,9 @@
|
||||
<td class="text-xs" x-text="new Date(wf.created_at).toLocaleDateString()"></td>
|
||||
<td>
|
||||
<button class="btn btn-primary btn-sm" @click="showRunModal(wf)">Run</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="showEditModal(wf)">Edit</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="viewRuns(wf)">History</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteWorkflow(wf)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -1337,6 +1440,39 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Edit modal -->
|
||||
<template x-if="editModal">
|
||||
<div class="modal-overlay" @click.self="editModal = null" @keydown.escape.window="editModal = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3 x-text="'Edit: ' + editModal.name"></h3><button class="modal-close" @click="editModal = null">×</button></div>
|
||||
<div class="form-group"><label>Name</label><input class="form-input" x-model="editWf.name" placeholder="Workflow name"></div>
|
||||
<div class="form-group"><label>Description</label><input class="form-input" x-model="editWf.description" placeholder="What does this workflow do?"></div>
|
||||
<div class="mb-4">
|
||||
<div class="form-group" style="margin:0"><label>Steps</label></div>
|
||||
<div class="text-xs text-dim mb-2">Each step runs an agent. Use <code style="color:var(--accent)">{{input}}</code> in prompts to pass the previous step's output.</div>
|
||||
<template x-for="(step, i) in editWf.steps" :key="i">
|
||||
<div class="card mt-2" style="padding:10px">
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-xs text-dim font-bold" x-text="'#' + (i+1)" style="width:24px"></span>
|
||||
<input class="form-input" style="flex:1" x-model="step.name" placeholder="Step name">
|
||||
<input class="form-input" style="flex:1" x-model="step.agent_name" placeholder="Agent name">
|
||||
<select class="form-select" style="width:120px" x-model="step.mode">
|
||||
<option value="sequential">Sequential</option>
|
||||
<option value="fan_out">Fan Out</option>
|
||||
<option value="conditional">Conditional</option>
|
||||
<option value="loop">Loop</option>
|
||||
</select>
|
||||
<button class="btn btn-danger btn-sm" @click="editWf.steps.splice(i,1)">×</button>
|
||||
</div>
|
||||
<input class="form-input mt-2" x-model="step.prompt" placeholder="Prompt template (use {{input}})">
|
||||
</div>
|
||||
</template>
|
||||
<button class="btn btn-ghost btn-sm mt-2" @click="editWf.steps.push({name:'',agent_name:'',mode:'sequential',prompt:'{{input}}'})">+ Add Step</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" @click="saveWorkflow()">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1420,7 +1556,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Label</label>
|
||||
<input class="form-input" x-model="selectedNode.label" style="font-size:11px">
|
||||
<input class="form-input" x-model="selectedNode.label" @input="applyNodeEdit()" style="font-size:11px">
|
||||
</div>
|
||||
|
||||
<!-- Agent config -->
|
||||
@@ -1428,7 +1564,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Agent</label>
|
||||
<select class="form-select" x-model="selectedNode.config.agent_name" style="font-size:11px">
|
||||
<select class="form-select" x-model="selectedNode.config.agent_name" @change="applyNodeEdit()" style="font-size:11px">
|
||||
<option value="">Select agent...</option>
|
||||
<template x-for="a in agents" :key="a.id || a.name">
|
||||
<option :value="a.name" x-text="a.name"></option>
|
||||
@@ -1437,11 +1573,11 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Prompt Template</label>
|
||||
<textarea class="form-textarea" x-model="selectedNode.config.prompt" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
|
||||
<textarea class="form-textarea" x-model="selectedNode.config.prompt" @input="applyNodeEdit()" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Model (optional)</label>
|
||||
<input class="form-input" x-model="selectedNode.config.model" style="font-size:11px" placeholder="Default model">
|
||||
<input class="form-input" x-model="selectedNode.config.model" @input="applyNodeEdit()" style="font-size:11px" placeholder="Default model">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1451,7 +1587,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Expression</label>
|
||||
<input class="form-input" x-model="selectedNode.config.expression" style="font-size:11px" placeholder="output.contains('yes')">
|
||||
<input class="form-input" x-model="selectedNode.config.expression" @input="applyNodeEdit()" style="font-size:11px" placeholder="output.contains('yes')">
|
||||
</div>
|
||||
<div class="text-xs text-dim">Top port = true, bottom port = false</div>
|
||||
</div>
|
||||
@@ -1462,11 +1598,11 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Max Iterations</label>
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" style="font-size:11px" min="1" max="100">
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" @input="applyNodeEdit()" style="font-size:11px" min="1" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Until (stop condition)</label>
|
||||
<input class="form-input" x-model="selectedNode.config.until" style="font-size:11px" placeholder="output === 'done'">
|
||||
<input class="form-input" x-model="selectedNode.config.until" @input="applyNodeEdit()" style="font-size:11px" placeholder="output === 'done'">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1476,7 +1612,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Fan-out Count</label>
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" style="font-size:11px" min="2" max="10">
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" @input="applyNodeEdit()" style="font-size:11px" min="2" max="10">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1486,7 +1622,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Strategy</label>
|
||||
<select class="form-select" x-model="selectedNode.config.strategy" style="font-size:11px">
|
||||
<select class="form-select" x-model="selectedNode.config.strategy" @change="applyNodeEdit()" style="font-size:11px">
|
||||
<option value="all">Wait for all</option>
|
||||
<option value="first">First to finish</option>
|
||||
<option value="majority">Majority vote</option>
|
||||
@@ -2124,7 +2260,7 @@
|
||||
<!-- Search bar with live search and clear button -->
|
||||
<div class="search-input mb-4" style="position:relative">
|
||||
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
|
||||
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
|
||||
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
|
||||
<button x-show="clawhubSearch" @click="clearSearch()" class="search-clear-btn" title="Clear search (Esc)">×</button>
|
||||
</div>
|
||||
|
||||
@@ -2175,7 +2311,7 @@
|
||||
<div class="flex gap-3 items-center">
|
||||
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || isSkillInstalled(skill.slug)" x-text="isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
|
||||
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || skill.installed || isSkillInstalled(skill.slug)" x-text="skill.installed || isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2198,7 +2334,7 @@
|
||||
<span class="text-xs text-dim" x-show="skill.stars" x-text="skill.stars + ' stars'"></span>
|
||||
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || isSkillInstalled(skill.slug)" x-text="isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
|
||||
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || skill.installed || isSkillInstalled(skill.slug)" x-text="skill.installed || isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2482,6 +2618,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button class="btn btn-ghost btn-sm" @click="loadStats(inst)">Stats</button>
|
||||
<template x-if="isTraderHand(inst)">
|
||||
<button class="btn btn-primary btn-sm" @click="openDashboard(inst)">Dashboard</button>
|
||||
</template>
|
||||
<template x-if="isBrowserHand(inst)">
|
||||
<button class="btn btn-ghost btn-sm" @click="openBrowserViewer(inst)">View Browser</button>
|
||||
</template>
|
||||
@@ -2594,9 +2733,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<!-- ═══ Step 1: Dependencies ═══ -->
|
||||
<div class="hand-wizard-body" x-show="setupStep === 1">
|
||||
<template x-for="req in (setupWizard.requirements || [])" :key="req.key">
|
||||
<div class="dep-card" :class="req.satisfied ? 'dep-met' : 'dep-missing'">
|
||||
<div class="dep-card" :class="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'dep-met' : 'dep-missing'">
|
||||
<div class="dep-card-header">
|
||||
<div class="dep-status-icon" :class="[req.satisfied ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="req.satisfied ? '\u2713' : '\u2717'"></div>
|
||||
<div class="dep-status-icon" :class="[(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? '\u2713' : '\u2717'"></div>
|
||||
<span class="dep-card-title" x-text="req.label"></span>
|
||||
<template x-if="req.install && req.install.estimated_time">
|
||||
<span class="dep-time-badge" x-text="req.install.estimated_time"></span>
|
||||
@@ -2639,24 +2778,32 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</ol>
|
||||
</template>
|
||||
|
||||
<!-- API Key: numbered steps + signup link -->
|
||||
<!-- API Key: input field + numbered steps + signup link -->
|
||||
<template x-if="req.type === 'ApiKey' && req.install">
|
||||
<div>
|
||||
<template x-if="req.install.steps && req.install.steps.length">
|
||||
<ol class="api-key-steps">
|
||||
<template x-for="step in req.install.steps" :key="step">
|
||||
<li x-text="step"></li>
|
||||
</template>
|
||||
</ol>
|
||||
</template>
|
||||
<template x-if="req.install.env_example">
|
||||
<div class="install-block" style="margin-top:8px">
|
||||
<div class="install-cmd">
|
||||
<code x-text="req.install.env_example"></code>
|
||||
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
|
||||
<div style="margin-bottom:10px">
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px" x-text="'Paste your ' + req.label + ':'"></label>
|
||||
<input type="password" class="form-input" x-model="apiKeyInputs[req.key]" :placeholder="req.label" style="width:100%;font-family:var(--font-mono);font-size:12px">
|
||||
<div class="text-xs" style="margin-top:4px;color:var(--green)" x-show="apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== ''">✓ Token entered</div>
|
||||
</div>
|
||||
<details style="margin-bottom:8px">
|
||||
<summary class="text-xs text-dim" style="cursor:pointer;user-select:none">Or set as environment variable</summary>
|
||||
<template x-if="req.install.steps && req.install.steps.length">
|
||||
<ol class="api-key-steps">
|
||||
<template x-for="step in req.install.steps" :key="step">
|
||||
<li x-text="step"></li>
|
||||
</template>
|
||||
</ol>
|
||||
</template>
|
||||
<template x-if="req.install.env_example">
|
||||
<div class="install-block" style="margin-top:8px">
|
||||
<div class="install-cmd">
|
||||
<code x-text="req.install.env_example"></code>
|
||||
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</details>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<template x-if="req.install.signup_url">
|
||||
<a :href="req.install.signup_url" target="_blank" rel="noopener" class="btn btn-primary btn-sm">Get API Key →</a>
|
||||
@@ -2779,7 +2926,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
|
||||
<!-- Text type -->
|
||||
<template x-if="setting.setting_type === 'text'">
|
||||
<input type="text" class="input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
|
||||
<input type="text" class="form-input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2889,6 +3036,152 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trader Dashboard Modal -->
|
||||
<template x-if="dashboardOpen">
|
||||
<div class="modal-overlay" @click.self="closeDashboard()" @keydown.escape.window="closeDashboard()">
|
||||
<div class="trader-dashboard">
|
||||
<!-- Header -->
|
||||
<div class="trader-dashboard-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="font-size:1.4rem">📈</span>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:1.1rem" x-text="dashboardData ? (dashboardData.agent_name || 'Trading Hand') : 'Trading Hand'"></div>
|
||||
<div class="text-xs text-dim">Live Trading Dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost btn-sm" @click="refreshDashboard()">Refresh</button>
|
||||
<button class="modal-close" @click="closeDashboard()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="dashboardLoading" class="text-center" style="padding:60px 0">
|
||||
<div class="spinner"></div>
|
||||
<div class="text-dim mt-2">Loading dashboard data...</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<div class="trader-dashboard-body" x-show="!dashboardLoading && dashboardData">
|
||||
<!-- KPI Row -->
|
||||
<div class="trader-kpi-row">
|
||||
<div class="trader-kpi-card">
|
||||
<div class="trader-kpi-label">Portfolio Value</div>
|
||||
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.portfolio_value || '-') : '-'"></div>
|
||||
</div>
|
||||
<div class="trader-kpi-card">
|
||||
<div class="trader-kpi-label">Total P&L</div>
|
||||
<div class="trader-kpi-value" :class="dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('+') ? 'kpi-positive' : (dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('-') ? 'kpi-negative' : '')" x-text="dashboardData ? (dashboardData.total_pnl || '-') : '-'"></div>
|
||||
</div>
|
||||
<div class="trader-kpi-card">
|
||||
<div class="trader-kpi-label">Win Rate</div>
|
||||
<div class="trader-kpi-value" x-text="dashboardData && dashboardData.win_rate ? (dashboardData.win_rate + '%') : '-'"></div>
|
||||
</div>
|
||||
<div class="trader-kpi-card">
|
||||
<div class="trader-kpi-label">Sharpe Ratio</div>
|
||||
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.sharpe_ratio || '-') : '-'"></div>
|
||||
</div>
|
||||
<div class="trader-kpi-card">
|
||||
<div class="trader-kpi-label">Max Drawdown</div>
|
||||
<div class="trader-kpi-value kpi-negative" x-text="dashboardData && dashboardData.max_drawdown ? (dashboardData.max_drawdown + '%') : '-'"></div>
|
||||
</div>
|
||||
<div class="trader-kpi-card">
|
||||
<div class="trader-kpi-label">Trades</div>
|
||||
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.trades_count || '0') : '0'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 1: Equity Curve + Daily P&L -->
|
||||
<div class="trader-chart-row">
|
||||
<div class="trader-chart-panel" style="flex:2">
|
||||
<div class="trader-chart-title">Equity Curve</div>
|
||||
<div class="trader-chart-wrap">
|
||||
<canvas id="traderEquityChart"></canvas>
|
||||
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.equity_curve || !dashboardData.equity_curve.length">No equity data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trader-chart-panel" style="flex:1">
|
||||
<div class="trader-chart-title">Daily P&L</div>
|
||||
<div class="trader-chart-wrap">
|
||||
<canvas id="traderPnlChart"></canvas>
|
||||
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.daily_pnl || !dashboardData.daily_pnl.length">No P&L data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 2: Signal Radar + Watchlist Heatmap -->
|
||||
<div class="trader-chart-row">
|
||||
<div class="trader-chart-panel" style="flex:1;max-width:320px">
|
||||
<div class="trader-chart-title">Signal Radar</div>
|
||||
<div class="trader-chart-wrap" style="max-height:280px">
|
||||
<canvas id="traderRadarChart"></canvas>
|
||||
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.signal_radar">No signal data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trader-chart-panel" style="flex:2">
|
||||
<div class="trader-chart-title">Watchlist Heatmap</div>
|
||||
<div class="trader-heatmap-wrap" x-show="dashboardData && dashboardData.watchlist_heatmap && dashboardData.watchlist_heatmap.length">
|
||||
<table class="trader-heatmap-table">
|
||||
<thead>
|
||||
<tr><th>Ticker</th><th>Change</th><th>Signal</th><th>Confidence</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="item in (dashboardData ? dashboardData.watchlist_heatmap || [] : [])" :key="item.ticker">
|
||||
<tr>
|
||||
<td style="font-weight:600" x-text="item.ticker"></td>
|
||||
<td :class="item.change_pct >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(item.change_pct >= 0 ? '+' : '') + item.change_pct + '%'"></td>
|
||||
<td><span class="signal-badge" :class="'signal-' + (item.signal || 'hold').toLowerCase()" x-text="item.signal || 'HOLD'"></span></td>
|
||||
<td>
|
||||
<div class="confidence-bar-wrap">
|
||||
<div class="confidence-bar" :style="'width:' + (item.confidence || 0) + '%'" :class="item.confidence >= 70 ? 'conf-high' : (item.confidence >= 40 ? 'conf-mid' : 'conf-low')"></div>
|
||||
<span class="confidence-label" x-text="(item.confidence || 0) + '%'"></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.watchlist_heatmap || !dashboardData.watchlist_heatmap.length">No watchlist data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Trades Table -->
|
||||
<div class="trader-chart-panel">
|
||||
<div class="trader-chart-title">Recent Trades</div>
|
||||
<div x-show="dashboardData && dashboardData.recent_trades && dashboardData.recent_trades.length">
|
||||
<table class="trader-trades-table">
|
||||
<thead>
|
||||
<tr><th>Date</th><th>Ticker</th><th>Side</th><th>Price</th><th>Qty</th><th>P&L</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="trade in (dashboardData ? dashboardData.recent_trades || [] : [])" :key="trade.date + trade.ticker">
|
||||
<tr>
|
||||
<td class="text-dim" x-text="trade.date"></td>
|
||||
<td style="font-weight:600" x-text="trade.ticker"></td>
|
||||
<td><span class="trade-side-badge" :class="trade.side === 'BUY' ? 'trade-buy' : 'trade-sell'" x-text="trade.side"></span></td>
|
||||
<td x-text="'$' + Number(trade.price || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
|
||||
<td x-text="trade.qty"></td>
|
||||
<td :class="(trade.pnl || 0) >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(trade.pnl >= 0 ? '+$' : '-$') + Math.abs(trade.pnl || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.recent_trades || !dashboardData.recent_trades.length">No trades yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No data state -->
|
||||
<div x-show="!dashboardLoading && !dashboardData" class="text-center" style="padding:60px 0">
|
||||
<div style="font-size:2rem;margin-bottom:8px">📈</div>
|
||||
<div class="text-dim">Could not load dashboard data.</div>
|
||||
<button class="btn btn-ghost btn-sm mt-3" @click="refreshDashboard()">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Activation result toast -->
|
||||
<div x-show="activateResult" x-transition class="info-card" style="position:fixed;bottom:24px;right:24px;z-index:200;max-width:360px" @click="activateResult = null">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -2995,6 +3288,30 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Add Custom Provider -->
|
||||
<div class="info-card mt-4" style="border:1px solid var(--border)">
|
||||
<h4 style="margin-top:0">Add Custom Provider</h4>
|
||||
<p class="text-xs text-dim mb-2">Connect any OpenAI-compatible API (vLLM, LiteLLM, LocalAI, etc.)</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
|
||||
<div>
|
||||
<label class="text-xs text-dim">Provider Name</label>
|
||||
<input class="form-input" x-model="customProviderName" placeholder="e.g. my-local-llm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Base URL (required)</label>
|
||||
<input class="form-input" x-model="customProviderUrl" placeholder="http://localhost:8080/v1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="text-xs text-dim">API Key (optional)</label>
|
||||
<input class="form-input" type="password" x-model="customProviderKey" placeholder="sk-... (leave blank if not needed)">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="addCustomProvider()" :disabled="!customProviderName.trim() || !customProviderUrl.trim() || addingCustomProvider">
|
||||
<span x-show="!addingCustomProvider">Add Provider</span>
|
||||
<span x-show="addingCustomProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
||||
</button>
|
||||
<span class="text-xs text-dim ml-2" x-text="customProviderStatus"></span>
|
||||
</div>
|
||||
<div class="empty-state" x-show="!providers.length">
|
||||
<h4>No providers found</h4>
|
||||
<p class="hint">Provider information could not be loaded. Check that the API is running.</p>
|
||||
@@ -3051,9 +3368,15 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
|
||||
<div x-show="!filteredModels.length && !loading" style="text-align:center;padding:32px 16px">
|
||||
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">🤖</div>
|
||||
<h3 style="margin:0 0 4px;font-size:14px" x-text="models.length ? 'No models match your search' : 'No models available'"></h3>
|
||||
<p class="text-xs text-dim" x-text="models.length ? 'Try a different search term or clear filters.' : 'Configure an LLM provider to see available models.'"></p>
|
||||
<button class="btn btn-ghost btn-sm mt-2" x-show="models.length && (modelSearch || modelProviderFilter)" @click="modelSearch=''; modelProviderFilter=''">Clear Filters</button>
|
||||
</div>
|
||||
<div class="table-wrap" x-show="filteredModels.length">
|
||||
<table>
|
||||
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th></tr></thead>
|
||||
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="m in filteredModels" :key="m.id">
|
||||
<tr>
|
||||
@@ -3064,6 +3387,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<td class="text-xs" x-text="formatCost(m.input_cost_per_m)"></td>
|
||||
<td class="text-xs" x-text="formatCost(m.output_cost_per_m)"></td>
|
||||
<td><span class="badge" :class="m.available ? 'badge-success' : 'badge-muted'" x-text="m.available ? 'Available' : 'Needs Key'"></span></td>
|
||||
<td><button x-show="m.tier === 'custom'" class="btn btn-ghost btn-sm" @click="deleteCustomModel(m.id)" title="Delete custom model" style="padding:2px 6px;font-size:11px;color:var(--text-muted)">✕</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
@@ -3078,6 +3402,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<input placeholder="Search tools..." x-model="toolSearch">
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredTools.length + ' of ' + tools.length + ' tools'"></div>
|
||||
<div x-show="!filteredTools.length && !loading" style="text-align:center;padding:32px 16px">
|
||||
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">🔧</div>
|
||||
<h3 style="margin:0 0 4px;font-size:14px" x-text="tools.length ? 'No tools match your search' : 'No tools available'"></h3>
|
||||
<p class="text-xs text-dim" x-text="tools.length ? 'Try a different search term.' : 'Tools will appear once agents are configured.'"></p>
|
||||
<button class="btn btn-ghost btn-sm mt-2" x-show="tools.length && toolSearch" @click="toolSearch=''">Clear Search</button>
|
||||
</div>
|
||||
<div class="table-wrap" x-show="filteredTools.length">
|
||||
<table>
|
||||
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
|
||||
@@ -3121,7 +3451,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header" style="text-transform:capitalize" x-text="section.replace(/_/g, ' ')"></div>
|
||||
<div class="detail-grid" style="margin-top:12px">
|
||||
<template x-for="field in fields" :key="section + '.' + field.name">
|
||||
<template x-for="field in (fields.fields || [])" :key="section + '.' + field.name">
|
||||
<div class="detail-row" style="align-items:center">
|
||||
<span class="detail-label" x-text="field.label || field.name"></span>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
||||
@@ -3141,8 +3471,8 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<select class="form-select" style="width:180px"
|
||||
:value="configValues[section] && configValues[section][field.name]"
|
||||
@change="configValues[section] = configValues[section] || {}; configValues[section][field.name] = $event.target.value; markConfigDirty(section, field.name)">
|
||||
<template x-for="opt in field.options" :key="opt">
|
||||
<option :value="opt" x-text="opt" :selected="configValues[section] && configValues[section][field.name] === opt"></option>
|
||||
<template x-for="opt in field.options" :key="typeof opt === 'object' ? opt.id : opt">
|
||||
<option :value="typeof opt === 'object' ? opt.id : opt" x-text="typeof opt === 'object' ? (opt.name || opt.display_name || opt.id) : opt" :selected="configValues[section] && configValues[section][field.name] === (typeof opt === 'object' ? opt.id : opt)"></option>
|
||||
</template>
|
||||
</select>
|
||||
</template>
|
||||
@@ -3287,11 +3617,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<!-- Network tab -->
|
||||
<div x-show="tab === 'network'" x-data="{
|
||||
netStatus: null, a2aAgents: [], a2aDiscoverUrl: '', a2aDiscovering: false,
|
||||
async loadNetStatus() { try { this.netStatus = await (await fetch('/api/network/status')).json(); } catch(e) {} },
|
||||
async loadA2aAgents() { try { let r = await (await fetch('/api/a2a/agents')).json(); this.a2aAgents = r.agents || []; } catch(e) {} },
|
||||
async loadNetStatus() { try { this.netStatus = await OpenFangAPI.get('/api/network/status'); } catch(e) {} },
|
||||
async loadA2aAgents() { try { let r = await OpenFangAPI.get('/api/a2a/agents'); this.a2aAgents = r.agents || []; } catch(e) {} },
|
||||
async discoverA2a() {
|
||||
if (!this.a2aDiscoverUrl) return; this.a2aDiscovering = true;
|
||||
try { await fetch('/api/a2a/discover', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:this.a2aDiscoverUrl})}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
|
||||
try { await OpenFangAPI.post('/api/a2a/discover', {url:this.a2aDiscoverUrl}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
|
||||
this.a2aDiscovering = false;
|
||||
}
|
||||
}" x-init="loadNetStatus(); loadA2aAgents()">
|
||||
@@ -3373,14 +3703,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div x-show="tab === 'budget'" x-data="{
|
||||
budgetData: null, agentRanking: [], budgetLoading: true,
|
||||
editMode: false,
|
||||
editHourly: '', editDaily: '', editMonthly: '', editAlert: '',
|
||||
editHourly: '', editDaily: '', editMonthly: '', editAlert: '', editTokenLimit: '',
|
||||
saving: false,
|
||||
async loadBudget() {
|
||||
this.budgetLoading = true;
|
||||
try {
|
||||
let [b, a] = await Promise.all([
|
||||
fetch('/api/budget').then(r => r.json()),
|
||||
fetch('/api/budget/agents').then(r => r.json())
|
||||
OpenFangAPI.get('/api/budget'),
|
||||
OpenFangAPI.get('/api/budget/agents')
|
||||
]);
|
||||
this.budgetData = b;
|
||||
this.agentRanking = a.agents || [];
|
||||
@@ -3392,6 +3722,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
this.editDaily = this.budgetData.daily_limit || 0;
|
||||
this.editMonthly = this.budgetData.monthly_limit || 0;
|
||||
this.editAlert = ((this.budgetData.alert_threshold || 0.8) * 100).toFixed(0);
|
||||
this.editTokenLimit = this.budgetData.default_max_llm_tokens_per_hour || 0;
|
||||
this.editMode = true;
|
||||
},
|
||||
async saveBudget() {
|
||||
@@ -3403,12 +3734,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
if (+this.editMonthly !== this.budgetData.monthly_limit) body.max_monthly_usd = +this.editMonthly;
|
||||
let alertVal = (+this.editAlert) / 100;
|
||||
if (Math.abs(alertVal - this.budgetData.alert_threshold) > 0.001) body.alert_threshold = alertVal;
|
||||
await fetch('/api/budget', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
if (+this.editTokenLimit !== (this.budgetData.default_max_llm_tokens_per_hour || 0)) body.default_max_llm_tokens_per_hour = +this.editTokenLimit;
|
||||
await OpenFangAPI.put('/api/budget', body);
|
||||
this.editMode = false;
|
||||
await this.loadBudget();
|
||||
} catch(e) { alert('Failed to save: ' + e); }
|
||||
} catch(e) { OpenFangToast.error('Failed to save: ' + (e.message || e)); }
|
||||
this.saving = false;
|
||||
},
|
||||
fmtTokens(v) { return v > 0 ? (v >= 1000000 ? (v/1000000).toFixed(1)+'M' : v >= 1000 ? (v/1000).toFixed(0)+'K' : v) : 'per-agent'; },
|
||||
pctColor(pct) { return pct >= 0.8 ? '#ef4444' : pct >= 0.5 ? '#eab308' : '#22c55e'; },
|
||||
fmtUsd(v) { return v > 0 ? '$' + v.toFixed(4) : 'unlimited'; }
|
||||
}" x-init="loadBudget()">
|
||||
@@ -3448,9 +3781,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-3" x-show="budgetData.alert_threshold > 0 && !editMode">
|
||||
<div class="text-xs text-dim mb-1" x-show="budgetData.alert_threshold > 0 && !editMode">
|
||||
Alert threshold: <span x-text="(budgetData.alert_threshold * 100).toFixed(0) + '%'"></span> of any limit
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-3" x-show="!editMode">
|
||||
Hourly token limit (per agent): <span x-text="fmtTokens(budgetData.default_max_llm_tokens_per_hour || 0)"></span>
|
||||
</div>
|
||||
|
||||
<!-- Edit limits form -->
|
||||
<div x-show="editMode" class="card" style="margin:12px 0;padding:12px;border:1px solid var(--accent);border-radius:6px">
|
||||
@@ -3472,7 +3808,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<input type="number" step="5" min="0" max="100" x-model="editAlert" class="input" style="width:100%;margin-top:2px" placeholder="80">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-dim">Set to 0 for unlimited. Changes apply immediately (in-memory, not persisted to config.toml).</div>
|
||||
<div style="margin-bottom:8px">
|
||||
<label class="text-xs text-dim">Hourly Token Limit (per agent, 0 = use per-agent values)</label>
|
||||
<input type="number" step="100000" min="0" x-model="editTokenLimit" class="input" style="width:260px;margin-top:2px" placeholder="0 = per-agent default">
|
||||
</div>
|
||||
<div class="text-xs text-dim">Set to 0 for unlimited/per-agent default. Changes apply immediately (in-memory, not persisted to config.toml).</div>
|
||||
<button class="btn btn-sm mt-2" @click="editMode = false" style="margin-right:8px">Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -3480,7 +3820,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
|
||||
<div class="table-wrap" x-show="agentRanking.length">
|
||||
<table>
|
||||
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th></tr></thead>
|
||||
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th><th>Token Limit/hr</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="a in agentRanking" :key="a.agent_id">
|
||||
<tr>
|
||||
@@ -3489,6 +3829,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<td class="text-dim" x-text="fmtUsd(a.hourly_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtUsd(a.daily_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtUsd(a.monthly_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtTokens(a.max_llm_tokens_per_hour || 0)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
@@ -3973,7 +4314,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div x-data="logsPage">
|
||||
<div class="page-header">
|
||||
<h2>Logs</h2>
|
||||
<div class="flex gap-2 items-center" x-show="tab === 'live'">
|
||||
<div class="flex gap-2 items-center flex-wrap" x-show="tab === 'live'">
|
||||
<!-- Connection status indicator -->
|
||||
<span class="live-indicator" :class="connectionClass">
|
||||
<span class="live-dot"></span>
|
||||
@@ -4368,7 +4709,29 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj)">
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider === 'claude-code'">
|
||||
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
|
||||
<div class="card-header">Configure Claude Code</div>
|
||||
<div class="text-xs text-dim mb-2" style="line-height:1.8">
|
||||
Claude Code uses its own CLI authentication — no API key needed.
|
||||
</div>
|
||||
<div style="background:var(--bg);border-radius:4px;padding:10px 12px;margin-bottom:12px;font-size:12px;line-height:1.8">
|
||||
<div><span style="color:var(--accent)">1.</span> Install: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">npm install -g @anthropic-ai/claude-code</code></div>
|
||||
<div><span style="color:var(--accent)">2.</span> Authenticate: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">claude auth</code></div>
|
||||
<div><span style="color:var(--accent)">3.</span> Click <strong>Detect</strong> below to verify</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click="detectClaudeCode()" :disabled="testingProvider">
|
||||
<span x-show="!testingProvider">Detect Claude Code</span>
|
||||
<span x-show="testingProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
||||
</button>
|
||||
<div x-show="testResult" class="mt-2">
|
||||
<div x-show="testResult && testResult.status === 'ok'" class="badge badge-success" style="padding:6px 12px">Claude Code detected<span x-show="testResult && testResult.latency_ms" x-text="' (' + (testResult ? testResult.latency_ms : '') + 'ms)'"></span></div>
|
||||
<div x-show="testResult && testResult.status !== 'ok'" class="badge badge-error" style="padding:6px 12px">Claude Code CLI not detected. Make sure you’ve run: <code>npm install -g @anthropic-ai/claude-code && claude auth</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider !== 'claude-code'">
|
||||
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
|
||||
<div class="card-header" x-text="'Configure ' + selectedProviderObj.display_name"></div>
|
||||
<div class="text-xs text-dim mb-2" x-show="selectedProviderObj.api_key_env">
|
||||
@@ -4458,7 +4821,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div class="card" style="border-left:3px solid var(--accent)">
|
||||
<div class="form-group" style="margin-bottom:8px">
|
||||
<label>Agent Name</label>
|
||||
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="createAgent()">
|
||||
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) createAgent()">
|
||||
</div>
|
||||
<div class="text-xs text-dim" x-text="'Will use ' + templates[selectedTemplate].provider + ' / ' + templates[selectedTemplate].model + ' with ' + profileInfo(templates[selectedTemplate].profile).label + ' profile'"></div>
|
||||
<div class="mt-2">
|
||||
@@ -4504,7 +4867,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<!-- Input -->
|
||||
<div style="display:flex;gap:8px;margin-top:12px">
|
||||
<input class="form-input" type="text" x-model="tryItInput" placeholder="Type a message..."
|
||||
@keydown.enter="sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
|
||||
@keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
|
||||
<button class="btn btn-primary btn-sm" @click="sendTryItMessage(tryItInput)" :disabled="tryItSending || !tryItInput.trim()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4627,7 +4990,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div class="page-body" x-init="loadData()">
|
||||
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading runtime info...</span></div>
|
||||
<div x-show="!loading">
|
||||
<div class="grid grid-cols-4" style="gap:16px;margin-bottom:24px">
|
||||
<div class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Uptime</div>
|
||||
<div class="stat-value" x-text="uptime"></div>
|
||||
@@ -4690,3 +5053,5 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
|
||||
<!-- Toast notification container -->
|
||||
<div id="toast-container" class="toast-container" aria-live="polite"></div>
|
||||
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
|
||||
|
||||
@@ -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,16 +210,33 @@ document.addEventListener('alpine:init', function() {
|
||||
|
||||
async checkAuth() {
|
||||
try {
|
||||
// Use a protected endpoint (not in the public allowlist) to detect
|
||||
// whether the server requires an API key.
|
||||
// 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 || e.message.indexOf('Unauthorized') >= 0)) {
|
||||
// Only show prompt if we don't already have a saved key
|
||||
var saved = localStorage.getItem('openfang-api-key');
|
||||
if (saved) {
|
||||
// Saved key might be stale — clear it and show prompt
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
}
|
||||
@@ -181,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');
|
||||
@@ -274,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: '',
|
||||
@@ -62,7 +79,12 @@ function agentsPage() {
|
||||
// -- Model switch --
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
editingProvider: false,
|
||||
newProviderValue: '',
|
||||
modelSaving: false,
|
||||
// -- Fallback chain --
|
||||
editingFallback: false,
|
||||
newFallbackValue: '',
|
||||
|
||||
// -- Templates state --
|
||||
tplTemplates: [],
|
||||
@@ -72,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: {
|
||||
@@ -260,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?';
|
||||
}
|
||||
@@ -297,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;
|
||||
@@ -316,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 || '',
|
||||
@@ -331,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) {
|
||||
@@ -367,7 +375,7 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// ── Multi-step wizard navigation ──
|
||||
openSpawnWizard() {
|
||||
async openSpawnWizard() {
|
||||
this.showSpawnModal = true;
|
||||
this.spawnStep = 1;
|
||||
this.spawnMode = 'wizard';
|
||||
@@ -375,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() {
|
||||
@@ -400,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') {
|
||||
@@ -409,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 = ["*"]');
|
||||
@@ -552,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) {
|
||||
@@ -586,8 +617,9 @@ function agentsPage() {
|
||||
if (!this.detailAgent || !this.newModelValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
OpenFangToast.success('Model changed (memory reset)');
|
||||
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
|
||||
@@ -601,6 +633,61 @@ function agentsPage() {
|
||||
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;
|
||||
@@ -651,12 +738,12 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
@@ -37,6 +37,7 @@ function chatPage() {
|
||||
// Model switcher dropdown
|
||||
showModelSwitcher: false,
|
||||
modelSwitcherFilter: '',
|
||||
modelSwitcherProviderFilter: '',
|
||||
modelSwitcherIdx: 0,
|
||||
modelSwitching: false,
|
||||
_modelCache: null,
|
||||
@@ -99,14 +100,25 @@ function chatPage() {
|
||||
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 || [];
|
||||
if (!this.modelSwitcherFilter) return models;
|
||||
var f = this.modelSwitcherFilter.toLowerCase();
|
||||
var provFilter = this.modelSwitcherProviderFilter;
|
||||
var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : '';
|
||||
if (!provFilter && !textFilter) return models;
|
||||
return models.filter(function(m) {
|
||||
return m.id.toLowerCase().indexOf(f) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(f) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(f) !== -1;
|
||||
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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -220,6 +232,7 @@ function chatPage() {
|
||||
var now = Date.now();
|
||||
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
|
||||
this.modelSwitcherFilter = '';
|
||||
this.modelSwitcherProviderFilter = '';
|
||||
this.modelSwitcherIdx = 0;
|
||||
this.showModelSwitcher = true;
|
||||
this.$nextTick(function() {
|
||||
@@ -234,6 +247,7 @@ function chatPage() {
|
||||
self._modelCacheTime = Date.now();
|
||||
self.modelPickerList = models;
|
||||
self.modelSwitcherFilter = '';
|
||||
self.modelSwitcherProviderFilter = '';
|
||||
self.modelSwitcherIdx = 0;
|
||||
self.showModelSwitcher = true;
|
||||
self.$nextTick(function() {
|
||||
@@ -250,9 +264,10 @@ function chatPage() {
|
||||
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() {
|
||||
self.currentAgent.model_name = model.id;
|
||||
self.currentAgent.model_provider = model.provider;
|
||||
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;
|
||||
@@ -406,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 {
|
||||
@@ -511,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(); });
|
||||
}
|
||||
@@ -600,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;
|
||||
|
||||
@@ -614,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') {
|
||||
@@ -626,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
|
||||
@@ -666,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
|
||||
@@ -674,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: [] });
|
||||
}
|
||||
@@ -681,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) {
|
||||
@@ -699,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) {
|
||||
@@ -733,6 +789,7 @@ function chatPage() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.messages.splice(trIdx, 1, lastMsg3);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
@@ -1009,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) {
|
||||
@@ -1082,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; }
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
},
|
||||
|
||||
@@ -26,6 +26,11 @@ function settingsPage() {
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
customProviderName: '',
|
||||
customProviderUrl: '',
|
||||
customProviderKey: '',
|
||||
customProviderStatus: '',
|
||||
addingCustomProvider: false,
|
||||
loading: true,
|
||||
loadError: '',
|
||||
|
||||
@@ -258,6 +263,17 @@ function settingsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
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([
|
||||
@@ -279,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);
|
||||
}
|
||||
@@ -333,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';
|
||||
},
|
||||
|
||||
@@ -379,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();
|
||||
@@ -488,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;
|
||||
|
||||
@@ -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] || '';
|
||||
},
|
||||
|
||||
@@ -38,6 +38,11 @@ function workflowBuilder() {
|
||||
],
|
||||
|
||||
_renderScheduled: false,
|
||||
_lastClickNodeId: null,
|
||||
_lastClickTime: 0,
|
||||
_didDrag: false,
|
||||
_didConnect: false,
|
||||
_didPan: false,
|
||||
|
||||
async init() {
|
||||
var self = this;
|
||||
@@ -334,8 +339,23 @@ function workflowBuilder() {
|
||||
|
||||
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 = {
|
||||
@@ -350,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 };
|
||||
},
|
||||
@@ -357,6 +378,7 @@ 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);
|
||||
@@ -364,12 +386,14 @@ function workflowBuilder() {
|
||||
}
|
||||
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
|
||||
@@ -378,11 +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.scheduleRender();
|
||||
this._didDrag = false;
|
||||
this._didConnect = false;
|
||||
this._didPan = false;
|
||||
if (needsRender) {
|
||||
this.scheduleRender();
|
||||
}
|
||||
},
|
||||
|
||||
onCanvasWheel: function(e) {
|
||||
@@ -427,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 ──────────────────────────────────
|
||||
|
||||
@@ -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
@@ -77,6 +77,8 @@ async fn start_test_server_with_provider(
|
||||
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()
|
||||
@@ -222,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]
|
||||
@@ -247,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()
|
||||
@@ -255,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
|
||||
@@ -270,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()
|
||||
@@ -278,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]
|
||||
@@ -617,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
|
||||
@@ -633,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
|
||||
@@ -643,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]] {
|
||||
@@ -661,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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -704,9 +707,22 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
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))
|
||||
@@ -750,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))
|
||||
|
||||
@@ -114,6 +114,8 @@ async fn test_full_daemon_lifecycle() {
|
||||
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()
|
||||
@@ -238,6 +240,8 @@ async fn test_server_immediate_responsiveness() {
|
||||
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()
|
||||
|
||||
@@ -58,6 +58,8 @@ async fn start_test_server() -> TestServer {
|
||||
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()
|
||||
@@ -541,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)
|
||||
|
||||
+1076
-123
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");
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ pub struct DiscordAdapter {
|
||||
token: Zeroizing<String>,
|
||||
client: reqwest::Client,
|
||||
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<String>, 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 {}: {:?}",
|
||||
@@ -423,6 +440,8 @@ async fn parse_discord_message(
|
||||
d: &serde_json::Value,
|
||||
bot_user_id: &Arc<RwLock<Option<String>>>,
|
||||
allowed_guilds: &[String],
|
||||
allowed_users: &[String],
|
||||
ignore_bots: bool,
|
||||
) -> Option<ChannelMessage> {
|
||||
let author = d.get("author")?;
|
||||
let author_id = author["id"].as_str()?;
|
||||
@@ -434,8 +453,14 @@ 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;
|
||||
}
|
||||
|
||||
@@ -486,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(),
|
||||
@@ -497,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -523,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");
|
||||
@@ -545,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());
|
||||
}
|
||||
|
||||
@@ -565,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());
|
||||
}
|
||||
|
||||
@@ -586,11 +681,12 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()]).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".into()]).await;
|
||||
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[], true).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -609,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");
|
||||
@@ -634,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());
|
||||
}
|
||||
|
||||
@@ -653,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");
|
||||
}
|
||||
|
||||
@@ -675,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".to_string(), "456".to_string()], 37376);
|
||||
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;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
const SYNC_TIMEOUT_MS: u64 = 30000;
|
||||
@@ -35,6 +35,8 @@ pub struct MatrixAdapter {
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Sync token for resuming /sync.
|
||||
since_token: Arc<RwLock<Option<String>>>,
|
||||
/// Whether to auto-accept room invites.
|
||||
auto_accept_invites: bool,
|
||||
}
|
||||
|
||||
impl MatrixAdapter {
|
||||
@@ -44,6 +46,7 @@ impl MatrixAdapter {
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
allowed_rooms: Vec<String>,
|
||||
auto_accept_invites: bool,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
@@ -55,6 +58,7 @@ impl MatrixAdapter {
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
since_token: Arc::new(RwLock::new(None)),
|
||||
auto_accept_invites,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,12 +120,84 @@ impl MatrixAdapter {
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(test)]
|
||||
fn is_allowed_room(&self, room_id: &str) -> bool {
|
||||
self.allowed_rooms.is_empty() || self.allowed_rooms.iter().any(|r| r == room_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
|
||||
async fn accept_invite(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
access_token: &str,
|
||||
room_id: &str,
|
||||
) {
|
||||
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/join");
|
||||
match client
|
||||
.post(&url)
|
||||
.bearer_auth(access_token)
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
info!("Matrix: auto-accepted invite to {room_id}");
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
warn!("Matrix: failed to accept invite to {room_id}: {status}");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Matrix: error accepting invite to {room_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the number of joined members in a room.
|
||||
async fn get_room_member_count(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
access_token: &str,
|
||||
room_id: &str,
|
||||
) -> Option<usize> {
|
||||
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/joined_members");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body["joined"].as_object().map(|m| m.len())
|
||||
}
|
||||
|
||||
/// Do an initial /sync with timeout=0 to get the since token without processing events.
|
||||
/// This prevents replaying old messages when the adapter first connects.
|
||||
async fn initial_sync(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
access_token: &str,
|
||||
) -> Option<String> {
|
||||
let url = format!(
|
||||
"{homeserver}/_matrix/client/v3/sync?timeout=0&filter={{\"room\":{{\"timeline\":{{\"limit\":0}}}}}}"
|
||||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body["next_batch"].as_str().map(String::from)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for MatrixAdapter {
|
||||
fn name(&self) -> &str {
|
||||
@@ -143,14 +219,32 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let homeserver = self.homeserver_url.clone();
|
||||
let access_token = self.access_token.clone();
|
||||
let user_id = self.user_id.clone();
|
||||
// Use the validated user ID from /whoami instead of the config value.
|
||||
// Matrix server delegation or casing differences can cause self.user_id
|
||||
// to not match the sender field in timeline events, making the bot
|
||||
// process its own replies in an infinite loop (see #757).
|
||||
let user_id = validated_user;
|
||||
let allowed_rooms = self.allowed_rooms.clone();
|
||||
let client = self.client.clone();
|
||||
let since_token = Arc::clone(&self.since_token);
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
let auto_accept = self.auto_accept_invites;
|
||||
|
||||
// FIX #4: Do an initial sync to get the since token, skipping old messages.
|
||||
if since_token.read().await.is_none() {
|
||||
if let Some(token) = initial_sync(&client, &homeserver, access_token.as_str()).await {
|
||||
info!("Matrix: initial sync complete, skipping old messages");
|
||||
*since_token.write().await = Some(token);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
// Track recently seen event IDs to prevent duplicate processing
|
||||
// on sync token races or reconnects.
|
||||
let mut seen_events: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::new();
|
||||
const MAX_SEEN: usize = 500;
|
||||
|
||||
loop {
|
||||
// Build /sync URL
|
||||
@@ -168,7 +262,7 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
info!("Matrix adapter shutting down");
|
||||
break;
|
||||
}
|
||||
result = client.get(&url).bearer_auth(&*access_token).send() => {
|
||||
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
|
||||
match result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -203,6 +297,24 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
*since_token.write().await = Some(next.to_string());
|
||||
}
|
||||
|
||||
// FIX #1: Auto-accept room invites.
|
||||
if auto_accept {
|
||||
if let Some(invites) = body["rooms"]["invite"].as_object() {
|
||||
for (room_id, _invite_data) in invites {
|
||||
if !allowed_rooms.is_empty()
|
||||
&& !allowed_rooms.iter().any(|r| r == room_id)
|
||||
{
|
||||
debug!(
|
||||
"Matrix: ignoring invite to {room_id} (not in allowed_rooms)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accept_invite(&client, &homeserver, access_token.as_str(), room_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process room events
|
||||
if let Some(rooms) = body["rooms"]["join"].as_object() {
|
||||
for (room_id, room_data) in rooms {
|
||||
@@ -223,6 +335,21 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
continue; // Skip own messages
|
||||
}
|
||||
|
||||
// Dedup: skip events we've already processed.
|
||||
let event_id_str =
|
||||
event["event_id"].as_str().unwrap_or("").to_string();
|
||||
if !event_id_str.is_empty() {
|
||||
if seen_events.contains(&event_id_str) {
|
||||
debug!("Matrix: skipping duplicate event {event_id_str}");
|
||||
continue;
|
||||
}
|
||||
seen_events.insert(event_id_str.clone());
|
||||
// Prevent unbounded growth
|
||||
if seen_events.len() > MAX_SEEN {
|
||||
seen_events.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let content = event["content"]["body"].as_str().unwrap_or("");
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
@@ -243,11 +370,67 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
ChannelContent::Text(content.to_string())
|
||||
};
|
||||
|
||||
let event_id = event["event_id"].as_str().unwrap_or("").to_string();
|
||||
// FIX #2: Detect @mentions in message text.
|
||||
let mut metadata = HashMap::new();
|
||||
if content.contains(&user_id) {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
access_token.as_str(),
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
.map(|count| count > 2)
|
||||
.unwrap_or(true);
|
||||
|
||||
// For DMs, auto-set was_mentioned so dm_policy works.
|
||||
if !is_group {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
metadata.insert("is_dm".to_string(), serde_json::json!(true));
|
||||
}
|
||||
|
||||
// FIX #2: Detect @mentions in message text.
|
||||
let mut metadata = HashMap::new();
|
||||
if content.contains(&user_id) {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
access_token.as_str(),
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
.map(|count| count > 2)
|
||||
.unwrap_or(true);
|
||||
|
||||
// For DMs, auto-set was_mentioned so dm_policy works.
|
||||
if !is_group {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
metadata.insert("is_dm".to_string(), serde_json::json!(true));
|
||||
}
|
||||
|
||||
let channel_msg = ChannelMessage {
|
||||
channel: ChannelType::Matrix,
|
||||
platform_message_id: event_id,
|
||||
platform_message_id: event_id_str,
|
||||
sender: ChannelUser {
|
||||
platform_id: room_id.clone(),
|
||||
display_name: sender.to_string(),
|
||||
@@ -256,9 +439,9 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
content: msg_content,
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: true,
|
||||
is_group,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
metadata,
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
@@ -330,6 +513,7 @@ mod tests {
|
||||
"@bot:matrix.org".to_string(),
|
||||
"access_token".to_string(),
|
||||
vec![],
|
||||
false,
|
||||
);
|
||||
assert_eq!(adapter.name(), "matrix");
|
||||
}
|
||||
@@ -341,6 +525,7 @@ mod tests {
|
||||
"@bot:matrix.org".to_string(),
|
||||
"token".to_string(),
|
||||
vec!["!room1:matrix.org".to_string()],
|
||||
false,
|
||||
);
|
||||
assert!(adapter.is_allowed_room("!room1:matrix.org"));
|
||||
assert!(!adapter.is_allowed_room("!room2:matrix.org"));
|
||||
@@ -350,6 +535,7 @@ mod tests {
|
||||
"@bot:matrix.org".to_string(),
|
||||
"token".to_string(),
|
||||
vec![],
|
||||
false,
|
||||
);
|
||||
assert!(open.is_allowed_room("!any:matrix.org"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
//! MQTT channel adapter.
|
||||
//!
|
||||
//! Provides a generic MQTT pub/sub interface for IoT and messaging integration.
|
||||
//! Supports standard MQTT 3.1.1/5.0 brokers with optional TLS and authentication.
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! ```toml
|
||||
//! [channels.mqtt]
|
||||
//! broker_url = "tcp://broker.hivemq.com:1883"
|
||||
//! subscribe_topic = "openfang/inbox"
|
||||
//! publish_topic = "openfang/outbox"
|
||||
//! username_env = "MQTT_USERNAME"
|
||||
//! password_env = "MQTT_PASSWORD"
|
||||
//! use_tls = false
|
||||
//! qos = 1
|
||||
//! ```
|
||||
//!
|
||||
//! # Message Format
|
||||
//!
|
||||
//! Incoming messages are expected as UTF-8 text. The adapter supports:
|
||||
//! - Plain text messages
|
||||
//! - JSON payloads with `{"text": "message"}` format
|
||||
//! - Command messages starting with `/`
|
||||
|
||||
use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use futures::Stream;
|
||||
use rumqttc::{AsyncClient, Event, Incoming, MqttOptions, QoS};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Maximum MQTT message length.
|
||||
const MAX_MESSAGE_LEN: usize = 4096;
|
||||
|
||||
/// Type alias for the publish channel sender.
|
||||
type PublishSender = Arc<RwLock<Option<mpsc::Sender<(String, String)>>>>;
|
||||
|
||||
/// MQTT pub/sub channel adapter.
|
||||
///
|
||||
/// Connects to an MQTT broker, subscribes to a topic for incoming messages,
|
||||
/// and publishes responses to another topic.
|
||||
pub struct MqttAdapter {
|
||||
/// MQTT broker URL (e.g., `"tcp://broker.hivemq.com:1883"`).
|
||||
broker_url: String,
|
||||
/// Client identifier (auto-generated if empty).
|
||||
client_id: String,
|
||||
/// Topic to subscribe to for incoming messages.
|
||||
subscribe_topic: String,
|
||||
/// Topic to publish responses to.
|
||||
publish_topic: String,
|
||||
/// Optional username for authentication.
|
||||
username: Option<String>,
|
||||
/// Optional password for authentication.
|
||||
password: Option<String>,
|
||||
/// Use TLS/SSL connection.
|
||||
use_tls: bool,
|
||||
/// Keep-alive interval in seconds.
|
||||
keep_alive: u16,
|
||||
/// Clean session flag.
|
||||
clean_session: bool,
|
||||
/// QoS level for subscriptions.
|
||||
qos: QoS,
|
||||
/// Shutdown signal.
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Sender for publishing messages (used to communicate with the event loop task).
|
||||
publish_tx: PublishSender,
|
||||
}
|
||||
|
||||
impl MqttAdapter {
|
||||
/// Create a new MQTT adapter.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `broker_url` - MQTT broker URL (e.g., `"tcp://broker.hivemq.com:1883"`).
|
||||
/// * `client_id` - Client identifier (auto-generated if empty).
|
||||
/// * `subscribe_topic` - Topic to subscribe to for incoming messages.
|
||||
/// * `publish_topic` - Topic to publish responses to (defaults to subscribe_topic if empty).
|
||||
/// * `username` - Optional username for authentication.
|
||||
/// * `password` - Optional password for authentication.
|
||||
/// * `use_tls` - Use TLS/SSL connection.
|
||||
/// * `keep_alive` - Keep-alive interval in seconds.
|
||||
/// * `clean_session` - Clean session flag.
|
||||
/// * `qos` - QoS level (0, 1, or 2).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
broker_url: String,
|
||||
client_id: String,
|
||||
subscribe_topic: String,
|
||||
publish_topic: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
use_tls: bool,
|
||||
keep_alive: u16,
|
||||
clean_session: bool,
|
||||
qos: u8,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let publish_topic = if publish_topic.is_empty() {
|
||||
subscribe_topic.clone()
|
||||
} else {
|
||||
publish_topic
|
||||
};
|
||||
let qos = match qos {
|
||||
0 => QoS::AtMostOnce,
|
||||
2 => QoS::ExactlyOnce,
|
||||
_ => QoS::AtLeastOnce,
|
||||
};
|
||||
|
||||
Self {
|
||||
broker_url,
|
||||
client_id,
|
||||
subscribe_topic,
|
||||
publish_topic,
|
||||
username,
|
||||
password,
|
||||
use_tls,
|
||||
keep_alive,
|
||||
clean_session,
|
||||
qos,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
publish_tx: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse broker URL into host and port.
|
||||
fn parse_broker_url(&self) -> Result<(String, u16), Box<dyn std::error::Error>> {
|
||||
let url = self.broker_url.trim();
|
||||
|
||||
// Handle different URL schemes
|
||||
if let Some(rest) = url.strip_prefix("tcp://") {
|
||||
Self::parse_host_port(rest, 1883)
|
||||
} else if let Some(rest) = url.strip_prefix("ssl://") {
|
||||
Self::parse_host_port(rest, 8883)
|
||||
} else if self.use_tls && !url.contains("://") {
|
||||
// Plain host with TLS flag
|
||||
Self::parse_host_port(url, 8883)
|
||||
} else if url.contains("://") {
|
||||
Err(format!("Unsupported MQTT URL scheme: {url}").into())
|
||||
} else {
|
||||
// Plain host:port or just host (no TLS)
|
||||
Self::parse_host_port(url, 1883)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse host:port string.
|
||||
fn parse_host_port(s: &str, default_port: u16) -> Result<(String, u16), Box<dyn std::error::Error>> {
|
||||
let s = s.trim();
|
||||
if let Some(colon_pos) = s.rfind(':') {
|
||||
let host = s[..colon_pos].to_string();
|
||||
let port = s[colon_pos + 1..].parse::<u16>()?;
|
||||
Ok((host, port))
|
||||
} else {
|
||||
Ok((s.to_string(), default_port))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build MQTT options.
|
||||
fn build_mqtt_options(&self) -> Result<MqttOptions, Box<dyn std::error::Error>> {
|
||||
let (host, port) = self.parse_broker_url()?;
|
||||
let client_id = if self.client_id.is_empty() {
|
||||
format!("openfang-{}", uuid::Uuid::new_v4())
|
||||
} else {
|
||||
self.client_id.clone()
|
||||
};
|
||||
|
||||
let mut options = MqttOptions::new(client_id, host, port);
|
||||
options.set_keep_alive(Duration::from_secs(self.keep_alive as u64));
|
||||
options.set_clean_session(self.clean_session);
|
||||
|
||||
if let (Some(user), Some(pass)) = (&self.username, &self.password) {
|
||||
options.set_credentials(user, pass);
|
||||
}
|
||||
|
||||
// Note: TLS support requires additional configuration with rustls
|
||||
// For now, we use native TLS through the use_tls flag
|
||||
if self.use_tls {
|
||||
// rumqttc handles TLS automatically when using ssl:// or with explicit config
|
||||
// This is a simplified approach; production use may need custom TLS config
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
/// Parse incoming MQTT payload.
|
||||
fn parse_payload(payload: &[u8]) -> Option<String> {
|
||||
if payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try UTF-8 first
|
||||
if let Ok(text) = std::str::from_utf8(payload) {
|
||||
// Check for JSON format {"text": "message"}
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(text) {
|
||||
if let Some(text_val) = json.get("text").and_then(|v| v.as_str()) {
|
||||
return Some(text_val.to_string());
|
||||
}
|
||||
}
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Publish a message to the configured topic.
|
||||
async fn publish_message(&self, text: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tx_guard = self.publish_tx.read().await;
|
||||
if let Some(tx) = tx_guard.as_ref() {
|
||||
let chunks = split_message(text, MAX_MESSAGE_LEN);
|
||||
for chunk in chunks {
|
||||
tx.send((self.publish_topic.clone(), chunk.to_string()))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send publish request: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err("MQTT client not connected".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for MqttAdapter {
|
||||
fn name(&self) -> &str {
|
||||
"mqtt"
|
||||
}
|
||||
|
||||
fn channel_type(&self) -> ChannelType {
|
||||
ChannelType::Mqtt
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>> {
|
||||
let options = self.build_mqtt_options()?;
|
||||
let (client, mut eventloop) = AsyncClient::new(options, 10);
|
||||
|
||||
info!(
|
||||
"MQTT adapter connecting to {} (subscribe: {}, publish: {})",
|
||||
self.broker_url, self.subscribe_topic, self.publish_topic
|
||||
);
|
||||
|
||||
// Subscribe to topic
|
||||
client.subscribe(&self.subscribe_topic, self.qos).await?;
|
||||
|
||||
// Channel for incoming messages
|
||||
let (msg_tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
|
||||
// Channel for outgoing publish requests
|
||||
let (publish_tx, mut publish_rx) = mpsc::channel::<(String, String)>(64);
|
||||
|
||||
// Store the publish sender
|
||||
{
|
||||
let mut tx_guard = self.publish_tx.write().await;
|
||||
*tx_guard = Some(publish_tx);
|
||||
}
|
||||
|
||||
let subscribe_topic = self.subscribe_topic.clone();
|
||||
let qos = self.qos;
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
|
||||
// Spawn the event loop task
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
let max_backoff = Duration::from_secs(60);
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("MQTT adapter shutting down");
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("MQTT adapter shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
publish_req = publish_rx.recv() => {
|
||||
if let Some((topic, payload)) = publish_req {
|
||||
if let Err(e) = client.publish(&topic, qos, false, payload).await {
|
||||
warn!("MQTT publish error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
event = eventloop.poll() => {
|
||||
match event {
|
||||
Ok(Event::Incoming(Incoming::Publish(publish))) => {
|
||||
backoff = Duration::from_secs(1); // Reset backoff on success
|
||||
|
||||
let topic = publish.topic.clone();
|
||||
if topic != subscribe_topic {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(text) = Self::parse_payload(&publish.payload) {
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = if text.starts_with('/') {
|
||||
let parts: Vec<&str> = text.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(text)
|
||||
};
|
||||
|
||||
let msg = ChannelMessage {
|
||||
channel: ChannelType::Mqtt,
|
||||
platform_message_id: format!("{:?}", publish.pkid),
|
||||
sender: ChannelUser {
|
||||
platform_id: "mqtt-user".to_string(),
|
||||
display_name: "MQTT User".to_string(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content,
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: true,
|
||||
thread_id: None,
|
||||
metadata: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(
|
||||
"topic".to_string(),
|
||||
serde_json::Value::String(topic.clone()),
|
||||
);
|
||||
m.insert(
|
||||
"qos".to_string(),
|
||||
serde_json::Value::Number((publish.qos as i64).into()),
|
||||
);
|
||||
m
|
||||
},
|
||||
};
|
||||
|
||||
if msg_tx.send(msg).await.is_err() {
|
||||
info!("MQTT receiver dropped, stopping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Incoming(Incoming::ConnAck(_))) => {
|
||||
info!("MQTT connected to broker");
|
||||
backoff = Duration::from_secs(1);
|
||||
}
|
||||
Ok(Event::Incoming(Incoming::Disconnect)) => {
|
||||
warn!("MQTT disconnected from broker");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("MQTT connection error: {}, backing off for {:?}", e, backoff);
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(max_backoff);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("MQTT event loop stopped");
|
||||
});
|
||||
|
||||
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&self,
|
||||
_user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let text = match content {
|
||||
ChannelContent::Text(t) => t,
|
||||
ChannelContent::Command { name, args } => {
|
||||
if args.is_empty() {
|
||||
format!("/{name}")
|
||||
} else {
|
||||
format!("/{} {}", name, args.join(" "))
|
||||
}
|
||||
}
|
||||
_ => "(Unsupported content type)".to_string(),
|
||||
};
|
||||
self.publish_message(&text).await
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// MQTT has no typing indicator concept.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
|
||||
// Clear the publish channel
|
||||
let mut tx_guard = self.publish_tx.write().await;
|
||||
*tx_guard = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mqtt_adapter_creation() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.hivemq.com:1883".to_string(),
|
||||
"test-client".to_string(),
|
||||
"test/topic".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
assert_eq!(adapter.name(), "mqtt");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Mqtt);
|
||||
assert_eq!(adapter.subscribe_topic, "test/topic");
|
||||
assert_eq!(adapter.publish_topic, "test/topic"); // Falls back to subscribe_topic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mqtt_adapter_with_separate_publish_topic() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.hivemq.com:1883".to_string(),
|
||||
String::new(),
|
||||
"inbox".to_string(),
|
||||
"outbox".to_string(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
assert_eq!(adapter.subscribe_topic, "inbox");
|
||||
assert_eq!(adapter.publish_topic, "outbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_tcp() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com:1883".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 1883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_tcp_default_port() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 1883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_ssl() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"ssl://broker.example.com:8883".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 8883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_plain_host() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 1883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_payload_text() {
|
||||
let payload = b"Hello, MQTT!";
|
||||
let result = MqttAdapter::parse_payload(payload);
|
||||
assert_eq!(result, Some("Hello, MQTT!".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_payload_json() {
|
||||
let payload = br#"{"text": "Hello from JSON"}"#;
|
||||
let result = MqttAdapter::parse_payload(payload);
|
||||
assert_eq!(result, Some("Hello from JSON".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_payload_empty() {
|
||||
let payload = b"";
|
||||
let result = MqttAdapter::parse_payload(payload);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qos_conversion() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
0,
|
||||
);
|
||||
assert_eq!(adapter.qos, QoS::AtMostOnce);
|
||||
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
assert_eq!(adapter.qos, QoS::AtLeastOnce);
|
||||
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
2,
|
||||
);
|
||||
assert_eq!(adapter.qos, QoS::ExactlyOnce);
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,10 @@ impl ChannelAdapter for NostrAdapter {
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
let pubkey = self.derive_pubkey();
|
||||
info!("Nostr adapter starting (pubkey: {}...)", &pubkey[..16]);
|
||||
info!(
|
||||
"Nostr adapter starting (pubkey: {}...)",
|
||||
openfang_types::truncate_str(&pubkey, 16)
|
||||
);
|
||||
|
||||
if self.relays.is_empty() {
|
||||
return Err("Nostr: no relay URLs configured".into());
|
||||
@@ -339,7 +342,7 @@ impl ChannelAdapter for NostrAdapter {
|
||||
platform_id: sender_pubkey.clone(),
|
||||
display_name: format!(
|
||||
"{}...",
|
||||
&sender_pubkey[..8.min(sender_pubkey.len())]
|
||||
openfang_types::truncate_str(&sender_pubkey, 8)
|
||||
),
|
||||
openfang_user: None,
|
||||
},
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct AgentRouter {
|
||||
default_agent: Option<AgentId>,
|
||||
/// Per-channel-type default agent (e.g., Telegram -> agent_a, Discord -> agent_b).
|
||||
channel_defaults: DashMap<String, AgentId>,
|
||||
/// Per-channel-type default agent *name* (for re-resolution when UUID becomes stale).
|
||||
channel_default_names: DashMap<String, String>,
|
||||
/// Sorted bindings (most specific first). Uses Mutex for runtime updates via Arc.
|
||||
bindings: Mutex<Vec<(AgentBinding, String)>>,
|
||||
/// Broadcast configuration. Uses Mutex for runtime updates via Arc.
|
||||
@@ -50,6 +52,7 @@ impl AgentRouter {
|
||||
direct_routes: DashMap::new(),
|
||||
default_agent: None,
|
||||
channel_defaults: DashMap::new(),
|
||||
channel_default_names: DashMap::new(),
|
||||
bindings: Mutex::new(Vec::new()),
|
||||
broadcast: Mutex::new(BroadcastConfig::default()),
|
||||
agent_name_cache: DashMap::new(),
|
||||
@@ -66,6 +69,31 @@ impl AgentRouter {
|
||||
self.channel_defaults.insert(channel_key, agent_id);
|
||||
}
|
||||
|
||||
/// Set a per-channel-type default agent AND remember the agent name for
|
||||
/// re-resolution when the cached UUID becomes stale (e.g. after agent restart).
|
||||
pub fn set_channel_default_with_name(
|
||||
&self,
|
||||
channel_key: String,
|
||||
agent_id: AgentId,
|
||||
agent_name: String,
|
||||
) {
|
||||
self.channel_defaults.insert(channel_key.clone(), agent_id);
|
||||
self.channel_default_names.insert(channel_key, agent_name);
|
||||
}
|
||||
|
||||
/// Retrieve the stored agent name for a channel default (if any).
|
||||
pub fn channel_default_name(&self, channel_key: &str) -> Option<String> {
|
||||
self.channel_default_names
|
||||
.get(channel_key)
|
||||
.map(|r| r.clone())
|
||||
}
|
||||
|
||||
/// Update the cached agent ID for a channel default (after re-resolution).
|
||||
pub fn update_channel_default(&self, channel_key: &str, new_agent_id: AgentId) {
|
||||
self.channel_defaults
|
||||
.insert(channel_key.to_string(), new_agent_id);
|
||||
}
|
||||
|
||||
/// Set a user's default agent.
|
||||
pub fn set_user_default(&self, user_key: String, agent_id: AgentId) {
|
||||
self.user_defaults.insert(user_key, agent_id);
|
||||
@@ -327,6 +355,7 @@ fn channel_type_to_str(ct: &ChannelType) -> &str {
|
||||
ChannelType::WebChat => "webchat",
|
||||
ChannelType::CLI => "cli",
|
||||
ChannelType::Custom(s) => s.as_str(),
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use futures::{SinkExt, Stream, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
@@ -32,10 +33,25 @@ pub struct SlackAdapter {
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Bot's own user ID (populated after auth.test).
|
||||
bot_user_id: Arc<RwLock<Option<String>>>,
|
||||
/// Threads where the bot was @-mentioned. Maps thread_ts -> last interaction time.
|
||||
active_threads: Arc<DashMap<String, Instant>>,
|
||||
/// How long to track a thread after last interaction.
|
||||
thread_ttl: Duration,
|
||||
/// Whether auto-thread-reply is enabled.
|
||||
auto_thread_reply: bool,
|
||||
/// Whether to unfurl (expand previews for) links in posted messages.
|
||||
unfurl_links: bool,
|
||||
}
|
||||
|
||||
impl SlackAdapter {
|
||||
pub fn new(app_token: String, bot_token: String, allowed_channels: Vec<String>) -> Self {
|
||||
pub fn new(
|
||||
app_token: String,
|
||||
bot_token: String,
|
||||
allowed_channels: Vec<String>,
|
||||
auto_thread_reply: bool,
|
||||
thread_ttl_hours: u64,
|
||||
unfurl_links: bool,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
app_token: Zeroizing::new(app_token),
|
||||
@@ -45,6 +61,10 @@ impl SlackAdapter {
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
bot_user_id: Arc::new(RwLock::new(None)),
|
||||
active_threads: Arc::new(DashMap::new()),
|
||||
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
|
||||
auto_thread_reply,
|
||||
unfurl_links,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,14 +96,20 @@ impl SlackAdapter {
|
||||
&self,
|
||||
channel_id: &str,
|
||||
text: &str,
|
||||
thread_ts: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let chunks = split_message(text, SLACK_MSG_LIMIT);
|
||||
|
||||
for chunk in chunks {
|
||||
let body = serde_json::json!({
|
||||
let mut body = serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": chunk,
|
||||
"unfurl_links": self.unfurl_links,
|
||||
"unfurl_media": self.unfurl_links,
|
||||
});
|
||||
if let Some(ts) = thread_ts {
|
||||
body["thread_ts"] = serde_json::json!(ts);
|
||||
}
|
||||
|
||||
let resp: serde_json::Value = self
|
||||
.client
|
||||
@@ -133,6 +159,30 @@ impl ChannelAdapter for SlackAdapter {
|
||||
let allowed_channels = self.allowed_channels.clone();
|
||||
let client = self.client.clone();
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
let active_threads = self.active_threads.clone();
|
||||
let auto_thread_reply = self.auto_thread_reply;
|
||||
|
||||
// Spawn periodic cleanup of expired thread entries.
|
||||
{
|
||||
let active_threads = self.active_threads.clone();
|
||||
let thread_ttl = self.thread_ttl;
|
||||
let mut cleanup_shutdown = self.shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
active_threads.retain(|_, last| last.elapsed() < thread_ttl);
|
||||
}
|
||||
_ = cleanup_shutdown.changed() => {
|
||||
if *cleanup_shutdown.borrow() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = INITIAL_BACKOFF;
|
||||
@@ -240,8 +290,14 @@ impl ChannelAdapter for SlackAdapter {
|
||||
|
||||
// Extract the event
|
||||
let event = &payload["payload"]["event"];
|
||||
if let Some(msg) =
|
||||
parse_slack_event(event, &bot_user_id, &allowed_channels).await
|
||||
if let Some(msg) = parse_slack_event(
|
||||
event,
|
||||
&bot_user_id,
|
||||
&allowed_channels,
|
||||
&active_threads,
|
||||
auto_thread_reply,
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
"Slack message from {}: {:?}",
|
||||
@@ -289,10 +345,30 @@ impl ChannelAdapter for SlackAdapter {
|
||||
let channel_id = &user.platform_id;
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(channel_id, &text).await?;
|
||||
self.api_send_message(channel_id, &text, None).await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(channel_id, "(Unsupported content type)")
|
||||
self.api_send_message(channel_id, "(Unsupported content type)", None)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_in_thread(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
thread_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let channel_id = &user.platform_id;
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(channel_id, &text, Some(thread_id))
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(channel_id, "(Unsupported content type)", Some(thread_id))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
@@ -335,9 +411,11 @@ async fn parse_slack_event(
|
||||
event: &serde_json::Value,
|
||||
bot_user_id: &Arc<RwLock<Option<String>>>,
|
||||
allowed_channels: &[String],
|
||||
active_threads: &Arc<DashMap<String, Instant>>,
|
||||
auto_thread_reply: bool,
|
||||
) -> Option<ChannelMessage> {
|
||||
let event_type = event["type"].as_str()?;
|
||||
if event_type != "message" {
|
||||
if event_type != "message" && event_type != "app_mention" {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -413,6 +491,50 @@ async fn parse_slack_event(
|
||||
ChannelContent::Text(text.to_string())
|
||||
};
|
||||
|
||||
// Extract thread_id: threaded replies have `thread_ts`, top-level messages
|
||||
// use their own `ts` so the reply will start a thread under the original.
|
||||
let thread_id = msg_data["thread_ts"]
|
||||
.as_str()
|
||||
.or_else(|| event["thread_ts"].as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| Some(ts.to_string()));
|
||||
|
||||
// Check if the bot was @-mentioned (for group_policy = "mention_only")
|
||||
let mut metadata = HashMap::new();
|
||||
if event_type == "app_mention" {
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true));
|
||||
}
|
||||
|
||||
// Determine the real thread_ts from the event (None for top-level messages).
|
||||
let real_thread_ts = msg_data["thread_ts"]
|
||||
.as_str()
|
||||
.or_else(|| event["thread_ts"].as_str());
|
||||
|
||||
let mut explicitly_mentioned = false;
|
||||
if let Some(ref bid) = *bot_user_id.read().await {
|
||||
let mention_tag = format!("<@{bid}>");
|
||||
if text.contains(&mention_tag) {
|
||||
explicitly_mentioned = true;
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
|
||||
|
||||
// Track thread for auto-reply on subsequent messages.
|
||||
if let Some(tts) = real_thread_ts {
|
||||
active_threads.insert(tts.to_string(), Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-reply to follow-up messages in tracked threads.
|
||||
if !explicitly_mentioned && auto_thread_reply {
|
||||
if let Some(tts) = real_thread_ts {
|
||||
if let Some(mut entry) = active_threads.get_mut(tts) {
|
||||
// Refresh TTL and mark as mentioned so dispatch proceeds.
|
||||
*entry = Instant::now();
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Slack,
|
||||
platform_message_id: ts.to_string(),
|
||||
@@ -425,8 +547,8 @@ async fn parse_slack_event(
|
||||
target_agent: None,
|
||||
timestamp,
|
||||
is_group: true,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
thread_id,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -445,7 +567,9 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Slack);
|
||||
assert_eq!(msg.sender.platform_id, "C789");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello agent!"));
|
||||
@@ -463,7 +587,7 @@ mod tests {
|
||||
"bot_id": "B999"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -478,7 +602,7 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -494,12 +618,25 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed channels
|
||||
let msg =
|
||||
parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()]).await;
|
||||
let msg = parse_slack_event(
|
||||
&event,
|
||||
&bot_id,
|
||||
&["C111".to_string(), "C222".to_string()],
|
||||
&Arc::new(DashMap::new()),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed channels
|
||||
let msg = parse_slack_event(&event, &bot_id, &["C789".to_string()]).await;
|
||||
let msg = parse_slack_event(
|
||||
&event,
|
||||
&bot_id,
|
||||
&["C789".to_string()],
|
||||
&Arc::new(DashMap::new()),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -516,7 +653,7 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -531,7 +668,9 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -556,7 +695,9 @@ mod tests {
|
||||
"ts": "1700000001.000200"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Slack);
|
||||
assert_eq!(msg.sender.platform_id, "C789");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message text"));
|
||||
@@ -568,8 +709,37 @@ mod tests {
|
||||
"xapp-test".to_string(),
|
||||
"xoxb-test".to_string(),
|
||||
vec!["C123".to_string()],
|
||||
true,
|
||||
24,
|
||||
true,
|
||||
);
|
||||
assert_eq!(adapter.name(), "slack");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Slack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slack_adapter_unfurl_links_enabled() {
|
||||
let adapter = SlackAdapter::new(
|
||||
"xapp-test".to_string(),
|
||||
"xoxb-test".to_string(),
|
||||
vec![],
|
||||
true,
|
||||
24,
|
||||
true,
|
||||
);
|
||||
assert!(adapter.unfurl_links);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slack_adapter_unfurl_links_disabled() {
|
||||
let adapter = SlackAdapter::new(
|
||||
"xapp-test".to_string(),
|
||||
"xoxb-test".to_string(),
|
||||
vec![],
|
||||
true,
|
||||
24,
|
||||
false,
|
||||
);
|
||||
assert!(!adapter.unfurl_links);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user