mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 17:01:59 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acf2587e46 | ||
|
|
4583157b49 | ||
|
|
7185ea8808 | ||
|
|
4aa1508f54 | ||
|
|
5447bf7f1d | ||
|
|
b77ebfb897 | ||
|
|
2323cd5e67 | ||
|
|
df29e5e8b9 | ||
|
|
8b411c21c4 | ||
|
|
b00af5eddd | ||
|
|
36177425c4 | ||
|
|
6bed6c04ff | ||
|
|
4c496be02f | ||
|
|
7f7b071528 | ||
|
|
2d1fb8171c | ||
|
|
e683acc565 | ||
|
|
5396889ff1 | ||
|
|
c9e8d31571 | ||
|
|
d8ad91572e | ||
|
|
c27bfebd17 | ||
|
|
f05ba5e42f | ||
|
|
d9e72abb4b | ||
|
|
505a8e8080 | ||
|
|
5cc865e6e6 | ||
|
|
6a1ce40d86 | ||
|
|
fbb7936234 | ||
|
|
569e76c79a | ||
|
|
88ad029999 | ||
|
|
838836b29c | ||
|
|
8564872181 | ||
|
|
efbefa1682 | ||
|
|
bdcd440cd6 | ||
|
|
25516c7f87 | ||
|
|
ae2706bdab | ||
|
|
32299bb506 | ||
|
|
6f8463fc91 | ||
|
|
6b03cb2e9d | ||
|
|
8cb7541678 | ||
|
|
6b5b7674d3 | ||
|
|
247dca508b | ||
|
|
90d16e52be | ||
|
|
68bde60fac | ||
|
|
a422058049 | ||
|
|
6ba0bfb7ef | ||
|
|
7699b86037 | ||
|
|
e31216d5ec | ||
|
|
538e943d3d | ||
|
|
94fca22124 | ||
|
|
37e2043ed7 | ||
|
|
31eb833cdf | ||
|
|
15da248faf | ||
|
|
c27a6f3609 | ||
|
|
f792f1a14b | ||
|
|
5e228336e4 | ||
|
|
8b10930e40 | ||
|
|
5c1b1508a2 | ||
|
|
701fcd8e2e | ||
|
|
118eacea64 | ||
|
|
aaad1fdf32 | ||
|
|
dd8c53026e | ||
|
|
218f2dba1f | ||
|
|
24aca4e31d | ||
|
|
3cce1eb3fb | ||
|
|
c89958b66d | ||
|
|
b0a92456bf | ||
|
|
67bbcc623d | ||
|
|
a91bfc0e9c | ||
|
|
948117d5de | ||
|
|
2dedab2a8b | ||
|
|
8642c4d442 | ||
|
|
46a6eb33d9 | ||
|
|
99b4ce2931 | ||
|
|
87932f5da0 | ||
|
|
9130811433 | ||
|
|
325734c6aa | ||
|
|
faf2cf9211 | ||
|
|
15ed29c667 | ||
|
|
1d1bf0fb09 | ||
|
|
d3363142b2 | ||
|
|
76929a41aa | ||
|
|
fe34a37e6f | ||
|
|
4b63eb18cc | ||
|
|
fe21d4b4df | ||
|
|
f52bc53e47 | ||
|
|
10f7ee1885 | ||
|
|
7bc6591338 | ||
|
|
53f2066945 | ||
|
|
c69dd84184 | ||
|
|
c1356fc95d | ||
|
|
aabf83b351 | ||
|
|
da6b567ac3 | ||
|
|
ccdd7943a2 | ||
|
|
7fe87babe6 | ||
|
|
9c0e1637a5 | ||
|
|
fd450dbfc2 | ||
|
|
81176dc626 | ||
|
|
ef9096f7c5 | ||
|
|
fbb5bb1ae9 | ||
|
|
c435a6adcd | ||
|
|
f67c4e8754 | ||
|
|
3b237ac526 | ||
|
|
96c572df32 | ||
|
|
17e0d519ca | ||
|
|
37c233d489 | ||
|
|
92f7e996de | ||
|
|
b1c4061247 | ||
|
|
79aa34c77a | ||
|
|
4ae2961b1c | ||
|
|
6a90aa08df | ||
|
|
356500bb1e | ||
|
|
40bd7e2c11 | ||
|
|
a7197d7b97 | ||
|
|
bc26d5e8c3 | ||
|
|
84d90ad342 | ||
|
|
9fee63d58c | ||
|
|
40903cceee | ||
|
|
5a86141677 | ||
|
|
e97eb6fff3 | ||
|
|
93b57bdd52 | ||
|
|
0227ff1790 | ||
|
|
525d7d844a | ||
|
|
f2587995a2 | ||
|
|
e6bab993ae | ||
|
|
a39a675ba9 | ||
|
|
0ce390e09f | ||
|
|
88eeaa6a4d | ||
|
|
3db5d3a825 | ||
|
|
5a1f372612 | ||
|
|
a9f15b2d23 | ||
|
|
45e7ea7948 | ||
|
|
de8a692036 | ||
|
|
8d3d77dd99 | ||
|
|
d3d9fa842d | ||
|
|
ff44cfbe87 | ||
|
|
ce89d05987 | ||
|
|
00c0ff60de | ||
|
|
07af248a07 | ||
|
|
6ab07d155e | ||
|
|
e2b0a54720 | ||
|
|
6f519b9122 | ||
|
|
983519c8e8 | ||
|
|
c9701627a9 | ||
|
|
643a22b295 | ||
|
|
890ab8c177 | ||
|
|
1b9a68dc0b | ||
|
|
2b6286e469 | ||
|
|
589f32c8e5 | ||
|
|
528a7b9ff7 | ||
|
|
6851bbab09 | ||
|
|
9323edccf4 | ||
|
|
0bccba10cf | ||
|
|
2daaf92ad7 | ||
|
|
80359b4487 | ||
|
|
b866bb6b21 | ||
|
|
3fa8c6c527 | ||
|
|
e322afbebd | ||
|
|
a4c6c038a0 | ||
|
|
864e957261 | ||
|
|
f9921780e2 | ||
|
|
1c049d06c1 | ||
|
|
6f86f7a857 | ||
|
|
c47e15c1ce | ||
|
|
185ebc429f | ||
|
|
87626ae1c9 | ||
|
|
4a8af88fe8 | ||
|
|
2ef5704132 | ||
|
|
747314b19b | ||
|
|
3787c13fb1 | ||
|
|
8136281e68 | ||
|
|
779389e68d | ||
|
|
4e3569778e | ||
|
|
6b19bac049 | ||
|
|
2671791742 | ||
|
|
1ca86c4284 | ||
|
|
a603dc9d96 | ||
|
|
8a2197126c | ||
|
|
c743a72481 | ||
|
|
605ce747ec | ||
|
|
34a27de85e | ||
|
|
3e7d57b095 | ||
|
|
e988572c82 | ||
|
|
2d94963627 | ||
|
|
d94508e72e | ||
|
|
af51f6c971 | ||
|
|
e1790b5380 | ||
|
|
be1c4dba47 | ||
|
|
51a5f7983c | ||
|
|
760f35f9de | ||
|
|
64b1ec5a7e | ||
|
|
dc6119d2cc | ||
|
|
a8b4d3b48d | ||
|
|
36ba675f02 | ||
|
|
546816f692 | ||
|
|
80af18a174 | ||
|
|
abeaaf5446 | ||
|
|
3854e3eb89 | ||
|
|
73d50c0284 | ||
|
|
6403871aa1 | ||
|
|
df22a3db64 | ||
|
|
c1a14e884e | ||
|
|
77bef773e5 | ||
|
|
a26f762635 | ||
|
|
62b6aa4eb8 | ||
|
|
c000392093 | ||
|
|
ae32af1b06 | ||
|
|
d3972b23c0 | ||
|
|
ded95f3180 | ||
|
|
09ec6f5549 | ||
|
|
47c743f17c | ||
|
|
489fc1312c | ||
|
|
0408d65d8f | ||
|
|
8d14d0c225 | ||
|
|
07963779be | ||
|
|
28d01acf91 | ||
|
|
be149597e6 | ||
|
|
3b21494867 | ||
|
|
4565988e2e | ||
|
|
4714efb9e4 | ||
|
|
365bec868c | ||
|
|
a78299ed3d | ||
|
|
eebb83c79a | ||
|
|
0b59205b0c | ||
|
|
3f8ceabc51 | ||
|
|
4921ee5ece | ||
|
|
0c4769a07f | ||
|
|
167b37f10e | ||
|
|
545e710abb | ||
|
|
e421594185 | ||
|
|
2fe926c3b9 | ||
|
|
618e83714c | ||
|
|
8b925d8a04 | ||
|
|
449a29418d | ||
|
|
46eac44635 | ||
|
|
9372cc6ff2 | ||
|
|
9cf37eab22 | ||
|
|
79ca1cda32 | ||
|
|
50c51dd6b7 | ||
|
|
d75a56a0f6 | ||
|
|
ce3344a994 | ||
|
|
656e2734ce | ||
|
|
3c221dc3ca | ||
|
|
cfda9b9bfc | ||
|
|
a428b1cd66 | ||
|
|
51d358f9d9 | ||
|
|
1c61b869c0 | ||
|
|
fc902a9ceb | ||
|
|
a3cefa424c | ||
|
|
06d0479419 | ||
|
|
613a7d4a3b | ||
|
|
6ed6d3ac3b | ||
|
|
9f72d921c3 | ||
|
|
4b5aba28cf | ||
|
|
bbed72b491 | ||
|
|
55395c80db | ||
|
|
946363e919 | ||
|
|
64631a31e6 | ||
|
|
cf38b49e4d | ||
|
|
8ba84ada9d | ||
|
|
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 | ||
|
|
dd95f24980 | ||
|
|
1cf36241e4 | ||
|
|
2ab31f3d3e | ||
|
|
2915cb2113 | ||
|
|
7b1057df0c | ||
|
|
0b99ac4071 | ||
|
|
19260945bd | ||
|
|
ad90d417cc | ||
|
|
4eae7502d2 | ||
|
|
d6326d8967 | ||
|
|
8ec3766da3 | ||
|
|
90fc171e26 | ||
|
|
972a52ff9c | ||
|
|
6b78838416 | ||
|
|
13051b2f06 | ||
|
|
aed4bf62ae | ||
|
|
a039e395f5 | ||
|
|
67b30c1549 | ||
|
|
40bdf4316b | ||
|
|
e3c05a9d47 | ||
|
|
6ab77612f5 | ||
|
|
a3073007a1 | ||
|
|
a95fb4a96b |
@@ -32,6 +32,9 @@
|
||||
# Fireworks AI
|
||||
# FIREWORKS_API_KEY=...
|
||||
|
||||
# Novita AI (multi-model gateway)
|
||||
# NOVITA_API_KEY=...
|
||||
|
||||
# ─── Local LLM Providers (no API key needed) ─────────────────────────
|
||||
|
||||
# Ollama (default: http://localhost:11434)
|
||||
|
||||
@@ -91,7 +91,9 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- run: cargo fmt --check
|
||||
# Gate every workspace crate on rustfmt to keep `cargo fmt --all --check` clean.
|
||||
# See issue #1121.
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
audit:
|
||||
name: Security Audit
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
projectPath: crates/openfang-desktop
|
||||
args: ${{ matrix.platform.args }}
|
||||
|
||||
# ── CLI Binary (5 platforms) ──────────────────────────────────────────────
|
||||
# ── CLI Binary (7 platforms) ──────────────────────────────────────────────
|
||||
cli:
|
||||
name: CLI / ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -148,6 +148,9 @@ jobs:
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-22.04
|
||||
archive: tar.gz
|
||||
- target: armv7-unknown-linux-gnueabihf
|
||||
os: ubuntu-22.04
|
||||
archive: tar.gz
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
archive: tar.gz
|
||||
@@ -169,17 +172,17 @@ jobs:
|
||||
- name: Install build deps (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev
|
||||
- name: Install cross (Linux aarch64)
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
- name: Install cross (Linux aarch64/armv7)
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu' || matrix.target == 'armv7-unknown-linux-gnueabihf'
|
||||
run: cargo install cross --locked
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: cli-${{ matrix.target }}
|
||||
- name: Build CLI (cross)
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu' || matrix.target == 'armv7-unknown-linux-gnueabihf'
|
||||
run: cross build --release --target ${{ matrix.target }} --bin openfang
|
||||
- name: Build CLI
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu' && matrix.target != 'armv7-unknown-linux-gnueabihf'
|
||||
run: cargo build --release --target ${{ matrix.target }} --bin openfang
|
||||
- name: Ad-hoc codesign CLI binary (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
@@ -201,7 +204,7 @@ jobs:
|
||||
$hash = (Get-FileHash "openfang-${{ matrix.target }}.zip" -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash openfang-${{ matrix.target }}.zip" | Out-File -Encoding ASCII "openfang-${{ matrix.target }}.zip.sha256"
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: openfang-${{ matrix.target }}.*
|
||||
env:
|
||||
@@ -235,5 +238,17 @@ jobs:
|
||||
tags: |
|
||||
ghcr.io/rightnow-ai/openfang:latest
|
||||
ghcr.io/rightnow-ai/openfang:${{ steps.version.outputs.version }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/RightNow-AI/openfang
|
||||
org.opencontainers.image.licenses=MIT
|
||||
org.opencontainers.image.description=OpenFang Agent OS — single-binary Rust agent framework
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Set GHCR package visibility to public
|
||||
run: |
|
||||
curl -fsSL -X PATCH \
|
||||
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/orgs/RightNow-AI/packages/container/openfang \
|
||||
-d '{"visibility":"public"}'
|
||||
|
||||
@@ -45,3 +45,6 @@ Thumbs.db
|
||||
*.swo
|
||||
*~
|
||||
.serena/
|
||||
|
||||
# Personal deploy scripts
|
||||
scripts/deploy-remote.sh
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
## Health Stack
|
||||
|
||||
- typecheck: cargo build --workspace --lib
|
||||
- lint: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- test: cargo test --workspace
|
||||
- shell: shellcheck scripts/install.sh
|
||||
@@ -5,6 +5,37 @@ All notable changes to OpenFang will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.10] - 2026-04-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Non-loopback requests with no `api_key` configured now return 401 by default. Opt out with `OPENFANG_ALLOW_NO_AUTH=1`. Fixes the B1/B2 authentication bypass from #1034.
|
||||
- Agent `context.md` is re-read on every turn so external updates take effect mid-session. Opt out per agent with `cache_context = true` on the manifest. Fixes #843.
|
||||
- `openfang config get default_model.base_url` now prints the configured URL instead of an empty string. Missing keys return a clear "not found" error. Fixes #905.
|
||||
- `schedule_create`, `schedule_list`, and `schedule_delete` tools plus the `/api/schedules` routes now use the kernel cron scheduler, so scheduled jobs actually fire. One-shot idempotent migration imports legacy shared-memory entries at startup. Fixes #1069.
|
||||
- Multimodal user messages now combine text and image blocks into a single message so the LLM sees both. Fixes #1043.
|
||||
|
||||
### Added
|
||||
|
||||
- `openfang hand config <id>` subcommand: get, set, unset, and list settings on an active hand instance. Fixes #809.
|
||||
- Optional per-channel `prefix_agent_name` setting (`off` / `bracket` / `bold_bracket`). Wraps outbound agent responses so users in multi-agent channels can see which agent replied. Default is off, byte-identical to prior behavior. Fixes #980.
|
||||
|
||||
### Closed as invalid
|
||||
|
||||
- #818 and #819. Both reference a knowledge-domain API that does not exist on `main`. Filed against an unmerged feature branch (`plan/013-audit-remediation`). Close with a note to build the proposed validation and stale-timestamp surfacing into that feature when it lands.
|
||||
|
||||
## [0.5.9] - 2026-04-10
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING:** Dashboard password hashing switched from SHA256 to Argon2id. Existing `password_hash` values in `config.toml` must be regenerated with `openfang auth hash-password`. Only affects users with `[auth] enabled = true`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Dashboard passwords were hashed with plain SHA256 (no salt), making them vulnerable to rainbow table and GPU-accelerated brute force attacks. Now uses Argon2id with random salts.
|
||||
|
||||
## [0.1.0] - 2026-02-24
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+845
-427
File diff suppressed because it is too large
Load Diff
+14
-6
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.1"
|
||||
version = "0.6.9"
|
||||
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
|
||||
@@ -63,6 +63,7 @@ clap_complete = "4"
|
||||
|
||||
# HTTP client (for LLM drivers)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
|
||||
# Async trait
|
||||
async-trait = "0.1"
|
||||
@@ -75,16 +76,17 @@ bytes = "1"
|
||||
|
||||
# Futures
|
||||
futures = "0.3"
|
||||
prost = "0.14"
|
||||
|
||||
# WebSocket client (for Discord/Slack gateway)
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
url = "2"
|
||||
|
||||
# WASM sandbox
|
||||
wasmtime = "41"
|
||||
wasmtime = "43"
|
||||
|
||||
# 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"] }
|
||||
|
||||
@@ -113,7 +115,7 @@ rand = "0.8"
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
# Rate limiting
|
||||
governor = "0.8"
|
||||
governor = "0.10"
|
||||
|
||||
# Interactive CLI
|
||||
ratatui = "0.29"
|
||||
@@ -129,6 +131,9 @@ 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"
|
||||
|
||||
@@ -138,9 +143,12 @@ 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"
|
||||
native-tls = { version = "0.2", features = ["vendored"] }
|
||||
mailparse = "0.16"
|
||||
|
||||
# MQTT client
|
||||
rumqttc = { version = "0.25", default-features = false, features = ["use-native-tls"] }
|
||||
|
||||
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
|
||||
@@ -3,3 +3,9 @@ pre-build = [
|
||||
"dpkg --add-architecture $CROSS_DEB_ARCH",
|
||||
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
|
||||
]
|
||||
|
||||
[target.armv7-unknown-linux-gnueabihf]
|
||||
pre-build = [
|
||||
"dpkg --add-architecture $CROSS_DEB_ARCH",
|
||||
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
|
||||
]
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM rust:1-slim-bookworm AS builder
|
||||
WORKDIR /build
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y pkg-config libssl-dev perl make && rm -rf /var/lib/apt/lists/*
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates ./crates
|
||||
COPY xtask ./xtask
|
||||
|
||||
@@ -19,25 +19,25 @@
|
||||
<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.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/version-0.6.9-green?style=flat-square" alt="v0.6.9" />
|
||||
<img src="https://img.shields.io/badge/tests-2,696%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> **v0.3.30 — Security Hardening Release (March 2026)**
|
||||
> **v0.5.10 (April 2026)**
|
||||
>
|
||||
> 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)
|
||||
> OpenFang is feature complete but still pre-1.0. Expect rough edges and 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)
|
||||
|
||||
---
|
||||
|
||||
## What is OpenFang?
|
||||
|
||||
OpenFang is an **open-source Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust.
|
||||
OpenFang is an **open-source Agent Operating System**. Not a chatbot framework. Not a Python wrapper around an LLM. Not a "multi-agent orchestrator." A full operating system for autonomous agents, built from scratch in Rust.
|
||||
|
||||
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you** — on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
|
||||
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you**: on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
|
||||
|
||||
The entire system compiles to a **single ~32MB binary**. One install, one command, your agents are live.
|
||||
|
||||
@@ -65,13 +65,13 @@ openfang start
|
||||
|
||||
<p align="center"><em>"Traditional agents wait for you to type. Hands work <strong>for</strong> you."</em></p>
|
||||
|
||||
**Hands** are OpenFang's core innovation — pre-built autonomous capability packages that run independently, on schedules, without you having to prompt them. This is not a chatbot. This is an agent that wakes up at 6 AM, researches your competitors, builds a knowledge graph, scores the findings, and delivers a report to your Telegram before you've had coffee.
|
||||
**Hands** are OpenFang's core innovation. Pre-built autonomous capability packages that run independently, on schedules, without you having to prompt them. This is not a chatbot. This is an agent that wakes up at 6 AM, researches your competitors, builds a knowledge graph, scores the findings, and delivers a report to your Telegram before you've had coffee.
|
||||
|
||||
Each Hand bundles:
|
||||
- **HAND.toml** — Manifest declaring tools, settings, requirements, and dashboard metrics
|
||||
- **System Prompt** — Multi-phase operational playbook (not a one-liner — these are 500+ word expert procedures)
|
||||
- **SKILL.md** — Domain expertise reference injected into context at runtime
|
||||
- **Guardrails** — Approval gates for sensitive actions (e.g. Browser Hand requires approval before any purchase)
|
||||
- **HAND.toml**: manifest declaring tools, settings, requirements, and dashboard metrics.
|
||||
- **System Prompt**: multi-phase operational playbook. Not a one-liner. These are 500+ word expert procedures.
|
||||
- **SKILL.md**: domain expertise reference injected into context at runtime.
|
||||
- **Guardrails**: approval gates for sensitive actions (e.g. Browser Hand requires approval before any purchase).
|
||||
|
||||
All compiled into the binary. No downloading, no pip install, no Docker pull.
|
||||
|
||||
@@ -81,14 +81,14 @@ All compiled into the binary. No downloading, no pip install, no Docker pull.
|
||||
|------|----------------------|
|
||||
| **Clip** | Takes a YouTube URL, downloads it, identifies the best moments, cuts them into vertical shorts with captions and thumbnails, optionally adds AI voice-over, and publishes to Telegram and WhatsApp. 8-phase pipeline. FFmpeg + yt-dlp + 5 STT backends. |
|
||||
| **Lead** | Runs daily. Discovers prospects matching your ICP, enriches them with web research, scores 0-100, deduplicates against your existing database, and delivers qualified leads in CSV/JSON/Markdown. Builds ICP profiles over time. |
|
||||
| **Collector** | OSINT-grade intelligence. You give it a target (company, person, topic). It monitors continuously — change detection, sentiment tracking, knowledge graph construction, and critical alerts when something important shifts. |
|
||||
| **Collector** | OSINT grade intelligence. You give it a target (company, person, topic). It monitors continuously: change detection, sentiment tracking, knowledge graph construction, and critical alerts when something important shifts. |
|
||||
| **Predictor** | Superforecasting engine. Collects signals from multiple sources, builds calibrated reasoning chains, makes predictions with confidence intervals, and tracks its own accuracy using Brier scores. Has a contrarian mode that deliberately argues against consensus. |
|
||||
| **Researcher** | Deep autonomous researcher. Cross-references multiple sources, evaluates credibility using CRAAP criteria (Currency, Relevance, Authority, Accuracy, Purpose), generates cited reports with APA formatting, supports multiple languages. |
|
||||
| **Twitter** | Autonomous Twitter/X account manager. Creates content in 7 rotating formats, schedules posts for optimal engagement, responds to mentions, tracks performance metrics. Has an approval queue — nothing posts without your OK. |
|
||||
| **Browser** | Web automation agent. Navigates sites, fills forms, clicks buttons, handles multi-step workflows. Uses Playwright bridge with session persistence. **Mandatory purchase approval gate** — it will never spend your money without explicit confirmation. |
|
||||
| **Twitter** | Autonomous Twitter/X account manager. Creates content in 7 rotating formats, schedules posts for optimal engagement, responds to mentions, tracks performance metrics. Has an approval queue, so nothing posts without your OK. |
|
||||
| **Browser** | Web automation agent. Navigates sites, fills forms, clicks buttons, handles multi-step workflows. Uses Playwright bridge with session persistence. **Mandatory purchase approval gate**: it will never spend your money without explicit confirmation. |
|
||||
|
||||
```bash
|
||||
# Activate the Researcher Hand — it starts working immediately
|
||||
# Activate the Researcher Hand. It starts working immediately.
|
||||
openfang hand activate researcher
|
||||
|
||||
# Check its progress anytime
|
||||
@@ -116,7 +116,7 @@ openfang hand list
|
||||
|
||||
### Benchmarks: Measured, Not Marketed
|
||||
|
||||
All data from official documentation and public repositories — February 2026.
|
||||
All data from official documentation and public repositories, February 2026.
|
||||
|
||||
#### Cold Start Time (lower is better)
|
||||
|
||||
@@ -203,7 +203,7 @@ AutoGen ███████████░░░░░░░░░░░░
|
||||
|
||||
---
|
||||
|
||||
## 16 Security Systems — Defense in Depth
|
||||
## 16 Security Systems: Defense in Depth
|
||||
|
||||
OpenFang doesn't bolt security on after the fact. Every layer is independently testable and operates without a single point of failure.
|
||||
|
||||
@@ -211,19 +211,19 @@ OpenFang doesn't bolt security on after the fact. Every layer is independently t
|
||||
|---|--------|-------------|
|
||||
| 1 | **WASM Dual-Metered Sandbox** | Tool code runs in WebAssembly with fuel metering + epoch interruption. A watchdog thread kills runaway code. |
|
||||
| 2 | **Merkle Hash-Chain Audit Trail** | Every action is cryptographically linked to the previous one. Tamper with one entry and the entire chain breaks. |
|
||||
| 3 | **Information Flow Taint Tracking** | Labels propagate through execution — secrets are tracked from source to sink. |
|
||||
| 3 | **Information Flow Taint Tracking** | Labels propagate through execution. Secrets are tracked from source to sink. |
|
||||
| 4 | **Ed25519 Signed Agent Manifests** | Every agent identity and capability set is cryptographically signed. |
|
||||
| 5 | **SSRF Protection** | Blocks private IPs, cloud metadata endpoints, and DNS rebinding attacks. |
|
||||
| 6 | **Secret Zeroization** | `Zeroizing<String>` auto-wipes API keys from memory the instant they're no longer needed. |
|
||||
| 7 | **OFP Mutual Authentication** | HMAC-SHA256 nonce-based, constant-time verification for P2P networking. |
|
||||
| 8 | **Capability Gates** | Role-based access control — agents declare required tools, the kernel enforces it. |
|
||||
| 8 | **Capability Gates** | Role based access control. Agents declare required tools, the kernel enforces it. |
|
||||
| 9 | **Security Headers** | CSP, X-Frame-Options, HSTS, X-Content-Type-Options on every response. |
|
||||
| 10 | **Health Endpoint Redaction** | Public health check returns minimal info. Full diagnostics require authentication. |
|
||||
| 11 | **Subprocess Sandbox** | `env_clear()` + selective variable passthrough. Process tree isolation with cross-platform kill. |
|
||||
| 12 | **Prompt Injection Scanner** | Detects override attempts, data exfiltration patterns, and shell reference injection in skills. |
|
||||
| 13 | **Loop Guard** | SHA256-based tool call loop detection with circuit breaker. Handles ping-pong patterns. |
|
||||
| 14 | **Session Repair** | 7-phase message history validation and automatic recovery from corruption. |
|
||||
| 15 | **Path Traversal Prevention** | Canonicalization with symlink escape prevention. `../` doesn't work here. |
|
||||
| 15 | **Path Traversal Prevention** | Canonicalization with symlink escape prevention. ``../`` doesn't work here. |
|
||||
| 16 | **GCRA Rate Limiter** | Cost-aware token bucket rate limiting with per-IP tracking and stale cleanup. |
|
||||
|
||||
---
|
||||
@@ -268,7 +268,7 @@ Each adapter supports per-channel model overrides, DM/group policies, rate limit
|
||||
|
||||
## WhatsApp Web Gateway (QR Code)
|
||||
|
||||
Connect your personal WhatsApp account to OpenFang via QR code — just like WhatsApp Web. No Meta Business account required.
|
||||
Connect your personal WhatsApp account to OpenFang via QR code, just like WhatsApp Web. No Meta Business account required.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -357,7 +357,7 @@ For production workloads, use the [WhatsApp Cloud API](https://developers.facebo
|
||||
|
||||
---
|
||||
|
||||
## 27 LLM Providers — 123+ Models
|
||||
## 27 LLM Providers, 123+ Models
|
||||
|
||||
3 native drivers (Anthropic, Gemini, OpenAI-compatible) route to 27 providers:
|
||||
|
||||
@@ -372,7 +372,7 @@ Intelligent routing with task complexity scoring, automatic fallback, cost track
|
||||
Already running OpenClaw? One command:
|
||||
|
||||
```bash
|
||||
# Migrate everything — agents, memory, skills, configs
|
||||
# Migrate everything: agents, memory, skills, configs.
|
||||
openfang migrate --from openclaw
|
||||
|
||||
# Migrate from a specific path
|
||||
@@ -410,7 +410,7 @@ curl -X POST localhost:4200/v1/chat/completions \
|
||||
# 1. Install (macOS/Linux)
|
||||
curl -fsSL https://openfang.sh/install | sh
|
||||
|
||||
# 2. Initialize — walks you through provider setup
|
||||
# 2. Initialize. Walks you through provider setup.
|
||||
openfang init
|
||||
|
||||
# 3. Start the daemon
|
||||
@@ -418,7 +418,7 @@ openfang start
|
||||
|
||||
# 4. Dashboard is live at http://localhost:4200
|
||||
|
||||
# 5. Activate a Hand — it starts working for you
|
||||
# 5. Activate a Hand. It starts working for you.
|
||||
openfang hand activate researcher
|
||||
|
||||
# 6. Chat with an agent
|
||||
@@ -462,14 +462,14 @@ cargo fmt --all -- --check
|
||||
|
||||
## Stability Notice
|
||||
|
||||
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:
|
||||
OpenFang v0.5.10 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is deep. 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)
|
||||
- **Edge cases** exist — if you find one, [open an issue](https://github.com/RightNow-AI/openfang/issues)
|
||||
- **Pin to a specific commit** for production deployments until v1.0
|
||||
- **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.
|
||||
- **Edge cases** exist. If you find one, [open an issue](https://github.com/RightNow-AI/openfang/issues).
|
||||
- **Pin to a specific commit** for production deployments until v1.0.
|
||||
|
||||
We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
|
||||
We ship fast and fix fast. The goal is a rock solid v1.0 by mid 2026.
|
||||
|
||||
---
|
||||
|
||||
@@ -481,7 +481,7 @@ To report a security vulnerability, email **jaber@rightnowai.co**. We take all r
|
||||
|
||||
## License
|
||||
|
||||
MIT — use it however you want.
|
||||
MIT. Use it however you want.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,6 +38,8 @@ hmac = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { 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;
|
||||
@@ -48,11 +49,13 @@ use openfang_channels::discourse::DiscourseAdapter;
|
||||
use openfang_channels::gitter::GitterAdapter;
|
||||
use openfang_channels::gotify::GotifyAdapter;
|
||||
use openfang_channels::linkedin::LinkedInAdapter;
|
||||
use openfang_channels::mqtt::MqttAdapter;
|
||||
use openfang_channels::mumble::MumbleAdapter;
|
||||
use openfang_channels::ntfy::NtfyAdapter;
|
||||
use openfang_channels::webhook::WebhookAdapter;
|
||||
use openfang_channels::wecom::WeComAdapter;
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::kernel_handle::KernelHandle;
|
||||
use openfang_types::agent::AgentId;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -521,6 +524,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
timeout_secs: None,
|
||||
},
|
||||
delivery: openfang_types::scheduler::CronDelivery::None,
|
||||
delivery_targets: Vec::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
last_run: None,
|
||||
next_run: None,
|
||||
@@ -809,10 +813,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
async fn free_response_channels(&self, channel_type: &str) -> Vec<String> {
|
||||
let channels = &self.kernel.config.channels;
|
||||
match channel_type {
|
||||
"discord" => channels
|
||||
.discord
|
||||
.as_ref()
|
||||
.map(|c| c.free_response_channels.clone())
|
||||
.unwrap_or_default(),
|
||||
// Add other channel types here as needed (e.g., "telegram" => ...)
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn authorize_channel_user(
|
||||
&self,
|
||||
channel_type: &str,
|
||||
@@ -877,6 +895,23 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_channel_message(
|
||||
&self,
|
||||
channel_type: &str,
|
||||
recipient: &str,
|
||||
message: &str,
|
||||
) -> Result<(), String> {
|
||||
<OpenFangKernel as KernelHandle>::send_channel_message(
|
||||
&self.kernel,
|
||||
channel_type,
|
||||
recipient,
|
||||
message,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn check_auto_reply(&self, agent_id: AgentId, message: &str) -> Option<String> {
|
||||
// Check if auto-reply should fire for this message
|
||||
let channel_type = "bridge"; // Generic; the bridge layer handles specifics
|
||||
@@ -1131,11 +1166,12 @@ pub async fn start_channel_bridge_with_config(
|
||||
if let Some(ref tg_config) = config.telegram {
|
||||
if let Some(token) = read_token(&tg_config.bot_token_env, "Telegram") {
|
||||
let poll_interval = Duration::from_secs(tg_config.poll_interval_secs);
|
||||
let adapter = Arc::new(TelegramAdapter::new(
|
||||
let adapter = Arc::new(TelegramAdapter::with_thread_routes(
|
||||
token,
|
||||
tg_config.allowed_users.clone(),
|
||||
poll_interval,
|
||||
tg_config.api_url.clone(),
|
||||
tg_config.thread_routes.clone(),
|
||||
));
|
||||
adapters.push((adapter, tg_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1150,6 +1186,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
dc_config.allowed_users.clone(),
|
||||
dc_config.ignore_bots,
|
||||
dc_config.intents,
|
||||
dc_config.auto_thread.clone(),
|
||||
));
|
||||
adapters.push((adapter, dc_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1214,10 +1251,17 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Matrix
|
||||
if let Some(ref mx_config) = config.matrix {
|
||||
if let Some(token) = read_token(&mx_config.access_token_env, "Matrix") {
|
||||
let adapter = Arc::new(MatrixAdapter::new(
|
||||
// MSC2918 refresh-token support: optional env var, when present the
|
||||
// adapter auto-recovers from M_UNKNOWN_TOKEN 401s.
|
||||
let refresh = mx_config
|
||||
.refresh_token_env
|
||||
.as_deref()
|
||||
.and_then(|env| read_token(env, "Matrix refresh"));
|
||||
let adapter = Arc::new(MatrixAdapter::with_refresh_token(
|
||||
mx_config.homeserver_url.clone(),
|
||||
mx_config.user_id.clone(),
|
||||
token,
|
||||
refresh,
|
||||
mx_config.allowed_rooms.clone(),
|
||||
mx_config.auto_accept_invites,
|
||||
));
|
||||
@@ -1431,16 +1475,23 @@ pub async fn start_channel_bridge_with_config(
|
||||
.encrypt_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| read_token(env, "Feishu encrypt_key"));
|
||||
let adapter = 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(),
|
||||
));
|
||||
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_with_region(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
region,
|
||||
)),
|
||||
};
|
||||
adapters.push((adapter, fs_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
@@ -1448,8 +1499,15 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Revolt
|
||||
if let Some(ref rv_config) = config.revolt {
|
||||
if let Some(token) = read_token(&rv_config.bot_token_env, "Revolt") {
|
||||
let adapter = Arc::new(RevoltAdapter::new(token));
|
||||
adapters.push((adapter, rv_config.default_agent.clone()));
|
||||
let mut adapter = RevoltAdapter::with_urls(
|
||||
token,
|
||||
rv_config.api_url.clone(),
|
||||
rv_config.ws_url.clone(),
|
||||
);
|
||||
if !rv_config.allowed_channels.is_empty() {
|
||||
adapter.set_allowed_channels(rv_config.allowed_channels.clone());
|
||||
}
|
||||
adapters.push((Arc::new(adapter), rv_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1671,6 +1729,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());
|
||||
}
|
||||
@@ -1875,4 +1952,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,35 @@
|
||||
//! Exposes agent management, status, and chat via JSON REST endpoints.
|
||||
//! The kernel runs in-process; the CLI connects over HTTP.
|
||||
|
||||
/// Decode percent-encoded strings (e.g. `%2B` → `+`).
|
||||
/// Used to normalise `?token=` values that browsers encode with `encodeURIComponent`.
|
||||
pub(crate) fn percent_decode(input: &str) -> String {
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
|
||||
out.push(hi << 4 | lo);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_else(|_| input.to_string())
|
||||
}
|
||||
|
||||
fn hex_val(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub mod channel_bridge;
|
||||
pub mod middleware;
|
||||
pub mod openai_compat;
|
||||
|
||||
@@ -49,14 +49,23 @@ pub struct AuthState {
|
||||
pub api_key: String,
|
||||
pub auth_enabled: bool,
|
||||
pub session_secret: String,
|
||||
/// Set from `OPENFANG_ALLOW_NO_AUTH=1` to permit running without an api_key
|
||||
/// on a non-loopback bind. Off by default so empty keys fail closed.
|
||||
pub allow_no_auth: bool,
|
||||
}
|
||||
|
||||
/// Bearer token authentication middleware.
|
||||
///
|
||||
/// 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 `api_key` is empty (no key configured) the server defaults to
|
||||
/// fail-closed for any request that does NOT originate from loopback.
|
||||
/// Loopback traffic (127.0.0.1 / ::1) is always allowed through with no
|
||||
/// key so single-user local setups keep zero-config UX. To explicitly
|
||||
/// run a no-auth server on a LAN/WAN address, set
|
||||
/// `OPENFANG_ALLOW_NO_AUTH=1`; this opts out of fail-closed and is
|
||||
/// reported loudly at startup.
|
||||
///
|
||||
/// When dashboard auth is enabled, session cookies are also accepted.
|
||||
pub async fn auth(
|
||||
@@ -67,17 +76,17 @@ pub async fn auth(
|
||||
// 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 is_loopback = request
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(false); // SECURITY: default-deny; unknown origin is NOT loopback
|
||||
|
||||
// Shutdown is loopback-only (CLI on same machine). Skip token auth only
|
||||
// when the request is from loopback.
|
||||
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); // SECURITY: default-deny — unknown origin is NOT loopback
|
||||
if is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
if path == "/api/shutdown" && is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth (dashboard needs these).
|
||||
@@ -117,6 +126,7 @@ pub async fn auth(
|
||||
|| (path == "/api/hands/active" && is_get)
|
||||
|| (path.starts_with("/api/hands/") && is_get)
|
||||
|| (path == "/api/skills" && is_get)
|
||||
|| (path.starts_with("/api/skills/") && path.ends_with("/config") && is_get)
|
||||
|| (path == "/api/sessions" && is_get)
|
||||
|| (path == "/api/integrations" && is_get)
|
||||
|| (path == "/api/integrations/available" && is_get)
|
||||
@@ -133,12 +143,29 @@ pub async fn auth(
|
||||
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.
|
||||
// If no API key configured and no dashboard login is active, fail closed
|
||||
// for anything that did not come from loopback. Opting out of this
|
||||
// behavior requires setting `OPENFANG_ALLOW_NO_AUTH=1`, which is logged
|
||||
// loudly at startup.
|
||||
//
|
||||
// See issue #1034 (B1/B2): empty api_key previously bypassed auth for
|
||||
// all origins, exposing agent config, channel tokens, and LLM keys on
|
||||
// any LAN-reachable bind.
|
||||
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;
|
||||
if is_loopback || auth_state.allow_no_auth {
|
||||
return next.run(request).await;
|
||||
}
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("www-authenticate", "Bearer")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"error": "API key required for non-loopback requests. Set OPENFANG_API_KEY or bind to 127.0.0.1."
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
let api_key = api_key_trimmed.as_str();
|
||||
|
||||
@@ -167,13 +194,14 @@ pub async fn auth(
|
||||
|
||||
// Also check ?token= query parameter (for EventSource/SSE clients that
|
||||
// cannot set custom headers, same approach as WebSocket auth).
|
||||
let query_token = request
|
||||
let query_token_decoded = request
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")));
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(crate::percent_decode);
|
||||
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks.
|
||||
let query_auth = query_token.map(|token| {
|
||||
let query_auth = query_token_decoded.as_deref().map(|token| {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != api_key.len() {
|
||||
return false;
|
||||
@@ -188,7 +216,7 @@ pub async fn auth(
|
||||
|
||||
// Check session cookie (dashboard login sessions)
|
||||
if auth_state.auth_enabled {
|
||||
if let Some(token) = extract_session_cookie(&request) {
|
||||
if let Some(token) = crate::session_auth::extract_session_cookie(request.headers()) {
|
||||
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
|
||||
.is_some()
|
||||
{
|
||||
@@ -214,21 +242,6 @@ 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;
|
||||
@@ -236,13 +249,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(),
|
||||
@@ -261,9 +277,123 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{Method, Request};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use std::net::SocketAddr;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[test]
|
||||
fn test_request_id_header_constant() {
|
||||
assert_eq!(REQUEST_ID_HEADER, "x-request-id");
|
||||
}
|
||||
|
||||
fn auth_state_empty() -> AuthState {
|
||||
AuthState {
|
||||
api_key: String::new(),
|
||||
auth_enabled: false,
|
||||
session_secret: String::new(),
|
||||
allow_no_auth: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_state_with_key(key: &str) -> AuthState {
|
||||
AuthState {
|
||||
api_key: key.to_string(),
|
||||
auth_enabled: false,
|
||||
session_secret: key.to_string(),
|
||||
allow_no_auth: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn ok_handler() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
fn router(state: AuthState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/agents/1", get(ok_handler))
|
||||
.route_layer(axum::middleware::from_fn_with_state(state, auth))
|
||||
}
|
||||
|
||||
fn req_from(ip: &str) -> Request<Body> {
|
||||
let addr: SocketAddr = format!("{ip}:40000").parse().unwrap();
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/agents/1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(addr));
|
||||
req
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_allows_loopback() {
|
||||
let app = router(auth_state_empty());
|
||||
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_blocks_lan_origin() {
|
||||
// Issue #1034 B1: previously 192.168/10/... could hit every non-public
|
||||
// endpoint when api_key was unset. Must now be 401.
|
||||
let app = router(auth_state_empty());
|
||||
let resp = app.oneshot(req_from("192.168.1.50")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_blocks_public_origin() {
|
||||
let app = router(auth_state_empty());
|
||||
let resp = app.oneshot(req_from("203.0.113.5")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_blocks_unknown_connect_info() {
|
||||
// Paranoia: if ConnectInfo is missing for any reason, we must fail
|
||||
// closed, not open.
|
||||
let app = router(auth_state_empty());
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/agents/1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_with_allow_no_auth_opens_everything() {
|
||||
let mut s = auth_state_empty();
|
||||
s.allow_no_auth = true;
|
||||
let app = router(s);
|
||||
let resp = app.oneshot(req_from("10.0.0.9")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_key_rejects_missing_token_from_loopback() {
|
||||
let app = router(auth_state_with_key("secret"));
|
||||
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_key_accepts_bearer() {
|
||||
let app = router(auth_state_with_key("secret"));
|
||||
let addr: SocketAddr = "127.0.0.1:40000".parse().unwrap();
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/agents/1")
|
||||
.header("authorization", "Bearer secret")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(addr));
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,12 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
|
||||
OaiContent::Null => return None,
|
||||
};
|
||||
|
||||
Some(Message { role, content })
|
||||
Some(Message {
|
||||
msg_id: uuid::Uuid::new_v4().to_string(),
|
||||
provider_msg_id: None,
|
||||
role,
|
||||
content,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -29,6 +29,16 @@ pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
|
||||
("POST", p) if p.contains("/run") => NonZeroU32::new(100).unwrap(),
|
||||
("POST", "/api/skills/install") => NonZeroU32::new(50).unwrap(),
|
||||
("POST", "/api/skills/uninstall") => NonZeroU32::new(10).unwrap(),
|
||||
("POST", "/api/skills/reload") => NonZeroU32::new(5).unwrap(),
|
||||
("GET", p) if p.starts_with("/api/skills/") && p.ends_with("/config") => {
|
||||
NonZeroU32::new(3).unwrap()
|
||||
}
|
||||
("PUT", p) if p.starts_with("/api/skills/") && p.ends_with("/config") => {
|
||||
NonZeroU32::new(10).unwrap()
|
||||
}
|
||||
("DELETE", p) if p.starts_with("/api/skills/") && p.contains("/config/") => {
|
||||
NonZeroU32::new(10).unwrap()
|
||||
}
|
||||
("POST", "/api/migrate") => NonZeroU32::new(100).unwrap(),
|
||||
("PUT", p) if p.contains("/update") => NonZeroU32::new(10).unwrap(),
|
||||
_ => NonZeroU32::new(5).unwrap(),
|
||||
|
||||
+1947
-353
File diff suppressed because it is too large
Load Diff
@@ -51,8 +51,13 @@ pub async fn build_router(
|
||||
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())),
|
||||
});
|
||||
|
||||
// Start WS cron broadcaster — subscribes to kernel event bus and pushes
|
||||
// cron job results to all connected WebSocket clients in real-time.
|
||||
ws::start_ws_cron_broadcaster(kernel.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.trim().is_empty() {
|
||||
@@ -103,8 +108,43 @@ pub async fn build_router(
|
||||
.allow_headers(tower_http::cors::Any)
|
||||
};
|
||||
|
||||
// Warn if dashboard auth is enabled but the password hash is not Argon2id.
|
||||
let ph = &state.kernel.config.auth.password_hash;
|
||||
if state.kernel.config.auth.enabled && !ph.is_empty() && !ph.starts_with("$argon2") {
|
||||
tracing::warn!(
|
||||
"Dashboard auth password_hash is not in Argon2id format. \
|
||||
Login will fail. Regenerate with: openfang auth hash-password"
|
||||
);
|
||||
}
|
||||
|
||||
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
let allow_no_auth = std::env::var("OPENFANG_ALLOW_NO_AUTH")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "on"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Fail-closed warning: if no api_key and no dashboard auth, and the
|
||||
// server is bound to a non-loopback address without an explicit opt-in,
|
||||
// shout about it. The middleware will reject non-loopback traffic.
|
||||
let bind_is_loopback = listen_addr.ip().is_loopback();
|
||||
if api_key.is_empty() && !state.kernel.config.auth.enabled && !bind_is_loopback {
|
||||
if allow_no_auth {
|
||||
tracing::warn!(
|
||||
"OPENFANG_ALLOW_NO_AUTH=1 is set. Running WITHOUT authentication on {}. \
|
||||
Anyone reachable at this address can read/write agents, channels, and keys.",
|
||||
listen_addr
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"No api_key configured and server is bound to {} (non-loopback). \
|
||||
Non-loopback requests will be rejected with 401. \
|
||||
Set OPENFANG_API_KEY (or api_key in config.toml), or bind to 127.0.0.1, \
|
||||
or set OPENFANG_ALLOW_NO_AUTH=1 to explicitly run open.",
|
||||
listen_addr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let auth_state = crate::middleware::AuthState {
|
||||
api_key: api_key.clone(),
|
||||
auth_enabled: state.kernel.config.auth.enabled,
|
||||
@@ -115,6 +155,7 @@ pub async fn build_router(
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
allow_no_auth,
|
||||
};
|
||||
let gcra_limiter = rate_limiter::create_rate_limiter();
|
||||
|
||||
@@ -145,6 +186,10 @@ pub async fn build_router(
|
||||
.delete(routes::kill_agent)
|
||||
.patch(routes::patch_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/uninstall",
|
||||
axum::routing::delete(routes::uninstall_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/mode",
|
||||
axum::routing::put(routes::set_agent_mode),
|
||||
@@ -158,6 +203,12 @@ pub async fn build_router(
|
||||
"/api/agents/{id}/start",
|
||||
axum::routing::post(routes::restart_agent),
|
||||
)
|
||||
.route(
|
||||
// Issue #890 — alias so dashboards and external orchestrators can
|
||||
// wake an inactive agent via a verb that matches the agent_activate tool.
|
||||
"/api/agents/{id}/activate",
|
||||
axum::routing::post(routes::restart_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/message",
|
||||
axum::routing::post(routes::send_message),
|
||||
@@ -306,6 +357,10 @@ pub async fn build_router(
|
||||
"/api/schedules/{id}/run",
|
||||
axum::routing::post(routes::run_schedule),
|
||||
)
|
||||
.route(
|
||||
"/api/schedules/{id}/delivery-log",
|
||||
axum::routing::get(routes::schedule_delivery_log),
|
||||
)
|
||||
// Workflow endpoints
|
||||
.route(
|
||||
"/api/workflows",
|
||||
@@ -335,6 +390,23 @@ pub async fn build_router(
|
||||
"/api/skills/uninstall",
|
||||
axum::routing::post(routes::uninstall_skill),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/reload",
|
||||
axum::routing::post(routes::reload_skills),
|
||||
)
|
||||
// Audit trail (issue #1174 — instance-side wrapper integration)
|
||||
.route(
|
||||
"/api/audit/append",
|
||||
axum::routing::post(routes::audit_append),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/{id}/config",
|
||||
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/{id}/config/{var_name}",
|
||||
axum::routing::delete(routes::delete_skill_config_var),
|
||||
)
|
||||
.route(
|
||||
"/api/marketplace/search",
|
||||
axum::routing::get(routes::marketplace_search),
|
||||
@@ -586,6 +658,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))
|
||||
|
||||
@@ -17,6 +17,25 @@ pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> Str
|
||||
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
|
||||
}
|
||||
|
||||
/// Extract the `openfang_session` cookie value from a `Cookie` header string.
|
||||
///
|
||||
/// Returns `None` if the header is absent or the cookie is not present.
|
||||
/// Used by both the HTTP auth middleware and the WebSocket upgrade handler so
|
||||
/// that browser sessions established via `sessionLogin()` are honored on both
|
||||
/// surfaces (issue #1085).
|
||||
pub fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
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())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -55,20 +74,27 @@ pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash a password with SHA256 for config storage.
|
||||
/// Hash a password with Argon2id for config storage.
|
||||
///
|
||||
/// Returns a PHC-format string (e.g. `$argon2id$v=19$m=19456,t=2,p=1$...`).
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
use sha2::Digest;
|
||||
hex::encode(Sha256::digest(password.as_bytes()))
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||
let salt = SaltString::generate(&mut rand::thread_rng());
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.expect("Argon2 hashing should not fail with valid inputs")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Verify a password against a stored SHA256 hash (constant-time).
|
||||
/// Verify a password against a stored Argon2id hash (PHC string format).
|
||||
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
|
||||
let computed = hash_password(password);
|
||||
use subtle::ConstantTimeEq;
|
||||
if computed.len() != stored_hash.len() {
|
||||
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
|
||||
let Ok(parsed) = PasswordHash::new(stored_hash) else {
|
||||
return false;
|
||||
}
|
||||
computed.as_bytes().ct_eq(stored_hash.as_bytes()).into()
|
||||
};
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -78,10 +104,31 @@ mod tests {
|
||||
#[test]
|
||||
fn test_hash_and_verify_password() {
|
||||
let hash = hash_password("secret123");
|
||||
assert!(
|
||||
hash.starts_with("$argon2id$"),
|
||||
"should produce Argon2id PHC string"
|
||||
);
|
||||
assert!(verify_password("secret123", &hash));
|
||||
assert!(!verify_password("wrong", &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_produces_unique_salts() {
|
||||
let h1 = hash_password("same");
|
||||
let h2 = hash_password("same");
|
||||
assert_ne!(h1, h2, "each hash should use a unique salt");
|
||||
assert!(verify_password("same", &h1));
|
||||
assert!(verify_password("same", &h2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_non_argon2_hash() {
|
||||
// A plain SHA256 hex string should no longer be accepted.
|
||||
use sha2::Digest;
|
||||
let sha256_hash = hex::encode(sha2::Sha256::digest(b"password"));
|
||||
assert!(!verify_password("password", &sha256_hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_verify_token() {
|
||||
let token = create_session_token("admin", "my-secret", 1);
|
||||
@@ -103,7 +150,46 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_hash_length_mismatch() {
|
||||
fn test_rejects_garbage_input() {
|
||||
assert!(!verify_password("x", "short"));
|
||||
assert!(!verify_password("x", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_malformed_argon2_hash() {
|
||||
// Starts with $argon2 but is not a valid PHC string.
|
||||
assert!(!verify_password("x", "$argon2id$garbage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_cookie_present() {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert(
|
||||
"cookie",
|
||||
"foo=bar; openfang_session=abc.def.ghi; baz=qux"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(extract_session_cookie(&h).as_deref(), Some("abc.def.ghi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_cookie_absent() {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert("cookie", "foo=bar; baz=qux".parse().unwrap());
|
||||
assert_eq!(extract_session_cookie(&h), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_cookie_no_header() {
|
||||
let h = axum::http::HeaderMap::new();
|
||||
assert_eq!(extract_session_cookie(&h), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_cookie_only_value() {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert("cookie", "openfang_session=lonely".parse().unwrap());
|
||||
assert_eq!(extract_session_cookie(&h).as_deref(), Some("lonely"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,15 @@ pub struct MessageResponse {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SkillInstallRequest {
|
||||
pub name: String,
|
||||
/// When true, reject the install unless the bundle ships a valid
|
||||
/// Ed25519 SignedManifest envelope bound to the on-disk manifest.
|
||||
/// Maps to `InstallOptions::require_signed` (issue #1170).
|
||||
#[serde(default)]
|
||||
pub require_signed: bool,
|
||||
/// Optional hex-encoded allow-list of acceptable signer public keys.
|
||||
/// Empty = TOFU (any valid signature accepted).
|
||||
#[serde(default)]
|
||||
pub allowed_signer_keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Request to uninstall a skill.
|
||||
@@ -107,3 +116,104 @@ pub struct ClawHubInstallRequest {
|
||||
/// ClawHub skill slug (e.g., "github-helper").
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
/// Query parameters for `GET /api/commands`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommandsQuery {
|
||||
/// Surface filter: `web` (default), `cli`, `channel`, or `all`.
|
||||
#[serde(default)]
|
||||
pub surface: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/audit/append` (issue #1174).
|
||||
///
|
||||
/// Lets external (instance-side) wrappers append entries to the Merkle hash
|
||||
/// chain audit log. The handler maps `event_type` to an `AuditAction` and
|
||||
/// records the entry through `kernel.audit_log`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuditAppendRequest {
|
||||
/// Operator-supplied event category. Case-insensitive, matched against the
|
||||
/// `AuditAction` enum variants (e.g. `tool_invoke`, `ConfigChange`,
|
||||
/// `agent_message`). Unknown values fall back to `ToolInvoke`.
|
||||
pub event_type: String,
|
||||
/// Agent or wrapper identifier responsible for the event. When empty,
|
||||
/// recorded as `"external-wrapper"`.
|
||||
#[serde(default)]
|
||||
pub agent_id: String,
|
||||
/// Free-form detail string (e.g. tool name, URL, file path).
|
||||
#[serde(default)]
|
||||
pub detail: String,
|
||||
/// Optional arbitrary payload. When present it is serialised to JSON and
|
||||
/// appended onto the entry's detail so the wrapper retains structured
|
||||
/// context without changing the on-chain schema.
|
||||
#[serde(default)]
|
||||
pub payload: Option<serde_json::Value>,
|
||||
/// Optional outcome string (`"ok"`, `"denied"`, or an error). Defaults to
|
||||
/// `"ok"` when omitted.
|
||||
#[serde(default)]
|
||||
pub outcome: Option<String>,
|
||||
/// Optional operator-supplied signing context (e.g. wrapper identity, key
|
||||
/// fingerprint). Mixed into the detail when present so the chain captures
|
||||
/// who attested to the event.
|
||||
#[serde(default)]
|
||||
pub signing_context: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skill_install_request_defaults_back_compat() {
|
||||
// Existing callers send `{"name": "..."}` only. New optional fields
|
||||
// must default cleanly (issue #1170).
|
||||
let req: SkillInstallRequest = serde_json::from_str(r#"{"name":"github-helper"}"#).unwrap();
|
||||
assert_eq!(req.name, "github-helper");
|
||||
assert!(!req.require_signed);
|
||||
assert!(req.allowed_signer_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_install_request_parses_require_signed() {
|
||||
let req: SkillInstallRequest = serde_json::from_str(
|
||||
r#"{"name":"x","require_signed":true,"allowed_signer_keys":["abc123"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(req.require_signed);
|
||||
assert_eq!(req.allowed_signer_keys, vec!["abc123".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_append_request_required_only() {
|
||||
// Only `event_type` is required; everything else must default.
|
||||
let req: AuditAppendRequest =
|
||||
serde_json::from_str(r#"{"event_type":"ToolInvoke"}"#).unwrap();
|
||||
assert_eq!(req.event_type, "ToolInvoke");
|
||||
assert!(req.agent_id.is_empty());
|
||||
assert!(req.detail.is_empty());
|
||||
assert!(req.payload.is_none());
|
||||
assert!(req.outcome.is_none());
|
||||
assert!(req.signing_context.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_append_request_full_payload() {
|
||||
let body = r#"{
|
||||
"event_type": "config_change",
|
||||
"agent_id": "wrapper-1",
|
||||
"detail": "rotated key",
|
||||
"payload": {"key_id": "k-42", "ts": 1700000000},
|
||||
"outcome": "ok",
|
||||
"signing_context": "ed25519:deadbeef"
|
||||
}"#;
|
||||
let req: AuditAppendRequest = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(req.event_type, "config_change");
|
||||
assert_eq!(req.agent_id, "wrapper-1");
|
||||
assert_eq!(req.detail, "rotated key");
|
||||
assert_eq!(req.outcome.as_deref(), Some("ok"));
|
||||
assert_eq!(req.signing_context.as_deref(), Some("ed25519:deadbeef"));
|
||||
let payload = req.payload.expect("payload present");
|
||||
assert_eq!(payload["key_id"], "k-42");
|
||||
assert_eq!(payload["ts"], 1_700_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -76,18 +82,35 @@ pub async fn sw_js() -> impl IntoResponse {
|
||||
|
||||
/// GET / — Serve the OpenFang Dashboard single-page application.
|
||||
///
|
||||
/// Returns the full SPA with ETag header based on package version for caching.
|
||||
/// 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' https://cdn.jsdelivr.net; \
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; \
|
||||
img-src 'self' data: blob:; \
|
||||
connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:* https://cdn.jsdelivr.net; \
|
||||
font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; \
|
||||
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"),
|
||||
(header::ETAG, ETAG),
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
"public, max-age=3600, must-revalidate",
|
||||
header::HeaderName::from_static("content-security-policy"),
|
||||
csp,
|
||||
),
|
||||
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||
],
|
||||
WEBCHAT_HTML,
|
||||
html,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -97,6 +120,7 @@ pub async fn webchat_page() -> impl IntoResponse {
|
||||
/// All vendor libraries (Alpine.js, marked.js, highlight.js) are bundled
|
||||
/// locally — no CDN dependency. Alpine.js is included LAST because it
|
||||
/// immediately processes x-data directives and fires alpine:init on load.
|
||||
/// KaTeX is loaded dynamically from jsdelivr CDN when needed for LaTeX rendering.
|
||||
const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/index_head.html"),
|
||||
"<style>\n",
|
||||
@@ -110,17 +134,17 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
"\n</style>\n",
|
||||
include_str!("../static/index_body.html"),
|
||||
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
|
||||
"<script>\n",
|
||||
"<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>\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"),
|
||||
@@ -162,7 +186,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>"
|
||||
|
||||
+713
-48
@@ -19,17 +19,19 @@ use axum::response::IntoResponse;
|
||||
use dashmap::DashMap;
|
||||
use futures::stream::SplitSink;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::kernel_handle::KernelHandle;
|
||||
use openfang_runtime::llm_driver::StreamEvent;
|
||||
use openfang_runtime::llm_errors;
|
||||
use openfang_types::agent::AgentId;
|
||||
use openfang_types::commands::{self, Surfaces};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Per-IP WebSocket connection tracker.
|
||||
@@ -97,6 +99,62 @@ fn ws_tracker() -> &'static DashMap<IpAddr, AtomicUsize> {
|
||||
TRACKER.get_or_init(DashMap::new)
|
||||
}
|
||||
|
||||
/// Per-agent WebSocket sender entry.
|
||||
struct WsSender {
|
||||
sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
|
||||
}
|
||||
|
||||
/// Global registry: agent_id → active WebSocket senders.
|
||||
/// Uses RwLock for fine-grained read/write access to the sender list.
|
||||
fn ws_agent_connections() -> &'static DashMap<AgentId, RwLock<Vec<WsSender>>> {
|
||||
static REGISTRY: std::sync::OnceLock<DashMap<AgentId, RwLock<Vec<WsSender>>>> =
|
||||
std::sync::OnceLock::new();
|
||||
REGISTRY.get_or_init(DashMap::new)
|
||||
}
|
||||
|
||||
/// Register a WebSocket connection for an agent (async).
|
||||
pub async fn register_ws_connection(
|
||||
agent_id: AgentId,
|
||||
sender: Arc<Mutex<SplitSink<WebSocket, Message>>>,
|
||||
) {
|
||||
let entry = ws_agent_connections().entry(agent_id).or_default();
|
||||
let mut senders = entry.value().write().await;
|
||||
senders.push(WsSender { sender });
|
||||
}
|
||||
|
||||
/// Deregister a WebSocket connection for an agent.
|
||||
/// Returns the number of remaining connections for this agent.
|
||||
pub async fn deregister_ws_connection(
|
||||
agent_id: AgentId,
|
||||
sender: &Arc<Mutex<SplitSink<WebSocket, Message>>>,
|
||||
) -> usize {
|
||||
let entry = match ws_agent_connections().get(&agent_id) {
|
||||
Some(e) => e,
|
||||
None => return 0,
|
||||
};
|
||||
let mut senders = entry.value().write().await;
|
||||
senders.retain(|s| !Arc::ptr_eq(&s.sender, sender));
|
||||
senders.len()
|
||||
}
|
||||
|
||||
/// Broadcast a JSON message to all active WebSocket connections for an agent.
|
||||
/// Returns the number of connections the message was sent to.
|
||||
pub async fn broadcast_to_ws(agent_id: AgentId, msg: serde_json::Value) -> usize {
|
||||
let entry = match ws_agent_connections().get(&agent_id) {
|
||||
Some(e) => e,
|
||||
None => return 0,
|
||||
};
|
||||
let senders = entry.value().read().await;
|
||||
let mut success_count = 0;
|
||||
for ws_sender in senders.iter() {
|
||||
let sender = &ws_sender.sender;
|
||||
if send_json(sender, &msg).await.is_ok() {
|
||||
success_count += 1;
|
||||
}
|
||||
}
|
||||
success_count
|
||||
}
|
||||
|
||||
/// RAII guard that decrements the connection count on drop.
|
||||
struct WsConnectionGuard {
|
||||
ip: IpAddr,
|
||||
@@ -132,11 +190,121 @@ fn try_acquire_ws_slot(ip: IpAddr) -> Option<WsConnectionGuard> {
|
||||
// WS Upgrade Handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parameters for [`check_ws_auth`]. Kept as a struct so the auth gate stays
|
||||
/// pure and unit-testable without an `AppState` or live socket.
|
||||
pub(crate) struct WsAuthCtx<'a> {
|
||||
/// Trimmed API key from kernel config. Empty string means no key configured.
|
||||
pub api_key: &'a str,
|
||||
/// Whether dashboard session login is enabled in config.
|
||||
pub auth_enabled: bool,
|
||||
/// Secret used to verify session cookies (api_key when set, else password hash).
|
||||
pub session_secret: &'a str,
|
||||
/// Whether the request originated from a loopback address.
|
||||
pub is_loopback: bool,
|
||||
/// True iff `OPENFANG_ALLOW_NO_AUTH=1` is set (loose mode for LAN binds).
|
||||
pub allow_no_auth: bool,
|
||||
pub headers: &'a axum::http::HeaderMap,
|
||||
pub uri: &'a axum::http::Uri,
|
||||
}
|
||||
|
||||
/// Pure auth gate for WebSocket upgrades.
|
||||
///
|
||||
/// Returns `Ok(())` if the request should be allowed through, or
|
||||
/// `Err(StatusCode::UNAUTHORIZED)` otherwise. Accepts:
|
||||
/// 1. `Authorization: Bearer <api_key>` header
|
||||
/// 2. `?token=<api_key>` query parameter
|
||||
/// 3. `openfang_session=<token>` cookie when dashboard auth is enabled
|
||||
/// 4. Loopback origin when no api_key is configured
|
||||
/// 5. Any origin when `OPENFANG_ALLOW_NO_AUTH=1`
|
||||
///
|
||||
/// Fix for issue #1085: previously only (1), (2), and (4) were honored, so
|
||||
/// dashboard users logged in via session cookie saw "No active connection"
|
||||
/// because the WS upgrade rejected them even though HTTP requests succeeded.
|
||||
pub(crate) fn check_ws_auth(ctx: &WsAuthCtx<'_>) -> Result<(), axum::http::StatusCode> {
|
||||
use axum::http::StatusCode;
|
||||
|
||||
// No api_key configured: behavior depends on whether dashboard auth is on.
|
||||
//
|
||||
// Issue #1189: previously this path allowed any loopback request through
|
||||
// when api_key was empty, EVEN IF dashboard auth was enabled. That diverged
|
||||
// from the HTTP middleware (which only opens the loopback no-auth path
|
||||
// when api_key is empty AND auth.enabled is false). A local attacker with
|
||||
// loopback access could chat with agents over WS even when the operator
|
||||
// had configured dashboard credentials. Now mirror HTTP exactly.
|
||||
if ctx.api_key.is_empty() {
|
||||
// When dashboard auth is configured, require a valid session cookie
|
||||
// regardless of bind address. Loopback no longer bypasses login.
|
||||
if ctx.auth_enabled {
|
||||
if !ctx.session_secret.is_empty() {
|
||||
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
|
||||
if crate::session_auth::verify_session_token(&token, ctx.session_secret)
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
// No api_key AND dashboard auth disabled: keep the dev convenience
|
||||
// path (loopback or explicit OPENFANG_ALLOW_NO_AUTH=1).
|
||||
if ctx.is_loopback || ctx.allow_no_auth {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// SECURITY: 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 = ctx
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|token| ct_eq(token, ctx.api_key))
|
||||
.unwrap_or(false);
|
||||
if header_auth {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let query_auth = ctx
|
||||
.uri
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(crate::percent_decode)
|
||||
.map(|token| ct_eq(&token, ctx.api_key))
|
||||
.unwrap_or(false);
|
||||
if query_auth {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Dashboard session cookie (issue #1085). When auth_enabled is on the
|
||||
// session_secret is set by server.rs to either the api_key or the
|
||||
// configured password hash, mirroring the HTTP auth middleware.
|
||||
if ctx.auth_enabled && !ctx.session_secret.is_empty() {
|
||||
if let Some(token) = crate::session_auth::extract_session_cookie(ctx.headers) {
|
||||
if crate::session_auth::verify_session_token(&token, ctx.session_secret).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
|
||||
/// GET /api/agents/:id/ws — Upgrade to WebSocket for real-time chat.
|
||||
///
|
||||
/// SECURITY: Authenticates via Bearer token in Authorization header
|
||||
/// or `?token=` query parameter (for browser WebSocket clients that
|
||||
/// cannot set custom headers).
|
||||
/// SECURITY: Authenticates via Bearer token in Authorization header,
|
||||
/// `?token=` query parameter (for browser WebSocket clients that cannot
|
||||
/// set custom headers), or the `openfang_session` cookie set by the
|
||||
/// dashboard's session login flow (issue #1085).
|
||||
pub async fn agent_ws(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -145,37 +313,43 @@ pub async fn agent_ws(
|
||||
headers: axum::http::HeaderMap,
|
||||
uri: axum::http::Uri,
|
||||
) -> impl IntoResponse {
|
||||
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
|
||||
// Trim whitespace so empty/whitespace-only api_key disables auth.
|
||||
// SECURITY: Authenticate WebSocket upgrades (bypasses HTTP middleware).
|
||||
// Trim whitespace so empty/whitespace-only api_key still triggers the
|
||||
// fail-closed path for non-loopback origins (see issue #1034 B2).
|
||||
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 is_loopback = addr.ip().is_loopback();
|
||||
let allow_no_auth = std::env::var("OPENFANG_ALLOW_NO_AUTH")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "on"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let header_auth = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
// Mirror the session_secret derivation in server.rs::AuthState so cookies
|
||||
// issued by /api/auth/login verify the same way over HTTP and WS.
|
||||
let auth_enabled = state.kernel.config.auth.enabled;
|
||||
let session_secret_owned: String = if !api_key.is_empty() {
|
||||
api_key.to_string()
|
||||
} else if auth_enabled {
|
||||
state.kernel.config.auth.password_hash.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let query_auth = uri
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
let auth_ctx = WsAuthCtx {
|
||||
api_key,
|
||||
auth_enabled,
|
||||
session_secret: &session_secret_owned,
|
||||
is_loopback,
|
||||
allow_no_auth,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
|
||||
if !header_auth && !query_auth {
|
||||
warn!("WebSocket upgrade rejected: invalid auth");
|
||||
return axum::http::StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
if let Err(status) = check_ws_auth(&auth_ctx) {
|
||||
warn!(
|
||||
ip = %addr.ip(),
|
||||
"WebSocket upgrade rejected: no valid Bearer token, ?token=, or openfang_session cookie"
|
||||
);
|
||||
return status.into_response();
|
||||
}
|
||||
|
||||
// SECURITY: Enforce per-IP WebSocket connection limit
|
||||
@@ -196,9 +370,29 @@ pub async fn agent_ws(
|
||||
}
|
||||
};
|
||||
|
||||
// Verify agent exists
|
||||
if state.kernel.registry.get(agent_id).is_none() {
|
||||
return axum::http::StatusCode::NOT_FOUND.into_response();
|
||||
// Verify agent exists.
|
||||
// Retry up to 5 times with 200ms backoff to handle a timing race where
|
||||
// the client connects before the agent finishes registering (#804).
|
||||
{
|
||||
let mut found = state.kernel.registry.get(agent_id).is_some();
|
||||
if !found {
|
||||
for attempt in 1..=4 {
|
||||
debug!(
|
||||
agent_id = %id,
|
||||
attempt,
|
||||
"Agent not found yet, retrying in 200ms"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
if state.kernel.registry.get(agent_id).is_some() {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
warn!(agent_id = %id, "Agent not found after 5 lookup attempts");
|
||||
return axum::http::StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let id_str = id.clone();
|
||||
@@ -226,6 +420,9 @@ async fn handle_agent_ws(
|
||||
let (sender, mut receiver) = socket.split();
|
||||
let sender = Arc::new(Mutex::new(sender));
|
||||
|
||||
// Register this connection in the global agent-WS registry
|
||||
register_ws_connection(agent_id, Arc::clone(&sender)).await;
|
||||
|
||||
// Per-connection verbose level (default: Full)
|
||||
let verbose = Arc::new(AtomicU8::new(VerboseLevel::Full as u8));
|
||||
|
||||
@@ -378,7 +575,8 @@ async fn handle_agent_ws(
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
// Cleanup: deregister from agent-WS registry and abort background tasks
|
||||
deregister_ws_connection(agent_id, &sender).await;
|
||||
update_handle.abort();
|
||||
info!(agent_id = %id_str, "WebSocket disconnected");
|
||||
}
|
||||
@@ -816,8 +1014,17 @@ async fn handle_command(
|
||||
args: &str,
|
||||
verbose: &Arc<AtomicU8>,
|
||||
) -> serde_json::Value {
|
||||
match cmd {
|
||||
"new" | "reset" => match state.kernel.reset_session(agent_id) {
|
||||
// Canonicalise through the unified command registry. This resolves aliases
|
||||
// (e.g. `reset` -> `new`) and is case-insensitive. If the command is not
|
||||
// registered on the WEB surface, fall through to the existing match so any
|
||||
// legacy/un-registered handlers still work byte-identically.
|
||||
let canonical: &str = commands::resolve(cmd)
|
||||
.filter(|def| def.surfaces.contains(Surfaces::WEB))
|
||||
.map(|def| def.name)
|
||||
.unwrap_or(cmd);
|
||||
|
||||
match canonical {
|
||||
"new" => match state.kernel.reset_session(agent_id) {
|
||||
Ok(()) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": "Session reset. Chat history cleared."})
|
||||
}
|
||||
@@ -831,15 +1038,34 @@ async fn handle_command(
|
||||
serde_json::json!({"type": "error", "content": format!("Compaction failed: {e}")})
|
||||
}
|
||||
},
|
||||
"stop" => match state.kernel.stop_agent_run(agent_id) {
|
||||
Ok(true) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": "Run cancelled."})
|
||||
"stop" => {
|
||||
// If this agent is owned by an active hand instance, deactivate the
|
||||
// hand entirely so the user can re-activate it (issue #1164).
|
||||
if let Some(instance) = state.kernel.hand_registry.find_by_agent(agent_id) {
|
||||
match state.kernel.deactivate_hand(instance.instance_id) {
|
||||
Ok(()) => serde_json::json!({
|
||||
"type": "command_result",
|
||||
"command": cmd,
|
||||
"message": format!("Hand '{}' deactivated.", instance.hand_id),
|
||||
}),
|
||||
Err(e) => {
|
||||
serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match state.kernel.stop_agent_run(agent_id) {
|
||||
Ok(true) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": "Run cancelled."})
|
||||
}
|
||||
Ok(false) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": "No active run to cancel."})
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")})
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": "No active run to cancel."})
|
||||
}
|
||||
Err(e) => serde_json::json!({"type": "error", "content": format!("Stop failed: {e}")}),
|
||||
},
|
||||
}
|
||||
"model" => {
|
||||
if args.is_empty() {
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
@@ -986,7 +1212,20 @@ async fn handle_command(
|
||||
};
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": msg})
|
||||
}
|
||||
_ => serde_json::json!({"type": "error", "content": format!("Unknown command: {cmd}")}),
|
||||
"help" => {
|
||||
serde_json::json!({
|
||||
"type": "command_result",
|
||||
"command": cmd,
|
||||
"message": commands::render_help(Surfaces::WEB),
|
||||
})
|
||||
}
|
||||
_ => serde_json::json!({
|
||||
"type": "error",
|
||||
"content": format!(
|
||||
"Unknown command: /{cmd}\n\n{}",
|
||||
commands::render_help(Surfaces::WEB)
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -998,11 +1237,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")
|
||||
@@ -1010,12 +1252,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)
|
||||
@@ -1025,6 +1270,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,
|
||||
}))
|
||||
@@ -1037,18 +1283,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,
|
||||
})),
|
||||
@@ -1056,6 +1305,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,
|
||||
@@ -1063,6 +1313,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,
|
||||
@@ -1261,6 +1512,110 @@ pub fn strip_think_tags(text: &str) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cron Job WS Broadcasting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Start a background task that subscribes to the kernel's event bus and
|
||||
/// broadcasts cron job results to all connected WebSocket clients for the
|
||||
/// relevant agent.
|
||||
///
|
||||
/// This runs independently of the channel bridge — it uses the kernel's
|
||||
/// event bus to receive `CronJobExecuted` events and pushes them to WS.
|
||||
pub fn start_ws_cron_broadcaster(kernel: Arc<OpenFangKernel>) {
|
||||
tokio::spawn(async move {
|
||||
let mut rx = kernel.event_bus.subscribe_all();
|
||||
loop {
|
||||
let event = rx.recv().await;
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if let openfang_types::event::EventPayload::System(
|
||||
openfang_types::event::SystemEvent::CronJobExecuted {
|
||||
agent_id,
|
||||
job_id,
|
||||
job_name,
|
||||
trigger_message,
|
||||
response,
|
||||
delivered_to_channel: _,
|
||||
},
|
||||
) = event.payload
|
||||
{
|
||||
// Build the trigger message (synthetic user message from cron)
|
||||
let trigger_msg = serde_json::json!({
|
||||
"type": "message",
|
||||
"content": trigger_message,
|
||||
"source": "cron",
|
||||
"job_id": job_id,
|
||||
"job_name": job_name
|
||||
});
|
||||
let _ = broadcast_to_ws(agent_id, trigger_msg).await;
|
||||
|
||||
// Send typing start
|
||||
let _ = broadcast_to_ws(
|
||||
agent_id,
|
||||
serde_json::json!({"state": "start", "type": "typing"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send streaming phase
|
||||
let _ = broadcast_to_ws(
|
||||
agent_id,
|
||||
serde_json::json!({"detail": null, "phase": "streaming", "type": "phase"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send text delta (full response since we don't have streaming chunks)
|
||||
let text_delta = serde_json::json!({
|
||||
"content": response,
|
||||
"type": "text_delta"
|
||||
});
|
||||
let _ = broadcast_to_ws(agent_id, text_delta).await;
|
||||
|
||||
// Send done phase
|
||||
let _ = broadcast_to_ws(
|
||||
agent_id,
|
||||
serde_json::json!({"detail": null, "phase": "done", "type": "phase"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send typing stop
|
||||
let _ = broadcast_to_ws(
|
||||
agent_id,
|
||||
serde_json::json!({"state": "stop", "type": "typing"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Send final response (mimics the format from agent_loop)
|
||||
let response_msg = serde_json::json!({
|
||||
"type": "response",
|
||||
"content": response,
|
||||
"context_pressure": "low",
|
||||
"cost_usd": null,
|
||||
"input_tokens": 0,
|
||||
"iterations": 0,
|
||||
"output_tokens": 0
|
||||
});
|
||||
let _ = broadcast_to_ws(agent_id, response_msg).await;
|
||||
|
||||
info!(
|
||||
agent_id = %agent_id,
|
||||
job_id = %job_id,
|
||||
"Cron job result broadcast to WS"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(lagged_messages = n, "WS cron broadcaster lagged, skipping");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
info!("WS cron broadcaster channel closed, stopping");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1360,4 +1715,314 @@ mod tests {
|
||||
assert_eq!(strip_think_tags("No thinking here"), "No thinking here");
|
||||
assert_eq!(strip_think_tags("<think>all thinking</think>"), "");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// WebSocket auth gate (issue #1085)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn empty_uri() -> axum::http::Uri {
|
||||
"/api/agents/x/ws".parse().unwrap()
|
||||
}
|
||||
|
||||
fn uri_with_token(tok: &str) -> axum::http::Uri {
|
||||
format!("/api/agents/x/ws?token={tok}").parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_accepts_bearer_token() {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert("authorization", "Bearer secret".parse().unwrap());
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "secret",
|
||||
auth_enabled: false,
|
||||
session_secret: "secret",
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(check_ws_auth(&ctx).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_accepts_query_token() {
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = uri_with_token("secret");
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "secret",
|
||||
auth_enabled: false,
|
||||
session_secret: "secret",
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(check_ws_auth(&ctx).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_accepts_session_cookie() {
|
||||
// Issue #1085: the dashboard logs in via cookie, so WS must accept it.
|
||||
let secret = "shared-secret";
|
||||
let token = crate::session_auth::create_session_token("alice", secret, 1);
|
||||
let cookie = format!("foo=bar; openfang_session={token}");
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert("cookie", cookie.parse().unwrap());
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: secret,
|
||||
auth_enabled: true,
|
||||
session_secret: secret,
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(
|
||||
check_ws_auth(&ctx).is_ok(),
|
||||
"valid session cookie should authorize WS upgrade"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_session_cookie_rejected_when_auth_disabled() {
|
||||
// If dashboard auth is off, cookies must not grant access.
|
||||
let secret = "shared-secret";
|
||||
let token = crate::session_auth::create_session_token("alice", secret, 1);
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
format!("openfang_session={token}").parse().unwrap(),
|
||||
);
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: secret,
|
||||
auth_enabled: false,
|
||||
session_secret: secret,
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_rejects_wrong_session_cookie() {
|
||||
// Cookie signed with the wrong secret must fail.
|
||||
let bad = crate::session_auth::create_session_token("alice", "other-secret", 1);
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert("cookie", format!("openfang_session={bad}").parse().unwrap());
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "secret",
|
||||
auth_enabled: true,
|
||||
session_secret: "secret",
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_rejects_when_no_credentials() {
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "secret",
|
||||
auth_enabled: true,
|
||||
session_secret: "secret",
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_rejects_wrong_bearer() {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert("authorization", "Bearer wrong".parse().unwrap());
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "secret",
|
||||
auth_enabled: false,
|
||||
session_secret: "secret",
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_empty_key_loopback_ok() {
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: false,
|
||||
session_secret: "",
|
||||
is_loopback: true,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(check_ws_auth(&ctx).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_empty_key_non_loopback_rejected() {
|
||||
// Issue #1034 B2 regression guard.
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: false,
|
||||
session_secret: "",
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_empty_key_allow_no_auth_opens() {
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: false,
|
||||
session_secret: "",
|
||||
is_loopback: false,
|
||||
allow_no_auth: true,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(check_ws_auth(&ctx).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_empty_key_session_cookie_grants_non_loopback() {
|
||||
// When only dashboard login is configured (no api_key, auth_enabled=true),
|
||||
// a valid session cookie must allow non-loopback WS upgrades.
|
||||
let secret = "password-hash-style-secret";
|
||||
let token = crate::session_auth::create_session_token("admin", secret, 1);
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
format!("openfang_session={token}").parse().unwrap(),
|
||||
);
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: true,
|
||||
session_secret: secret,
|
||||
is_loopback: false,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(check_ws_auth(&ctx).is_ok());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Issue #1189: WS auth must mirror HTTP middleware. When dashboard auth
|
||||
// is enabled, loopback + empty api_key + no cookie must NOT bypass.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ws_auth_dashboard_on_loopback_empty_key_no_cookie_rejected() {
|
||||
// Issue #1189 regression guard: previously this returned Ok(()) because
|
||||
// the empty-api_key branch allowed any loopback request through, even
|
||||
// when dashboard credentials were configured. HTTP middleware rejects
|
||||
// this path; WS must too.
|
||||
let secret = "password-hash-style-secret";
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: true,
|
||||
session_secret: secret,
|
||||
is_loopback: true,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert_eq!(
|
||||
check_ws_auth(&ctx).unwrap_err(),
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
"loopback must not bypass dashboard auth when api_key is empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_dashboard_on_loopback_valid_cookie_accepted() {
|
||||
// With dashboard auth on, a valid session cookie is the supported
|
||||
// credential and must upgrade successfully from loopback too.
|
||||
let secret = "password-hash-style-secret";
|
||||
let token = crate::session_auth::create_session_token("admin", secret, 1);
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
format!("openfang_session={token}").parse().unwrap(),
|
||||
);
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: true,
|
||||
session_secret: secret,
|
||||
is_loopback: true,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(
|
||||
check_ws_auth(&ctx).is_ok(),
|
||||
"valid session cookie should authorize loopback WS upgrade"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_auth_dashboard_off_loopback_empty_key_accepted() {
|
||||
// Preserve the development convenience path: when dashboard auth is
|
||||
// NOT configured AND api_key is empty, loopback still upgrades.
|
||||
let headers = axum::http::HeaderMap::new();
|
||||
let uri = empty_uri();
|
||||
let ctx = WsAuthCtx {
|
||||
api_key: "",
|
||||
auth_enabled: false,
|
||||
session_secret: "",
|
||||
is_loopback: true,
|
||||
allow_no_auth: false,
|
||||
headers: &headers,
|
||||
uri: &uri,
|
||||
};
|
||||
assert!(
|
||||
check_ws_auth(&ctx).is_ok(),
|
||||
"loopback dev path must work when dashboard auth is disabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: var(--shadow-xs), var(--shadow-inset);
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-dim); box-shadow: var(--shadow-sm), var(--shadow-accent); transform: translateY(-1px); }
|
||||
@@ -312,6 +312,12 @@ tr:hover td { background: var(--surface2); }
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
/* Issue #1026: live indicator for agents currently calling the LLM */
|
||||
@keyframes agent-inferencing-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 var(--accent); }
|
||||
50% { transform: scale(1.25); opacity: 0.85; box-shadow: 0 0 0 4px rgba(255, 92, 0, 0); }
|
||||
}
|
||||
|
||||
.message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
@@ -538,7 +544,7 @@ tr:hover td { background: var(--surface2); }
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
@@ -647,6 +653,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;
|
||||
@@ -859,7 +870,7 @@ mark.search-highlight {
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1274,8 +1285,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; }
|
||||
@@ -1283,6 +1297,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; }
|
||||
@@ -1985,7 +2000,7 @@ mark.search-highlight {
|
||||
}
|
||||
|
||||
.filter-pill:hover { border-color: var(--accent); color: var(--text); }
|
||||
.filter-pill.active { background: var(--accent); color: var(--bg-primary); border-color: var(--accent); }
|
||||
.filter-pill.active { background: var(--accent); color: var(--text-on-accent); border-color: var(--accent); }
|
||||
|
||||
/* ── Difficulty badges ── */
|
||||
.difficulty-badge {
|
||||
@@ -2249,7 +2264,7 @@ mark.search-highlight {
|
||||
.wizard-progress-step.wiz-active .wizard-progress-circle {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
}
|
||||
|
||||
@@ -2476,7 +2491,7 @@ mark.search-highlight {
|
||||
/* ── Try-It Mini Chat ── */
|
||||
.tryit-messages { max-height: 200px; overflow-y: auto; margin: 12px 0; }
|
||||
.tryit-msg { padding: 6px 10px; border-radius: 6px; margin: 4px 0; font-size: 12px; line-height: 1.5; word-break: break-word; }
|
||||
.tryit-msg-user { background: var(--accent); color: var(--bg-primary); margin-left: 40px; }
|
||||
.tryit-msg-user { background: var(--accent); color: var(--text-on-accent); margin-left: 40px; }
|
||||
.tryit-msg-agent { background: var(--surface2); margin-right: 40px; }
|
||||
|
||||
/* ── Suggested Message Chips ── */
|
||||
@@ -2494,7 +2509,7 @@ mark.search-highlight {
|
||||
.channel-steps { display: flex; align-items: center; gap: 0; margin-bottom: 20px; }
|
||||
.channel-step-item { display: flex; align-items: center; gap: 6px; flex: 1; }
|
||||
.channel-step-num { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; border: 2px solid var(--border); color: var(--text-dim); flex-shrink: 0; transition: all 0.2s; }
|
||||
.channel-step-num.active { border-color: var(--accent); background: var(--accent); color: var(--bg-primary); }
|
||||
.channel-step-num.active { border-color: var(--accent); background: var(--accent); color: var(--text-on-accent); }
|
||||
.channel-step-num.done { border-color: var(--success); background: var(--success); color: #000; }
|
||||
.channel-step-label { font-size: 11px; color: var(--text-dim); }
|
||||
.channel-step-label.active { color: var(--accent); font-weight: 600; }
|
||||
@@ -2511,7 +2526,7 @@ mark.search-highlight {
|
||||
.wizard-category-pills { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.wizard-category-pill { padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 600; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--text-dim); transition: all 0.15s; font-family: var(--font-mono); }
|
||||
.wizard-category-pill:hover { border-color: var(--accent); color: var(--text); }
|
||||
.wizard-category-pill.active { background: var(--accent); color: var(--bg-primary); border-color: var(--accent); }
|
||||
.wizard-category-pill.active { background: var(--accent); color: var(--text-on-accent); border-color: var(--accent); }
|
||||
|
||||
/* ── Capability Preview Panel ── */
|
||||
.capability-preview { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px; margin-top: 12px; }
|
||||
@@ -2617,7 +2632,7 @@ mark.search-highlight {
|
||||
.personality-pill.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: 0 0 12px var(--accent-subtle);
|
||||
}
|
||||
|
||||
@@ -2684,7 +2699,7 @@ mark.search-highlight {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.nav-section-chevron {
|
||||
font-size: 8px;
|
||||
font-size: 16px;
|
||||
transition: transform var(--transition-fast);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -2790,7 +2805,7 @@ mark.search-highlight {
|
||||
.hand-step-item.active .hand-step-num {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.hand-step-item.done .hand-step-num {
|
||||
border-color: var(--success);
|
||||
@@ -3244,7 +3259,7 @@ mark.search-highlight {
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.trader-dashboard {
|
||||
background: var(--bg-card);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 96vw;
|
||||
@@ -3261,7 +3276,7 @@ mark.search-highlight {
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-card);
|
||||
background: var(--surface);
|
||||
z-index: 10;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
@@ -3321,6 +3336,7 @@ mark.search-highlight {
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
.trader-chart-title {
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* OpenFang Layout — Grid + Sidebar + Responsive */
|
||||
|
||||
/* Firefox compat: hide x-cloak elements until Alpine.js initializes.
|
||||
Without this, the sidebar flashes hidden in Firefox while Alpine
|
||||
processes the nested x-data scopes for nav sections. */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
@@ -120,11 +125,11 @@
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
font-size: 9px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-muted);
|
||||
padding: 12px 12px 4px;
|
||||
padding: 12px 12px 6px 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -154,7 +159,7 @@
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-sm), 0 2px 8px rgba(255, 92, 0, 0.2);
|
||||
}
|
||||
@@ -243,6 +248,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)); }
|
||||
@@ -277,6 +290,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) {
|
||||
@@ -285,7 +300,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%; }
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
/* Chat-specific */
|
||||
--agent-bg: #F5F4F2;
|
||||
--user-bg: #FFF3E6;
|
||||
--text-on-accent: #FFFFFF;
|
||||
|
||||
/* Layout */
|
||||
--sidebar-width: 240px;
|
||||
@@ -91,16 +92,17 @@
|
||||
--bg: #080706;
|
||||
--bg-primary: #0F0E0E;
|
||||
--bg-elevated: #161413;
|
||||
--surface: #1F1D1C;
|
||||
--surface2: #2A2725;
|
||||
--surface: #242221;
|
||||
--surface2: #2F2D2C;
|
||||
--surface3: #1A1817;
|
||||
--border: #2D2A28;
|
||||
--border-light: #3D3A38;
|
||||
--border-subtle: #232120;
|
||||
--text: #F0EFEE;
|
||||
--text-secondary: #C4C0BC;
|
||||
--text-dim: #8A8380;
|
||||
--text-muted: #5C5754;
|
||||
--border: #363230;
|
||||
--border-light: #4A4644;
|
||||
--border-subtle: #2D2A28;
|
||||
--text: #FFFFFF;
|
||||
--text-secondary: #D1D1D1;
|
||||
--text-dim: #9FA0A0;
|
||||
--text-muted: #6B6663;
|
||||
--text-on-accent: #FFFFFF;
|
||||
--accent: #FF5C00;
|
||||
--accent-light: #FF7A2E;
|
||||
--accent-dim: #E05200;
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
{
|
||||
"app.name": "OpenFang",
|
||||
"app.version": "v",
|
||||
|
||||
"nav.chat": "Chat",
|
||||
"nav.monitor": "Monitor",
|
||||
"nav.overview": "Overview",
|
||||
"nav.analytics": "Analytics",
|
||||
"nav.logs": "Logs",
|
||||
"nav.agents": "Agents",
|
||||
"nav.sessions": "Sessions",
|
||||
"nav.approvals": "Approvals",
|
||||
"nav.comms": "Comms",
|
||||
"nav.automation": "Automation",
|
||||
"nav.workflows": "Workflows",
|
||||
"nav.scheduler": "Scheduler",
|
||||
"nav.extensions": "Extensions",
|
||||
"nav.channels": "Channels",
|
||||
"nav.skills": "Skills",
|
||||
"nav.hands": "Hands",
|
||||
"nav.system": "System",
|
||||
"nav.runtime": "Runtime",
|
||||
"nav.settings": "Settings",
|
||||
|
||||
"auth.sign_in": "Sign In",
|
||||
"auth.enter_credentials": "Enter your dashboard credentials.",
|
||||
"auth.username": "Username",
|
||||
"auth.password": "Password",
|
||||
"auth.api_key_required": "API Key Required",
|
||||
"auth.api_key_desc": "This instance requires an API key. Enter the key from your config.toml.",
|
||||
"auth.api_key_hint": "Add api_key = \"your-key\" at the top of ~/.openfang/config.toml (not under any [section]).",
|
||||
"auth.enter_api_key": "Enter API key...",
|
||||
"auth.unlock_dashboard": "Unlock Dashboard",
|
||||
"auth.login_failed": "Login failed",
|
||||
|
||||
"status.agents_running": "agent(s) running",
|
||||
"status.connecting": "Connecting...",
|
||||
"status.reconnecting": "Reconnecting...",
|
||||
"status.disconnected": "disconnected",
|
||||
"status.ws": "WS",
|
||||
"status.http": "HTTP",
|
||||
"status.ready": "Ready",
|
||||
"status.loading": "Loading...",
|
||||
"status.loading_workflows": "Loading workflows...",
|
||||
"status.loading_channels": "Loading channels...",
|
||||
"status.loading_skills": "Loading skills...",
|
||||
"status.loading_jobs": "Loading scheduled jobs...",
|
||||
"status.loading_triggers": "Loading triggers...",
|
||||
"status.loading_history": "Loading run history...",
|
||||
"status.loading_hands": "Loading hands...",
|
||||
"status.loading_active_hands": "Loading active hands...",
|
||||
"status.loading_mcp": "Loading MCP servers...",
|
||||
"status.loading_files": "Loading files...",
|
||||
"status.loading_skills_details": "Loading skills details...",
|
||||
"status.no_channels_match": "No channels match your search",
|
||||
|
||||
"actions.logout": "Logout",
|
||||
"actions.new_agent": "New Agent",
|
||||
"actions.browse_skills": "Browse Skills",
|
||||
"actions.add_channel": "Add Channel",
|
||||
"actions.create_workflow": "Create Workflow",
|
||||
"actions.settings": "Settings",
|
||||
"actions.create_agent": "Create Agent",
|
||||
"actions.configure_provider": "Configure Provider",
|
||||
"actions.cancel": "Cancel",
|
||||
"actions.confirm": "Confirm",
|
||||
"actions.save": "Save",
|
||||
"actions.delete": "Delete",
|
||||
"actions.edit": "Edit",
|
||||
"actions.clone": "Clone",
|
||||
"actions.stop": "Stop",
|
||||
"actions.run": "Run",
|
||||
"actions.enable": "Enable",
|
||||
"actions.disable": "Disable",
|
||||
"actions.view_all": "View All",
|
||||
"actions.retry": "Retry",
|
||||
"actions.refresh": "Refresh",
|
||||
"actions.approve": "Approve",
|
||||
"actions.reject": "Reject",
|
||||
"actions.update": "Update",
|
||||
"actions.test_connection": "Test Connection",
|
||||
"actions.remove": "Remove",
|
||||
"actions.save_test": "Save & Test",
|
||||
"actions.export_toml": "Export TOML",
|
||||
"actions.save_workflow": "Save Workflow",
|
||||
"actions.auto_layout": "Auto Layout",
|
||||
"actions.clear": "Clear",
|
||||
"actions.zoom_out": "Zoom out",
|
||||
"actions.zoom_in": "Zoom in",
|
||||
"actions.fit": "Fit",
|
||||
"actions.duplicate": "Duplicate",
|
||||
"actions.copy_clipboard": "Copy to Clipboard",
|
||||
"actions.copied": "Copied!",
|
||||
"actions.copy": "Copy",
|
||||
"actions.hide_code": "Hide Code",
|
||||
"actions.view_code": "View Code",
|
||||
"actions.install": "Install",
|
||||
"actions.installing": "Installing...",
|
||||
"actions.installed": "Installed",
|
||||
"actions.load_more": "Load More",
|
||||
"actions.back_to_browse": "Back to browse",
|
||||
"actions.activate": "Activate",
|
||||
"actions.create_schedule": "Create Schedule",
|
||||
"actions.submit": "Submit",
|
||||
"actions.close": "Close",
|
||||
"actions.next": "Next",
|
||||
"actions.back": "Back",
|
||||
"actions.spawn_agent": "Spawn Agent",
|
||||
"actions.spawning": "Spawning...",
|
||||
"actions.create_job": "Create Job",
|
||||
"actions.spawn_wizard": "Wizard",
|
||||
"actions.raw_toml": "Raw TOML",
|
||||
"actions.setup_wizard": "Setup Wizard",
|
||||
"actions.configure_manually": "Configure Manually",
|
||||
"actions.dismiss": "Dismiss",
|
||||
"actions.create_workflow_btn": "Create Workflow",
|
||||
"actions.execute": "Execute",
|
||||
"actions.executing": "Executing...",
|
||||
"actions.generated_toml": "Generated TOML",
|
||||
"actions.running": "Running...",
|
||||
|
||||
"footer.shortcuts": "Ctrl+K agents | Ctrl+N new",
|
||||
|
||||
"theme.light": "Light",
|
||||
"theme.system": "System",
|
||||
"theme.dark": "Dark",
|
||||
|
||||
"errors.connection_error": "Connection Error",
|
||||
"errors.daemon_unreachable": "Cannot reach daemon — is openfang running?",
|
||||
"errors.not_authorized": "Not authorized — check your API key",
|
||||
"errors.permission_denied": "Permission denied",
|
||||
"errors.resource_not_found": "Resource not found",
|
||||
"errors.rate_limited": "Rate limited — slow down and try again",
|
||||
"errors.request_too_large": "Request too large",
|
||||
"errors.server_error": "Server error — check daemon logs",
|
||||
"errors.daemon_unavailable": "Daemon unavailable — is it running?",
|
||||
"errors.unexpected": "Unexpected error",
|
||||
"errors.reconnected": "Reconnected",
|
||||
"errors.connection_lost": "Connection lost, reconnecting...",
|
||||
"errors.switched_http": "Connection lost — switched to HTTP mode",
|
||||
"errors.connection_lost": "Connection lost, reconnecting...",
|
||||
"errors.switched_http": "Connection lost — switched to HTTP mode",
|
||||
"errors.reconnected": "Reconnected",
|
||||
|
||||
"toasts.approval_waiting": "An agent is waiting for approval. Open Approvals to review.",
|
||||
"toasts.agent_created": "Agent Created",
|
||||
"toasts.agent_stopped": "Agent Stopped",
|
||||
"toasts.tool_used": "Tool Used",
|
||||
"toasts.tool_completed": "Tool Completed",
|
||||
"toasts.message_in": "Message In",
|
||||
"toasts.response_sent": "Response Sent",
|
||||
"toasts.session_reset": "Session Reset",
|
||||
"toasts.compacted": "Compacted",
|
||||
"toasts.model_changed": "Model Changed",
|
||||
"toasts.login_attempt": "Login Attempt",
|
||||
"toasts.login_ok": "Login OK",
|
||||
"toasts.login_failed": "Login Failed",
|
||||
"toasts.denied": "Denied",
|
||||
"toasts.rate_limited": "Rate Limited",
|
||||
"toasts.workflow_run": "Workflow Run",
|
||||
"toasts.trigger_fired": "Trigger Fired",
|
||||
"toasts.skill_installed": "Skill Installed",
|
||||
"toasts.mcp_connected": "MCP Connected",
|
||||
"toasts.session_deleted": "Session deleted",
|
||||
|
||||
"overview.welcome": "Welcome to OpenFang",
|
||||
"overview.getting_started": "Getting Started",
|
||||
"overview.setup_wizard": "Setup Wizard",
|
||||
"overview.steps_completed": "of 5 steps completed",
|
||||
"overview.agents_running": "Agents Running",
|
||||
"overview.tokens_used": "Tokens Used",
|
||||
"overview.total_cost": "Total Cost",
|
||||
"overview.uptime": "Uptime",
|
||||
"overview.channels": "Channels",
|
||||
"overview.skills": "Skills",
|
||||
"overview.mcp_servers": "MCP Servers",
|
||||
"overview.tool_calls": "Tool Calls",
|
||||
"overview.providers": "Providers",
|
||||
"overview.recent_activity": "Recent Activity",
|
||||
"overview.no_recent_activity": "No Recent Activity",
|
||||
"overview.chat_with_agent": "Chat with an Agent",
|
||||
"overview.system_health": "System Health",
|
||||
"overview.healthy": "Healthy",
|
||||
"overview.unreachable": "Unreachable",
|
||||
"overview.security_systems": "Security Systems",
|
||||
"overview.llm_providers": "LLM Providers",
|
||||
"overview.defense_active": "9 defense-in-depth systems active",
|
||||
"overview.quick_actions": "Quick Actions",
|
||||
|
||||
"setup.configure_provider": "Configure an LLM provider",
|
||||
"setup.create_first_agent": "Create your first agent",
|
||||
"setup.send_first_message": "Send your first message",
|
||||
"setup.connect_channel": "Connect a messaging channel",
|
||||
"setup.browse_install_skill": "Browse or install a skill",
|
||||
|
||||
"tooltips.cooling_down": "cooling down (rate limited)",
|
||||
"tooltips.circuit_open": "circuit breaker open",
|
||||
"tooltips.ready": "ready",
|
||||
"tooltips.not_configured": "not configured",
|
||||
|
||||
"chat.placeholder": "Message OpenFang... (/ for commands)",
|
||||
"chat.ready": "Ready",
|
||||
"chat.generating": "Generating...",
|
||||
"chat.queued": "queued",
|
||||
"chat.sessions": "Sessions",
|
||||
"chat.new_session": "+ New",
|
||||
"chat.no_sessions": "No sessions",
|
||||
"chat.search_messages": "Search messages...",
|
||||
"chat.select_agent": "Select an agent to start chatting",
|
||||
"chat.recording": "Recording... release to send",
|
||||
"chat.drop_files": "Drop files here",
|
||||
"chat.attach_file": "Attach file",
|
||||
"chat.stop_generating": "Stop generating",
|
||||
"chat.switch_model": "Switch model",
|
||||
"chat.search_models": "Search models...",
|
||||
"chat.no_models_found": "No models found",
|
||||
"chat.available_models": "Available models — pick one or keep typing",
|
||||
"chat.switching": "Switching...",
|
||||
"chat.model_switched": "Switched to",
|
||||
"chat.model_switch_failed": "Model switch failed",
|
||||
"chat.using_http_mode": "Using HTTP mode (no streaming)",
|
||||
"chat.session_name_prompt": "Session name (optional):",
|
||||
"chat.session_created": "Session created",
|
||||
"chat.session_create_failed": "Failed to create session",
|
||||
"chat.stop_agent_title": "Stop Agent",
|
||||
"chat.stop_agent_confirm": "Stop agent",
|
||||
"chat.agent_stopped": "Agent stopped",
|
||||
"chat.stop_agent_failed": "Failed to stop agent",
|
||||
"chat.welcome_message": "**Welcome to OpenFang Chat!**\n\n- Type `/` to see available commands\n- `/help` shows all commands\n- `/think on` enables extended reasoning\n- `/context` shows context window usage\n- `/verbose off` hides tool details\n- `Ctrl+Shift+F` toggles focus mode\n- Drag & drop files to attach them\n- `Ctrl+/` opens the command palette",
|
||||
|
||||
"chat.slash.help": "Show available commands",
|
||||
"chat.slash.agents": "Switch to Agents page",
|
||||
"chat.slash.new": "New session (clear history)",
|
||||
"chat.slash.compact": "Compact session context",
|
||||
"chat.slash.model": "Show or switch model (/model [name])",
|
||||
"chat.slash.stop": "Cancel current agent run",
|
||||
"chat.slash.usage": "Show token usage",
|
||||
"chat.slash.think": "Toggle reasoning (/think [on|off|stream])",
|
||||
"chat.slash.context": "Show context window usage",
|
||||
"chat.slash.verbose": "Toggle tool details (/verbose [off|on|full])",
|
||||
"chat.slash.queue": "Check if agent is processing",
|
||||
"chat.slash.status": "Show system status",
|
||||
"chat.slash.clear": "Clear chat",
|
||||
"chat.slash.exit": "Disconnect from agent",
|
||||
"chat.slash.budget": "Show budget limits and costs",
|
||||
"chat.slash.peers": "Show OFP network status",
|
||||
"chat.slash.a2a": "List A2A agents",
|
||||
|
||||
"commands.help": "Show available commands",
|
||||
"commands.agents": "Switch to Agents page",
|
||||
"commands.new": "Reset session",
|
||||
"commands.switch": "Switch agent",
|
||||
"commands.clear": "Clear conversation",
|
||||
"commands.model": "Switch model",
|
||||
"commands.think": "Toggle reasoning mode",
|
||||
"commands.focus": "Toggle focus mode",
|
||||
"commands.theme": "Cycle theme",
|
||||
|
||||
"tips.commands": "Type / for commands",
|
||||
"tips.think": "/think on for reasoning",
|
||||
"tips.focus": "Ctrl+Shift+F for focus mode",
|
||||
|
||||
"agents.info": "Info",
|
||||
"agents.files": "Files",
|
||||
"agents.config": "Config",
|
||||
"agents.chat": "Chat",
|
||||
"agents.clone": "Clone",
|
||||
"agents.clear_history": "Clear History",
|
||||
"agents.change": "Change",
|
||||
"agents.none_fallback": "None — add a fallback chain",
|
||||
"agents.add": "+ Add",
|
||||
"agents.loading_files": "Loading files...",
|
||||
"agents.no_workspace_files": "No workspace files found",
|
||||
"agents.save_config": "Save Config",
|
||||
"agents.tool_filters": "Tool Filters",
|
||||
"agents.allowlist": "Allowlist",
|
||||
"agents.blocklist": "Blocklist",
|
||||
"agents.agent_name": "Agent Name",
|
||||
"agents.emoji": "Emoji",
|
||||
"agents.color": "Color",
|
||||
"agents.archetype": "Archetype",
|
||||
"agents.provider": "Provider",
|
||||
"agents.model": "Model",
|
||||
"agents.system_prompt": "System Prompt",
|
||||
"agents.soul_persona": "Soul / Persona",
|
||||
"agents.tool_profile": "Tool Profile",
|
||||
"agents.minimal_profile": "Minimal — Read-only file access",
|
||||
"agents.coding_profile": "Coding — Files + shell + web fetch",
|
||||
"agents.fullstack_profile": "Full-Stack — Files + shell + web fetch + search",
|
||||
"agents.research_profile": "Research — Web + search + analysis",
|
||||
"agents.admin_profile": "Admin — Full system access (dangerous)",
|
||||
"agents.agent_created": "Agent Created",
|
||||
"agents.agent_stopped": "Agent Stopped",
|
||||
"agents.agent_deleted": "Agent Deleted",
|
||||
|
||||
"presets.professional": "Professional",
|
||||
"presets.professional_desc": "Precise, business-oriented assistant focused on efficiency and clarity. Prioritizes actionable insights and structured communication.",
|
||||
"presets.professional_soul": "Communicate in a clear, professional tone. Be direct and structured. Use formal language and data-driven reasoning. Prioritize accuracy over personality.",
|
||||
"presets.friendly": "Friendly",
|
||||
"presets.friendly_desc": "Warm and approachable assistant that builds rapport and uses conversational language. Great for brainstorming and exploration.",
|
||||
"presets.friendly_soul": "Be warm, approachable, and conversational. Use casual language and show genuine interest in the user. Add personality to your responses while staying helpful.",
|
||||
"presets.technical": "Technical",
|
||||
"presets.technical_desc": "Expert developer companion optimized for code, architecture, and technical problem-solving. Precise terminology, deep dives, benchmarks.",
|
||||
"presets.technical_soul": "Focus on technical accuracy and depth. Use precise terminology. Show your work and reasoning. Prefer code examples and structured explanations.",
|
||||
"presets.creative": "Creative",
|
||||
"presets.creative_desc": "Imaginative collaborator for content creation, design thinking, and unconventional solutions. Embraces ambiguity and explores possibilities.",
|
||||
"presets.creative_soul": "Be imaginative and expressive. Use vivid language, analogies, and unexpected connections. Encourage creative thinking and explore multiple perspectives.",
|
||||
"presets.concise": "Concise",
|
||||
"presets.concise_desc": "Minimal and direct assistant that respects your time. Cuts through noise to deliver focused, actionable responses.",
|
||||
"presets.concise_soul": "Be extremely brief and to the point. No filler, no pleasantries. Answer in the fewest words possible while remaining accurate and complete.",
|
||||
"presets.mentor": "Mentor",
|
||||
"presets.mentor_desc": "Patient educator that explains concepts thoroughly, provides context, and guides learning. Socratic method when appropriate.",
|
||||
"presets.mentor_soul": "Be patient and encouraging like a great teacher. Break down complex topics step by step. Ask guiding questions. Celebrate progress and build confidence.",
|
||||
|
||||
"agents.profile.minimal": "Minimal",
|
||||
"agents.profile.minimal_desc": "Read-only file access",
|
||||
"agents.profile.coding": "Coding",
|
||||
"agents.profile.coding_desc": "Files + shell + web fetch",
|
||||
"agents.profile.research": "Research",
|
||||
"agents.profile.research_desc": "Web search + file read/write",
|
||||
"agents.profile.messaging": "Messaging",
|
||||
"agents.profile.messaging_desc": "Agents + memory access",
|
||||
"agents.profile.automation": "Automation",
|
||||
"agents.profile.automation_desc": "All tools except custom",
|
||||
"agents.profile.balanced": "Balanced",
|
||||
"agents.profile.balanced_desc": "General-purpose tool set",
|
||||
"agents.profile.precise": "Precise",
|
||||
"agents.profile.precise_desc": "Focused tool set for accuracy",
|
||||
"agents.profile.creative": "Creative",
|
||||
"agents.profile.creative_desc": "Full tools with creative emphasis",
|
||||
"agents.profile.full": "Full",
|
||||
"agents.profile.full_desc": "All 35+ tools",
|
||||
|
||||
"wizard.general_assistant": "General Assistant",
|
||||
"wizard.general_assistant_desc": "You are a versatile AI assistant that helps users with a wide range of tasks. You are knowledgeable, helpful, and able to adapt to the user's needs.",
|
||||
"wizard.code_helper": "Code Helper",
|
||||
"wizard.code_helper_desc": "You are an expert programming assistant specialized in software development. You help write, debug, and refactor code across multiple languages.",
|
||||
"wizard.researcher": "Research Assistant",
|
||||
"wizard.researcher_desc": "You are a research assistant that helps users find, analyze, and synthesize information from various sources.",
|
||||
"wizard.writer": "Writer",
|
||||
"wizard.writer_desc": "You are a skilled writer that helps with content creation, editing, and creative writing projects.",
|
||||
"wizard.data_analyst": "Data Analyst",
|
||||
"wizard.data_analyst_desc": "You are a data analyst that helps explore, analyze, and visualize data to extract insights.",
|
||||
"wizard.devops": "DevOps Engineer",
|
||||
"wizard.devops_desc": "You are a DevOps engineer that helps with infrastructure, deployment, CI/CD, and system administration.",
|
||||
"wizard.support": "Customer Support",
|
||||
"wizard.support_desc": "You are a customer support representative that helps resolve inquiries with patience and professionalism.",
|
||||
"wizard.tutor": "Tutor",
|
||||
"wizard.tutor_desc": "You are an educational tutor that explains concepts clearly and adapts teaching to the student's level.",
|
||||
"wizard.api_designer": "API Designer",
|
||||
"wizard.api_designer_desc": "You are an API designer that helps create well-structured, intuitive APIs following best practices.",
|
||||
"wizard.meeting_notes": "Meeting Notes",
|
||||
"wizard.meeting_notes_desc": "You are a meeting notes specialist that summarizes discussions, extracts action items, and tracks decisions.",
|
||||
|
||||
"wizard.step_welcome": "Welcome",
|
||||
"wizard.step_provider": "Provider",
|
||||
"wizard.step_agent": "Agent",
|
||||
"wizard.step_try_it": "Try It",
|
||||
"wizard.step_channel": "Channel",
|
||||
"wizard.step_done": "Done",
|
||||
|
||||
"wizard.cat_general": "General",
|
||||
"wizard.cat_development": "Development",
|
||||
"wizard.cat_research": "Research",
|
||||
"wizard.cat_writing": "Writing",
|
||||
"wizard.cat_business": "Business",
|
||||
|
||||
"wizard.channel_telegram": "Telegram",
|
||||
"wizard.channel_telegram_desc": "Connect your agent to a Telegram bot for messaging.",
|
||||
"wizard.channel_telegram_token": "Bot Token",
|
||||
"wizard.channel_telegram_help": "Create a bot via @BotFather on Telegram to get your token.",
|
||||
"wizard.channel_discord": "Discord",
|
||||
"wizard.channel_discord_desc": "Connect your agent to a Discord server via bot token.",
|
||||
"wizard.channel_discord_token": "Bot Token",
|
||||
"wizard.channel_discord_help": "Create a Discord application at discord.com/developers and add a bot.",
|
||||
"wizard.channel_slack": "Slack",
|
||||
"wizard.channel_slack_desc": "Connect your agent to a Slack workspace.",
|
||||
"wizard.channel_slack_token": "Bot Token",
|
||||
"wizard.channel_slack_help": "Create a Slack app at api.slack.com/apps and install it to your workspace.",
|
||||
|
||||
"wizard.profile_minimal": "Minimal",
|
||||
"wizard.profile_minimal_desc": "Read-only file access",
|
||||
"wizard.profile_coding": "Coding",
|
||||
"wizard.profile_coding_desc": "Files + shell + web fetch",
|
||||
"wizard.profile_research": "Research",
|
||||
"wizard.profile_research_desc": "Web search + file read/write",
|
||||
"wizard.profile_balanced": "Balanced",
|
||||
"wizard.profile_balanced_desc": "General-purpose tool set",
|
||||
"wizard.profile_precise": "Precise",
|
||||
"wizard.profile_precise_desc": "Focused tool set for accuracy",
|
||||
"wizard.profile_creative": "Creative",
|
||||
"wizard.profile_creative_desc": "Full tools with creative emphasis",
|
||||
"wizard.profile_full": "Full",
|
||||
"wizard.profile_full_desc": "All 35+ tools",
|
||||
|
||||
"wizard.enter_api_key": "Please enter an API key",
|
||||
"wizard.api_key_saved": "API key saved for",
|
||||
"wizard.failed_save_key": "Failed to save key:",
|
||||
"wizard.connected": "connected",
|
||||
"wizard.connection_failed": "Connection failed",
|
||||
"wizard.test_failed": "Test failed:",
|
||||
"wizard.enter_agent_name": "Please enter a name for your agent",
|
||||
"wizard.agent_created": "Agent created",
|
||||
"wizard.failed_create_agent": "Failed to create agent:",
|
||||
"wizard.enter_token": "Please enter the",
|
||||
"wizard.channel_configured": "configured and activated.",
|
||||
"wizard.failed_configure": "Failed:",
|
||||
|
||||
"wizard.suggestions.general.1": "What can you help me with?",
|
||||
"wizard.suggestions.general.2": "Tell me a fun fact",
|
||||
"wizard.suggestions.general.3": "Summarize the latest AI news",
|
||||
"wizard.suggestions.development.1": "Write a Python hello world",
|
||||
"wizard.suggestions.development.2": "Explain async/await",
|
||||
"wizard.suggestions.development.3": "Review this code snippet",
|
||||
"wizard.suggestions.research.1": "Explain quantum computing simply",
|
||||
"wizard.suggestions.research.2": "Compare React vs Vue",
|
||||
"wizard.suggestions.research.3": "What are the latest trends in AI?",
|
||||
"wizard.suggestions.writing.1": "Help me write a professional email",
|
||||
"wizard.suggestions.writing.2": "Improve this paragraph",
|
||||
"wizard.suggestions.writing.3": "Write a blog intro about AI",
|
||||
"wizard.suggestions.business.1": "Draft a meeting agenda",
|
||||
"wizard.suggestions.business.2": "How do I handle a complaint?",
|
||||
"wizard.suggestions.business.3": "Create a project status update",
|
||||
|
||||
"approvals.title": "Execution Approvals",
|
||||
"approvals.pending": "pending",
|
||||
"approvals.all": "All",
|
||||
"approvals.pending_tab": "Pending",
|
||||
"approvals.approved": "Approved",
|
||||
"approvals.rejected": "Rejected",
|
||||
"approvals.expired": "Expired",
|
||||
"approvals.no_approvals": "No approvals",
|
||||
"approvals.approve": "Approve",
|
||||
"approvals.reject": "Reject",
|
||||
|
||||
"workflows.title": "Workflows",
|
||||
"workflows.visual_builder": "Visual Builder",
|
||||
"workflows.what_are": "What are Workflows?",
|
||||
"workflows.no_workflows": "No workflows yet",
|
||||
"workflows.sequential": "Sequential",
|
||||
"workflows.fan_out": "Fan Out",
|
||||
"workflows.conditional": "Conditional",
|
||||
"workflows.loop": "Loop",
|
||||
"workflows.add_step": "+ Add Step",
|
||||
"workflows.execute": "Execute",
|
||||
"workflows.result": "Result",
|
||||
"workflows.node_palette": "Node Palette",
|
||||
"workflows.drag_nodes": "Drag nodes onto the canvas",
|
||||
"workflows.steps_connections": "steps, connections",
|
||||
"workflows.agent": "Agent",
|
||||
"workflows.prompt_template": "Prompt Template",
|
||||
"workflows.expression": "Expression",
|
||||
"workflows.top_port_true": "Top port = true, bottom port = false",
|
||||
"workflows.max_iterations": "Max Iterations",
|
||||
"workflows.until_stop": "Until (stop condition)",
|
||||
"workflows.fan_out_count": "Fan-out Count",
|
||||
"workflows.wait_all": "Wait for all",
|
||||
"workflows.first_finish": "First to finish",
|
||||
"workflows.majority_vote": "Majority vote",
|
||||
"workflows.connection_selected": "Connection selected",
|
||||
"workflows.delete_connection": "Delete Connection",
|
||||
|
||||
"scheduler.title": "Scheduler",
|
||||
"scheduler.scheduled_jobs": "Scheduled Jobs",
|
||||
"scheduler.event_triggers": "Event Triggers",
|
||||
"scheduler.run_history": "Run History",
|
||||
"scheduler.new_job": "+ New Job",
|
||||
"scheduler.job_name": "Job Name",
|
||||
"scheduler.cron_expression": "Cron Expression",
|
||||
"scheduler.quick_presets": "Quick Presets",
|
||||
"scheduler.target_agent": "Target Agent",
|
||||
"scheduler.any_agent": "Any available agent",
|
||||
"scheduler.message_send": "Message to Send",
|
||||
"scheduler.enabled": "Enabled (will start running immediately)",
|
||||
"scheduler.disabled": "Disabled (create paused)",
|
||||
"scheduler.active": "Active",
|
||||
"scheduler.paused": "Paused",
|
||||
"scheduler.cron_job": "Cron Job",
|
||||
"scheduler.trigger": "Trigger",
|
||||
"scheduler.no_jobs": "No scheduled jobs",
|
||||
"scheduler.no_triggers": "No event triggers",
|
||||
"scheduler.no_history": "No run history yet",
|
||||
|
||||
"channels.title": "Channels",
|
||||
"channels.configured": "configured",
|
||||
"channels.search": "Search channels...",
|
||||
"channels.setup": "Set up",
|
||||
"channels.edit": "Edit",
|
||||
"channels.configure": "Configure",
|
||||
"channels.verify": "Verify",
|
||||
"channels.ready": "Ready",
|
||||
"channels.is_ready": "is ready!",
|
||||
"channels.get_credentials": "How to get credentials",
|
||||
"channels.show_advanced": "Show advanced",
|
||||
"channels.hide_advanced": "Hide advanced",
|
||||
"channels.connecting": "Connecting to WhatsApp Web gateway...",
|
||||
"channels.linked_success": "WhatsApp linked successfully!",
|
||||
"channels.business_api": "Business API",
|
||||
|
||||
"skills.title": "Skills & Ecosystem",
|
||||
"skills.installed": "Installed",
|
||||
"skills.clawhub": "ClawHub",
|
||||
"skills.mcp_servers": "MCP Servers",
|
||||
"skills.quick_start": "Quick Start",
|
||||
"skills.no_installed": "No skills installed",
|
||||
"skills.browse_clawhub": "Browse ClawHub",
|
||||
"skills.search_clawhub": "Search ClawHub skills...",
|
||||
"skills.trending": "Trending",
|
||||
"skills.most_downloaded": "Most Downloaded",
|
||||
"skills.most_starred": "Most Starred",
|
||||
"skills.recently_updated": "Recently Updated",
|
||||
"skills.categories": "CATEGORIES",
|
||||
"skills.already_installed": "Already Installed",
|
||||
"skills.no_skills_found": "No skills found",
|
||||
"skills.security_warnings": "Security Warnings",
|
||||
"skills.security_scan": "Skills are security-scanned before installation",
|
||||
"skills.create": "Create Skill",
|
||||
"skills.created": "Created",
|
||||
|
||||
"skills.cat_coding": "Coding & IDEs",
|
||||
"skills.cat_git": "Git & GitHub",
|
||||
"skills.cat_frontend": "Web & Frontend",
|
||||
"skills.cat_devops": "DevOps & Cloud",
|
||||
"skills.cat_database": "Database",
|
||||
"skills.cat_security": "Security",
|
||||
"skills.cat_ai": "AI & ML",
|
||||
"skills.cat_data": "Data & Analytics",
|
||||
"skills.cat_mobile": "Mobile",
|
||||
"skills.cat_desktop": "Desktop Apps",
|
||||
"skills.cat_api": "API & Integrations",
|
||||
"skills.cat_testing": "Testing",
|
||||
"skills.cat_docs": "Documentation",
|
||||
"skills.cat_productivity": "Productivity",
|
||||
"skills.cat_other": "Other",
|
||||
|
||||
"skills.cat_browser": "Browser & Automation",
|
||||
"skills.cat_search": "Search & Research",
|
||||
"skills.cat_communication": "Communication",
|
||||
"skills.cat_media": "Media & Streaming",
|
||||
"skills.cat_notes": "Notes & PKM",
|
||||
"skills.cat_cli": "CLI Utilities",
|
||||
"skills.cat_marketing": "Marketing & Sales",
|
||||
"skills.cat_finance": "Finance",
|
||||
"skills.cat_smarthome": "Smart Home & IoT",
|
||||
|
||||
"skills.uninstall_skill": "Uninstall Skill",
|
||||
"skills.uninstall_confirm": "Uninstall skill",
|
||||
|
||||
"skills.source_clawhub": "ClawHub",
|
||||
"skills.source_openclaw": "OpenClaw",
|
||||
"skills.source_builtin": "Built-in",
|
||||
"skills.source_local": "Local",
|
||||
|
||||
"hands.title": "Hands — Curated Autonomous Capability Packages",
|
||||
"hands.available": "Available",
|
||||
"hands.active": "Active",
|
||||
"hands.ready": "Ready",
|
||||
"hands.setup_needed": "Setup needed",
|
||||
"hands.requirements": "REQUIREMENTS",
|
||||
"hands.details": "Details",
|
||||
"hands.no_hands": "No hands available",
|
||||
|
||||
"sessions.title": "Sessions",
|
||||
"sessions.memory": "Memory",
|
||||
"sessions.delete_session": "Delete Session",
|
||||
"sessions.delete_confirm": "This will permanently remove the session and its messages.",
|
||||
"sessions.delete_key": "Delete Key",
|
||||
"sessions.delete_key_confirm": "Delete key",
|
||||
|
||||
"logs.title": "Logs",
|
||||
"logs.live": "Live",
|
||||
"logs.audit_trail": "Audit Trail",
|
||||
|
||||
"settings.title": "Settings",
|
||||
"settings.providers": "Providers",
|
||||
"settings.models": "Models",
|
||||
"settings.config": "Config",
|
||||
"settings.tools": "Tools",
|
||||
"settings.migration": "Migration",
|
||||
"settings.security": "Security",
|
||||
"settings.network": "Network",
|
||||
"settings.migration": "Migration",
|
||||
"settings.language": "Language",
|
||||
|
||||
"settings.sec_path_traversal": "Path Traversal Prevention",
|
||||
"settings.sec_path_traversal_desc": "Blocks attempts to access files outside the workspace directory using .. or absolute paths.",
|
||||
"settings.sec_ssrf": "SSRF Protection",
|
||||
"settings.sec_ssrf_desc": "Prevents agents from making requests to internal IP ranges (localhost, cloud metadata, private networks).",
|
||||
"settings.sec_capability": "Capability-Based Access Control",
|
||||
"settings.sec_capability_desc": "Agents can only access explicitly granted capabilities. No implicit access to tools or data.",
|
||||
"settings.sec_taint": "Taint Tracking",
|
||||
"settings.sec_taint_desc": "Tracks untrusted data (user input, file content) through agent reasoning to prevent prompt injection.",
|
||||
"settings.sec_sandbox": "WASM Sandbox",
|
||||
"settings.sec_sandbox_desc": "Executes untrusted code in isolated WebAssembly sandboxes with memory and syscall restrictions.",
|
||||
"settings.sec_audit": "Merkle Audit",
|
||||
"settings.sec_audit_desc": "Maintains a verifiable audit log of all agent actions using Merkle tree cryptography.",
|
||||
"settings.sec_workspace": "Workspace Isolation",
|
||||
"settings.sec_workspace_desc": "Each agent has an isolated workspace directory. No cross-agent file access unless explicitly granted.",
|
||||
"settings.sec_rate_limit": "Rate Limiting",
|
||||
"settings.sec_rate_limit_desc": "Enforces per-agent and global rate limits to prevent resource exhaustion and cost overruns.",
|
||||
"settings.sec_approval": "Execution Approvals",
|
||||
"settings.sec_approval_desc": "Requires human approval for high-risk actions (shell commands, file writes, external requests).",
|
||||
|
||||
"settings.sec_enabled": "Enabled",
|
||||
"settings.sec_disabled": "Disabled",
|
||||
"settings.sec_inherited": "Inherited",
|
||||
"settings.sec_global": "Global"
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* OpenFang i18n (Internationalization) Module
|
||||
*
|
||||
* Provides runtime language switching for the OpenFang dashboard UI.
|
||||
* Supports English (default) and Russian.
|
||||
*
|
||||
* Usage:
|
||||
* - HTML: <span data-i18n="nav.overview">Overview</span>
|
||||
* - JS: window.t('nav.overview')
|
||||
* - Auto-applies translations on load based on stored/preferred language
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Language store
|
||||
let currentLang = 'en';
|
||||
let translations = {};
|
||||
let isInitialized = false;
|
||||
|
||||
/**
|
||||
* Load translations from a JSON file
|
||||
* @param {string} lang - Language code (en, ru)
|
||||
* @returns {Promise<Object>} Translation object
|
||||
*/
|
||||
async function loadTranslations(lang) {
|
||||
try {
|
||||
// Use cached translations if available
|
||||
if (window.__i18nCache && window.__i18nCache[lang]) {
|
||||
return window.__i18nCache[lang];
|
||||
}
|
||||
|
||||
const response = await fetch(`/i18n/${lang}.json`);
|
||||
if (!response.ok) {
|
||||
console.warn(`[i18n] Failed to load ${lang}.json, falling back to en`);
|
||||
if (lang !== 'en') {
|
||||
return loadTranslations('en');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Cache for future use
|
||||
if (!window.__i18nCache) window.__i18nCache = {};
|
||||
window.__i18nCache[lang] = data;
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`[i18n] Error loading translations for ${lang}:`, error);
|
||||
if (lang !== 'en') {
|
||||
return loadTranslations('en');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a translated string by key
|
||||
* @param {string} key - Translation key (e.g., 'nav.overview')
|
||||
* @param {Object} params - Optional interpolation parameters
|
||||
* @returns {string} Translated string or key if not found
|
||||
*/
|
||||
function t(key, params) {
|
||||
if (!isInitialized) {
|
||||
console.warn('[i18n] Not initialized, returning key');
|
||||
return key;
|
||||
}
|
||||
|
||||
let text = translations[key] || key;
|
||||
|
||||
// Handle interpolation (e.g., 'Hello, {{name}}')
|
||||
if (params && typeof params === 'object') {
|
||||
Object.keys(params).forEach(param => {
|
||||
text = text.replace(new RegExp(`{{${param}}}`, 'g'), params[param]);
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply translations to all elements with data-i18n attribute
|
||||
* Also updates the <html> lang attribute
|
||||
*/
|
||||
function applyTranslations() {
|
||||
// Update document language
|
||||
document.documentElement.lang = currentLang;
|
||||
|
||||
// Find and translate all elements with data-i18n attribute
|
||||
const elements = document.querySelectorAll('[data-i18n]');
|
||||
elements.forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const translation = t(key);
|
||||
|
||||
// Check if element is a form input/textarea
|
||||
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||||
// For form elements, only update if it's a placeholder or aria-label
|
||||
if (el.hasAttribute('placeholder')) {
|
||||
el.placeholder = translation;
|
||||
}
|
||||
if (el.hasAttribute('aria-label')) {
|
||||
el.setAttribute('aria-label', translation);
|
||||
}
|
||||
if (el.hasAttribute('title')) {
|
||||
el.setAttribute('title', translation);
|
||||
}
|
||||
} else {
|
||||
// For regular elements, update text content
|
||||
el.textContent = translation;
|
||||
}
|
||||
});
|
||||
|
||||
// Update elements with data-i18n-* attributes for attributes
|
||||
const attrElements = document.querySelectorAll('[data-i18n-placeholder], [data-i18n-title], [data-i18n-aria-label]');
|
||||
attrElements.forEach(el => {
|
||||
if (el.hasAttribute('data-i18n-placeholder')) {
|
||||
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
|
||||
}
|
||||
if (el.hasAttribute('data-i18n-title')) {
|
||||
el.title = t(el.getAttribute('data-i18n-title'));
|
||||
}
|
||||
if (el.hasAttribute('data-i18n-aria-label')) {
|
||||
el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria-label')));
|
||||
}
|
||||
});
|
||||
|
||||
// Update meta tags
|
||||
const metaDesc = document.querySelector('meta[name="description"]');
|
||||
if (metaDesc) {
|
||||
const desc = t('app.description', { name: 'OpenFang' });
|
||||
if (desc !== 'app.description') {
|
||||
metaDesc.content = desc;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[i18n] Applied translations for language: ${currentLang}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current language and apply translations
|
||||
* @param {string} lang - Language code (en, ru)
|
||||
* @param {boolean} persist - Whether to save to localStorage
|
||||
*/
|
||||
async function setLanguage(lang, persist = true) {
|
||||
if (!['en', 'ru'].includes(lang)) {
|
||||
console.warn(`[i18n] Unknown language: ${lang}, defaulting to en`);
|
||||
lang = 'en';
|
||||
}
|
||||
|
||||
currentLang = lang;
|
||||
translations = await loadTranslations(lang);
|
||||
isInitialized = true;
|
||||
|
||||
// Save preference
|
||||
if (persist) {
|
||||
localStorage.setItem('openfang_language', lang);
|
||||
}
|
||||
|
||||
// Apply to DOM
|
||||
applyTranslations();
|
||||
|
||||
// Dispatch event for Alpine.js components to react
|
||||
window.dispatchEvent(new CustomEvent('i18n:language-changed', {
|
||||
detail: { language: lang }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current language
|
||||
* @returns {string} Current language code
|
||||
*/
|
||||
function getLanguage() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize i18n system
|
||||
* Loads language preference and applies translations
|
||||
*/
|
||||
async function init() {
|
||||
// Determine language priority:
|
||||
// 1. localStorage (user preference)
|
||||
// 2. Browser language
|
||||
// 3. Default to English
|
||||
|
||||
let lang = localStorage.getItem('openfang_language');
|
||||
|
||||
if (!lang) {
|
||||
// Try to detect browser language
|
||||
const browserLang = navigator.language || navigator.userLanguage || '';
|
||||
if (browserLang.startsWith('ru')) {
|
||||
lang = 'ru';
|
||||
} else {
|
||||
lang = 'en';
|
||||
}
|
||||
}
|
||||
|
||||
await setLanguage(lang, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available languages
|
||||
* @returns {Array<{code: string, name: string}>}
|
||||
*/
|
||||
function getAvailableLanguages() {
|
||||
return [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'ru', name: 'Русский' }
|
||||
];
|
||||
}
|
||||
|
||||
// Expose to global scope
|
||||
window.i18n = {
|
||||
t,
|
||||
setLanguage,
|
||||
getLanguage,
|
||||
getAvailableLanguages,
|
||||
init,
|
||||
isInitialized: () => isInitialized
|
||||
};
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,607 @@
|
||||
{
|
||||
"app.name": "OpenFang",
|
||||
"app.version": "v",
|
||||
|
||||
"nav.chat": "Чат",
|
||||
"nav.monitor": "Мониторинг",
|
||||
"nav.overview": "Обзор",
|
||||
"nav.analytics": "Аналитика",
|
||||
"nav.logs": "Логи",
|
||||
"nav.agents": "Агенты",
|
||||
"nav.sessions": "Сессии",
|
||||
"nav.approvals": "Одобрения",
|
||||
"nav.comms": "Коммуникации",
|
||||
"nav.automation": "Автоматизация",
|
||||
"nav.workflows": "Рабочие процессы",
|
||||
"nav.scheduler": "Планировщик",
|
||||
"nav.extensions": "Расширения",
|
||||
"nav.channels": "Каналы",
|
||||
"nav.skills": "Навыки",
|
||||
"nav.hands": "Руки",
|
||||
"nav.system": "Система",
|
||||
"nav.runtime": "Среда выполнения",
|
||||
"nav.settings": "Настройки",
|
||||
|
||||
"auth.sign_in": "Войти",
|
||||
"auth.enter_credentials": "Введите учётные данные панели управления.",
|
||||
"auth.username": "Имя пользователя",
|
||||
"auth.password": "Пароль",
|
||||
"auth.api_key_required": "Требуется API-ключ",
|
||||
"auth.api_key_desc": "Этот экземпляр требует API-ключ. Введите ключ из вашего config.toml.",
|
||||
"auth.api_key_hint": "Добавьте api_key = \"your-key\" в начало ~/.openfang/config.toml (не внутри секции).",
|
||||
"auth.enter_api_key": "Введите API-ключ...",
|
||||
"auth.unlock_dashboard": "Разблокировать панель",
|
||||
"auth.login_failed": "Ошибка входа",
|
||||
|
||||
"status.agents_running": "агент(ов) запущено",
|
||||
"status.connecting": "Подключение...",
|
||||
"status.reconnecting": "Переподключение...",
|
||||
"status.disconnected": "отключено",
|
||||
"status.ws": "ВС",
|
||||
"status.http": "HTTP",
|
||||
"status.ready": "Готово",
|
||||
"status.loading": "Загрузка...",
|
||||
"status.loading_workflows": "Загрузка рабочих процессов...",
|
||||
"status.loading_channels": "Загрузка каналов...",
|
||||
"status.loading_skills": "Загрузка навыков...",
|
||||
"status.loading_jobs": "Загрузка заданий...",
|
||||
"status.loading_triggers": "Загрузка триггеров...",
|
||||
"status.loading_history": "Загрузка истории...",
|
||||
"status.loading_hands": "Загрузка модулей...",
|
||||
"status.loading_active_hands": "Загрузка активных модулей...",
|
||||
"status.loading_mcp": "Загрузка MCP-серверов...",
|
||||
"status.loading_files": "Загрузка файлов...",
|
||||
"status.loading_skills_details": "Загрузка деталей навыков...",
|
||||
"status.no_channels_match": "Нет каналов по запросу",
|
||||
|
||||
"actions.logout": "Выйти",
|
||||
"actions.new_agent": "Новый агент",
|
||||
"actions.browse_skills": "Навыки",
|
||||
"actions.add_channel": "Добавить канал",
|
||||
"actions.create_workflow": "Создать процесс",
|
||||
"actions.settings": "Настройки",
|
||||
"actions.create_agent": "Создать агента",
|
||||
"actions.configure_provider": "Настроить провайдера",
|
||||
"actions.cancel": "Отмена",
|
||||
"actions.confirm": "Подтвердить",
|
||||
"actions.save": "Сохранить",
|
||||
"actions.delete": "Удалить",
|
||||
"actions.edit": "Редактировать",
|
||||
"actions.clone": "Клонировать",
|
||||
"actions.stop": "Остановить",
|
||||
"actions.run": "Запустить",
|
||||
"actions.enable": "Включить",
|
||||
"actions.disable": "Отключить",
|
||||
"actions.view_all": "Показать все",
|
||||
"actions.retry": "Повторить",
|
||||
"actions.refresh": "Обновить",
|
||||
"actions.approve": "Одобрить",
|
||||
"actions.reject": "Отклонить",
|
||||
"actions.update": "Обновить",
|
||||
"actions.test_connection": "Проверить подключение",
|
||||
"actions.remove": "Удалить",
|
||||
"actions.save_test": "Сохранить и проверить",
|
||||
"actions.export_toml": "Экспорт TOML",
|
||||
"actions.save_workflow": "Сохранить процесс",
|
||||
"actions.auto_layout": "Автораскладка",
|
||||
"actions.clear": "Очистить",
|
||||
"actions.zoom_out": "Уменьшить",
|
||||
"actions.zoom_in": "Увеличить",
|
||||
"actions.fit": "По размеру",
|
||||
"actions.duplicate": "Дублировать",
|
||||
"actions.copy_clipboard": "Копировать в буфер",
|
||||
"actions.copied": "Скопировано!",
|
||||
"actions.copy": "Копировать",
|
||||
"actions.hide_code": "Скрыть код",
|
||||
"actions.view_code": "Показать код",
|
||||
"actions.install": "Установить",
|
||||
"actions.installing": "Установка...",
|
||||
"actions.installed": "Установлено",
|
||||
"actions.load_more": "Загрузить ещё",
|
||||
"actions.back_to_browse": "Назад",
|
||||
"actions.activate": "Активировать",
|
||||
"actions.create_schedule": "Создать расписание",
|
||||
"actions.submit": "Отправить",
|
||||
"actions.close": "Закрыть",
|
||||
"actions.next": "Далее",
|
||||
"actions.back": "Назад",
|
||||
"actions.spawn_agent": "Создать агента",
|
||||
"actions.spawning": "Создание...",
|
||||
"actions.spawn_wizard": "Мастер",
|
||||
"actions.raw_toml": "TOML",
|
||||
"actions.setup_wizard": "Мастер настройки",
|
||||
"actions.configure_manually": "Настроить вручную",
|
||||
"actions.dismiss": "Закрыть",
|
||||
"actions.create_workflow_btn": "Создать процесс",
|
||||
"actions.execute": "Выполнить",
|
||||
"actions.executing": "Выполнение...",
|
||||
"actions.generated_toml": "Сгенерированный TOML",
|
||||
"actions.running": "Выполняется...",
|
||||
|
||||
"footer.shortcuts": "Ctrl+K агенты | Ctrl+N новый",
|
||||
|
||||
"theme.light": "Светлая",
|
||||
"theme.system": "Системная",
|
||||
"theme.dark": "Тёмная",
|
||||
|
||||
"errors.connection_error": "Ошибка подключения",
|
||||
"errors.daemon_unreachable": "Не удаётся связаться с демоном — запущен ли openfang?",
|
||||
"errors.not_authorized": "Не авторизован — проверьте API-ключ",
|
||||
"errors.permission_denied": "Доступ запрещён",
|
||||
"errors.resource_not_found": "Ресурс не найден",
|
||||
"errors.rate_limited": "Превышен лимит — подождите и попробуйте снова",
|
||||
"errors.request_too_large": "Запрос слишком большой",
|
||||
"errors.server_error": "Ошибка сервера — проверьте логи демона",
|
||||
"errors.daemon_unavailable": "Демон недоступен — запущен ли он?",
|
||||
"errors.unexpected": "Неожиданная ошибка",
|
||||
"errors.reconnected": "Переподключено",
|
||||
"errors.connection_lost": "Соединение потеряно, переподключение...",
|
||||
"errors.switched_http": "Соединение потеряно — переход на режим HTTP",
|
||||
"errors.connection_lost": "Соединение потеряно, переподключение...",
|
||||
"errors.switched_http": "Соединение потеряно — переход на режим HTTP",
|
||||
"errors.reconnected": "Переподключено",
|
||||
|
||||
"toasts.approval_waiting": "Агент ожидает одобрения. Откройте раздел Одобрения.",
|
||||
"toasts.agent_created": "Агент создан",
|
||||
"toasts.agent_stopped": "Агент остановлен",
|
||||
"toasts.tool_used": "Инструмент использован",
|
||||
"toasts.tool_completed": "Инструмент завершён",
|
||||
"toasts.message_in": "Входящее сообщение",
|
||||
"toasts.response_sent": "Ответ отправлен",
|
||||
"toasts.session_reset": "Сессия сброшена",
|
||||
"toasts.compacted": "Сжато",
|
||||
"toasts.model_changed": "Модель изменена",
|
||||
"toasts.login_attempt": "Попытка входа",
|
||||
"toasts.login_ok": "Вход успешен",
|
||||
"toasts.login_failed": "Ошибка входа",
|
||||
"toasts.denied": "Отклонено",
|
||||
"toasts.rate_limited": "Лимит запросов",
|
||||
"toasts.workflow_run": "Запуск процесса",
|
||||
"toasts.trigger_fired": "Триггер сработал",
|
||||
"toasts.skill_installed": "Навык установлен",
|
||||
"toasts.mcp_connected": "MCP подключён",
|
||||
"toasts.session_deleted": "Сессия удалена",
|
||||
|
||||
"overview.welcome": "Добро пожаловать в OpenFang",
|
||||
"overview.getting_started": "Начало работы",
|
||||
"overview.setup_wizard": "Мастер настройки",
|
||||
"overview.steps_completed": "из 5 шагов выполнено",
|
||||
"overview.agents_running": "Агентов запущено",
|
||||
"overview.tokens_used": "Использовано токенов",
|
||||
"overview.total_cost": "Общая стоимость",
|
||||
"overview.uptime": "Время работы",
|
||||
"overview.channels": "Каналы",
|
||||
"overview.skills": "Навыки",
|
||||
"overview.mcp_servers": "MCP-серверы",
|
||||
"overview.tool_calls": "Вызовов инструментов",
|
||||
"overview.providers": "Провайдеры",
|
||||
"overview.recent_activity": "Недавняя активность",
|
||||
"overview.no_recent_activity": "Нет недавней активности",
|
||||
"overview.chat_with_agent": "Написать агенту",
|
||||
"overview.system_health": "Состояние системы",
|
||||
"overview.healthy": "Исправно",
|
||||
"overview.unreachable": "Недоступно",
|
||||
"overview.security_systems": "Системы безопасности",
|
||||
"overview.llm_providers": "LLM-провайдеры",
|
||||
"overview.defense_active": "9 уровней защиты активно",
|
||||
"overview.quick_actions": "Быстрые действия",
|
||||
|
||||
"setup.configure_provider": "Настройте LLM-провайдера",
|
||||
"setup.create_first_agent": "Создайте первого агента",
|
||||
"setup.send_first_message": "Отправьте первое сообщение",
|
||||
"setup.connect_channel": "Подключите канал связи",
|
||||
"setup.browse_install_skill": "Найдите или установите навык",
|
||||
|
||||
"tooltips.cooling_down": "остывает (лимит запросов)",
|
||||
"tooltips.circuit_open": "автомат сработал",
|
||||
"tooltips.ready": "готово",
|
||||
"tooltips.not_configured": "не настроено",
|
||||
|
||||
"chat.placeholder": "Напишите OpenFang... (/ для команд)",
|
||||
"chat.ready": "Готово",
|
||||
"chat.generating": "Генерация...",
|
||||
"chat.queued": "в очереди",
|
||||
"chat.sessions": "Сессии",
|
||||
"chat.new_session": "+ Новая",
|
||||
"chat.no_sessions": "Нет сессий",
|
||||
"chat.search_messages": "Поиск сообщений...",
|
||||
"chat.select_agent": "Выберите агента для начала общения",
|
||||
"chat.recording": "Запись... отпустите для отправки",
|
||||
"chat.drop_files": "Перетащите файлы сюда",
|
||||
"chat.attach_file": "Прикрепить файл",
|
||||
"chat.stop_generating": "Остановить генерацию",
|
||||
"chat.switch_model": "Сменить модель",
|
||||
"chat.search_models": "Поиск моделей...",
|
||||
"chat.no_models_found": "Модели не найдены",
|
||||
"chat.available_models": "Доступные модели — выберите или продолжите ввод",
|
||||
"chat.switching": "Переключение...",
|
||||
"chat.model_switched": "Модель изменена на",
|
||||
"chat.model_switch_failed": "Не удалось сменить модель",
|
||||
"chat.using_http_mode": "Используется HTTP режим (без потоковой передачи)",
|
||||
"chat.session_name_prompt": "Название сессии (необязательно):",
|
||||
"chat.session_created": "Сессия создана",
|
||||
"chat.session_create_failed": "Не удалось создать сессию",
|
||||
"chat.stop_agent_title": "Остановить агента",
|
||||
"chat.stop_agent_confirm": "Остановить агента",
|
||||
"chat.agent_stopped": "Агент остановлен",
|
||||
"chat.stop_agent_failed": "Не удалось остановить агента",
|
||||
"chat.welcome_message": "**Добро пожаловать в OpenFang Чат!**\n\n- Введите `/` для просмотра команд\n- `/help` покажет все команды\n- `/think on` включает расширенные размышления\n- `/context` покажет использование контекста\n- `/verbose off` скроет детали инструментов\n- `Ctrl+Shift+F` переключает режим фокуса\n- Перетащите файлы для прикрепления\n- `Ctrl+/` открывает палитру команд",
|
||||
|
||||
"chat.slash.help": "Показать доступные команды",
|
||||
"chat.slash.agents": "Перейти на страницу агентов",
|
||||
"chat.slash.new": "Новая сессия (очистить историю)",
|
||||
"chat.slash.compact": "Сжать контекст сессии",
|
||||
"chat.slash.model": "Показать или сменить модель (/model [имя])",
|
||||
"chat.slash.stop": "Отменить текущий запуск агента",
|
||||
"chat.slash.usage": "Показать использование токенов",
|
||||
"chat.slash.think": "Переключить размышления (/think [on|off|stream])",
|
||||
"chat.slash.context": "Показать использование контекста",
|
||||
"chat.slash.verbose": "Переключить детали инструментов (/verbose [off|on|full])",
|
||||
"chat.slash.queue": "Проверить очередь обработки",
|
||||
"chat.slash.status": "Показать статус системы",
|
||||
"chat.slash.clear": "Очистить чат",
|
||||
"chat.slash.exit": "Отключиться от агента",
|
||||
"chat.slash.budget": "Показать лимиты и расходы",
|
||||
"chat.slash.peers": "Показать статус сети OFP",
|
||||
"chat.slash.a2a": "Список A2A агентов",
|
||||
|
||||
"commands.help": "Показать доступные команды",
|
||||
"commands.agents": "Перейти на страницу агентов",
|
||||
"commands.new": "Новая сессия",
|
||||
"commands.switch": "Сменить агента",
|
||||
"commands.clear": "Очистить диалог",
|
||||
"commands.model": "Сменить модель",
|
||||
"commands.think": "Переключить режим размышлений",
|
||||
"commands.focus": "Переключить режим фокуса",
|
||||
"commands.theme": "Сменить тему",
|
||||
|
||||
"tips.commands": "Введите / для команд",
|
||||
"tips.think": "/think on для размышлений",
|
||||
"tips.focus": "Ctrl+Shift+F для режима фокуса",
|
||||
|
||||
"agents.info": "Информация",
|
||||
"agents.files": "Файлы",
|
||||
"agents.config": "Настройки",
|
||||
"agents.chat": "Чат",
|
||||
"agents.clone": "Клонировать",
|
||||
"agents.clear_history": "Очистить историю",
|
||||
"agents.change": "Изменить",
|
||||
"agents.none_fallback": "Нет — добавить цепочку резервов",
|
||||
"agents.add": "+ Добавить",
|
||||
"agents.loading_files": "Загрузка файлов...",
|
||||
"agents.no_workspace_files": "Файлы рабочей области не найдены",
|
||||
"agents.save_config": "Сохранить настройки",
|
||||
"agents.tool_filters": "Фильтры инструментов",
|
||||
"agents.allowlist": "Белый список",
|
||||
"agents.blocklist": "Чёрный список",
|
||||
"agents.agent_name": "Имя агента",
|
||||
"agents.emoji": "Эмодзи",
|
||||
"agents.color": "Цвет",
|
||||
"agents.archetype": "Архетип",
|
||||
"agents.provider": "Провайдер",
|
||||
"agents.model": "Модель",
|
||||
"agents.system_prompt": "Системный промпт",
|
||||
"agents.soul_persona": "Душa / Персона",
|
||||
"agents.tool_profile": "Профиль инструментов",
|
||||
"agents.minimal_profile": "Минимальный — только чтение файлов",
|
||||
"agents.coding_profile": "Кодинг — файлы + оболочка + веб-запросы",
|
||||
"agents.fullstack_profile": "Full-Stack — файлы + оболочка + веб + поиск",
|
||||
"agents.research_profile": "Исследование — веб + поиск + анализ",
|
||||
"agents.admin_profile": "Админ — полный доступ к системе (опасно)",
|
||||
"agents.agent_created": "Агент создан",
|
||||
"agents.agent_stopped": "Агент остановлен",
|
||||
"agents.agent_deleted": "Агент удалён",
|
||||
|
||||
"presets.professional": "Деловой",
|
||||
"presets.professional_desc": "Точный, бизнес-ориентированный ассистент, сосредоточенный на эффективности и ясности. Приоритет — практические выводы и структурированная коммуникация.",
|
||||
"presets.professional_soul": "Общайтесь чётко и профессионально. Будьте прямым и структурированным. Используйте формальный язык и выводы на основе данных. ставьте точность выше личности.",
|
||||
"presets.friendly": "Дружелюбный",
|
||||
"presets.friendly_desc": "Тёплый и открытый ассистент, который выстраивает rapport и использует разговорный язык. Отлично подходит для мозгового штурма и исследования.",
|
||||
"presets.friendly_soul": "Будьте тёплым, доступным и разговорчивым. Используйте неформальный язык и проявляйте искренний интерес к пользователю. Добавляйте личность к вашим ответам, оставаясь полезным.",
|
||||
"presets.technical": "Технический",
|
||||
"presets.technical_desc": "Эксперт-помощник по разработке, оптимизированный для кода, архитектуры и технических задач. Точная терминология, глубокие погружения, бенчмарки.",
|
||||
"presets.technical_soul": "Сосредоточьтесь на технической точности и глубине. Используйте точную терминологию. Покажите вашу работу и рассуждения. Предпочитайте примеры кода и структурированные объяснения.",
|
||||
"presets.creative": "Креативный",
|
||||
"presets.creative_desc": "Творческий партнёр для создания контента, дизайн-мышления и нестандартных решений. Приветствует неоднозначность и исследует возможности.",
|
||||
"presets.creative_soul": "Будьте изобретательным и выразительным. Используйте яркий язык, аналогии и неожиданные связи. Поощряйте творческое мышление и исследуйте различные перспективы.",
|
||||
"presets.concise": "Краткий",
|
||||
"presets.concise_desc": "Минималистичный и прямой ассистент, который ценит ваше время. Убирает лишнее и даёт сфокусированные, практичные ответы.",
|
||||
"presets.concise_soul": "Будьте предельно кратким и точным. Без воды и формальностей. Отвечайте наименьшим количеством слов, оставаясь точным и полным.",
|
||||
"presets.mentor": "Наставник",
|
||||
"presets.mentor_desc": "Терпеливый педагог, который подробно объясняет концепции, даёт контекст и направляет обучение. Метод Сократа при необходимости.",
|
||||
"presets.mentor_soul": "Будьте терпеливым и ободряющим как хороший учитель. Разбивайте сложные темы по шагам. Задавайте направляющие вопросы. Празднуйте прогресс и укрепляйте уверенность.",
|
||||
|
||||
"agents.profile.minimal": "Минимальный",
|
||||
"agents.profile.minimal_desc": "Только чтение файлов",
|
||||
"agents.profile.coding": "Кодинг",
|
||||
"agents.profile.coding_desc": "Файлы + оболочка + веб-запросы",
|
||||
"agents.profile.research": "Исследование",
|
||||
"agents.profile.research_desc": "Веб-поиск + чтение/запись файлов",
|
||||
"agents.profile.messaging": "Коммуникации",
|
||||
"agents.profile.messaging_desc": "Агенты + доступ к памяти",
|
||||
"agents.profile.automation": "Автоматизация",
|
||||
"agents.profile.automation_desc": "Все инструменты кроме пользовательских",
|
||||
"agents.profile.balanced": "Сбалансированный",
|
||||
"agents.profile.balanced_desc": "Набор инструментов общего назначения",
|
||||
"agents.profile.precise": "Точный",
|
||||
"agents.profile.precise_desc": "Фокусированный набор инструментов для точности",
|
||||
"agents.profile.creative": "Креативный",
|
||||
"agents.profile.creative_desc": "Полный набор инструментов с творческим уклоном",
|
||||
"agents.profile.full": "Полный",
|
||||
"agents.profile.full_desc": "Все 35+ инструментов",
|
||||
|
||||
"wizard.general_assistant": "Универсальный ассистент",
|
||||
"wizard.general_assistant_desc": "Вы универсальный AI-ассистент, который помогает пользователям с широким кругом задач. Вы знающий, полезный и способны адаптироваться к потребностям пользователя.",
|
||||
"wizard.code_helper": "Помощник по коду",
|
||||
"wizard.code_helper_desc": "Вы опытный программный ассистент, специализирующийся на разработке ПО. Вы помогаете писать, отлаживать и рефакторить код на разных языках.",
|
||||
"wizard.researcher": "Исследовательский ассистент",
|
||||
"wizard.researcher_desc": "Вы исследовательский ассистент, который помогает находить, анализировать и синтезировать информацию из различных источников.",
|
||||
"wizard.writer": "Писатель",
|
||||
"wizard.writer_desc": "Вы квалифицированный писатель, который помогает с созданием контента, редактированием и творческими проектами.",
|
||||
"wizard.data_analyst": "Аналитик данных",
|
||||
"wizard.data_analyst_desc": "Вы аналитик данных, который помогает исследовать, анализировать и визуализировать данные для извлечения инсайтов.",
|
||||
"wizard.devops": "DevOps-инженер",
|
||||
"wizard.devops_desc": "Вы DevOps-инженер, который помогает с инфраструктурой, деплоем, CI/CD и системным администрированием.",
|
||||
"wizard.support": "Поддержка клиентов",
|
||||
"wizard.support_desc": "Вы представитель поддержки клиентов, который помогает решать вопросы с терпением и профессионализмом.",
|
||||
"wizard.tutor": "Репетитор",
|
||||
"wizard.tutor_desc": "Вы образовательный репетитор, который ясно объясняет концепции и адаптирует обучение к уровню ученика.",
|
||||
"wizard.api_designer": "API-дизайнер",
|
||||
"wizard.api_designer_desc": "Вы дизайнер API, который помогает создавать хорошо структурированные, интуитивные API по лучшим практикам.",
|
||||
"wizard.meeting_notes": "Заметки к встрече",
|
||||
"wizard.meeting_notes_desc": "Вы специалист по заметкам встреч, который суммирует обсуждения, извлекает задачи и отслеживает решения.",
|
||||
|
||||
"wizard.step_welcome": "Приветствие",
|
||||
"wizard.step_provider": "Провайдер",
|
||||
"wizard.step_agent": "Агент",
|
||||
"wizard.step_try_it": "Попробовать",
|
||||
"wizard.step_channel": "Канал",
|
||||
"wizard.step_done": "Готово",
|
||||
|
||||
"wizard.cat_general": "Общее",
|
||||
"wizard.cat_development": "Разработка",
|
||||
"wizard.cat_research": "Исследования",
|
||||
"wizard.cat_writing": "Написание",
|
||||
"wizard.cat_business": "Бизнес",
|
||||
|
||||
"wizard.channel_telegram": "Telegram",
|
||||
"wizard.channel_telegram_desc": "Подключите агента к Telegram-боту для обмена сообщениями.",
|
||||
"wizard.channel_telegram_token": "Токен бота",
|
||||
"wizard.channel_telegram_help": "Создайте бота через @BotFather в Telegram, чтобы получить токен.",
|
||||
"wizard.channel_discord": "Discord",
|
||||
"wizard.channel_discord_desc": "Подключите агента к Discord-серверу через токен бота.",
|
||||
"wizard.channel_discord_token": "Токен бота",
|
||||
"wizard.channel_discord_help": "Создайте приложение Discord на discord.com/developers и добавьте бота.",
|
||||
"wizard.channel_slack": "Slack",
|
||||
"wizard.channel_slack_desc": "Подключите агента к рабочему пространству Slack.",
|
||||
"wizard.channel_slack_token": "Токен бота",
|
||||
"wizard.channel_slack_help": "Создайте приложение Slack на api.slack.com/apps и установите его в рабочее пространство.",
|
||||
|
||||
"wizard.profile_minimal": "Минимальный",
|
||||
"wizard.profile_minimal_desc": "Только чтение файлов",
|
||||
"wizard.profile_coding": "Кодинг",
|
||||
"wizard.profile_coding_desc": "Файлы + оболочка + веб-запросы",
|
||||
"wizard.profile_research": "Исследование",
|
||||
"wizard.profile_research_desc": "Веб-поиск + чтение/запись файлов",
|
||||
"wizard.profile_balanced": "Сбалансированный",
|
||||
"wizard.profile_balanced_desc": "Набор инструментов общего назначения",
|
||||
"wizard.profile_precise": "Точный",
|
||||
"wizard.profile_precise_desc": "Фокусированный набор инструментов для точности",
|
||||
"wizard.profile_creative": "Креативный",
|
||||
"wizard.profile_creative_desc": "Полный набор инструментов с творческим уклоном",
|
||||
"wizard.profile_full": "Полный",
|
||||
"wizard.profile_full_desc": "Все 35+ инструментов",
|
||||
|
||||
"wizard.enter_api_key": "Пожалуйста, введите API-ключ",
|
||||
"wizard.api_key_saved": "API-ключ сохранён для",
|
||||
"wizard.failed_save_key": "Не удалось сохранить ключ:",
|
||||
"wizard.connected": "подключён",
|
||||
"wizard.connection_failed": "Ошибка подключения",
|
||||
"wizard.test_failed": "Тест не прошёл:",
|
||||
"wizard.enter_agent_name": "Пожалуйста, введите имя агента",
|
||||
"wizard.agent_created": "Агент создан",
|
||||
"wizard.failed_create_agent": "Не удалось создать агента:",
|
||||
"wizard.enter_token": "Пожалуйста, введите",
|
||||
"wizard.channel_configured": "настроен и активирован.",
|
||||
"wizard.failed_configure": "Ошибка:",
|
||||
|
||||
"wizard.suggestions.general.1": "Чем вы можете помочь?",
|
||||
"wizard.suggestions.general.2": "Расскажите интересный факт",
|
||||
"wizard.suggestions.general.3": "Резюмируйте последние новости AI",
|
||||
"wizard.suggestions.development.1": "Напишите Python hello world",
|
||||
"wizard.suggestions.development.2": "Объясните async/await",
|
||||
"wizard.suggestions.development.3": "Проверьте этот фрагмент кода",
|
||||
"wizard.suggestions.research.1": "Объясните квантовые вычисления просто",
|
||||
"wizard.suggestions.research.2": "Сравните React и Vue",
|
||||
"wizard.suggestions.research.3": "Какие последние тренды в AI?",
|
||||
"wizard.suggestions.writing.1": "Помогите написать профессиональное письмо",
|
||||
"wizard.suggestions.writing.2": "Улучшите этот абзац",
|
||||
"wizard.suggestions.writing.3": "Напишите введение в блог об AI",
|
||||
"wizard.suggestions.business.1": "Составьте повестку встречи",
|
||||
"wizard.suggestions.business.2": "Как обработать жалобу?",
|
||||
"wizard.suggestions.business.3": "Создайте статус-отчёт проекта",
|
||||
|
||||
"approvals.title": "Одобрения выполнения",
|
||||
"approvals.pending": "ожидает",
|
||||
"approvals.all": "Все",
|
||||
"approvals.pending_tab": "Ожидающие",
|
||||
"approvals.approved": "Одобрено",
|
||||
"approvals.rejected": "Отклонено",
|
||||
"approvals.expired": "Истекло",
|
||||
"approvals.no_approvals": "Нет одобрений",
|
||||
"approvals.approve": "Одобрить",
|
||||
"approvals.reject": "Отклонить",
|
||||
|
||||
"workflows.title": "Рабочие процессы",
|
||||
"workflows.visual_builder": "Визуальный конструктор",
|
||||
"workflows.what_are": "Что такое рабочие процессы?",
|
||||
"workflows.no_workflows": "Нет рабочих процессов",
|
||||
"workflows.sequential": "Последовательный",
|
||||
"workflows.fan_out": "Распределение",
|
||||
"workflows.conditional": "Условный",
|
||||
"workflows.loop": "Цикл",
|
||||
"workflows.add_step": "+ Добавить шаг",
|
||||
"workflows.execute": "Выполнить",
|
||||
"workflows.result": "Результат",
|
||||
"workflows.node_palette": "Палитра узлов",
|
||||
"workflows.drag_nodes": "Перетащите узлы на холст",
|
||||
"workflows.steps_connections": "шагов, связей",
|
||||
"workflows.agent": "Агент",
|
||||
"workflows.prompt_template": "Шаблон промпта",
|
||||
"workflows.expression": "Выражение",
|
||||
"workflows.top_port_true": "Верхний порт = истина, нижний = ложь",
|
||||
"workflows.max_iterations": "Макс. итераций",
|
||||
"workflows.until_stop": "До (условие остановки)",
|
||||
"workflows.fan_out_count": "Количество ветвей",
|
||||
"workflows.wait_all": "Ждать все",
|
||||
"workflows.first_finish": "Первый завершился",
|
||||
"workflows.majority_vote": "Большинство",
|
||||
"workflows.connection_selected": "Связь выбрана",
|
||||
"workflows.delete_connection": "Удалить связь",
|
||||
|
||||
"scheduler.title": "Планировщик",
|
||||
"scheduler.scheduled_jobs": "Запланированные задания",
|
||||
"scheduler.event_triggers": "Триггеры событий",
|
||||
"scheduler.run_history": "История запусков",
|
||||
"scheduler.new_job": "+ Новое задание",
|
||||
"scheduler.job_name": "Название задания",
|
||||
"scheduler.cron_expression": "Cron-выражение",
|
||||
"scheduler.quick_presets": "Быстрые шаблоны",
|
||||
"scheduler.target_agent": "Целевой агент",
|
||||
"scheduler.any_agent": "Любой доступный агент",
|
||||
"scheduler.message_send": "Сообщение для отправки",
|
||||
"scheduler.enabled": "Включено (запустится сразу)",
|
||||
"scheduler.disabled": "Отключено (создать приостановленным)",
|
||||
"scheduler.active": "Активно",
|
||||
"scheduler.paused": "Приостановлено",
|
||||
"scheduler.cron_job": "Cron-задание",
|
||||
"scheduler.trigger": "Триггер",
|
||||
"scheduler.no_jobs": "Нет запланированных заданий",
|
||||
"scheduler.no_triggers": "Нет триггеров событий",
|
||||
"scheduler.no_history": "Нет истории запусков",
|
||||
|
||||
"channels.title": "Каналы",
|
||||
"channels.configured": "настроено",
|
||||
"channels.search": "Поиск каналов...",
|
||||
"channels.setup": "Настроить",
|
||||
"channels.edit": "Изменить",
|
||||
"channels.configure": "Настройка",
|
||||
"channels.verify": "Проверить",
|
||||
"channels.ready": "Готово",
|
||||
"channels.is_ready": "готово!",
|
||||
"channels.get_credentials": "Как получить учётные данные",
|
||||
"channels.show_advanced": "Показать расширенные",
|
||||
"channels.hide_advanced": "Скрыть расширенные",
|
||||
"channels.connecting": "Подключение к шлюзу WhatsApp Web...",
|
||||
"channels.linked_success": "WhatsApp успешно связан!",
|
||||
"channels.business_api": "Business API",
|
||||
|
||||
"skills.title": "Навыки и экосистема",
|
||||
"skills.installed": "Установленные",
|
||||
"skills.clawhub": "ClawHub",
|
||||
"skills.mcp_servers": "MCP-серверы",
|
||||
"skills.quick_start": "Быстрый старт",
|
||||
"skills.no_installed": "Нет установленных навыков",
|
||||
"skills.browse_clawhub": "Обзор ClawHub",
|
||||
"skills.search_clawhub": "Поиск навыков ClawHub...",
|
||||
"skills.trending": "Популярные",
|
||||
"skills.most_downloaded": "Самые скачиваемые",
|
||||
"skills.most_starred": "Самые оценённые",
|
||||
"skills.recently_updated": "Недавно обновлённые",
|
||||
"skills.categories": "КАТЕГОРИИ",
|
||||
"skills.already_installed": "Уже установлено",
|
||||
"skills.no_skills_found": "Навыки не найдены",
|
||||
"skills.security_warnings": "Предупреждения безопасности",
|
||||
"skills.security_scan": "Навыки проверяются на безопасность перед установкой",
|
||||
"skills.create": "Создать навык",
|
||||
"skills.created": "Создан",
|
||||
|
||||
"skills.cat_coding": "Кодинг и IDE",
|
||||
"skills.cat_git": "Git и GitHub",
|
||||
"skills.cat_frontend": "Веб и фронтенд",
|
||||
"skills.cat_devops": "DevOps и облака",
|
||||
"skills.cat_database": "Базы данных",
|
||||
"skills.cat_security": "Безопасность",
|
||||
"skills.cat_ai": "AI и ML",
|
||||
"skills.cat_data": "Данные и аналитика",
|
||||
"skills.cat_mobile": "Мобильная разработка",
|
||||
"skills.cat_desktop": "Десктопные приложения",
|
||||
"skills.cat_api": "API и интеграции",
|
||||
"skills.cat_testing": "Тестирование",
|
||||
"skills.cat_docs": "Документация",
|
||||
"skills.cat_productivity": "Продуктивность",
|
||||
"skills.cat_other": "Другое",
|
||||
|
||||
"skills.cat_browser": "Браузер и автоматизация",
|
||||
"skills.cat_search": "Поиск и исследования",
|
||||
"skills.cat_communication": "Коммуникации",
|
||||
"skills.cat_media": "Медиа и стриминг",
|
||||
"skills.cat_notes": "Заметки и PKM",
|
||||
"skills.cat_cli": "CLI утилиты",
|
||||
"skills.cat_marketing": "Маркетинг и продажи",
|
||||
"skills.cat_finance": "Финансы",
|
||||
"skills.cat_smarthome": "Умный дом и IoT",
|
||||
|
||||
"skills.uninstall_skill": "Удалить навык",
|
||||
"skills.uninstall_confirm": "Удалить навык",
|
||||
|
||||
"skills.source_clawhub": "ClawHub",
|
||||
"skills.source_openclaw": "OpenClaw",
|
||||
"skills.source_builtin": "Встроенный",
|
||||
"skills.source_local": "Локальный",
|
||||
|
||||
"hands.title": "Руки — Наборы автономных возможностей",
|
||||
"hands.available": "Доступные",
|
||||
"hands.active": "Активные",
|
||||
"hands.ready": "Готово",
|
||||
"hands.setup_needed": "Требуется настройка",
|
||||
"hands.requirements": "ТРЕБОВАНИЯ",
|
||||
"hands.details": "Подробности",
|
||||
"hands.no_hands": "Нет доступных модулей",
|
||||
|
||||
"sessions.title": "Сессии",
|
||||
"sessions.memory": "Память",
|
||||
"sessions.delete_session": "Удалить сессию",
|
||||
"sessions.delete_confirm": "Это навсегда удалит сессию и все её сообщения.",
|
||||
"sessions.delete_key": "Удалить ключ",
|
||||
"sessions.delete_key_confirm": "Удалить ключ",
|
||||
|
||||
"logs.title": "Логи",
|
||||
"logs.live": "Онлайн",
|
||||
"logs.audit_trail": "Аудит",
|
||||
|
||||
"settings.title": "Настройки",
|
||||
"settings.providers": "Провайдеры",
|
||||
"settings.models": "Модели",
|
||||
"settings.config": "Конфигурация",
|
||||
"settings.tools": "Инструменты",
|
||||
"settings.migration": "Миграция",
|
||||
"settings.security": "Безопасность",
|
||||
"settings.network": "Сеть",
|
||||
"settings.migration": "Миграция",
|
||||
"settings.language": "Язык",
|
||||
|
||||
"settings.sec_path_traversal": "Защита от обхода пути",
|
||||
"settings.sec_path_traversal_desc": "Блокирует попытки доступа к файлам за пределами рабочей директории через .. или абсолютные пути.",
|
||||
"settings.sec_ssrf": "Защита от SSRF",
|
||||
"settings.sec_ssrf_desc": "Предотвращает запросы агентов к внутренним IP-диапазонам (localhost, облачный метаданные, частные сети).",
|
||||
"settings.sec_capability": "Управление доступом по возможностям",
|
||||
"settings.sec_capability_desc": "Агенты могут получать доступ только к явно предоставленным возможностям. Нет неявного доступа к инструментам или данным.",
|
||||
"settings.sec_taint": "Отслеживание заражения",
|
||||
"settings.sec_taint_desc": "Отслеживает ненадёжные данные (ввод пользователя, содержимое файлов) через рассуждения агента для предотвращения инъекции промпта.",
|
||||
"settings.sec_sandbox": "WASM-песочница",
|
||||
"settings.sec_sandbox_desc": "Выполняет ненадёжный код в изолированных WebAssembly-песочницах с ограничениями памяти и системных вызовов.",
|
||||
"settings.sec_audit": "Меркл-проверка",
|
||||
"settings.sec_audit_desc": "Ведёт верифицируемый журнал аудита всех действий агентов с использованием криптографии деревьев Меркла.",
|
||||
"settings.sec_workspace": "Изоляция рабочих областей",
|
||||
"settings.sec_workspace_desc": "Каждый агент имеет изолированную рабочую директорию. Нет межагентного доступа к файлам без явного разрешения.",
|
||||
"settings.sec_rate_limit": "Ограничение частоты",
|
||||
"settings.sec_rate_limit_desc": "Устанавливает лимиты на запросы для каждого агента и глобально для предотвращения истощения ресурсов и перерасхода.",
|
||||
"settings.sec_approval": "Одобрения выполнения",
|
||||
"settings.sec_approval_desc": "Требует одобрения человека для рискованных действий (команды оболочки, запись файлов, внешние запросы).",
|
||||
|
||||
"settings.sec_enabled": "Включено",
|
||||
"settings.sec_disabled": "Отключено",
|
||||
"settings.sec_inherited": "Унаследовано",
|
||||
"settings.sec_global": "Глобально"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -224,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');
|
||||
@@ -237,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) {
|
||||
@@ -265,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();
|
||||
};
|
||||
@@ -297,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();
|
||||
|
||||
@@ -18,7 +18,7 @@ if (typeof marked !== 'undefined') {
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
|
||||
@@ -40,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: '',
|
||||
@@ -51,14 +53,23 @@ function agentsPage() {
|
||||
'\u{2764}\uFE0F', '\u{1F31F}', '\u{1F527}', '\u{1F4DD}', '\u{1F4A1}', '\u{1F3A8}'
|
||||
],
|
||||
archetypeOptions: ['Assistant', 'Researcher', 'Coder', 'Writer', 'DevOps', 'Support', 'Analyst', 'Custom'],
|
||||
personalityPresets: [
|
||||
{ id: 'professional', label: 'Professional', soul: 'Communicate in a clear, professional tone. Be direct and structured. Use formal language and data-driven reasoning. Prioritize accuracy over personality.' },
|
||||
{ id: 'friendly', label: 'Friendly', soul: 'Be warm, approachable, and conversational. Use casual language and show genuine interest in the user. Add personality to your responses while staying helpful.' },
|
||||
{ id: 'technical', label: 'Technical', soul: 'Focus on technical accuracy and depth. Use precise terminology. Show your work and reasoning. Prefer code examples and structured explanations.' },
|
||||
{ id: 'creative', label: 'Creative', soul: 'Be imaginative and expressive. Use vivid language, analogies, and unexpected connections. Encourage creative thinking and explore multiple perspectives.' },
|
||||
{ id: 'concise', label: 'Concise', soul: 'Be extremely brief and to the point. No filler, no pleasantries. Answer in the fewest words possible while remaining accurate and complete.' },
|
||||
{ id: 'mentor', label: 'Mentor', soul: 'Be patient and encouraging like a great teacher. Break down complex topics step by step. Ask guiding questions. Celebrate progress and build confidence.' }
|
||||
],
|
||||
_personalityPresetsLoaded: false,
|
||||
personalityPresets: [], // Loaded dynamically with i18n
|
||||
|
||||
// Load personality presets with i18n
|
||||
loadPersonalityPresets: function() {
|
||||
if (this._personalityPresetsLoaded) return;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
this.personalityPresets = [
|
||||
{ id: 'professional', label: t('presets.professional'), soul: t('presets.professional_soul') },
|
||||
{ id: 'friendly', label: t('presets.friendly'), soul: t('presets.friendly_soul') },
|
||||
{ id: 'technical', label: t('presets.technical'), soul: t('presets.technical_soul') },
|
||||
{ id: 'creative', label: t('presets.creative'), soul: t('presets.creative_soul') },
|
||||
{ id: 'concise', label: t('presets.concise'), soul: t('presets.concise_soul') },
|
||||
{ id: 'mentor', label: t('presets.mentor'), soul: t('presets.mentor_soul') }
|
||||
];
|
||||
this._personalityPresetsLoaded = true;
|
||||
},
|
||||
|
||||
// -- Detail modal tabs --
|
||||
detailTab: 'info',
|
||||
@@ -92,112 +103,36 @@ 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();
|
||||
// Load personality presets with i18n
|
||||
this.loadPersonalityPresets();
|
||||
},
|
||||
|
||||
// ── Profile Descriptions ──
|
||||
profileDescriptions: {
|
||||
minimal: { label: 'Minimal', desc: 'Read-only file access' },
|
||||
coding: { label: 'Coding', desc: 'Files + shell + web fetch' },
|
||||
research: { label: 'Research', desc: 'Web search + file read/write' },
|
||||
messaging: { label: 'Messaging', desc: 'Agents + memory access' },
|
||||
automation: { label: 'Automation', desc: 'All tools except custom' },
|
||||
balanced: { label: 'Balanced', desc: 'General-purpose tool set' },
|
||||
precise: { label: 'Precise', desc: 'Focused tool set for accuracy' },
|
||||
creative: { label: 'Creative', desc: 'Full tools with creative emphasis' },
|
||||
full: { label: 'Full', desc: 'All 35+ tools' }
|
||||
// ── Profile Descriptions (loaded dynamically with i18n) ──
|
||||
_profileDescriptionsLoaded: false,
|
||||
profileDescriptions: {},
|
||||
loadProfileDescriptions: function() {
|
||||
if (this._profileDescriptionsLoaded) return;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
this.profileDescriptions = {
|
||||
minimal: { label: t('agents.profile.minimal'), desc: t('agents.profile.minimal_desc') },
|
||||
coding: { label: t('agents.profile.coding'), desc: t('agents.profile.coding_desc') },
|
||||
research: { label: t('agents.profile.research'), desc: t('agents.profile.research_desc') },
|
||||
messaging: { label: t('agents.profile.messaging'), desc: t('agents.profile.messaging_desc') },
|
||||
automation: { label: t('agents.profile.automation'), desc: t('agents.profile.automation_desc') },
|
||||
balanced: { label: t('agents.profile.balanced'), desc: t('agents.profile.balanced_desc') },
|
||||
precise: { label: t('agents.profile.precise'), desc: t('agents.profile.precise_desc') },
|
||||
creative: { label: t('agents.profile.creative'), desc: t('agents.profile.creative_desc') },
|
||||
full: { label: t('agents.profile.full'), desc: t('agents.profile.full_desc') }
|
||||
};
|
||||
this._profileDescriptionsLoaded = true;
|
||||
},
|
||||
profileInfo: function(name) {
|
||||
this.loadProfileDescriptions();
|
||||
return this.profileDescriptions[name] || { label: name, desc: '' };
|
||||
},
|
||||
|
||||
@@ -280,6 +215,9 @@ function agentsPage() {
|
||||
this.loadError = '';
|
||||
try {
|
||||
await Alpine.store('app').refreshAgents();
|
||||
await this.loadTemplates();
|
||||
this.loadPersonalityPresets();
|
||||
this.loadProfileDescriptions();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load agents. Is the daemon running?';
|
||||
}
|
||||
@@ -317,10 +255,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: 'default',
|
||||
model: 'default',
|
||||
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 = "default"\nmodel = "default"\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: 'default',
|
||||
model: 'default',
|
||||
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 = "default"\nmodel = "default"\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: 'default',
|
||||
model: 'default',
|
||||
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 = "default"\nmodel = "default"\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: 'default',
|
||||
model: 'default',
|
||||
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 = "default"\nmodel = "default"\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: 'default',
|
||||
model: 'default',
|
||||
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 = "default"\nmodel = "default"\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: 'default',
|
||||
model: 'default',
|
||||
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 = "default"\nmodel = "default"\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;
|
||||
@@ -336,29 +337,38 @@ function agentsPage() {
|
||||
OpenFangAPI.wsDisconnect();
|
||||
},
|
||||
|
||||
buildConfigForm(agent) {
|
||||
var identity = (agent && agent.identity) || {};
|
||||
return {
|
||||
name: (agent && agent.name) || '',
|
||||
system_prompt: (agent && agent.system_prompt) || '',
|
||||
emoji: identity.emoji || '',
|
||||
color: identity.color || '#FF5C00',
|
||||
archetype: identity.archetype || '',
|
||||
vibe: identity.vibe || ''
|
||||
};
|
||||
},
|
||||
|
||||
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 || '',
|
||||
emoji: (agent.identity && agent.identity.emoji) || '',
|
||||
color: (agent.identity && agent.identity.color) || '#FF5C00',
|
||||
archetype: (agent.identity && agent.identity.archetype) || '',
|
||||
vibe: (agent.identity && agent.identity.vibe) || ''
|
||||
};
|
||||
this.showDetailModal = true;
|
||||
// Fetch full agent detail to get fallback_models
|
||||
// Load the full detail payload before opening the modal so editable
|
||||
// fields such as system_prompt and identity metadata are hydrated.
|
||||
var detail = agent;
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
|
||||
this.detailAgent._fallbacks = full.fallback_models || [];
|
||||
} catch(e) { /* ignore */ }
|
||||
detail = Object.assign({}, agent, full, {
|
||||
identity: Object.assign({}, (agent && agent.identity) || {}, (full && full.identity) || {})
|
||||
});
|
||||
} catch(e) { /* fall back to list payload */ }
|
||||
this.detailAgent = detail;
|
||||
this.detailAgent._fallbacks = detail.fallback_models || [];
|
||||
this.configForm = this.buildConfigForm(detail);
|
||||
this.showDetailModal = true;
|
||||
},
|
||||
|
||||
killAgent(agent) {
|
||||
@@ -375,6 +385,29 @@ function agentsPage() {
|
||||
});
|
||||
},
|
||||
|
||||
// Issue #1163: uninstall an agent (kill + remove ~/.openfang/agents/<name>/).
|
||||
uninstallAgent(agent) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm(
|
||||
'Uninstall Agent',
|
||||
'Uninstall agent "' + agent.name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
var res = await OpenFangAPI.del('/api/agents/' + agent.id + '/uninstall');
|
||||
var msg = 'Agent "' + agent.name + '" uninstalled';
|
||||
if (res && res.dir_removed === false) {
|
||||
msg += ' (no on-disk files found)';
|
||||
}
|
||||
OpenFangToast.success(msg);
|
||||
self.showDetailModal = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
killAllAgents() {
|
||||
var list = this.filteredAgents;
|
||||
if (!list.length) return;
|
||||
@@ -403,18 +436,26 @@ function agentsPage() {
|
||||
this.selectedPreset = '';
|
||||
this.soulContent = '';
|
||||
this.spawnForm.name = '';
|
||||
this.spawnForm.provider = 'groq';
|
||||
this.spawnForm.model = 'llama-3.3-70b-versatile';
|
||||
this.spawnForm.provider = 'default';
|
||||
this.spawnForm.model = 'default';
|
||||
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 res = await fetch('/api/status');
|
||||
if (res.ok) {
|
||||
var status = await res.json();
|
||||
if (status.default_provider) this.spawnForm.provider = status.default_provider;
|
||||
if (status.default_model) this.spawnForm.model = status.default_model;
|
||||
}
|
||||
} catch(e) { /* keep hardcoded defaults */ }
|
||||
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() {
|
||||
@@ -590,15 +631,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) {
|
||||
|
||||
@@ -42,34 +42,31 @@ function chatPage() {
|
||||
modelSwitching: false,
|
||||
_modelCache: null,
|
||||
_modelCacheTime: 0,
|
||||
slashCommands: [
|
||||
{ cmd: '/help', desc: 'Show available commands' },
|
||||
{ cmd: '/agents', desc: 'Switch to Agents page' },
|
||||
{ cmd: '/new', desc: 'Reset session (clear history)' },
|
||||
{ cmd: '/compact', desc: 'Trigger LLM session compaction' },
|
||||
{ cmd: '/model', desc: 'Show or switch model (/model [name])' },
|
||||
{ cmd: '/stop', desc: 'Cancel current agent run' },
|
||||
{ cmd: '/usage', desc: 'Show session token usage & cost' },
|
||||
{ cmd: '/think', desc: 'Toggle extended thinking (/think [on|off|stream])' },
|
||||
{ cmd: '/context', desc: 'Show context window usage & pressure' },
|
||||
{ cmd: '/verbose', desc: 'Cycle tool detail level (/verbose [off|on|full])' },
|
||||
{ cmd: '/queue', desc: 'Check if agent is processing' },
|
||||
{ cmd: '/status', desc: 'Show system status' },
|
||||
{ cmd: '/clear', desc: 'Clear chat display' },
|
||||
{ cmd: '/exit', desc: 'Disconnect from agent' },
|
||||
{ cmd: '/budget', desc: 'Show spending limits and current costs' },
|
||||
{ cmd: '/peers', desc: 'Show OFP peer network status' },
|
||||
{ cmd: '/a2a', desc: 'List discovered external A2A agents' }
|
||||
],
|
||||
slashCommands: [], // Loaded dynamically with i18n in init()
|
||||
_slashCommandsLoaded: false,
|
||||
tokenCount: 0,
|
||||
|
||||
// ── Tip Bar ──
|
||||
tipIndex: 0,
|
||||
tips: ['Type / for commands', '/think on for reasoning', 'Ctrl+Shift+F for focus mode', 'Drag files to attach', '/model to switch models', '/context to check usage', '/verbose off to hide tool details'],
|
||||
tips: [],
|
||||
_tipsInitialized: false,
|
||||
tipTimer: null,
|
||||
get currentTip() {
|
||||
if (localStorage.getItem('of-tips-off') === 'true') return '';
|
||||
return this.tips[this.tipIndex % this.tips.length];
|
||||
if (!this._tipsInitialized) {
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
this.tips = [
|
||||
t('tips.commands'),
|
||||
t('tips.think'),
|
||||
t('tips.focus'),
|
||||
'Drag files to attach',
|
||||
'/model to switch models',
|
||||
'/context to check usage',
|
||||
'/verbose off to hide tool details'
|
||||
];
|
||||
this._tipsInitialized = true;
|
||||
}
|
||||
return this.tips[this.tipIndex % this.tips.length] || '';
|
||||
},
|
||||
dismissTips: function() { localStorage.setItem('of-tips-off', 'true'); },
|
||||
startTipCycle: function() {
|
||||
@@ -137,12 +134,38 @@ function chatPage() {
|
||||
init() {
|
||||
var self = this;
|
||||
|
||||
// Initialize slash commands with i18n
|
||||
this.initSlashCommands();
|
||||
|
||||
// Start tip cycle
|
||||
this.startTipCycle();
|
||||
|
||||
// Fetch dynamic commands from server
|
||||
this.fetchCommands();
|
||||
|
||||
// Observe DOM for new messages and render LaTeX
|
||||
this._latexObserver = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
mutation.addedNodes.forEach(function(node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
var bubbles = node.querySelector ? node.querySelectorAll('.message-bubble') : [];
|
||||
if (node.classList && node.classList.contains('message-bubble')) {
|
||||
bubbles = [node];
|
||||
}
|
||||
bubbles.forEach(function(bubble) {
|
||||
if (bubble.textContent && hasLatexDelimiters(bubble.textContent)) {
|
||||
renderLatex(bubble);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
this._latexObserver.observe(document.getElementById('messages') || document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Ctrl+/ keyboard shortcut
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||
@@ -175,6 +198,10 @@ function chatPage() {
|
||||
if (store.pendingAgent) {
|
||||
self.selectAgent(store.pendingAgent);
|
||||
store.pendingAgent = null;
|
||||
} else {
|
||||
// Restore previously active agent after page refresh (#1179).
|
||||
// The agent list may not be loaded yet, so resolve once it appears.
|
||||
self._restoreActiveAgent();
|
||||
}
|
||||
|
||||
// Watch for future pending agent selections (e.g., user clicks agent while on chat)
|
||||
@@ -185,6 +212,13 @@ function chatPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// Re-attempt restore once the agent list arrives from the server
|
||||
this.$watch('$store.app.agents', function(agents) {
|
||||
if (!self.currentAgent && agents && agents.length) {
|
||||
self._restoreActiveAgent();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for slash commands + model autocomplete
|
||||
this.$watch('inputText', function(val) {
|
||||
var modelMatch = val.match(/^\/model\s+(.*)$/i);
|
||||
@@ -264,35 +298,78 @@ function chatPage() {
|
||||
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
this.modelSwitching = true;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
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));
|
||||
OpenFangToast.success(t('chat.model_switched') + ' ' + (model.display_name || model.id));
|
||||
self.showModelSwitcher = false;
|
||||
self.modelSwitching = false;
|
||||
}).catch(function(e) {
|
||||
OpenFangToast.error('Switch failed: ' + e.message);
|
||||
OpenFangToast.error(t('chat.model_switch_failed') + ': ' + e.message);
|
||||
self.modelSwitching = false;
|
||||
});
|
||||
},
|
||||
|
||||
// Fetch dynamic slash commands from server
|
||||
// Initialize slash commands with i18n translations
|
||||
initSlashCommands: function() {
|
||||
if (this._slashCommandsLoaded) return;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
this.slashCommands = [
|
||||
{ cmd: '/help', desc: t('chat.slash.help') },
|
||||
{ cmd: '/agents', desc: t('chat.slash.agents') },
|
||||
{ cmd: '/new', desc: t('chat.slash.new') },
|
||||
{ cmd: '/compact', desc: t('chat.slash.compact') },
|
||||
{ cmd: '/model', desc: t('chat.slash.model') },
|
||||
{ cmd: '/stop', desc: t('chat.slash.stop') },
|
||||
{ cmd: '/usage', desc: t('chat.slash.usage') },
|
||||
{ cmd: '/think', desc: t('chat.slash.think') },
|
||||
{ cmd: '/context', desc: t('chat.slash.context') },
|
||||
{ cmd: '/verbose', desc: t('chat.slash.verbose') },
|
||||
{ cmd: '/queue', desc: t('chat.slash.queue') },
|
||||
{ cmd: '/status', desc: t('chat.slash.status') },
|
||||
{ cmd: '/clear', desc: t('chat.slash.clear') },
|
||||
{ cmd: '/exit', desc: t('chat.slash.exit') },
|
||||
{ cmd: '/budget', desc: t('chat.slash.budget') },
|
||||
{ cmd: '/peers', desc: t('chat.slash.peers') },
|
||||
{ cmd: '/a2a', desc: t('chat.slash.a2a') }
|
||||
];
|
||||
this._slashCommandsLoaded = true;
|
||||
},
|
||||
|
||||
// Fetch slash commands from the unified registry (/api/commands?surface=web).
|
||||
// Replaces the hardcoded initSlashCommands() list once loaded — ensures
|
||||
// the help panel and autocomplete stay in sync with the backend registry.
|
||||
fetchCommands: function() {
|
||||
var self = this;
|
||||
OpenFangAPI.get('/api/commands').then(function(data) {
|
||||
if (data.commands && data.commands.length) {
|
||||
// Build a set of known cmds to avoid duplicates
|
||||
var existing = {};
|
||||
self.slashCommands.forEach(function(c) { existing[c.cmd] = true; });
|
||||
data.commands.forEach(function(c) {
|
||||
if (!existing[c.cmd]) {
|
||||
self.slashCommands.push({ cmd: c.cmd, desc: c.desc || '', source: c.source || 'server' });
|
||||
existing[c.cmd] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(function() { /* silent — use hardcoded list */ });
|
||||
OpenFangAPI.get('/api/commands?surface=web').then(function(data) {
|
||||
var cmds = (data && data.commands) || [];
|
||||
if (!cmds.length) return;
|
||||
self.slashCommands = cmds.map(function(c) {
|
||||
// Prefer unified-registry shape { name, aliases, description, category, requires_agent }.
|
||||
// Fall back to legacy { cmd, desc } shape so older shims keep working.
|
||||
if (c.name) {
|
||||
return {
|
||||
cmd: '/' + c.name,
|
||||
desc: c.description || '',
|
||||
category: c.category || 'general',
|
||||
aliases: c.aliases || [],
|
||||
requires_agent: !!c.requires_agent,
|
||||
source: 'registry'
|
||||
};
|
||||
}
|
||||
return {
|
||||
cmd: c.cmd,
|
||||
desc: c.desc || '',
|
||||
category: c.category || 'general',
|
||||
aliases: c.aliases || [],
|
||||
requires_agent: !!c.requires_agent,
|
||||
source: c.source || 'server'
|
||||
};
|
||||
});
|
||||
self._slashCommandsLoaded = true;
|
||||
}).catch(function() { /* silent — keep hardcoded fallback list */ });
|
||||
},
|
||||
|
||||
get filteredSlashCommands() {
|
||||
@@ -303,6 +380,44 @@ function chatPage() {
|
||||
});
|
||||
},
|
||||
|
||||
// Render `/help` output grouped by category, mirroring the
|
||||
// backend's render_help(Surfaces::WEB). Falls back to a flat list if
|
||||
// categories are not populated (pre-fetch hardcoded list).
|
||||
renderHelpText: function() {
|
||||
var order = ['general', 'session', 'model', 'control', 'memory', 'info', 'automation', 'monitoring'];
|
||||
var labels = {
|
||||
general: 'General', session: 'Session', model: 'Model', control: 'Control',
|
||||
memory: 'Memory', info: 'Info', automation: 'Automation', monitoring: 'Monitoring'
|
||||
};
|
||||
var anyCategorised = this.slashCommands.some(function(c) { return c.category; });
|
||||
if (!anyCategorised) {
|
||||
return this.slashCommands.map(function(c) {
|
||||
return '`' + c.cmd + '` \u2014 ' + c.desc;
|
||||
}).join('\n');
|
||||
}
|
||||
var groups = {};
|
||||
this.slashCommands.forEach(function(c) {
|
||||
var cat = c.category || 'general';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(c);
|
||||
});
|
||||
var lines = ['**Available commands:**'];
|
||||
order.forEach(function(cat) {
|
||||
var list = groups[cat];
|
||||
if (!list || !list.length) return;
|
||||
lines.push('');
|
||||
lines.push('**' + (labels[cat] || cat) + '**');
|
||||
list.forEach(function(c) {
|
||||
var aliasText = '';
|
||||
if (c.aliases && c.aliases.length) {
|
||||
aliasText = ' (aliases: ' + c.aliases.map(function(a) { return '/' + a; }).join(', ') + ')';
|
||||
}
|
||||
lines.push('- `' + c.cmd + '`' + aliasText + ' \u2014 ' + c.desc);
|
||||
});
|
||||
});
|
||||
return lines.join('\n');
|
||||
},
|
||||
|
||||
// Clear any stuck typing indicator after 120s
|
||||
_resetTypingTimeout: function() {
|
||||
var self = this;
|
||||
@@ -328,7 +443,7 @@ function chatPage() {
|
||||
cmdArgs = cmdArgs || '';
|
||||
switch (cmd) {
|
||||
case '/help':
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: self.slashCommands.map(function(c) { return '`' + c.cmd + '` — ' + c.desc; }).join('\n'), meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: self.renderHelpText(), meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
break;
|
||||
case '/agents':
|
||||
@@ -392,7 +507,7 @@ function chatPage() {
|
||||
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
|
||||
OpenFangAPI.wsSend({ type: 'command', command: 'context', args: '' });
|
||||
} else {
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected. Connect to an agent first.', meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
@@ -400,7 +515,7 @@ function chatPage() {
|
||||
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
|
||||
OpenFangAPI.wsSend({ type: 'command', command: 'verbose', args: cmdArgs });
|
||||
} else {
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected. Connect to an agent first.', meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
@@ -408,7 +523,7 @@ function chatPage() {
|
||||
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
|
||||
OpenFangAPI.wsSend({ type: 'command', command: 'queue', args: '' });
|
||||
} else {
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected.', meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + ').', meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
@@ -447,6 +562,7 @@ function chatPage() {
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
window.dispatchEvent(new Event('close-chat'));
|
||||
break;
|
||||
case '/budget':
|
||||
@@ -482,25 +598,35 @@ function chatPage() {
|
||||
}
|
||||
},
|
||||
|
||||
// Restore the previously-active agent (set in selectAgent) after a page
|
||||
// refresh, so the WebSocket re-attaches to the same session and any
|
||||
// in-flight tool output streams back into the chat (#1179).
|
||||
_restoreActiveAgent: function() {
|
||||
var storedId = null;
|
||||
try { storedId = localStorage.getItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
if (!storedId) return;
|
||||
var agents = (Alpine.store('app') && Alpine.store('app').agents) || [];
|
||||
var match = null;
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i] && agents[i].id === storedId) { match = agents[i]; break; }
|
||||
}
|
||||
if (match) {
|
||||
this.selectAgent(match);
|
||||
}
|
||||
},
|
||||
|
||||
selectAgent(agent) {
|
||||
this.currentAgent = agent;
|
||||
this.messages = [];
|
||||
try { localStorage.setItem('of-active-agent', agent.id); } catch(e) { /* ignore */ }
|
||||
this.connectWs(agent.id);
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
// Show welcome tips on first use
|
||||
if (!localStorage.getItem('of-chat-tips-seen')) {
|
||||
var localMsgId = 0;
|
||||
this.messages.push({
|
||||
id: ++localMsgId,
|
||||
id: ++msgId,
|
||||
role: 'system',
|
||||
text: '**Welcome to OpenFang Chat!**\n\n' +
|
||||
'- Type `/` to see available commands\n' +
|
||||
'- `/help` shows all commands\n' +
|
||||
'- `/think on` enables extended reasoning\n' +
|
||||
'- `/context` shows context window usage\n' +
|
||||
'- `/verbose off` hides tool details\n' +
|
||||
'- `Ctrl+Shift+F` toggles focus mode\n' +
|
||||
'- Drag & drop files to attach them\n' +
|
||||
'- `Ctrl+/` opens the command palette',
|
||||
text: t('chat.welcome_message'),
|
||||
meta: '',
|
||||
tools: []
|
||||
});
|
||||
@@ -519,7 +645,14 @@ function chatPage() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/agents/' + agentId + '/session');
|
||||
if (data.messages && data.messages.length) {
|
||||
self.messages = data.messages.map(function(m) {
|
||||
// Defense-in-depth (#935): never render system-role messages in the
|
||||
// conversation history view, even if the backend somehow returns
|
||||
// one. The server already filters these out by default, but we
|
||||
// guard here too so a regression cannot leak the system prompt.
|
||||
var visible = data.messages.filter(function(m) {
|
||||
return m && m.role !== 'System' && m.role !== 'system';
|
||||
});
|
||||
self.messages = visible.map(function(m) {
|
||||
var role = m.role === 'User' ? 'user' : (m.role === 'System' ? 'system' : 'agent');
|
||||
var text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
|
||||
// Sanitize any raw function-call text from history
|
||||
@@ -557,7 +690,8 @@ function chatPage() {
|
||||
// Multi-session: create a new session
|
||||
async createSession() {
|
||||
if (!this.currentAgent) return;
|
||||
var label = prompt('Session name (optional):');
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
var label = prompt(t('chat.session_name_prompt'));
|
||||
if (label === null) return; // cancelled
|
||||
try {
|
||||
await OpenFangAPI.post('/api/agents/' + this.currentAgent.id + '/sessions', {
|
||||
@@ -567,9 +701,9 @@ function chatPage() {
|
||||
await this.loadSession(this.currentAgent.id);
|
||||
this.messages = [];
|
||||
this.scrollToBottom();
|
||||
if (typeof OpenFangToast !== 'undefined') OpenFangToast.success('New session created');
|
||||
if (typeof OpenFangToast !== 'undefined') OpenFangToast.success(t('chat.session_created'));
|
||||
} catch(e) {
|
||||
if (typeof OpenFangToast !== 'undefined') OpenFangToast.error('Failed to create session');
|
||||
if (typeof OpenFangToast !== 'undefined') OpenFangToast.error(t('chat.session_create_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -614,6 +748,15 @@ function chatPage() {
|
||||
switch (data.type) {
|
||||
case 'connected': break;
|
||||
|
||||
// Incoming message from server (e.g., cron trigger) — display as user message
|
||||
case 'message':
|
||||
if (data.content) {
|
||||
var meta = data.source === 'cron' ? '[Scheduled: ' + (data.job_name || data.job_id || '') + ']' : '';
|
||||
this.messages.push({ id: ++msgId, role: 'user', text: data.content, meta: meta, tools: [], images: [], ts: Date.now() });
|
||||
this.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
|
||||
// Legacy thinking event (backward compat)
|
||||
case 'thinking':
|
||||
if (!this.messages.length || !this.messages[this.messages.length - 1].thinking) {
|
||||
@@ -622,8 +765,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;
|
||||
|
||||
@@ -636,9 +783,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') {
|
||||
@@ -648,7 +797,8 @@ 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)) {
|
||||
// Skip phases that have no user-meaningful display text — "streaming"
|
||||
// and "done" are lifecycle signals, not status to show in the chat bubble.
|
||||
@@ -664,6 +814,7 @@ function chatPage() {
|
||||
if (!phaseMsg._reasoning) phaseMsg._reasoning = '';
|
||||
phaseMsg._reasoning += (data.detail || '') + '\n';
|
||||
phaseMsg.text = '<details><summary>Reasoning...</summary>\n\n' + phaseMsg._reasoning + '</details>';
|
||||
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.
|
||||
@@ -676,13 +827,15 @@ function chatPage() {
|
||||
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
|
||||
@@ -711,6 +864,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: [] });
|
||||
}
|
||||
@@ -718,17 +875,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: 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) {
|
||||
@@ -736,12 +896,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) {
|
||||
@@ -770,6 +932,7 @@ function chatPage() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.messages.splice(trIdx, 1, lastMsg3);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
@@ -986,8 +1149,9 @@ function chatPage() {
|
||||
}
|
||||
|
||||
// HTTP fallback
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
if (!OpenFangAPI.isWsConnected()) {
|
||||
OpenFangToast.info('Using HTTP mode (no streaming)');
|
||||
OpenFangToast.info(t('chat.using_http_mode'));
|
||||
}
|
||||
this.messages.push({ id: ++msgId, role: 'agent', text: '', meta: '', thinking: true, tools: [], ts: Date.now() });
|
||||
this.scrollToBottom();
|
||||
@@ -1030,22 +1194,55 @@ function chatPage() {
|
||||
killAgent() {
|
||||
if (!this.currentAgent) return;
|
||||
var self = this;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
var name = this.currentAgent.name;
|
||||
OpenFangToast.confirm('Stop Agent', 'Stop agent "' + name + '"? The agent will be shut down.', async function() {
|
||||
OpenFangToast.confirm(t('chat.stop_agent_title'), t('chat.stop_agent_confirm') + ' "' + name + '"?', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/agents/' + self.currentAgent.id);
|
||||
OpenFangAPI.wsDisconnect();
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
OpenFangToast.success('Agent "' + name + '" stopped');
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
OpenFangToast.success(t('chat.agent_stopped') + ' "' + name + '"');
|
||||
Alpine.store('app').refreshAgents();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to stop agent: ' + e.message);
|
||||
OpenFangToast.error(t('chat.stop_agent_failed') + ': ' + e.message);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Permanently uninstall the agent: kill + remove ~/.openfang/agents/<name>/
|
||||
// Issue #1163.
|
||||
uninstallAgent: function() {
|
||||
if (!this.currentAgent) return;
|
||||
var self = this;
|
||||
var name = this.currentAgent.name;
|
||||
var agentId = this.currentAgent.id;
|
||||
OpenFangToast.confirm(
|
||||
'Uninstall Agent',
|
||||
'Uninstall agent "' + name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
var res = await OpenFangAPI.del('/api/agents/' + agentId + '/uninstall');
|
||||
OpenFangAPI.wsDisconnect();
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
var msg = 'Agent "' + name + '" uninstalled';
|
||||
if (res && res.dir_removed === false) {
|
||||
msg += ' (no on-disk files found)';
|
||||
}
|
||||
OpenFangToast.success(msg);
|
||||
Alpine.store('app').refreshAgents();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
_latexTimer: null,
|
||||
scrollToBottom() {
|
||||
var self = this;
|
||||
@@ -1125,6 +1322,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; }
|
||||
},
|
||||
|
||||
@@ -39,7 +39,7 @@ function commsPage() {
|
||||
startSSE() {
|
||||
if (this.sseSource) this.sseSource.close();
|
||||
var self = this;
|
||||
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
|
||||
var url = '/api/comms/events/stream';
|
||||
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
|
||||
this.sseSource = new EventSource(url);
|
||||
this.sseSource.onmessage = function(ev) {
|
||||
|
||||
@@ -122,6 +122,8 @@ function handsPage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Initialize optional instance name (for multi-instance hands).
|
||||
data.instanceName = '';
|
||||
this.setupWizard = data;
|
||||
// Skip deps step if no requirements
|
||||
var hasReqs = data.requirements && data.requirements.length > 0;
|
||||
@@ -408,8 +410,14 @@ function handsPage() {
|
||||
}
|
||||
this.activatingId = handId;
|
||||
try {
|
||||
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', { config: config });
|
||||
this.showToast('Hand "' + handId + '" activated as ' + (data.agent_name || data.instance_id));
|
||||
var payload = { config: config };
|
||||
var name = (this.setupWizard.instanceName || '').trim();
|
||||
if (name) {
|
||||
payload.instance_name = name;
|
||||
}
|
||||
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', payload);
|
||||
var label = data.instance_name || data.agent_name || data.instance_id;
|
||||
this.showToast('Hand "' + handId + '" activated as ' + label);
|
||||
this.closeSetupWizard();
|
||||
await this.loadActive();
|
||||
this.tab = 'active';
|
||||
|
||||
@@ -26,13 +26,33 @@ function schedulerPage() {
|
||||
cron: '',
|
||||
agent_id: '',
|
||||
message: '',
|
||||
enabled: true
|
||||
enabled: true,
|
||||
delivery_targets: []
|
||||
},
|
||||
creating: false,
|
||||
|
||||
// -- Run Now state --
|
||||
runningJobId: '',
|
||||
|
||||
// -- Delivery targets picker (create modal) --
|
||||
showTargetPicker: false,
|
||||
pickerType: 'channel',
|
||||
draftTarget: null,
|
||||
|
||||
// -- Expanded job / delivery log state --
|
||||
expandedJobId: '',
|
||||
deliveryLog: { targets: [], entries: [] },
|
||||
deliveryLogLoading: false,
|
||||
deliveryLogError: '',
|
||||
|
||||
// -- Edit targets state (per-existing-job) --
|
||||
editingTargetsJobId: '',
|
||||
editingTargets: [],
|
||||
savingTargets: false,
|
||||
|
||||
// -- Available channel types (populated from /api/channels) --
|
||||
channelTypes: [],
|
||||
|
||||
// Cron presets
|
||||
cronPresets: [
|
||||
{ label: 'Every minute', cron: '* * * * *' },
|
||||
@@ -55,12 +75,32 @@ function schedulerPage() {
|
||||
this.loadError = '';
|
||||
try {
|
||||
await this.loadJobs();
|
||||
// Channels are optional — failure is non-fatal for the scheduler page.
|
||||
this.loadChannelTypes();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load scheduler data.';
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadChannelTypes() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/channels');
|
||||
// /api/channels returns an array of channel descriptors; pull names.
|
||||
var list = Array.isArray(data) ? data : (data && data.channels) || [];
|
||||
var names = [];
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var ch = list[i];
|
||||
var name = ch && (ch.name || ch.display_name || ch.channel_type);
|
||||
if (name && names.indexOf(name) === -1) names.push(name);
|
||||
}
|
||||
this.channelTypes = names;
|
||||
} catch(e) {
|
||||
// Fall through silently — the form uses a plain input as fallback.
|
||||
this.channelTypes = [];
|
||||
}
|
||||
},
|
||||
|
||||
async loadJobs() {
|
||||
var data = await OpenFangAPI.get('/api/cron/jobs');
|
||||
var raw = data.jobs || [];
|
||||
@@ -82,6 +122,7 @@ function schedulerPage() {
|
||||
last_run: j.last_run,
|
||||
next_run: j.next_run,
|
||||
delivery: j.delivery ? j.delivery.kind || '' : '',
|
||||
delivery_targets: Array.isArray(j.delivery_targets) ? j.delivery_targets : [],
|
||||
created_at: j.created_at
|
||||
};
|
||||
});
|
||||
@@ -162,9 +203,12 @@ function schedulerPage() {
|
||||
delivery: { kind: 'last_channel' },
|
||||
enabled: this.newJob.enabled
|
||||
};
|
||||
if (this.newJob.delivery_targets && this.newJob.delivery_targets.length) {
|
||||
body.delivery_targets = this.newJob.delivery_targets.map(this.sanitizeTarget);
|
||||
}
|
||||
await OpenFangAPI.post('/api/cron/jobs', body);
|
||||
this.showCreateForm = false;
|
||||
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
|
||||
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true, delivery_targets: [] };
|
||||
OpenFangToast.success('Schedule "' + jobName + '" created');
|
||||
await this.loadJobs();
|
||||
} catch(e) {
|
||||
@@ -201,19 +245,225 @@ 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 = '';
|
||||
},
|
||||
|
||||
// ── Delivery target editing (create modal) ──
|
||||
|
||||
openTargetPicker() {
|
||||
this.pickerType = 'channel';
|
||||
this.draftTarget = this.blankTarget('channel');
|
||||
this.showTargetPicker = true;
|
||||
},
|
||||
|
||||
cancelTargetPicker() {
|
||||
this.showTargetPicker = false;
|
||||
this.draftTarget = null;
|
||||
},
|
||||
|
||||
onPickerTypeChange() {
|
||||
this.draftTarget = this.blankTarget(this.pickerType);
|
||||
},
|
||||
|
||||
blankTarget(type) {
|
||||
if (type === 'channel') {
|
||||
return { type: 'channel', channel_type: '', recipient: '' };
|
||||
}
|
||||
if (type === 'webhook') {
|
||||
return { type: 'webhook', url: '', auth_header: '' };
|
||||
}
|
||||
if (type === 'local_file') {
|
||||
return { type: 'local_file', path: '', append: false };
|
||||
}
|
||||
if (type === 'email') {
|
||||
return { type: 'email', to: '', subject_template: '' };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
addDraftTarget() {
|
||||
var err = this.validateTarget(this.draftTarget);
|
||||
if (err) {
|
||||
OpenFangToast.warn(err);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(this.newJob.delivery_targets)) this.newJob.delivery_targets = [];
|
||||
this.newJob.delivery_targets.push(this.sanitizeTarget(this.draftTarget));
|
||||
this.showTargetPicker = false;
|
||||
this.draftTarget = null;
|
||||
},
|
||||
|
||||
removeTarget(idx) {
|
||||
if (!Array.isArray(this.newJob.delivery_targets)) return;
|
||||
this.newJob.delivery_targets.splice(idx, 1);
|
||||
},
|
||||
|
||||
validateTarget(t) {
|
||||
if (!t || !t.type) return 'Pick a target type';
|
||||
if (t.type === 'channel') {
|
||||
if (!t.channel_type || !t.channel_type.trim()) return 'Channel type is required';
|
||||
if (!t.recipient || !t.recipient.trim()) return 'Recipient is required';
|
||||
} else if (t.type === 'webhook') {
|
||||
if (!t.url || !t.url.trim()) return 'Webhook URL is required';
|
||||
if (t.url.indexOf('http://') !== 0 && t.url.indexOf('https://') !== 0) {
|
||||
return 'Webhook URL must start with http:// or https://';
|
||||
}
|
||||
} else if (t.type === 'local_file') {
|
||||
if (!t.path || !t.path.trim()) return 'File path is required';
|
||||
} else if (t.type === 'email') {
|
||||
if (!t.to || !t.to.trim()) return 'Recipient email is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Strip empty-string optional fields so serde accepts the payload cleanly.
|
||||
sanitizeTarget(t) {
|
||||
if (!t) return null;
|
||||
var out = { type: t.type };
|
||||
if (t.type === 'channel') {
|
||||
out.channel_type = (t.channel_type || '').trim();
|
||||
out.recipient = (t.recipient || '').trim();
|
||||
} else if (t.type === 'webhook') {
|
||||
out.url = (t.url || '').trim();
|
||||
if (t.auth_header && t.auth_header.trim()) out.auth_header = t.auth_header.trim();
|
||||
} else if (t.type === 'local_file') {
|
||||
out.path = (t.path || '').trim();
|
||||
out.append = !!t.append;
|
||||
} else if (t.type === 'email') {
|
||||
out.to = (t.to || '').trim();
|
||||
if (t.subject_template && t.subject_template.trim()) {
|
||||
out.subject_template = t.subject_template.trim();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
// ── Chip rendering helpers ──
|
||||
|
||||
targetChipLabel(t) {
|
||||
if (!t || !t.type) return '?';
|
||||
if (t.type === 'channel') return 'CHANNEL: ' + (t.channel_type || '?');
|
||||
if (t.type === 'webhook') return 'WEBHOOK';
|
||||
if (t.type === 'local_file') return 'FILE: ' + this.truncate(t.path || '', 28);
|
||||
if (t.type === 'email') return 'EMAIL: ' + this.truncate(t.to || '', 24);
|
||||
return t.type.toUpperCase();
|
||||
},
|
||||
|
||||
targetChipClass(t) {
|
||||
if (!t || !t.type) return 'badge-dim';
|
||||
if (t.type === 'channel') return 'badge-info';
|
||||
if (t.type === 'webhook') return 'badge-created';
|
||||
if (t.type === 'local_file') return 'badge-muted';
|
||||
if (t.type === 'email') return 'badge-warn';
|
||||
return 'badge-dim';
|
||||
},
|
||||
|
||||
targetSummary(t) {
|
||||
if (!t) return '';
|
||||
if (t.type === 'channel') return (t.channel_type || '?') + ' -> ' + (t.recipient || '?');
|
||||
if (t.type === 'webhook') return t.url || '(no url)';
|
||||
if (t.type === 'local_file') return (t.append ? 'append ' : 'overwrite ') + (t.path || '');
|
||||
if (t.type === 'email') {
|
||||
var base = t.to || '';
|
||||
if (t.subject_template) base += ' · subject: ' + t.subject_template;
|
||||
return base;
|
||||
}
|
||||
return JSON.stringify(t);
|
||||
},
|
||||
|
||||
// ── Expand row / delivery log ──
|
||||
|
||||
async toggleExpand(job) {
|
||||
if (this.expandedJobId === job.id) {
|
||||
this.expandedJobId = '';
|
||||
return;
|
||||
}
|
||||
this.expandedJobId = job.id;
|
||||
this.deliveryLog = { targets: [], entries: [] };
|
||||
this.deliveryLogError = '';
|
||||
this.deliveryLogLoading = true;
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/schedules/' + job.id + '/delivery-log');
|
||||
this.deliveryLog = {
|
||||
targets: Array.isArray(data.targets) ? data.targets : [],
|
||||
entries: Array.isArray(data.entries) ? data.entries : []
|
||||
};
|
||||
} catch(e) {
|
||||
this.deliveryLogError = e.message || 'Could not load delivery log.';
|
||||
}
|
||||
this.deliveryLogLoading = false;
|
||||
},
|
||||
|
||||
// ── Edit targets on existing job ──
|
||||
|
||||
startEditTargets(job) {
|
||||
this.editingTargetsJobId = job.id;
|
||||
// Clone so cancel doesn't mutate the loaded list.
|
||||
this.editingTargets = (job.delivery_targets || []).map(function(t) {
|
||||
return JSON.parse(JSON.stringify(t));
|
||||
});
|
||||
this.pickerType = 'channel';
|
||||
this.draftTarget = null;
|
||||
this.showTargetPicker = false;
|
||||
},
|
||||
|
||||
cancelEditTargets() {
|
||||
this.editingTargetsJobId = '';
|
||||
this.editingTargets = [];
|
||||
this.draftTarget = null;
|
||||
this.showTargetPicker = false;
|
||||
},
|
||||
|
||||
addEditTarget() {
|
||||
this.pickerType = 'channel';
|
||||
this.draftTarget = this.blankTarget('channel');
|
||||
this.showTargetPicker = true;
|
||||
},
|
||||
|
||||
addDraftTargetToEdit() {
|
||||
var err = this.validateTarget(this.draftTarget);
|
||||
if (err) {
|
||||
OpenFangToast.warn(err);
|
||||
return;
|
||||
}
|
||||
this.editingTargets.push(this.sanitizeTarget(this.draftTarget));
|
||||
this.showTargetPicker = false;
|
||||
this.draftTarget = null;
|
||||
},
|
||||
|
||||
removeEditTarget(idx) {
|
||||
this.editingTargets.splice(idx, 1);
|
||||
},
|
||||
|
||||
async saveEditTargets() {
|
||||
if (!this.editingTargetsJobId) return;
|
||||
this.savingTargets = true;
|
||||
try {
|
||||
var clean = this.editingTargets.map(this.sanitizeTarget);
|
||||
await OpenFangAPI.put('/api/schedules/' + this.editingTargetsJobId, {
|
||||
delivery_targets: clean
|
||||
});
|
||||
OpenFangToast.success('Delivery targets updated');
|
||||
this.cancelEditTargets();
|
||||
await this.loadJobs();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update targets: ' + (e.message || e));
|
||||
}
|
||||
this.savingTargets = false;
|
||||
},
|
||||
|
||||
// ── Trigger helpers ──
|
||||
|
||||
triggerType(pattern) {
|
||||
@@ -374,6 +624,12 @@ function schedulerPage() {
|
||||
} catch(e) { return 'never'; }
|
||||
},
|
||||
|
||||
truncate(s, n) {
|
||||
if (!s) return '';
|
||||
if (s.length <= n) return s;
|
||||
return s.substring(0, n - 1) + '…';
|
||||
},
|
||||
|
||||
jobCount() {
|
||||
var enabled = 0;
|
||||
for (var i = 0; i < this.jobs.length; i++) {
|
||||
|
||||
@@ -64,15 +64,20 @@ function sessionsPage() {
|
||||
|
||||
deleteSession(sessionId) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Delete Session', 'This will permanently remove the session and its messages.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/sessions/' + sessionId);
|
||||
self.sessions = self.sessions.filter(function(s) { return s.session_id !== sessionId; });
|
||||
OpenFangToast.success('Session deleted');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete session: ' + e.message);
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
OpenFangToast.confirm(
|
||||
t('sessions.delete_session') || 'Delete Session',
|
||||
t('sessions.delete_confirm') || 'This will permanently remove the session and its messages.',
|
||||
async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/sessions/' + sessionId);
|
||||
self.sessions = self.sessions.filter(function(s) { return s.session_id !== sessionId; });
|
||||
OpenFangToast.success('Session deleted');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete session: ' + e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
},
|
||||
|
||||
// -- Memory methods --
|
||||
@@ -108,15 +113,20 @@ function sessionsPage() {
|
||||
|
||||
deleteKey(key) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Delete Key', 'Delete key "' + key + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/memory/agents/' + self.memAgentId + '/kv/' + encodeURIComponent(key));
|
||||
OpenFangToast.success('Key "' + key + '" deleted');
|
||||
await self.loadKv();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete key: ' + e.message);
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
OpenFangToast.confirm(
|
||||
t('sessions.delete_key') || 'Delete Key',
|
||||
(t('sessions.delete_key_confirm') || 'Delete key') + ' "' + key + '"? This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/memory/agents/' + self.memAgentId + '/kv/' + encodeURIComponent(key));
|
||||
OpenFangToast.success('Key "' + key + '" deleted');
|
||||
await self.loadKv();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete key: ' + e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
},
|
||||
|
||||
startEdit(kv) {
|
||||
|
||||
@@ -25,6 +25,9 @@ function settingsPage() {
|
||||
providerUrlSaving: {},
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
providerSearch: '',
|
||||
providerStatusFilter: '',
|
||||
providerCategoryFilter: '',
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
customProviderName: '',
|
||||
customProviderUrl: '',
|
||||
@@ -338,6 +341,94 @@ function settingsPage() {
|
||||
return Object.keys(seen).sort();
|
||||
},
|
||||
|
||||
/// Coarse category for a provider used to group the Providers tab.
|
||||
/// Returns: 'frontier' | 'oss' | 'local' | 'aggregator' | 'regional' | 'other'.
|
||||
providerCategory(p) {
|
||||
if (!p) return 'other';
|
||||
if (p.is_local || p.key_required === false) return 'local';
|
||||
var id = (p.id || '').toLowerCase();
|
||||
var FRONTIER = ['anthropic','openai','gemini','google','xai','bedrock','azure','vertex'];
|
||||
var OSS = ['groq','together','fireworks','cerebras','sambanova','deepseek','mistral','perplexity','cohere','ai21','huggingface','replicate','nvidia','venice','novita','chutes'];
|
||||
var AGG = ['openrouter','litellm','github-copilot','claude-code'];
|
||||
var REGIONAL = ['qwen','minimax','zhipu','zai','moonshot','qianfan','volcengine','kimi'];
|
||||
if (FRONTIER.indexOf(id) !== -1) return 'frontier';
|
||||
if (REGIONAL.indexOf(id) !== -1) return 'regional';
|
||||
if (AGG.indexOf(id) !== -1) return 'aggregator';
|
||||
if (OSS.indexOf(id) !== -1) return 'oss';
|
||||
return 'other';
|
||||
},
|
||||
|
||||
providerCategoryLabel(cat) {
|
||||
switch (cat) {
|
||||
case 'frontier': return 'Frontier (Anthropic, OpenAI, Google, xAI, Bedrock)';
|
||||
case 'oss': return 'Open-Weight Hosts (Groq, Together, Fireworks, DeepSeek, etc.)';
|
||||
case 'aggregator': return 'Aggregators & Gateways (OpenRouter, GitHub Copilot)';
|
||||
case 'regional': return 'Regional / China (Qwen, Zhipu, Moonshot, MiniMax)';
|
||||
case 'local': return 'Local / Self-Hosted (Ollama, vLLM, LM Studio, Lemonade)';
|
||||
default: return 'Other Providers';
|
||||
}
|
||||
},
|
||||
|
||||
/// Stable category order for grouped rendering.
|
||||
get providerCategoriesOrdered() {
|
||||
return ['frontier', 'oss', 'aggregator', 'regional', 'local', 'other'];
|
||||
},
|
||||
|
||||
/// Returns filter-matched providers grouped by category, preserving order.
|
||||
/// Each entry: { category, label, items: [...] }. Empty groups are omitted.
|
||||
get providersGrouped() {
|
||||
var self = this;
|
||||
var filtered = this.filteredProviders;
|
||||
var by = {};
|
||||
filtered.forEach(function(p) {
|
||||
var c = self.providerCategory(p);
|
||||
if (!by[c]) by[c] = [];
|
||||
by[c].push(p);
|
||||
});
|
||||
// Sort each group: configured first, then alphabetical
|
||||
Object.keys(by).forEach(function(c) {
|
||||
by[c].sort(function(a, b) {
|
||||
var ac = a.auth_status === 'configured' ? 0 : 1;
|
||||
var bc = b.auth_status === 'configured' ? 0 : 1;
|
||||
if (ac !== bc) return ac - bc;
|
||||
return (a.display_name || a.id).localeCompare(b.display_name || b.id);
|
||||
});
|
||||
});
|
||||
var out = [];
|
||||
this.providerCategoriesOrdered.forEach(function(c) {
|
||||
if (by[c] && by[c].length) {
|
||||
out.push({ category: c, label: self.providerCategoryLabel(c), items: by[c] });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
},
|
||||
|
||||
get filteredProviders() {
|
||||
var self = this;
|
||||
return this.providers.filter(function(p) {
|
||||
if (self.providerStatusFilter === 'configured' && p.auth_status !== 'configured') return false;
|
||||
if (self.providerStatusFilter === 'unconfigured' && p.auth_status === 'configured') return false;
|
||||
if (self.providerCategoryFilter && self.providerCategory(p) !== self.providerCategoryFilter) return false;
|
||||
if (self.providerSearch) {
|
||||
var q = self.providerSearch.toLowerCase();
|
||||
if ((p.display_name || '').toLowerCase().indexOf(q) === -1 &&
|
||||
(p.id || '').toLowerCase().indexOf(q) === -1 &&
|
||||
(p.api_key_env || '').toLowerCase().indexOf(q) === -1) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
get configuredProviderCount() {
|
||||
return this.providers.filter(function(p) { return p.auth_status === 'configured'; }).length;
|
||||
},
|
||||
|
||||
clearProviderFilters() {
|
||||
this.providerSearch = '';
|
||||
this.providerStatusFilter = '';
|
||||
this.providerCategoryFilter = '';
|
||||
},
|
||||
|
||||
get uniqueTiers() {
|
||||
var seen = {};
|
||||
this.models.forEach(function(m) { if (m.tier) seen[m.tier] = true; });
|
||||
|
||||
@@ -30,31 +30,44 @@ function skillsPage() {
|
||||
skillCodeFilename: '',
|
||||
skillCodeLoading: false,
|
||||
|
||||
// Skill config modal (local skill configuration from SKILL.md frontmatter)
|
||||
configSkill: null, // skill object whose config is being edited
|
||||
configDeclared: {}, // { var_name: { description, env, default, required } }
|
||||
configResolved: {}, // { var_name: { value, source, is_secret } }
|
||||
configDraft: {}, // { var_name: user-edited string value }
|
||||
configRevealed: {}, // { var_name: bool } — toggle password reveal per row
|
||||
configLoading: false,
|
||||
configSaving: false,
|
||||
configError: '',
|
||||
|
||||
// MCP servers
|
||||
mcpServers: [],
|
||||
mcpLoading: false,
|
||||
|
||||
// Category definitions from the OpenClaw ecosystem
|
||||
categories: [
|
||||
{ id: 'coding', name: 'Coding & IDEs' },
|
||||
{ id: 'git', name: 'Git & GitHub' },
|
||||
{ id: 'web', name: 'Web & Frontend' },
|
||||
{ id: 'devops', name: 'DevOps & Cloud' },
|
||||
{ id: 'browser', name: 'Browser & Automation' },
|
||||
{ id: 'search', name: 'Search & Research' },
|
||||
{ id: 'ai', name: 'AI & LLMs' },
|
||||
{ id: 'data', name: 'Data & Analytics' },
|
||||
{ id: 'productivity', name: 'Productivity' },
|
||||
{ id: 'communication', name: 'Communication' },
|
||||
{ id: 'media', name: 'Media & Streaming' },
|
||||
{ id: 'notes', name: 'Notes & PKM' },
|
||||
{ id: 'security', name: 'Security' },
|
||||
{ id: 'cli', name: 'CLI Utilities' },
|
||||
{ id: 'marketing', name: 'Marketing & Sales' },
|
||||
{ id: 'finance', name: 'Finance' },
|
||||
{ id: 'smart-home', name: 'Smart Home & IoT' },
|
||||
{ id: 'docs', name: 'PDF & Documents' },
|
||||
],
|
||||
// Category definitions from the OpenClaw ecosystem (loaded from i18n)
|
||||
get categories() {
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
return [
|
||||
{ id: 'coding', name: t('skills.cat_coding') || 'Coding & IDEs' },
|
||||
{ id: 'git', name: t('skills.cat_git') || 'Git & GitHub' },
|
||||
{ id: 'web', name: t('skills.cat_frontend') || 'Web & Frontend' },
|
||||
{ id: 'devops', name: t('skills.cat_devops') || 'DevOps & Cloud' },
|
||||
{ id: 'browser', name: t('skills.cat_browser') || 'Browser & Automation' },
|
||||
{ id: 'search', name: t('skills.cat_search') || 'Search & Research' },
|
||||
{ id: 'ai', name: t('skills.cat_ai') || 'AI & ML' },
|
||||
{ id: 'data', name: t('skills.cat_data') || 'Data & Analytics' },
|
||||
{ id: 'productivity', name: t('skills.cat_productivity') || 'Productivity' },
|
||||
{ id: 'communication', name: t('skills.cat_communication') || 'Communication' },
|
||||
{ id: 'media', name: t('skills.cat_media') || 'Media & Streaming' },
|
||||
{ id: 'notes', name: t('skills.cat_notes') || 'Notes & PKM' },
|
||||
{ id: 'security', name: t('skills.cat_security') || 'Security' },
|
||||
{ id: 'cli', name: t('skills.cat_cli') || 'CLI Utilities' },
|
||||
{ id: 'marketing', name: t('skills.cat_marketing') || 'Marketing & Sales' },
|
||||
{ id: 'finance', name: t('skills.cat_finance') || 'Finance' },
|
||||
{ id: 'smart-home', name: t('skills.cat_smarthome') || 'Smart Home & IoT' },
|
||||
{ id: 'docs', name: t('skills.cat_docs') || 'Documentation' },
|
||||
];
|
||||
},
|
||||
|
||||
runtimeBadge: function(rt) {
|
||||
var r = (rt || '').toLowerCase();
|
||||
@@ -98,7 +111,8 @@ function skillsPage() {
|
||||
tags: s.tags || [],
|
||||
enabled: s.enabled !== false,
|
||||
source: s.source || { type: 'local' },
|
||||
has_prompt_context: !!s.has_prompt_context
|
||||
has_prompt_context: !!s.has_prompt_context,
|
||||
config_declared_count: s.config_declared_count || 0
|
||||
};
|
||||
});
|
||||
} catch(e) {
|
||||
@@ -108,6 +122,161 @@ function skillsPage() {
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
// ── Skill config editing ────────────────────────────────────────────
|
||||
async openSkillConfig(skill) {
|
||||
this.configSkill = skill;
|
||||
this.configDeclared = {};
|
||||
this.configResolved = {};
|
||||
this.configDraft = {};
|
||||
this.configRevealed = {};
|
||||
this.configError = '';
|
||||
this.configLoading = true;
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/skills/' + encodeURIComponent(skill.name) + '/config');
|
||||
this.configDeclared = data.declared || {};
|
||||
this.configResolved = data.resolved || {};
|
||||
// Pre-populate draft values only for vars the user has already
|
||||
// overridden — never copy redacted responses back into inputs or
|
||||
// they'd be re-saved as "****redacted****" strings.
|
||||
var names = Object.keys(this.configDeclared);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var n = names[i];
|
||||
var res = this.configResolved[n] || {};
|
||||
if (res.source === 'user' && !res.is_secret) {
|
||||
this.configDraft[n] = res.value == null ? '' : String(res.value);
|
||||
} else {
|
||||
this.configDraft[n] = '';
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
this.configError = e.message || 'Failed to load skill config.';
|
||||
}
|
||||
this.configLoading = false;
|
||||
},
|
||||
|
||||
closeSkillConfig() {
|
||||
this.configSkill = null;
|
||||
this.configDeclared = {};
|
||||
this.configResolved = {};
|
||||
this.configDraft = {};
|
||||
this.configRevealed = {};
|
||||
this.configError = '';
|
||||
},
|
||||
|
||||
configRowInvalid(name) {
|
||||
// A required var is invalid iff the user hasn't entered anything AND
|
||||
// no env/default resolves it. Source from the server tells us where
|
||||
// the current value came from; if it's "unresolved" and the draft is
|
||||
// blank, the save would write an empty string over the required var.
|
||||
var decl = this.configDeclared[name] || {};
|
||||
if (!decl.required) return false;
|
||||
var draft = (this.configDraft[name] || '').trim();
|
||||
if (draft) return false;
|
||||
var res = this.configResolved[name] || {};
|
||||
if (res.source === 'env' || res.source === 'default') return false;
|
||||
// If the user has an existing secret override we don't want to force
|
||||
// them to re-type it — treat that as "currently resolved".
|
||||
if (res.source === 'user') return false;
|
||||
return true;
|
||||
},
|
||||
|
||||
hasInvalidConfig() {
|
||||
var names = Object.keys(this.configDeclared);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
if (this.configRowInvalid(names[i])) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
toggleReveal(name) {
|
||||
this.configRevealed[name] = !this.configRevealed[name];
|
||||
},
|
||||
|
||||
sourceBadgeClass(source) {
|
||||
switch (source) {
|
||||
case 'user': return 'badge-success';
|
||||
case 'env': return 'badge-info';
|
||||
case 'default': return 'badge-dim';
|
||||
default: return 'badge-danger';
|
||||
}
|
||||
},
|
||||
|
||||
sourceBadgeLabel(res) {
|
||||
if (!res) return 'unresolved';
|
||||
switch (res.source) {
|
||||
case 'user': return 'user override';
|
||||
case 'env': return 'env' + ((this.configDeclared[res.__name] && this.configDeclared[res.__name].env) ? ':' + this.configDeclared[res.__name].env : '');
|
||||
case 'default': return 'default';
|
||||
default: return 'unresolved';
|
||||
}
|
||||
},
|
||||
|
||||
async saveSkillConfig() {
|
||||
if (!this.configSkill) return;
|
||||
if (this.hasInvalidConfig()) {
|
||||
OpenFangToast.error('Fill in all required variables before saving.');
|
||||
return;
|
||||
}
|
||||
this.configSaving = true;
|
||||
this.configError = '';
|
||||
// Only PUT values the user actually typed. Empty strings are dropped
|
||||
// so we don't silently clobber an env/default with "".
|
||||
var payload = {};
|
||||
var names = Object.keys(this.configDeclared);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var n = names[i];
|
||||
var v = (this.configDraft[n] || '').trim();
|
||||
if (v.length > 0) payload[n] = v;
|
||||
}
|
||||
try {
|
||||
await OpenFangAPI.put('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config', { values: payload });
|
||||
OpenFangToast.success('Saved, reloading agents\u2026');
|
||||
// Refresh the modal contents so the new source/value shows up.
|
||||
var refreshed = this.configSkill;
|
||||
await this.loadSkills();
|
||||
this.closeSkillConfig();
|
||||
// Find the possibly-refreshed skill object and reopen.
|
||||
var self = this;
|
||||
var updated = this.skills.find(function(s) { return s.name === refreshed.name; });
|
||||
if (updated) await self.openSkillConfig(updated);
|
||||
} catch(e) {
|
||||
this.configError = e.message || 'Save failed.';
|
||||
OpenFangToast.error('Save failed: ' + (e.message || 'unknown error'));
|
||||
}
|
||||
this.configSaving = false;
|
||||
},
|
||||
|
||||
async resetSkillConfigVar(name) {
|
||||
if (!this.configSkill) return;
|
||||
var decl = this.configDeclared[name] || {};
|
||||
var res = this.configResolved[name] || {};
|
||||
// If the server is already reporting a non-user source there's nothing
|
||||
// to remove; just clear the draft so the input disappears.
|
||||
if (res.source !== 'user') {
|
||||
this.configDraft[name] = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await OpenFangAPI.del('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config/' + encodeURIComponent(name));
|
||||
OpenFangToast.success('Reset ' + name);
|
||||
// Refresh modal state from server.
|
||||
var data = await OpenFangAPI.get('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config');
|
||||
this.configResolved = data.resolved || {};
|
||||
this.configDraft[name] = '';
|
||||
} catch(e) {
|
||||
var msg = e.message || 'Reset failed';
|
||||
if (msg.indexOf('required') !== -1 || msg.indexOf('409') !== -1) {
|
||||
OpenFangToast.error('Cannot reset: ' + decl.description + ' is required with no fallback.');
|
||||
} else {
|
||||
OpenFangToast.error('Reset failed: ' + msg);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
get configDeclaredNames() {
|
||||
return Object.keys(this.configDeclared).sort();
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
await this.loadSkills();
|
||||
},
|
||||
@@ -264,15 +433,20 @@ function skillsPage() {
|
||||
// Uninstall
|
||||
uninstallSkill: function(name) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Uninstall Skill', 'Uninstall skill "' + name + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
|
||||
OpenFangToast.success('Skill "' + name + '" uninstalled');
|
||||
await self.loadSkills();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
OpenFangToast.confirm(
|
||||
t('skills.uninstall_skill') || 'Uninstall Skill',
|
||||
t('skills.uninstall_confirm') + ' "' + name + '"? This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
|
||||
OpenFangToast.success('Skill "' + name + '" uninstalled');
|
||||
await self.loadSkills();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
},
|
||||
|
||||
// Create prompt-only skill
|
||||
|
||||
@@ -191,6 +191,33 @@ function analyticsPage() {
|
||||
return segments;
|
||||
},
|
||||
|
||||
donutSegmentsSvg() {
|
||||
var segments = this.donutSegments();
|
||||
if (!segments.length) return '';
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
var out = [];
|
||||
for (var i = 0; i < segments.length; i++) {
|
||||
var seg = segments[i];
|
||||
var title = seg.provider + ': ' + seg.percent + '% (' + this.formatCost(seg.cost) + ')';
|
||||
out.push(
|
||||
'<circle cx="80" cy="80" r="60" fill="none" stroke="' + escapeXml(seg.color) + '" stroke-width="24" stroke-dasharray="' + escapeXml(seg.dasharray) + '" stroke-dashoffset="' + escapeXml(seg.dashoffset) + '" transform="rotate(-90 80 80)" class="donut-segment">' +
|
||||
'<title>' + escapeXml(title) + '</title>' +
|
||||
'</circle>'
|
||||
);
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
},
|
||||
|
||||
// ── Bar chart (last 7 days) ──
|
||||
|
||||
barChartData() {
|
||||
@@ -218,6 +245,42 @@ function analyticsPage() {
|
||||
return result;
|
||||
},
|
||||
|
||||
barChartSvg() {
|
||||
var bars = this.barChartData();
|
||||
if (!bars.length) return '';
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
var out = [];
|
||||
for (var i = 0; i < bars.length; i++) {
|
||||
var bar = bars[i];
|
||||
var x = i * 50 + 18;
|
||||
var labelX = i * 50 + 30;
|
||||
var y = 150 - bar.barHeight;
|
||||
var costLabelY = y - 4;
|
||||
var title = bar.date + ': ' + this.formatCost(bar.cost) + ' (' + bar.calls + ' calls)';
|
||||
|
||||
out.push(
|
||||
'<g>' +
|
||||
'<rect x="' + x + '" y="' + y + '" width="24" height="' + bar.barHeight + '" rx="3" fill="var(--accent)" class="cost-bar" style="opacity:0.85">' +
|
||||
'<title>' + escapeXml(title) + '</title>' +
|
||||
'</rect>' +
|
||||
'<text x="' + labelX + '" y="166" text-anchor="middle" fill="var(--text-muted)" style="font-size:9px;font-family:var(--font-mono)">' + escapeXml(bar.dayName) + '</text>' +
|
||||
'<text x="' + labelX + '" y="' + costLabelY + '" text-anchor="middle" fill="var(--text-dim)" style="font-size:8px;font-family:var(--font-mono)">' + escapeXml(this.formatCost(bar.cost)) + '</text>' +
|
||||
'</g>'
|
||||
);
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
},
|
||||
|
||||
// ── Cost by model table (sorted by cost descending) ──
|
||||
|
||||
costByModelSorted() {
|
||||
|
||||
@@ -158,15 +158,17 @@ function wizardPage() {
|
||||
return this.templates.filter(function(t) { return t.category === cat; });
|
||||
},
|
||||
|
||||
// Step 3: Profile/tool descriptions
|
||||
profileDescriptions: {
|
||||
minimal: { label: 'Minimal', desc: 'Read-only file access' },
|
||||
coding: { label: 'Coding', desc: 'Files + shell + web fetch' },
|
||||
research: { label: 'Research', desc: 'Web search + file read/write' },
|
||||
balanced: { label: 'Balanced', desc: 'General-purpose tool set' },
|
||||
precise: { label: 'Precise', desc: 'Focused tool set for accuracy' },
|
||||
creative: { label: 'Creative', desc: 'Full tools with creative emphasis' },
|
||||
full: { label: 'Full', desc: 'All 35+ tools' }
|
||||
// Step 3: Profile/tool descriptions (loaded from i18n)
|
||||
get profileDescriptions() {
|
||||
return {
|
||||
minimal: { label: window.i18n ? window.i18n.t('wizard.profile_minimal') : 'Minimal', desc: window.i18n ? window.i18n.t('wizard.profile_minimal_desc') : 'Read-only file access' },
|
||||
coding: { label: window.i18n ? window.i18n.t('wizard.profile_coding') : 'Coding', desc: window.i18n ? window.i18n.t('wizard.profile_coding_desc') : 'Files + shell + web fetch' },
|
||||
research: { label: window.i18n ? window.i18n.t('wizard.profile_research') : 'Research', desc: window.i18n ? window.i18n.t('wizard.profile_research_desc') : 'Web search + file read/write' },
|
||||
balanced: { label: window.i18n ? window.i18n.t('wizard.profile_balanced') : 'Balanced', desc: window.i18n ? window.i18n.t('wizard.profile_balanced_desc') : 'General-purpose tool set' },
|
||||
precise: { label: window.i18n ? window.i18n.t('wizard.profile_precise') : 'Precise', desc: window.i18n ? window.i18n.t('wizard.profile_precise_desc') : 'Focused tool set for accuracy' },
|
||||
creative: { label: window.i18n ? window.i18n.t('wizard.profile_creative') : 'Creative', desc: window.i18n ? window.i18n.t('wizard.profile_creative_desc') : 'Full tools with creative emphasis' },
|
||||
full: { label: window.i18n ? window.i18n.t('wizard.profile_full') : 'Full', desc: window.i18n ? window.i18n.t('wizard.profile_full_desc') : 'All 35+ tools' }
|
||||
};
|
||||
},
|
||||
profileInfo: function(name) { return this.profileDescriptions[name] || { label: name, desc: '' }; },
|
||||
|
||||
@@ -174,12 +176,35 @@ function wizardPage() {
|
||||
tryItMessages: [],
|
||||
tryItInput: '',
|
||||
tryItSending: false,
|
||||
suggestedMessages: {
|
||||
'General': ['What can you help me with?', 'Tell me a fun fact', 'Summarize the latest AI news'],
|
||||
'Development': ['Write a Python hello world', 'Explain async/await', 'Review this code snippet'],
|
||||
'Research': ['Explain quantum computing simply', 'Compare React vs Vue', 'What are the latest trends in AI?'],
|
||||
'Writing': ['Help me write a professional email', 'Improve this paragraph', 'Write a blog intro about AI'],
|
||||
'Business': ['Draft a meeting agenda', 'How do I handle a complaint?', 'Create a project status update']
|
||||
get suggestedMessages() {
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
return {
|
||||
'General': [
|
||||
t('wizard.suggestions.general.1') || 'What can you help me with?',
|
||||
t('wizard.suggestions.general.2') || 'Tell me a fun fact',
|
||||
t('wizard.suggestions.general.3') || 'Summarize the latest AI news'
|
||||
],
|
||||
'Development': [
|
||||
t('wizard.suggestions.development.1') || 'Write a Python hello world',
|
||||
t('wizard.suggestions.development.2') || 'Explain async/await',
|
||||
t('wizard.suggestions.development.3') || 'Review this code snippet'
|
||||
],
|
||||
'Research': [
|
||||
t('wizard.suggestions.research.1') || 'Explain quantum computing simply',
|
||||
t('wizard.suggestions.research.2') || 'Compare React vs Vue',
|
||||
t('wizard.suggestions.research.3') || 'What are the latest trends in AI?'
|
||||
],
|
||||
'Writing': [
|
||||
t('wizard.suggestions.writing.1') || 'Help me write a professional email',
|
||||
t('wizard.suggestions.writing.2') || 'Improve this paragraph',
|
||||
t('wizard.suggestions.writing.3') || 'Write a blog intro about AI'
|
||||
],
|
||||
'Business': [
|
||||
t('wizard.suggestions.business.1') || 'Draft a meeting agenda',
|
||||
t('wizard.suggestions.business.2') || 'How do I handle a complaint?',
|
||||
t('wizard.suggestions.business.3') || 'Create a project status update'
|
||||
]
|
||||
};
|
||||
},
|
||||
get currentSuggestions() {
|
||||
var tpl = this.templates[this.selectedTemplate];
|
||||
@@ -204,38 +229,41 @@ function wizardPage() {
|
||||
|
||||
// Step 5: Channel setup (optional)
|
||||
channelType: '',
|
||||
channelOptions: [
|
||||
{
|
||||
name: 'telegram',
|
||||
display_name: 'Telegram',
|
||||
icon: 'TG',
|
||||
description: 'Connect your agent to a Telegram bot for messaging.',
|
||||
token_label: 'Bot Token',
|
||||
token_placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
||||
token_env: 'TELEGRAM_BOT_TOKEN',
|
||||
help: 'Create a bot via @BotFather on Telegram to get your token.'
|
||||
},
|
||||
{
|
||||
name: 'discord',
|
||||
display_name: 'Discord',
|
||||
icon: 'DC',
|
||||
description: 'Connect your agent to a Discord server via bot token.',
|
||||
token_label: 'Bot Token',
|
||||
token_placeholder: 'MTIz...abc',
|
||||
token_env: 'DISCORD_BOT_TOKEN',
|
||||
help: 'Create a Discord application at discord.com/developers and add a bot.'
|
||||
},
|
||||
{
|
||||
name: 'slack',
|
||||
display_name: 'Slack',
|
||||
icon: 'SL',
|
||||
description: 'Connect your agent to a Slack workspace.',
|
||||
token_label: 'Bot Token',
|
||||
token_placeholder: 'xoxb-...',
|
||||
token_env: 'SLACK_BOT_TOKEN',
|
||||
help: 'Create a Slack app at api.slack.com/apps and install it to your workspace.'
|
||||
}
|
||||
],
|
||||
get channelOptions() {
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
return [
|
||||
{
|
||||
name: 'telegram',
|
||||
display_name: t('wizard.channel_telegram') || 'Telegram',
|
||||
icon: 'TG',
|
||||
description: t('wizard.channel_telegram_desc') || 'Connect your agent to a Telegram bot for messaging.',
|
||||
token_label: t('wizard.channel_telegram_token') || 'Bot Token',
|
||||
token_placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
||||
token_env: 'TELEGRAM_BOT_TOKEN',
|
||||
help: t('wizard.channel_telegram_help') || 'Create a bot via @BotFather on Telegram to get your token.'
|
||||
},
|
||||
{
|
||||
name: 'discord',
|
||||
display_name: t('wizard.channel_discord') || 'Discord',
|
||||
icon: 'DC',
|
||||
description: t('wizard.channel_discord_desc') || 'Connect your agent to a Discord server via bot token.',
|
||||
token_label: t('wizard.channel_discord_token') || 'Bot Token',
|
||||
token_placeholder: 'MTIz...abc',
|
||||
token_env: 'DISCORD_BOT_TOKEN',
|
||||
help: t('wizard.channel_discord_help') || 'Create a Discord application at discord.com/developers and add a bot.'
|
||||
},
|
||||
{
|
||||
name: 'slack',
|
||||
display_name: t('wizard.channel_slack') || 'Slack',
|
||||
icon: 'SL',
|
||||
description: t('wizard.channel_slack_desc') || 'Connect your agent to a Slack workspace.',
|
||||
token_label: t('wizard.channel_slack_token') || 'Bot Token',
|
||||
token_placeholder: 'xoxb-...',
|
||||
token_env: 'SLACK_BOT_TOKEN',
|
||||
help: t('wizard.channel_slack_help') || 'Create a Slack app at api.slack.com/apps and install it to your workspace.'
|
||||
}
|
||||
];
|
||||
},
|
||||
channelToken: '',
|
||||
configuringChannel: false,
|
||||
channelConfigured: false,
|
||||
@@ -254,6 +282,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.';
|
||||
}
|
||||
@@ -288,7 +325,15 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
stepLabel(n) {
|
||||
var labels = ['Welcome', 'Provider', 'Agent', 'Try It', 'Channel', 'Done'];
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
var labels = [
|
||||
t('wizard.step_welcome') || 'Welcome',
|
||||
t('wizard.step_provider') || 'Provider',
|
||||
t('wizard.step_agent') || 'Agent',
|
||||
t('wizard.step_try_it') || 'Try It',
|
||||
t('wizard.step_channel') || 'Channel',
|
||||
t('wizard.step_done') || 'Done'
|
||||
];
|
||||
return labels[n - 1] || '';
|
||||
},
|
||||
|
||||
@@ -313,15 +358,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 = []; }
|
||||
},
|
||||
|
||||
@@ -382,7 +418,7 @@ function wizardPage() {
|
||||
if (!provider) return;
|
||||
var key = this.apiKeyInput.trim();
|
||||
if (!key) {
|
||||
OpenFangToast.error('Please enter an API key');
|
||||
OpenFangToast.error(window.i18n ? window.i18n.t('wizard.enter_api_key') : 'Please enter an API key');
|
||||
return;
|
||||
}
|
||||
this.savingKey = true;
|
||||
@@ -391,12 +427,12 @@ function wizardPage() {
|
||||
this.apiKeyInput = '';
|
||||
this.keySaved = true;
|
||||
this.setupSummary.provider = provider.display_name;
|
||||
OpenFangToast.success('API key saved for ' + provider.display_name);
|
||||
OpenFangToast.success((window.i18n ? window.i18n.t('wizard.api_key_saved') : 'API key saved for') + ' ' + provider.display_name);
|
||||
await this.loadProviders();
|
||||
// Auto-test after saving
|
||||
await this.testKey();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save key: ' + e.message);
|
||||
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_save_key') : 'Failed to save key:') + ' ' + e.message);
|
||||
}
|
||||
this.savingKey = false;
|
||||
},
|
||||
@@ -410,13 +446,13 @@ function wizardPage() {
|
||||
var result = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/test', {});
|
||||
this.testResult = result;
|
||||
if (result.status === 'ok') {
|
||||
OpenFangToast.success(provider.display_name + ' connected (' + (result.latency_ms || '?') + 'ms)');
|
||||
OpenFangToast.success(provider.display_name + ' ' + (window.i18n ? window.i18n.t('wizard.connected') : 'connected') + ' (' + (result.latency_ms || '?') + 'ms)');
|
||||
} else {
|
||||
OpenFangToast.error(provider.display_name + ': ' + (result.error || 'Connection failed'));
|
||||
OpenFangToast.error(provider.display_name + ': ' + (result.error || (window.i18n ? window.i18n.t('wizard.connection_failed') : 'Connection failed')));
|
||||
}
|
||||
} catch(e) {
|
||||
this.testResult = { status: 'error', error: e.message };
|
||||
OpenFangToast.error('Test failed: ' + e.message);
|
||||
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.test_failed') : 'Test failed:') + ' ' + e.message);
|
||||
}
|
||||
this.testingProvider = false;
|
||||
},
|
||||
@@ -458,7 +494,7 @@ function wizardPage() {
|
||||
if (!tpl) return;
|
||||
var name = this.agentName.trim();
|
||||
if (!name) {
|
||||
OpenFangToast.error('Please enter a name for your agent');
|
||||
OpenFangToast.error(window.i18n ? window.i18n.t('wizard.enter_agent_name') : 'Please enter a name for your agent');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -485,13 +521,13 @@ function wizardPage() {
|
||||
if (res.agent_id) {
|
||||
this.createdAgent = { id: res.agent_id, name: res.name || name };
|
||||
this.setupSummary.agent = res.name || name;
|
||||
OpenFangToast.success('Agent "' + (res.name || name) + '" created');
|
||||
OpenFangToast.success((window.i18n ? window.i18n.t('wizard.agent_created') : 'Agent') + ' "' + (res.name || name) + '" ' + (window.i18n ? window.i18n.t('wizard.agent_created_suffix') || 'created' : 'created'));
|
||||
await Alpine.store('app').refreshAgents();
|
||||
} else {
|
||||
OpenFangToast.error('Failed: ' + (res.error || 'Unknown error'));
|
||||
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_create_agent') : 'Failed:') + ' ' + (res.error || 'Unknown error'));
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to create agent: ' + e.message);
|
||||
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_create_agent') : 'Failed to create agent:') + ' ' + e.message);
|
||||
}
|
||||
this.creatingAgent = false;
|
||||
},
|
||||
@@ -538,7 +574,7 @@ function wizardPage() {
|
||||
if (!ch) return;
|
||||
var token = this.channelToken.trim();
|
||||
if (!token) {
|
||||
OpenFangToast.error('Please enter the ' + ch.token_label);
|
||||
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.enter_token') : 'Please enter the') + ' ' + ch.token_label);
|
||||
return;
|
||||
}
|
||||
this.configuringChannel = true;
|
||||
@@ -549,9 +585,9 @@ function wizardPage() {
|
||||
await OpenFangAPI.post('/api/channels/' + ch.name + '/configure', { fields: fields });
|
||||
this.channelConfigured = true;
|
||||
this.setupSummary.channel = ch.display_name;
|
||||
OpenFangToast.success(ch.display_name + ' configured and activated.');
|
||||
OpenFangToast.success(ch.display_name + ' ' + (window.i18n ? window.i18n.t('wizard.channel_configured') : 'configured and activated.'));
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed: ' + (e.message || 'Unknown error'));
|
||||
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_configure') : 'Failed:') + ' ' + (e.message || 'Unknown error'));
|
||||
}
|
||||
this.configuringChannel = false;
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#6366f1",
|
||||
"icons": [
|
||||
{"src": "/logo.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/logo.png", "sizes": "512x512", "type": "image/png"}
|
||||
{"src": "/logo.png", "sizes": "128x128", "type": "image/png"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ async fn start_test_server_with_provider(
|
||||
model: model.to_string(),
|
||||
api_key_env: api_key_env.to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
@@ -78,6 +79,7 @@ async fn start_test_server_with_provider(
|
||||
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()
|
||||
@@ -100,6 +102,10 @@ async fn start_test_server_with_provider(
|
||||
"/api/agents/{id}",
|
||||
axum::routing::delete(routes::kill_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/clone",
|
||||
axum::routing::post(routes::clone_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/triggers",
|
||||
axum::routing::get(routes::list_triggers).post(routes::create_trigger),
|
||||
@@ -121,6 +127,23 @@ async fn start_test_server_with_provider(
|
||||
axum::routing::get(routes::list_workflow_runs),
|
||||
)
|
||||
.route("/api/shutdown", axum::routing::post(routes::shutdown))
|
||||
.route("/api/commands", axum::routing::get(routes::list_commands))
|
||||
.route(
|
||||
"/api/schedules",
|
||||
axum::routing::get(routes::list_schedules).post(routes::create_schedule),
|
||||
)
|
||||
.route(
|
||||
"/api/schedules/{id}",
|
||||
axum::routing::delete(routes::delete_schedule).put(routes::update_schedule),
|
||||
)
|
||||
.route(
|
||||
"/api/schedules/{id}/delivery-log",
|
||||
axum::routing::get(routes::schedule_delivery_log),
|
||||
)
|
||||
.route(
|
||||
"/api/cron/jobs",
|
||||
axum::routing::get(routes::list_cron_jobs).post(routes::create_cron_job),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(middleware::request_logging))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(CorsLayer::permissive())
|
||||
@@ -283,6 +306,86 @@ async fn test_spawn_list_kill_agent() {
|
||||
assert_eq!(agents[0]["name"], "assistant");
|
||||
}
|
||||
|
||||
/// Regression test for issue #1026: GET /api/agents returns `is_inferencing`
|
||||
/// reflecting whether the agent has an in-flight LLM task. This drives the
|
||||
/// live dashboard indicator that shows which agents are calling the LLM.
|
||||
#[tokio::test]
|
||||
async fn test_list_agents_includes_inferencing_flag() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Spawn a test agent.
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", server.base_url))
|
||||
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let agent_id_str = body["agent_id"].as_str().unwrap().to_string();
|
||||
let agent_id: openfang_types::agent::AgentId = agent_id_str.parse().unwrap();
|
||||
|
||||
// Baseline: idle agent must report is_inferencing = false.
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
let test_agent = agents
|
||||
.iter()
|
||||
.find(|a| a["id"] == agent_id_str)
|
||||
.expect("spawned agent should appear in list");
|
||||
assert_eq!(
|
||||
test_agent["is_inferencing"], false,
|
||||
"freshly spawned agent should not be inferencing"
|
||||
);
|
||||
|
||||
// Simulate an in-flight LLM call by inserting a real AbortHandle into
|
||||
// the kernel's running_tasks map. This is exactly what the agent loop
|
||||
// does when it starts processing a message.
|
||||
let handle = tokio::spawn(async {
|
||||
// Long-lived task we will abort at end of test.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
});
|
||||
server
|
||||
.state
|
||||
.kernel
|
||||
.running_tasks
|
||||
.insert(agent_id, handle.abort_handle());
|
||||
|
||||
// Now list_agents should report is_inferencing = true for that agent.
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
let test_agent = agents
|
||||
.iter()
|
||||
.find(|a| a["id"] == agent_id_str)
|
||||
.expect("spawned agent should still appear in list");
|
||||
assert_eq!(
|
||||
test_agent["is_inferencing"], true,
|
||||
"agent with an entry in running_tasks must be flagged is_inferencing"
|
||||
);
|
||||
|
||||
// Other agents (the default assistant) must NOT be flagged.
|
||||
if let Some(other) = agents.iter().find(|a| a["id"] != agent_id_str) {
|
||||
assert_eq!(
|
||||
other["is_inferencing"], false,
|
||||
"agents without a running task must not be flagged"
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup so the spawned future does not outlive the test.
|
||||
server.state.kernel.running_tasks.remove(&agent_id);
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_session_empty() {
|
||||
let server = start_test_server().await;
|
||||
@@ -313,6 +416,119 @@ async fn test_agent_session_empty() {
|
||||
assert_eq!(body["messages"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
/// Regression test for #935: the GET /api/agents/:id/session endpoint
|
||||
/// must NOT expose internal system-prompt messages to the Web UI.
|
||||
///
|
||||
/// We construct a session containing a System message + a User message + an
|
||||
/// Assistant message, persist it via the kernel's memory store, then call the
|
||||
/// HTTP endpoint and assert:
|
||||
/// 1. The default response excludes the system message entirely.
|
||||
/// 2. `message_count` reflects only the visible (user + assistant) messages.
|
||||
/// 3. `raw_message_count` exposes the underlying total.
|
||||
/// 4. With `?include_system=true`, the system message IS returned (debug
|
||||
/// mode opt-in).
|
||||
#[tokio::test]
|
||||
async fn test_agent_session_filters_system_messages() {
|
||||
use openfang_types::message::{Message, Role};
|
||||
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Spawn agent
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", server.base_url))
|
||||
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let agent_id_str = body["agent_id"].as_str().unwrap().to_string();
|
||||
|
||||
// Look up the agent's session id and inject a forged history that
|
||||
// contains a system-role message (simulating what an OpenAI-compat
|
||||
// client could push, or what a future regression might persist).
|
||||
let agent_id: openfang_types::agent::AgentId = agent_id_str.parse().unwrap();
|
||||
let entry = server.state.kernel.registry.get(agent_id).unwrap();
|
||||
let session_id = entry.session_id;
|
||||
let mut session = server
|
||||
.state
|
||||
.kernel
|
||||
.memory
|
||||
.get_session(session_id)
|
||||
.unwrap()
|
||||
.expect("session should exist after spawn");
|
||||
|
||||
session.messages = vec![
|
||||
Message {
|
||||
role: Role::System,
|
||||
content: openfang_types::message::MessageContent::Text(
|
||||
"INTERNAL SYSTEM PROMPT — must not leak to UI".to_string(),
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
Message::user("hello"),
|
||||
Message::assistant("hi there"),
|
||||
];
|
||||
server.state.kernel.memory.save_session(&session).unwrap();
|
||||
|
||||
// --- Default request: system message must be filtered out ---
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/agents/{}/session",
|
||||
server.base_url, agent_id_str
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
|
||||
let messages = body["messages"].as_array().unwrap();
|
||||
assert_eq!(messages.len(), 2, "should only see user + assistant");
|
||||
assert_eq!(body["message_count"], 2);
|
||||
assert_eq!(body["raw_message_count"], 3);
|
||||
|
||||
// No message in the response should carry the System role label, and
|
||||
// the system prompt text MUST NOT appear anywhere in the payload.
|
||||
for m in messages {
|
||||
let role = m["role"].as_str().unwrap_or("");
|
||||
assert_ne!(role, "System", "system role leaked into UI history");
|
||||
assert_ne!(role, "system", "system role leaked into UI history");
|
||||
}
|
||||
let body_str = serde_json::to_string(&body).unwrap();
|
||||
assert!(
|
||||
!body_str.contains("INTERNAL SYSTEM PROMPT"),
|
||||
"system prompt content leaked into session response: {body_str}"
|
||||
);
|
||||
|
||||
// Verify the visible roles are exactly what we expect.
|
||||
assert_eq!(messages[0]["role"], "User");
|
||||
assert_eq!(messages[0]["content"], "hello");
|
||||
assert_eq!(messages[1]["role"], "Assistant");
|
||||
assert_eq!(messages[1]["content"], "hi there");
|
||||
|
||||
// --- Opt-in debug mode: ?include_system=true returns it ---
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/agents/{}/session?include_system=true",
|
||||
server.base_url, agent_id_str
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let messages = body["messages"].as_array().unwrap();
|
||||
assert_eq!(messages.len(), 3, "include_system=true should return all 3");
|
||||
assert_eq!(messages[0]["role"], "System");
|
||||
assert_eq!(
|
||||
messages[0]["content"],
|
||||
"INTERNAL SYSTEM PROMPT — must not leak to UI"
|
||||
);
|
||||
assert_eq!(body["message_count"], 3);
|
||||
assert_eq!(body["raw_message_count"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_message_with_llm() {
|
||||
if std::env::var("GROQ_API_KEY").is_err() {
|
||||
@@ -690,6 +906,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
model: "test-model".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
@@ -707,6 +924,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
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.kernel.config.api_key.trim().to_string();
|
||||
@@ -720,6 +938,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
allow_no_auth: true,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
@@ -742,6 +961,10 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
"/api/agents/{id}",
|
||||
axum::routing::delete(routes::kill_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/clone",
|
||||
axum::routing::post(routes::clone_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/triggers",
|
||||
axum::routing::get(routes::list_triggers).post(routes::create_trigger),
|
||||
@@ -868,3 +1091,710 @@ async fn test_auth_disabled_when_no_key() {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /api/commands — unified command registry endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Default (no surface query) returns web-surface commands.
|
||||
#[tokio::test]
|
||||
async fn test_commands_default_returns_web() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["surface"], "web");
|
||||
|
||||
let commands = body["commands"].as_array().expect("commands is array");
|
||||
assert!(!commands.is_empty(), "web surface should have commands");
|
||||
|
||||
// Every entry has the documented shape.
|
||||
for c in commands {
|
||||
assert!(c["name"].is_string());
|
||||
assert!(c["aliases"].is_array());
|
||||
assert!(c["description"].is_string());
|
||||
assert!(c["category"].is_string());
|
||||
assert!(c["requires_agent"].is_boolean());
|
||||
}
|
||||
|
||||
// Sanity: web surface must include `/help` and `/verbose` and must NOT
|
||||
// include CLI-only `/kill`.
|
||||
let names: Vec<&str> = commands
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(names.contains(&"help"));
|
||||
assert!(names.contains(&"verbose"));
|
||||
assert!(!names.contains(&"kill"));
|
||||
}
|
||||
|
||||
/// `?surface=cli` returns CLI-only commands and includes the alias array.
|
||||
#[tokio::test]
|
||||
async fn test_commands_cli_surface() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands?surface=cli", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["surface"], "cli");
|
||||
|
||||
let commands = body["commands"].as_array().unwrap();
|
||||
let names: Vec<&str> = commands
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(names.contains(&"kill"));
|
||||
assert!(names.contains(&"clear"));
|
||||
assert!(names.contains(&"exit"));
|
||||
// `start` is channel-only — must not appear on CLI.
|
||||
assert!(!names.contains(&"start"));
|
||||
|
||||
// `/exit` carries the `quit` alias.
|
||||
let exit = commands
|
||||
.iter()
|
||||
.find(|c| c["name"] == "exit")
|
||||
.expect("exit command must be present on CLI");
|
||||
let aliases = exit["aliases"].as_array().unwrap();
|
||||
assert!(
|
||||
aliases.iter().any(|a| a == "quit"),
|
||||
"quit alias should be attached to /exit"
|
||||
);
|
||||
}
|
||||
|
||||
/// `?surface=all` includes commands from every surface.
|
||||
#[tokio::test]
|
||||
async fn test_commands_all_surface() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands?surface=all", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["surface"], "all");
|
||||
|
||||
let names: Vec<&str> = body["commands"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
|
||||
// Surface-specific probes: all three unique-per-surface commands appear.
|
||||
assert!(names.contains(&"kill"), "CLI-only /kill missing from /all");
|
||||
assert!(
|
||||
names.contains(&"start"),
|
||||
"channel-only /start missing from /all"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"verbose"),
|
||||
"web-only /verbose missing from /all"
|
||||
);
|
||||
}
|
||||
|
||||
/// `?surface=channel` returns channel commands only.
|
||||
#[tokio::test]
|
||||
async fn test_commands_channel_surface() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands?surface=channel", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["surface"], "channel");
|
||||
|
||||
let names: Vec<&str> = body["commands"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(names.contains(&"start"));
|
||||
// CLI-only must not appear here.
|
||||
assert!(!names.contains(&"kill"));
|
||||
}
|
||||
|
||||
/// Unknown surface returns 400 with a JSON error body.
|
||||
#[tokio::test]
|
||||
async fn test_commands_invalid_surface_400() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/commands?surface=bogus", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let err = body["error"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
err.contains("bogus"),
|
||||
"error should mention the bad value: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule delivery_targets round-trip tests
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// These exercise the `/api/schedules` and `/api/cron/jobs` endpoints to
|
||||
// confirm `CronDeliveryTarget` variants round-trip cleanly through create /
|
||||
// list / update / delivery-log, and that bad input is rejected at the API
|
||||
// layer rather than silently dropped.
|
||||
|
||||
async fn spawn_test_agent(server: &TestServer) -> String {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", server.base_url))
|
||||
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
body["agent_id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// POST /api/schedules with all four `CronDeliveryTarget` variants should
|
||||
/// store them and return them on GET /api/schedules.
|
||||
#[tokio::test]
|
||||
async fn test_schedules_delivery_targets_roundtrip() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let agent_id = spawn_test_agent(&server).await;
|
||||
|
||||
let delivery_targets = serde_json::json!([
|
||||
{ "type": "channel", "channel_type": "telegram", "recipient": "chat_12345" },
|
||||
{ "type": "webhook", "url": "https://example.com/hook", "auth_header": "Bearer abc" },
|
||||
{ "type": "local_file", "path": "/tmp/openfang-test.log", "append": true },
|
||||
{ "type": "email", "to": "alice@example.com", "subject_template": "Cron: {job}" },
|
||||
]);
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/schedules", server.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"name": "multi-destination-test",
|
||||
"cron": "0 9 * * 1-5",
|
||||
"agent_id": agent_id,
|
||||
"message": "Generate the daily brief.",
|
||||
"enabled": true,
|
||||
"delivery_targets": delivery_targets,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let sched_id = body["id"]
|
||||
.as_str()
|
||||
.expect("created schedule id")
|
||||
.to_string();
|
||||
let got = body["delivery_targets"]
|
||||
.as_array()
|
||||
.expect("response must include delivery_targets");
|
||||
assert_eq!(got.len(), 4, "all four targets should round-trip");
|
||||
assert_eq!(got[0]["type"], "channel");
|
||||
assert_eq!(got[0]["channel_type"], "telegram");
|
||||
assert_eq!(got[0]["recipient"], "chat_12345");
|
||||
assert_eq!(got[1]["type"], "webhook");
|
||||
assert_eq!(got[1]["url"], "https://example.com/hook");
|
||||
assert_eq!(got[1]["auth_header"], "Bearer abc");
|
||||
assert_eq!(got[2]["type"], "local_file");
|
||||
assert_eq!(got[2]["append"], true);
|
||||
assert_eq!(got[3]["type"], "email");
|
||||
assert_eq!(got[3]["subject_template"], "Cron: {job}");
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/schedules", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let schedules = body["schedules"].as_array().unwrap();
|
||||
let created = schedules
|
||||
.iter()
|
||||
.find(|s| s["id"] == sched_id)
|
||||
.expect("created schedule must appear in list");
|
||||
let listed = created["delivery_targets"].as_array().unwrap();
|
||||
assert_eq!(listed.len(), 4);
|
||||
assert_eq!(listed[0]["channel_type"], "telegram");
|
||||
|
||||
let _ = client
|
||||
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// PUT /api/schedules/{id} with `delivery_targets` should fully replace the
|
||||
/// target list.
|
||||
#[tokio::test]
|
||||
async fn test_schedules_delivery_targets_update() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let agent_id = spawn_test_agent(&server).await;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/schedules", server.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"name": "update-target-test",
|
||||
"cron": "*/15 * * * *",
|
||||
"agent_id": agent_id,
|
||||
"message": "hi",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let sched_id = body["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(
|
||||
body["delivery_targets"].as_array().map(|a| a.len()),
|
||||
Some(0)
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.put(format!("{}/api/schedules/{}", server.base_url, sched_id))
|
||||
.json(&serde_json::json!({
|
||||
"delivery_targets": [
|
||||
{ "type": "webhook", "url": "https://new.example.com/hook" },
|
||||
{ "type": "local_file", "path": "/tmp/new.log" },
|
||||
]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["status"], "updated");
|
||||
let echoed = &body["schedule"]["delivery_targets"];
|
||||
let arr = echoed
|
||||
.as_array()
|
||||
.expect("schedule.delivery_targets must be array");
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0]["type"], "webhook");
|
||||
assert_eq!(arr[1]["type"], "local_file");
|
||||
|
||||
let resp = client
|
||||
.get(format!("{}/api/schedules", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let created = body["schedules"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["id"] == sched_id)
|
||||
.unwrap();
|
||||
let listed = created["delivery_targets"].as_array().unwrap();
|
||||
assert_eq!(listed.len(), 2);
|
||||
|
||||
let resp = client
|
||||
.put(format!("{}/api/schedules/{}", server.base_url, sched_id))
|
||||
.json(&serde_json::json!({"delivery_targets": []}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let resp = client
|
||||
.get(format!("{}/api/schedules", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let created = body["schedules"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["id"] == sched_id)
|
||||
.unwrap();
|
||||
let listed = created["delivery_targets"].as_array().unwrap();
|
||||
assert_eq!(listed.len(), 0);
|
||||
|
||||
let _ = client
|
||||
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Malformed `delivery_targets` should return 400, not silently succeed.
|
||||
#[tokio::test]
|
||||
async fn test_schedules_rejects_bad_delivery_target() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let agent_id = spawn_test_agent(&server).await;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/schedules", server.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"name": "bad-target-test",
|
||||
"cron": "*/10 * * * *",
|
||||
"agent_id": agent_id,
|
||||
"message": "hi",
|
||||
"delivery_targets": [
|
||||
{ "type": "channel" /* missing channel_type + recipient */ }
|
||||
]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let err = body["error"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
err.contains("delivery_targets"),
|
||||
"error should mention delivery_targets, got: {err}"
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/schedules", server.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"name": "bad-array-test",
|
||||
"cron": "*/10 * * * *",
|
||||
"agent_id": agent_id,
|
||||
"message": "hi",
|
||||
"delivery_targets": "not-an-array",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
}
|
||||
|
||||
/// GET /api/schedules/{id}/delivery-log returns the configured targets and an
|
||||
/// empty entries array for a known schedule, and 404 for a random UUID.
|
||||
#[tokio::test]
|
||||
async fn test_schedules_delivery_log_endpoint() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let agent_id = spawn_test_agent(&server).await;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/schedules", server.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"name": "log-test",
|
||||
"cron": "0 * * * *",
|
||||
"agent_id": agent_id,
|
||||
"message": "x",
|
||||
"delivery_targets": [
|
||||
{ "type": "webhook", "url": "https://example.com/h" }
|
||||
]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let sched_id = resp.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/schedules/{}/delivery-log",
|
||||
server.base_url, sched_id
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["schedule_id"], sched_id);
|
||||
let targets = body["targets"].as_array().expect("targets array");
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0]["type"], "webhook");
|
||||
let entries = body["entries"].as_array().expect("entries array");
|
||||
assert!(
|
||||
entries.is_empty(),
|
||||
"delivery history is not persisted yet — entries must be empty"
|
||||
);
|
||||
|
||||
let random = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/schedules/{}/delivery-log",
|
||||
server.base_url, random
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/schedules/not-a-uuid/delivery-log",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
|
||||
let _ = client
|
||||
.delete(format!("{}/api/schedules/{}", server.base_url, sched_id))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
/// POST /api/cron/jobs with `delivery_targets` should persist them and they
|
||||
/// should appear on the subsequent GET.
|
||||
#[tokio::test]
|
||||
async fn test_cron_jobs_delivery_targets_roundtrip() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let agent_id = spawn_test_agent(&server).await;
|
||||
|
||||
let resp = client
|
||||
.post(format!("{}/api/cron/jobs", server.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"agent_id": agent_id,
|
||||
"name": "cron-fanout",
|
||||
"schedule": { "kind": "cron", "expr": "*/20 * * * *" },
|
||||
"action": { "kind": "agent_turn", "message": "pulse" },
|
||||
"delivery": { "kind": "none" },
|
||||
"delivery_targets": [
|
||||
{ "type": "local_file", "path": "/tmp/pulse.log", "append": true },
|
||||
{ "type": "webhook", "url": "http://example.com/pulse" }
|
||||
]
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/cron/jobs?agent_id={}",
|
||||
server.base_url, agent_id
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let jobs = body["jobs"].as_array().unwrap();
|
||||
let job = jobs
|
||||
.iter()
|
||||
.find(|j| j["name"] == "cron-fanout")
|
||||
.expect("created job must be listed");
|
||||
let targets = job["delivery_targets"].as_array().expect("targets array");
|
||||
assert_eq!(targets.len(), 2);
|
||||
assert_eq!(targets[0]["type"], "local_file");
|
||||
assert_eq!(targets[0]["path"], "/tmp/pulse.log");
|
||||
assert_eq!(targets[0]["append"], true);
|
||||
assert_eq!(targets[1]["type"], "webhook");
|
||||
assert_eq!(targets[1]["url"], "http://example.com/pulse");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clone agent endpoint tests (issue #868)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Happy path: clone an existing template agent into a new agent with a
|
||||
/// distinct name. The clone must get a fresh ID, fresh workspace path, and
|
||||
/// inherit non-name manifest fields from the template.
|
||||
#[tokio::test]
|
||||
async fn test_clone_agent_happy_path() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Spawn a template agent.
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", server.base_url))
|
||||
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let template_id = body["agent_id"].as_str().unwrap().to_string();
|
||||
|
||||
// Clone it.
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/agents/{}/clone",
|
||||
server.base_url, template_id
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"new_name": "cloned-user-1",
|
||||
"overrides": {
|
||||
"description": "Cloned for user 1",
|
||||
"tags": ["clone", "user-1"]
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201, "clone should succeed");
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
|
||||
let new_id = body["agent_id"].as_str().unwrap();
|
||||
assert_ne!(new_id, template_id, "clone must have a fresh agent ID");
|
||||
assert_eq!(body["name"], "cloned-user-1");
|
||||
|
||||
// The full manifest should be returned and reflect the new name + overrides.
|
||||
let manifest = &body["manifest"];
|
||||
assert!(manifest.is_object(), "manifest must be returned");
|
||||
assert_eq!(manifest["name"], "cloned-user-1");
|
||||
assert_eq!(manifest["description"], "Cloned for user 1");
|
||||
assert_eq!(
|
||||
manifest["tags"].as_array().unwrap(),
|
||||
&vec![serde_json::json!("clone"), serde_json::json!("user-1"),]
|
||||
);
|
||||
// Inherited from template — the system_prompt should match.
|
||||
assert_eq!(
|
||||
manifest["model"]["system_prompt"],
|
||||
"You are a test agent. Reply concisely."
|
||||
);
|
||||
|
||||
// The agent list should now contain both template and clone.
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
let names: Vec<&str> = agents.iter().map(|a| a["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"test-agent"));
|
||||
assert!(names.contains(&"cloned-user-1"));
|
||||
}
|
||||
|
||||
/// Cloning into a name that's already taken must fail with 409 Conflict.
|
||||
#[tokio::test]
|
||||
async fn test_clone_agent_name_collision() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Spawn a template agent named "test-agent".
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", server.base_url))
|
||||
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let template_id = body["agent_id"].as_str().unwrap().to_string();
|
||||
|
||||
// First clone — succeeds.
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/agents/{}/clone",
|
||||
server.base_url, template_id
|
||||
))
|
||||
.json(&serde_json::json!({"new_name": "duplicate-name"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 201);
|
||||
|
||||
// Second clone with the same name — must be rejected.
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/agents/{}/clone",
|
||||
server.base_url, template_id
|
||||
))
|
||||
.json(&serde_json::json!({"new_name": "duplicate-name"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
409,
|
||||
"duplicate name must return 409 Conflict"
|
||||
);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(body["error"].as_str().unwrap().contains("already exists"));
|
||||
|
||||
// Cloning into the template's own name must also be rejected.
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/agents/{}/clone",
|
||||
server.base_url, template_id
|
||||
))
|
||||
.json(&serde_json::json!({"new_name": "test-agent"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 409);
|
||||
}
|
||||
|
||||
/// Cloning a non-existent template must return 404.
|
||||
#[tokio::test]
|
||||
async fn test_clone_agent_template_not_found() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Random valid UUID that does not match any agent.
|
||||
let bogus_id = "00000000-0000-0000-0000-000000000000";
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents/{}/clone", server.base_url, bogus_id))
|
||||
.json(&serde_json::json!({"new_name": "ghost-clone"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(body["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Template agent not found"));
|
||||
|
||||
// Malformed agent id → 400.
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents/not-a-uuid/clone", server.base_url))
|
||||
.json(&serde_json::json!({"new_name": "ghost-clone"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
}
|
||||
|
||||
/// Empty new_name must be rejected with 400.
|
||||
#[tokio::test]
|
||||
async fn test_clone_agent_empty_name_rejected() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Spawn a template agent.
|
||||
let resp = client
|
||||
.post(format!("{}/api/agents", server.base_url))
|
||||
.json(&serde_json::json!({"manifest_toml": TEST_MANIFEST}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
let template_id = body["agent_id"].as_str().unwrap().to_string();
|
||||
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/agents/{}/clone",
|
||||
server.base_url, template_id
|
||||
))
|
||||
.json(&serde_json::json!({"new_name": " "}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ async fn test_full_daemon_lifecycle() {
|
||||
model: "test".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
@@ -115,6 +116,7 @@ async fn test_full_daemon_lifecycle() {
|
||||
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()
|
||||
@@ -224,6 +226,7 @@ async fn test_server_immediate_responsiveness() {
|
||||
model: "test".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
@@ -240,6 +243,7 @@ async fn test_server_immediate_responsiveness() {
|
||||
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()
|
||||
|
||||
@@ -42,6 +42,7 @@ async fn start_test_server() -> TestServer {
|
||||
model: "test-model".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
@@ -59,6 +60,7 @@ async fn start_test_server() -> TestServer {
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Integration tests for the `/api/skills/{id}/config` surface.
|
||||
//!
|
||||
//! These boot a real kernel, start a real axum server on a random port, plant
|
||||
//! a synthetic skill on disk whose SKILL.md declares a `config:` section, and
|
||||
//! exercise GET / PUT / DELETE end to end. Bundled skills currently declare
|
||||
//! no runtime config, so the synthetic skill fixture is what lets us prove
|
||||
//! the wire contract.
|
||||
//!
|
||||
//! Run: cargo test -p openfang-api --test skill_config_api_test -- --nocapture
|
||||
|
||||
use axum::Router;
|
||||
use openfang_api::middleware;
|
||||
use openfang_api::routes::{self, AppState};
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_types::config::{DefaultModelConfig, KernelConfig};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test server harness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
home_dir: std::path::PathBuf,
|
||||
#[allow(dead_code)]
|
||||
state: Arc<AppState>,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
self.state.kernel.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a skill fixture under `<home>/skills/<name>/SKILL.md` that declares
|
||||
/// a `config:` section. This matches the on-disk format that OpenClaw skills
|
||||
/// use, so the loader's real `parse_skillmd_str` path is exercised.
|
||||
fn plant_skill_with_config(home: &std::path::Path, skill_name: &str) {
|
||||
let skill_dir = home.join("skills").join(skill_name);
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
// Leading four spaces inside YAML lists matter — keep them.
|
||||
let skillmd = format!(
|
||||
"---
|
||||
name: {skill_name}
|
||||
description: Synthetic skill for config endpoint tests
|
||||
config:
|
||||
github_token:
|
||||
description: GitHub personal access token
|
||||
env: OPENFANG_TEST_SKILLCFG_GH_TOKEN
|
||||
required: true
|
||||
default_branch:
|
||||
description: Default branch name
|
||||
default: main
|
||||
required: false
|
||||
---
|
||||
# Test Skill
|
||||
|
||||
Placeholder body so the parser accepts this as a valid prompt-only skill.
|
||||
"
|
||||
);
|
||||
std::fs::write(skill_dir.join("SKILL.md"), skillmd).unwrap();
|
||||
}
|
||||
|
||||
async fn start_test_server() -> TestServer {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let home = tmp.path().to_path_buf();
|
||||
|
||||
let config = KernelConfig {
|
||||
home_dir: home.clone(),
|
||||
data_dir: home.join("data"),
|
||||
default_model: DefaultModelConfig {
|
||||
provider: "ollama".to_string(),
|
||||
model: "test-model".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
|
||||
// Plant synthetic skill BEFORE booting so the initial skill load picks it up.
|
||||
plant_skill_with_config(&home, "test-config-skill");
|
||||
|
||||
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boot");
|
||||
let kernel = Arc::new(kernel);
|
||||
kernel.set_self_handle();
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
kernel,
|
||||
started_at: Instant::now(),
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/skills", axum::routing::get(routes::list_skills))
|
||||
.route(
|
||||
"/api/skills/{id}/config",
|
||||
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/{id}/config/{var_name}",
|
||||
axum::routing::delete(routes::delete_skill_config_var),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(middleware::request_logging))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state.clone());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test port");
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
TestServer {
|
||||
base_url: format!("http://{}", addr),
|
||||
home_dir: home,
|
||||
state,
|
||||
_tmp: tmp,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_config_returns_declared_and_resolved() {
|
||||
// Make sure no host leak from prior tests interferes.
|
||||
// SAFETY: single-threaded test, env var is unique to this test suite.
|
||||
unsafe { std::env::remove_var("OPENFANG_TEST_SKILLCFG_GH_TOKEN") };
|
||||
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
|
||||
assert_eq!(body["skill"], "test-config-skill");
|
||||
// Both vars declared
|
||||
assert!(body["declared"]["github_token"].is_object());
|
||||
assert!(body["declared"]["default_branch"].is_object());
|
||||
assert_eq!(body["declared"]["github_token"]["required"], true);
|
||||
assert_eq!(body["declared"]["default_branch"]["required"], false);
|
||||
|
||||
// github_token has no user override, no env, no default -> unresolved.
|
||||
assert_eq!(
|
||||
body["resolved"]["github_token"]["source"], "unresolved",
|
||||
"github_token should be unresolved without env"
|
||||
);
|
||||
assert!(body["resolved"]["github_token"]["is_secret"]
|
||||
.as_bool()
|
||||
.unwrap());
|
||||
|
||||
// default_branch falls back to default "main".
|
||||
assert_eq!(body["resolved"]["default_branch"]["source"], "default");
|
||||
assert_eq!(body["resolved"]["default_branch"]["value"], "main");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_config_redacts_secret_values_after_put() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Write a real-looking token via PUT.
|
||||
let payload = serde_json::json!({
|
||||
"values": {
|
||||
"github_token": "ghp_realsecretvalue_DO_NOT_LEAK",
|
||||
"default_branch": "develop"
|
||||
}
|
||||
});
|
||||
let resp = client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "PUT should succeed");
|
||||
|
||||
// GET back and confirm the secret is redacted and non-secret is visible.
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
|
||||
let returned_token = body["resolved"]["github_token"]["value"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(
|
||||
!returned_token.contains("realsecretvalue"),
|
||||
"secret leaked on the wire: {returned_token}"
|
||||
);
|
||||
assert!(
|
||||
returned_token.contains("redacted"),
|
||||
"expected redaction marker, got: {returned_token}"
|
||||
);
|
||||
assert_eq!(body["resolved"]["github_token"]["source"], "user");
|
||||
|
||||
// Non-secret var kept as-is.
|
||||
assert_eq!(body["resolved"]["default_branch"]["value"], "develop");
|
||||
assert_eq!(body["resolved"]["default_branch"]["source"], "user");
|
||||
|
||||
// config.toml persisted the change, including the full secret value
|
||||
// (redaction is only on the wire — disk is the source of truth).
|
||||
let cfg = std::fs::read_to_string(server.home_dir.join("config.toml")).unwrap();
|
||||
assert!(
|
||||
cfg.contains("[skills.test-config-skill]"),
|
||||
"skills section missing: {cfg}"
|
||||
);
|
||||
assert!(cfg.contains("realsecretvalue"));
|
||||
assert!(cfg.contains("develop"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_rejects_unknown_variable() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"values": { "nonexistent_var": "value" }
|
||||
});
|
||||
let resp = client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(body["error"].as_str().unwrap().contains("nonexistent_var"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_override_reverts_to_default() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Set override first.
|
||||
client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"values": { "default_branch": "develop" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Remove it.
|
||||
let resp = client
|
||||
.delete(format!(
|
||||
"{}/api/skills/test-config-skill/config/default_branch",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Now source should be "default" again with value "main".
|
||||
let body: serde_json::Value = client
|
||||
.get(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(body["resolved"]["default_branch"]["source"], "default");
|
||||
assert_eq!(body["resolved"]["default_branch"]["value"], "main");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_refuses_to_strand_required_var() {
|
||||
// github_token is required, has no default, and no env — so removing an
|
||||
// override would leave it unresolvable. The endpoint must refuse.
|
||||
// SAFETY: single-threaded test.
|
||||
unsafe { std::env::remove_var("OPENFANG_TEST_SKILLCFG_GH_TOKEN") };
|
||||
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Set an override.
|
||||
client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"values": { "github_token": "ghp_value" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Try to delete — should 409.
|
||||
let resp = client
|
||||
.delete(format!(
|
||||
"{}/api/skills/test-config-skill/config/github_token",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 409);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_unknown_skill_returns_404() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/skills/this-skill-does-not-exist/config",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_reloads_registry_so_agents_see_change() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"values": {
|
||||
"github_token": "ghp_new",
|
||||
"default_branch": "release"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The kernel's live override map must now hold the new values.
|
||||
let guard = server.state.kernel.skill_config_overrides.read().unwrap();
|
||||
let overrides = guard.as_ref().expect("override map set after PUT");
|
||||
let skill_cfg = overrides.get("test-config-skill").expect("skill present");
|
||||
assert_eq!(skill_cfg.get("github_token").unwrap(), "ghp_new");
|
||||
assert_eq!(skill_cfg.get("default_branch").unwrap(), "release");
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -37,6 +38,7 @@ lettre = { workspace = true }
|
||||
imap = { workspace = true }
|
||||
native-tls = { workspace = true }
|
||||
mailparse = { workspace = true }
|
||||
rumqttc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -326,19 +326,17 @@ impl ChannelAdapter for IrcAdapter {
|
||||
}
|
||||
|
||||
// RPL_WELCOME (001) — registration complete, join channels
|
||||
"001" => {
|
||||
if !joined {
|
||||
info!("IRC registered as {nick_clone}");
|
||||
for ch in &channels_clone {
|
||||
let join_cmd = format!("JOIN {ch}\r\n");
|
||||
if let Err(e) = writer.write_all(join_cmd.as_bytes()).await {
|
||||
warn!("IRC JOIN send failed: {e}");
|
||||
break 'inner true;
|
||||
}
|
||||
info!("IRC joining {ch}");
|
||||
"001" if !joined => {
|
||||
info!("IRC registered as {nick_clone}");
|
||||
for ch in &channels_clone {
|
||||
let join_cmd = format!("JOIN {ch}\r\n");
|
||||
if let Err(e) = writer.write_all(join_cmd.as_bytes()).await {
|
||||
warn!("IRC JOIN send failed: {e}");
|
||||
break 'inner true;
|
||||
}
|
||||
joined = true;
|
||||
info!("IRC joining {ch}");
|
||||
}
|
||||
joined = true;
|
||||
}
|
||||
|
||||
// PRIVMSG — incoming message
|
||||
|
||||
@@ -48,6 +48,7 @@ pub mod discourse;
|
||||
pub mod gitter;
|
||||
pub mod gotify;
|
||||
pub mod linkedin;
|
||||
pub mod mqtt;
|
||||
pub mod mumble;
|
||||
pub mod ntfy;
|
||||
pub mod webhook;
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// LINE push message API endpoint.
|
||||
@@ -62,8 +62,8 @@ impl LineAdapter {
|
||||
pub fn new(channel_secret: String, access_token: String, webhook_port: u16) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
channel_secret: Zeroizing::new(channel_secret),
|
||||
access_token: Zeroizing::new(access_token),
|
||||
channel_secret: Zeroizing::new(channel_secret.trim().to_string()),
|
||||
access_token: Zeroizing::new(access_token.trim().to_string()),
|
||||
webhook_port,
|
||||
client: reqwest::Client::new(),
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
@@ -96,12 +96,35 @@ impl LineAdapter {
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if result.len() != expected.len() {
|
||||
debug!(
|
||||
"LINE: signature length mismatch: computed={} received={}",
|
||||
result.len(),
|
||||
expected.len()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (a, b) in result.iter().zip(expected.iter()) {
|
||||
diff |= a ^ b;
|
||||
}
|
||||
if diff != 0 {
|
||||
let computed = base64::engine::general_purpose::STANDARD.encode(result);
|
||||
// Log first/last 4 chars of each signature for debugging without leaking full HMAC
|
||||
let comp_redacted = format!(
|
||||
"{}...{}",
|
||||
&computed[..4.min(computed.len())],
|
||||
&computed[computed.len().saturating_sub(4)..]
|
||||
);
|
||||
let recv_redacted = format!(
|
||||
"{}...{}",
|
||||
&signature[..4.min(signature.len())],
|
||||
&signature[signature.len().saturating_sub(4)..]
|
||||
);
|
||||
debug!(
|
||||
"LINE: signature mismatch: computed={comp_redacted} received={recv_redacted} body_len={}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
@@ -358,19 +381,18 @@ impl ChannelAdapter for LineAdapter {
|
||||
axum::routing::post({
|
||||
let secret = Arc::clone(&channel_secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
move |headers: axum::http::HeaderMap,
|
||||
body: axum::extract::Json<serde_json::Value>| {
|
||||
move |headers: axum::http::HeaderMap, body: axum::body::Bytes| {
|
||||
let secret = Arc::clone(&secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
async move {
|
||||
// Verify X-Line-Signature
|
||||
// Verify X-Line-Signature using the raw request
|
||||
// body bytes — NOT re-serialized JSON — because the
|
||||
// HMAC must be computed over the exact bytes LINE sent.
|
||||
let signature = headers
|
||||
.get("x-line-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let body_bytes = serde_json::to_vec(&body.0).unwrap_or_default();
|
||||
|
||||
// Create a temporary adapter-like verifier
|
||||
let adapter = LineAdapter {
|
||||
channel_secret: secret.as_ref().clone(),
|
||||
@@ -381,15 +403,23 @@ impl ChannelAdapter for LineAdapter {
|
||||
shutdown_rx: watch::channel(false).1,
|
||||
};
|
||||
|
||||
if !signature.is_empty()
|
||||
&& !adapter.verify_signature(&body_bytes, signature)
|
||||
if !signature.is_empty() && !adapter.verify_signature(&body, signature)
|
||||
{
|
||||
warn!("LINE: invalid webhook signature");
|
||||
return axum::http::StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
|
||||
// Parse the raw bytes into JSON after signature verification
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("LINE: failed to parse webhook body as JSON: {e}");
|
||||
return axum::http::StatusCode::BAD_REQUEST;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse events array
|
||||
if let Some(events) = body.0["events"].as_array() {
|
||||
if let Some(events) = parsed["events"].as_array() {
|
||||
for event in events {
|
||||
if let Some(msg) = parse_line_event(event) {
|
||||
let _ = tx.send(msg).await;
|
||||
@@ -626,6 +656,71 @@ mod tests {
|
||||
assert!(parse_line_event(&event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_with_raw_body() {
|
||||
// Verify that HMAC-SHA256 signature validation works with raw body bytes
|
||||
let secret = "test-channel-secret";
|
||||
let adapter = LineAdapter::new(secret.to_string(), "token".to_string(), 9000);
|
||||
|
||||
// Compute the expected signature manually
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let body = br#"{"events":[{"type":"message"}]}"#;
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let expected_sig =
|
||||
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
assert!(adapter.verify_signature(body, &expected_sig));
|
||||
|
||||
// Re-serialized JSON should NOT match (this was the bug)
|
||||
let parsed: serde_json::Value = serde_json::from_slice(body).unwrap();
|
||||
let reserialized = serde_json::to_vec(&parsed).unwrap();
|
||||
// The re-serialized form may differ in whitespace/key order
|
||||
// If it happens to be identical for this input, the test still validates
|
||||
// the core mechanism works with raw bytes
|
||||
if reserialized != body.to_vec() {
|
||||
assert!(!adapter.verify_signature(&reserialized, &expected_sig));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_secret_trimmed() {
|
||||
// Environment variables often have trailing newlines or spaces
|
||||
let adapter = LineAdapter::new(
|
||||
" my-secret\n".to_string(),
|
||||
" my-token\r\n".to_string(),
|
||||
9000,
|
||||
);
|
||||
assert_eq!(adapter.channel_secret.as_str(), "my-secret");
|
||||
assert_eq!(adapter.access_token.as_str(), "my-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_bad_base64() {
|
||||
let adapter = LineAdapter::new("secret".to_string(), "token".to_string(), 9000);
|
||||
assert!(!adapter.verify_signature(b"body", "not-valid-base64!!!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_wrong_secret() {
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let body = b"test body";
|
||||
let mut mac = HmacSha256::new_from_slice(b"wrong-secret").unwrap();
|
||||
mac.update(body);
|
||||
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
let adapter = LineAdapter::new("correct-secret".to_string(), "token".to_string(), 9000);
|
||||
assert!(!adapter.verify_signature(body, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_line_event_room_source() {
|
||||
let event = serde_json::json!({
|
||||
|
||||
@@ -18,14 +18,20 @@ use zeroize::Zeroizing;
|
||||
const SYNC_TIMEOUT_MS: u64 = 30000;
|
||||
const MAX_MESSAGE_LEN: usize = 4096;
|
||||
|
||||
/// Shared access + refresh token pair. Tokens are zeroized on drop and rotated
|
||||
/// in place when MSC2918 refresh succeeds.
|
||||
type TokenPair = Arc<RwLock<(Zeroizing<String>, Option<Zeroizing<String>>)>>;
|
||||
|
||||
/// Matrix channel adapter using the Client-Server API.
|
||||
pub struct MatrixAdapter {
|
||||
/// Matrix homeserver URL (e.g., `"https://matrix.org"`).
|
||||
homeserver_url: String,
|
||||
/// Bot's user ID (e.g., "@openfang:matrix.org").
|
||||
user_id: String,
|
||||
/// SECURITY: Access token is zeroized on drop.
|
||||
access_token: Zeroizing<String>,
|
||||
/// SECURITY: Access + refresh tokens are zeroized on drop. Stored behind
|
||||
/// an RwLock so the sync loop and send paths see rotated tokens after a
|
||||
/// MSC2918 /refresh call (matrix.org/MAS rotates both tokens every refresh).
|
||||
tokens: TokenPair,
|
||||
/// HTTP client.
|
||||
client: reqwest::Client,
|
||||
/// Allowed room IDs (empty = all joined rooms).
|
||||
@@ -40,19 +46,47 @@ pub struct MatrixAdapter {
|
||||
}
|
||||
|
||||
impl MatrixAdapter {
|
||||
/// Create a new Matrix adapter.
|
||||
/// Create a new Matrix adapter without a refresh token.
|
||||
pub fn new(
|
||||
homeserver_url: String,
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
allowed_rooms: Vec<String>,
|
||||
auto_accept_invites: bool,
|
||||
) -> Self {
|
||||
Self::with_refresh_token(
|
||||
homeserver_url,
|
||||
user_id,
|
||||
access_token,
|
||||
None,
|
||||
allowed_rooms,
|
||||
auto_accept_invites,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new Matrix adapter with an optional refresh token (MSC2918).
|
||||
///
|
||||
/// When `refresh_token` is `Some`, the adapter will automatically call
|
||||
/// `POST /_matrix/client/v3/refresh` on `401 M_UNKNOWN_TOKEN` responses
|
||||
/// and retry the failed request once. Both tokens rotate on each refresh
|
||||
/// under Matrix Authentication Service (MAS).
|
||||
pub fn with_refresh_token(
|
||||
homeserver_url: String,
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
allowed_rooms: Vec<String>,
|
||||
auto_accept_invites: bool,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let tokens: TokenPair = Arc::new(RwLock::new((
|
||||
Zeroizing::new(access_token),
|
||||
refresh_token.map(Zeroizing::new),
|
||||
)));
|
||||
Self {
|
||||
homeserver_url,
|
||||
user_id,
|
||||
access_token: Zeroizing::new(access_token),
|
||||
tokens,
|
||||
client: reqwest::Client::new(),
|
||||
allowed_rooms,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
@@ -62,6 +96,11 @@ impl MatrixAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current access token (cloned).
|
||||
async fn current_access_token(&self) -> String {
|
||||
self.tokens.read().await.0.as_str().to_string()
|
||||
}
|
||||
|
||||
/// Send a text message to a Matrix room.
|
||||
async fn api_send_message(
|
||||
&self,
|
||||
@@ -81,18 +120,46 @@ impl MatrixAdapter {
|
||||
"body": chunk,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let token = self.current_access_token().await;
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Matrix API error {status}: {body}").into());
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
|
||||
// Try a single refresh+retry on M_UNKNOWN_TOKEN (MSC2918).
|
||||
if attempt == 1
|
||||
&& status == reqwest::StatusCode::UNAUTHORIZED
|
||||
&& is_unknown_token_body(&body_text)
|
||||
{
|
||||
match try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens).await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!("Matrix: access token refreshed via MSC2918, retrying send");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"Matrix API error {status}: {body_text} (refresh failed: {e})"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Err(format!("Matrix API error {status}: {body_text}").into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,21 +170,32 @@ impl MatrixAdapter {
|
||||
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver_url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.send()
|
||||
.await?;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let token = self.current_access_token().await;
|
||||
let resp = self.client.get(&url).bearer_auth(&token).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
if resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
|
||||
return Ok(user_id);
|
||||
}
|
||||
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
if attempt == 1
|
||||
&& status == reqwest::StatusCode::UNAUTHORIZED
|
||||
&& is_unknown_token_body(&body_text)
|
||||
&& try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
info!("Matrix: access token refreshed via MSC2918, retrying /whoami");
|
||||
continue;
|
||||
}
|
||||
return Err("Matrix authentication failed".into());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
|
||||
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -126,6 +204,87 @@ impl MatrixAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect `M_UNKNOWN_TOKEN` errors in a Matrix response body.
|
||||
///
|
||||
/// Matrix returns 401 for multiple reasons; we only want to refresh on
|
||||
/// `M_UNKNOWN_TOKEN` (the access token expired or was revoked). See
|
||||
/// <https://spec.matrix.org/latest/client-server-api/#soft-logout>.
|
||||
fn is_unknown_token_body(body: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("errcode").and_then(|c| c.as_str()).map(String::from))
|
||||
.map(|c| c == "M_UNKNOWN_TOKEN")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether a Matrix 401 body indicates a hard logout (operator must re-login).
|
||||
///
|
||||
/// `soft_logout: true` (or absent — default per spec) means the device is still
|
||||
/// known to the server and a refresh-token grant is valid. `soft_logout: false`
|
||||
/// means the device was invalidated and the operator must perform a new
|
||||
/// `m.login.password` flow.
|
||||
fn is_hard_logout(body: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("soft_logout").and_then(|s| s.as_bool()))
|
||||
.map(|soft| !soft)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Call `POST /_matrix/client/v3/refresh` (MSC2918) and rotate the stored tokens.
|
||||
///
|
||||
/// On success, replaces the access token and (if the server returned one) the
|
||||
/// refresh token. MAS (matrix.org since 2025-04-07) rotates the refresh token
|
||||
/// on every call, so callers must use the new value next time.
|
||||
async fn try_refresh_tokens(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
tokens: &TokenPair,
|
||||
) -> Result<(), String> {
|
||||
let refresh_token = {
|
||||
let guard = tokens.read().await;
|
||||
match guard.1.as_ref() {
|
||||
Some(rt) => rt.as_str().to_string(),
|
||||
None => return Err("no refresh token configured".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{homeserver}/_matrix/client/v3/refresh");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "refresh_token": refresh_token }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("refresh request failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("refresh returned {status}: {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("refresh response parse error: {e}"))?;
|
||||
|
||||
let new_access = body
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "refresh response missing access_token".to_string())?;
|
||||
let new_refresh = body
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let mut guard = tokens.write().await;
|
||||
guard.0 = Zeroizing::new(new_access.to_string());
|
||||
if let Some(rt) = new_refresh {
|
||||
guard.1 = Some(Zeroizing::new(rt));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
|
||||
async fn accept_invite(
|
||||
client: &reqwest::Client,
|
||||
@@ -218,8 +377,12 @@ 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();
|
||||
let tokens = Arc::clone(&self.tokens);
|
||||
// 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);
|
||||
@@ -228,14 +391,20 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
|
||||
// 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 {
|
||||
let token = self.current_access_token().await;
|
||||
if let Some(next) = initial_sync(&client, &homeserver, &token).await {
|
||||
info!("Matrix: initial sync complete, skipping old messages");
|
||||
*since_token.write().await = Some(token);
|
||||
*since_token.write().await = Some(next);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -248,12 +417,13 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
url.push_str(&format!("&since={token}"));
|
||||
}
|
||||
|
||||
let current_token = tokens.read().await.0.as_str().to_string();
|
||||
let resp = tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Matrix adapter shutting down");
|
||||
break;
|
||||
}
|
||||
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
|
||||
result = client.get(&url).bearer_auth(¤t_token).send() => {
|
||||
match result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -267,7 +437,38 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("Matrix sync returned {}", resp.status());
|
||||
let status = resp.status();
|
||||
// MSC2918: on 401 M_UNKNOWN_TOKEN with a refresh token configured,
|
||||
// try refreshing once and loop again immediately. Hard logout
|
||||
// (soft_logout:false) is unrecoverable here — the operator must
|
||||
// perform a fresh m.login.password.
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
if is_unknown_token_body(&body_text) {
|
||||
if is_hard_logout(&body_text) {
|
||||
warn!(
|
||||
"Matrix: hard logout (soft_logout=false), operator must re-login"
|
||||
);
|
||||
} else {
|
||||
match try_refresh_tokens(&client, &homeserver, &tokens).await {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
"Matrix: access token refreshed via MSC2918, resuming /sync"
|
||||
);
|
||||
backoff = Duration::from_secs(1);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Matrix: token refresh failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Matrix sync returned {status}: {body_text}");
|
||||
}
|
||||
} else {
|
||||
warn!("Matrix sync returned {status}");
|
||||
}
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(60));
|
||||
continue;
|
||||
@@ -300,8 +501,8 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accept_invite(&client, &homeserver, access_token.as_str(), room_id)
|
||||
.await;
|
||||
let tok = tokens.read().await.0.as_str().to_string();
|
||||
accept_invite(&client, &homeserver, &tok, room_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,6 +527,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;
|
||||
@@ -346,7 +562,35 @@ 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 tok_for_count = tokens.read().await.0.as_str().to_string();
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
&tok_for_count,
|
||||
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();
|
||||
@@ -358,10 +602,11 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let tok_for_count = tokens.read().await.0.as_str().to_string();
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
access_token.as_str(),
|
||||
&tok_for_count,
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
@@ -379,7 +624,7 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
|
||||
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(),
|
||||
@@ -434,10 +679,11 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
"timeout": 5000,
|
||||
});
|
||||
|
||||
let token = self.current_access_token().await;
|
||||
let _ = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
@@ -467,6 +713,79 @@ mod tests {
|
||||
assert_eq!(adapter.name(), "matrix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_unknown_token_body() {
|
||||
// Real matrix.org body for M_UNKNOWN_TOKEN under MAS.
|
||||
let body =
|
||||
r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Token is not active","soft_logout":true}"#;
|
||||
assert!(is_unknown_token_body(body));
|
||||
assert!(!is_hard_logout(body));
|
||||
|
||||
let hard = r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Invalidated","soft_logout":false}"#;
|
||||
assert!(is_unknown_token_body(hard));
|
||||
assert!(is_hard_logout(hard));
|
||||
|
||||
let other = r#"{"errcode":"M_FORBIDDEN","error":"You are not allowed"}"#;
|
||||
assert!(!is_unknown_token_body(other));
|
||||
assert!(!is_hard_logout(other));
|
||||
|
||||
// Empty / non-JSON must not trigger refresh.
|
||||
assert!(!is_unknown_token_body(""));
|
||||
assert!(!is_unknown_token_body("not json"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_tokens_rotates_pair() {
|
||||
// Spin up a tiny axum server that mimics MSC2918 /refresh: rotates both
|
||||
// access and refresh tokens and returns the new pair.
|
||||
use axum::{routing::post, Json, Router};
|
||||
|
||||
async fn refresh_handler(Json(body): Json<serde_json::Value>) -> Json<serde_json::Value> {
|
||||
let incoming = body
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
assert_eq!(incoming, "old_refresh");
|
||||
Json(serde_json::json!({
|
||||
"access_token": "new_access",
|
||||
"refresh_token": "new_refresh",
|
||||
"expires_in_ms": 3_600_000u64,
|
||||
}))
|
||||
}
|
||||
|
||||
let app = Router::new().route("/_matrix/client/v3/refresh", post(refresh_handler));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let homeserver = format!("http://{addr}");
|
||||
let tokens: TokenPair = Arc::new(RwLock::new((
|
||||
Zeroizing::new("old_access".to_string()),
|
||||
Some(Zeroizing::new("old_refresh".to_string())),
|
||||
)));
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
try_refresh_tokens(&client, &homeserver, &tokens)
|
||||
.await
|
||||
.expect("refresh succeeds");
|
||||
|
||||
let guard = tokens.read().await;
|
||||
assert_eq!(guard.0.as_str(), "new_access");
|
||||
assert_eq!(guard.1.as_ref().map(|s| s.as_str()), Some("new_refresh"));
|
||||
drop(guard);
|
||||
|
||||
// Refresh with no refresh token configured must fail cleanly.
|
||||
let no_refresh: TokenPair = Arc::new(RwLock::new((Zeroizing::new("a".to_string()), None)));
|
||||
let err = try_refresh_tokens(&client, &homeserver, &no_refresh)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("no refresh token"));
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matrix_allowed_rooms() {
|
||||
let adapter = MatrixAdapter::new(
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,7 @@ impl ChannelAdapter for NextcloudAdapter {
|
||||
|
||||
// Use lookIntoFuture=1 and lastKnownMessageId for incremental polling
|
||||
let url = format!(
|
||||
"{}/ocs/v2.php/apps/spreed/api/v4/room/{}/chat?format=json&lookIntoFuture=1&limit=100&lastKnownMessageId={}",
|
||||
"{}/ocs/v2.php/apps/spreed/api/v1/chat/{}?format=json&lookIntoFuture=1&limit=100&lastKnownMessageId={}",
|
||||
server_url, room_token, last_id
|
||||
);
|
||||
|
||||
|
||||
@@ -94,6 +94,11 @@ impl RevoltAdapter {
|
||||
adapter
|
||||
}
|
||||
|
||||
/// Set allowed channel IDs (empty = all channels the bot is in).
|
||||
pub fn set_allowed_channels(&mut self, channels: Vec<String>) {
|
||||
self.allowed_channels = channels;
|
||||
}
|
||||
|
||||
/// Add the bot token header to a request builder.
|
||||
fn auth_header(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("x-bot-token", self.bot_token.as_str())
|
||||
|
||||
@@ -18,6 +18,10 @@ pub struct BindingContext {
|
||||
pub peer_id: String,
|
||||
/// Guild/server ID.
|
||||
pub guild_id: Option<String>,
|
||||
/// Channel/conversation ID (e.g. Discord channel, Slack conversation,
|
||||
/// Telegram chat, IRC channel name). Populated by bridges so bindings can
|
||||
/// route by room independent of which user posted.
|
||||
pub channel_id: Option<String>,
|
||||
/// User's roles.
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
@@ -143,6 +147,19 @@ impl AgentRouter {
|
||||
channel_type: &ChannelType,
|
||||
platform_user_id: &str,
|
||||
user_key: Option<&str>,
|
||||
) -> Option<AgentId> {
|
||||
self.resolve_with_channel_id(channel_type, platform_user_id, user_key, None)
|
||||
}
|
||||
|
||||
/// Resolve with an explicit channel/conversation ID, so bindings whose
|
||||
/// `match_rule.channel_id` is set can match. Used by bridges that know the
|
||||
/// room/conversation the message arrived in (Discord/Slack/Telegram/IRC).
|
||||
pub fn resolve_with_channel_id(
|
||||
&self,
|
||||
channel_type: &ChannelType,
|
||||
platform_user_id: &str,
|
||||
user_key: Option<&str>,
|
||||
channel_id: Option<&str>,
|
||||
) -> Option<AgentId> {
|
||||
let channel_key = format!("{channel_type:?}");
|
||||
|
||||
@@ -152,6 +169,7 @@ impl AgentRouter {
|
||||
account_id: None,
|
||||
peer_id: platform_user_id.to_string(),
|
||||
guild_id: None,
|
||||
channel_id: channel_id.map(|s| s.to_string()),
|
||||
roles: Vec::new(),
|
||||
};
|
||||
if let Some(agent_id) = self.resolve_binding(&ctx) {
|
||||
@@ -329,6 +347,11 @@ impl AgentRouter {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref cid) = rule.channel_id {
|
||||
if ctx.channel_id.as_ref() != Some(cid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if !rule.roles.is_empty() {
|
||||
// User must have at least one of the specified roles
|
||||
let has_role = rule.roles.iter().any(|r| ctx.roles.contains(r));
|
||||
@@ -355,6 +378,7 @@ fn channel_type_to_str(ct: &ChannelType) -> &str {
|
||||
ChannelType::WebChat => "webchat",
|
||||
ChannelType::CLI => "cli",
|
||||
ChannelType::Custom(s) => s.as_str(),
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,7 +662,129 @@ mod tests {
|
||||
guild_id: Some("guild".to_string()),
|
||||
roles: vec!["admin".to_string()],
|
||||
account_id: Some("bot".to_string()),
|
||||
channel_id: Some("ch_42".to_string()),
|
||||
};
|
||||
assert_eq!(full.specificity(), 17); // 8+4+2+2+1
|
||||
assert_eq!(full.specificity(), 25); // 8+8+4+2+2+1
|
||||
|
||||
// peer_id alone vs channel_id alone — both worth 8.
|
||||
let peer_only = BindingMatchRule {
|
||||
peer_id: Some("u".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let channel_id_only = BindingMatchRule {
|
||||
channel_id: Some("c".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(peer_only.specificity(), 8);
|
||||
assert_eq!(channel_id_only.specificity(), 8);
|
||||
|
||||
// Combined peer_id + channel_id (16) outranks either alone (8).
|
||||
let peer_and_channel = BindingMatchRule {
|
||||
peer_id: Some("u".to_string()),
|
||||
channel_id: Some("c".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(peer_and_channel.specificity(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_channel_id_match() {
|
||||
// A binding scoped to a specific Discord channel should match messages
|
||||
// from that channel and reject messages from other channels.
|
||||
let router = AgentRouter::new();
|
||||
let agent_id = AgentId::new();
|
||||
router.register_agent("ops-bot".to_string(), agent_id);
|
||||
router.load_bindings(&[AgentBinding {
|
||||
agent: "ops-bot".to_string(),
|
||||
match_rule: openfang_types::config::BindingMatchRule {
|
||||
channel: Some("discord".to_string()),
|
||||
channel_id: Some("1477803840265781391".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
}]);
|
||||
|
||||
// Same channel, any user — matches.
|
||||
let resolved = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"any-user",
|
||||
None,
|
||||
Some("1477803840265781391"),
|
||||
);
|
||||
assert_eq!(resolved, Some(agent_id));
|
||||
|
||||
// Different channel — no match.
|
||||
let resolved = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"any-user",
|
||||
None,
|
||||
Some("9999999999999999999"),
|
||||
);
|
||||
assert_eq!(resolved, None);
|
||||
|
||||
// Missing channel_id on the wire — no match (the binding is restrictive).
|
||||
let resolved =
|
||||
router.resolve_with_channel_id(&ChannelType::Discord, "any-user", None, None);
|
||||
assert_eq!(resolved, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_channel_id_plus_peer_outranks_channel_id_alone() {
|
||||
// user A in #medical → researcher; anyone else in #medical → general.
|
||||
let router = AgentRouter::new();
|
||||
let researcher = AgentId::new();
|
||||
let general = AgentId::new();
|
||||
router.register_agent("researcher".to_string(), researcher);
|
||||
router.register_agent("general".to_string(), general);
|
||||
router.load_bindings(&[
|
||||
AgentBinding {
|
||||
agent: "general".to_string(),
|
||||
match_rule: openfang_types::config::BindingMatchRule {
|
||||
channel_id: Some("ch-medical".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
AgentBinding {
|
||||
agent: "researcher".to_string(),
|
||||
match_rule: openfang_types::config::BindingMatchRule {
|
||||
channel_id: Some("ch-medical".to_string()),
|
||||
peer_id: Some("user-a".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// user-a in #medical → researcher (more specific wins)
|
||||
let r = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"user-a",
|
||||
None,
|
||||
Some("ch-medical"),
|
||||
);
|
||||
assert_eq!(r, Some(researcher));
|
||||
|
||||
// user-b in #medical → general (channel_id alone matches)
|
||||
let r = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"user-b",
|
||||
None,
|
||||
Some("ch-medical"),
|
||||
);
|
||||
assert_eq!(r, Some(general));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_match_rule_unknown_field_rejected() {
|
||||
// Typos like `channnel_id` must fail loudly at deserialization rather
|
||||
// than silently producing a wide-open binding. This is the highest-
|
||||
// leverage line in the patch from issue #1127.
|
||||
let bad = r#"{ "channnel_id": "ch-1" }"#;
|
||||
let r: Result<openfang_types::config::BindingMatchRule, _> = serde_json::from_str(bad);
|
||||
assert!(r.is_err(), "unknown field must be rejected by serde");
|
||||
|
||||
// Sanity: known fields still parse.
|
||||
let good = r#"{ "channel_id": "ch-1", "channel": "discord" }"#;
|
||||
let r: openfang_types::config::BindingMatchRule = serde_json::from_str(good).unwrap();
|
||||
assert_eq!(r.channel_id.as_deref(), Some("ch-1"));
|
||||
assert_eq!(r.channel.as_deref(), Some("discord"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,38 @@ const SLACK_API_BASE: &str = "https://slack.com/api";
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
const SLACK_MSG_LIMIT: usize = 3000;
|
||||
/// TTL for envelope_id dedup entries. Well above the typical Slack
|
||||
/// connection-rotation overlap window (< 10s).
|
||||
const ENVELOPE_TTL: Duration = Duration::from_secs(60);
|
||||
/// Soft cap on the dedup cache size. When exceeded we GC expired entries.
|
||||
/// Recent envelope IDs are not reused by Slack, so 10k is more than enough.
|
||||
const ENVELOPE_CACHE_CAP: usize = 10_000;
|
||||
|
||||
/// Returns true if `envelope_id` was already seen within `ENVELOPE_TTL`.
|
||||
/// On first sight, records the timestamp and returns false. Performs
|
||||
/// opportunistic GC of expired entries when the cache grows large.
|
||||
///
|
||||
/// Slack Socket Mode delivers the same event to multiple active WebSocket
|
||||
/// connections during connection rotation. Apps must dedupe on `envelope_id`
|
||||
/// to avoid double-processing.
|
||||
fn is_duplicate_envelope(cache: &DashMap<String, Instant>, envelope_id: &str) -> bool {
|
||||
if envelope_id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Opportunistic GC: bound growth without per-call work.
|
||||
if cache.len() > ENVELOPE_CACHE_CAP {
|
||||
cache.retain(|_, ts| ts.elapsed() < ENVELOPE_TTL);
|
||||
}
|
||||
|
||||
if let Some(prev) = cache.get(envelope_id) {
|
||||
if prev.elapsed() < ENVELOPE_TTL {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
cache.insert(envelope_id.to_string(), Instant::now());
|
||||
false
|
||||
}
|
||||
|
||||
/// Slack Socket Mode adapter.
|
||||
pub struct SlackAdapter {
|
||||
@@ -41,6 +73,9 @@ pub struct SlackAdapter {
|
||||
auto_thread_reply: bool,
|
||||
/// Whether to unfurl (expand previews for) links in posted messages.
|
||||
unfurl_links: bool,
|
||||
/// Recently-seen envelope_ids. Slack Socket Mode redelivers the same event
|
||||
/// across rotated WebSocket connections; this prevents double-processing.
|
||||
seen_envelopes: Arc<DashMap<String, Instant>>,
|
||||
}
|
||||
|
||||
impl SlackAdapter {
|
||||
@@ -65,6 +100,7 @@ impl SlackAdapter {
|
||||
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
|
||||
auto_thread_reply,
|
||||
unfurl_links,
|
||||
seen_envelopes: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +197,7 @@ impl ChannelAdapter for SlackAdapter {
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
let active_threads = self.active_threads.clone();
|
||||
let auto_thread_reply = self.auto_thread_reply;
|
||||
let seen_envelopes = self.seen_envelopes.clone();
|
||||
|
||||
// Spawn periodic cleanup of expired thread entries.
|
||||
{
|
||||
@@ -288,6 +325,14 @@ impl ChannelAdapter for SlackAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup: Slack redelivers the same event on the new
|
||||
// connection during the rotation overlap. Ack on
|
||||
// both, but only forward to the agent once.
|
||||
if is_duplicate_envelope(&seen_envelopes, envelope_id) {
|
||||
debug!("Slack: skipping duplicate envelope_id {envelope_id}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the event
|
||||
let event = &payload["payload"]["event"];
|
||||
if let Some(msg) = parse_slack_event(
|
||||
@@ -501,6 +546,9 @@ async fn parse_slack_event(
|
||||
|
||||
// Check if the bot was @-mentioned (for group_policy = "mention_only")
|
||||
let mut metadata = HashMap::new();
|
||||
// Stash the Slack user ID so the router can key bindings on user, not channel.
|
||||
// (`sender.platform_id` below is the channel ID, used for the send path.)
|
||||
metadata.insert("sender_user_id".to_string(), serde_json::json!(user_id));
|
||||
if event_type == "app_mention" {
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true));
|
||||
}
|
||||
@@ -742,4 +790,58 @@ mod tests {
|
||||
);
|
||||
assert!(!adapter.unfurl_links);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_envelope_dedup_skips_second_delivery() {
|
||||
// Simulates Slack redelivering the same event across a connection
|
||||
// rotation: the envelope is acked on both connections but the agent
|
||||
// must only see it once.
|
||||
let cache: DashMap<String, Instant> = DashMap::new();
|
||||
let envelope_id = "8d2e1c5a-4f3b-49a1-b6e2-7c0a9f1234ab";
|
||||
|
||||
// First delivery on the old connection: not a duplicate, forward.
|
||||
assert!(
|
||||
!is_duplicate_envelope(&cache, envelope_id),
|
||||
"first sight of envelope must not be flagged as duplicate"
|
||||
);
|
||||
|
||||
// Second delivery on the new connection: duplicate, skip.
|
||||
assert!(
|
||||
is_duplicate_envelope(&cache, envelope_id),
|
||||
"second sight of same envelope must be flagged as duplicate"
|
||||
);
|
||||
|
||||
// Simulate the receive-loop pattern: count how many times the agent
|
||||
// would actually be invoked across two deliveries.
|
||||
let mut agent_invocations = 0;
|
||||
for _delivery in 0..2 {
|
||||
if !is_duplicate_envelope(&cache, envelope_id) {
|
||||
agent_invocations += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
agent_invocations, 0,
|
||||
"after initial double-delivery, no further invocations should occur within TTL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_envelope_dedup_distinct_ids_pass_through() {
|
||||
let cache: DashMap<String, Instant> = DashMap::new();
|
||||
assert!(!is_duplicate_envelope(&cache, "envelope-a"));
|
||||
assert!(!is_duplicate_envelope(&cache, "envelope-b"));
|
||||
assert!(!is_duplicate_envelope(&cache, "envelope-c"));
|
||||
// Each unique envelope_id should be seen exactly once.
|
||||
assert_eq!(cache.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_envelope_dedup_empty_id_never_dedupes() {
|
||||
// Defensive: malformed payloads with no envelope_id should not poison
|
||||
// the cache or short-circuit forwarding.
|
||||
let cache: DashMap<String, Instant> = DashMap::new();
|
||||
assert!(!is_duplicate_envelope(&cache, ""));
|
||||
assert!(!is_duplicate_envelope(&cache, ""));
|
||||
assert_eq!(cache.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ pub enum ChannelType {
|
||||
Mattermost,
|
||||
WebChat,
|
||||
CLI,
|
||||
/// MQTT pub/sub messaging.
|
||||
Mqtt,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
@@ -48,6 +50,16 @@ pub enum ChannelContent {
|
||||
File {
|
||||
url: String,
|
||||
filename: String,
|
||||
/// Best-effort MIME type from the source platform (e.g. Discord's
|
||||
/// `attachments[].content_type`). `None` if the platform did not
|
||||
/// provide one; downstream consumers may sniff bytes or fall back
|
||||
/// to extension-based detection.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mime: Option<String>,
|
||||
/// Size in bytes, when known. Useful for capacity gating before
|
||||
/// the bridge attempts to materialize or transmit the file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
size: Option<u64>,
|
||||
},
|
||||
/// Local file data (bytes read from disk). Used by the proactive `channel_send`
|
||||
/// tool when `file_path` is provided instead of `file_url`.
|
||||
@@ -68,6 +80,12 @@ pub enum ChannelContent {
|
||||
name: String,
|
||||
args: Vec<String>,
|
||||
},
|
||||
/// A composite message carrying multiple content blocks (e.g. a Discord
|
||||
/// message with several attachments, or an image with a separate file
|
||||
/// sibling). Blocks are flat-mapped by the bridge into multiple LLM
|
||||
/// content blocks. Implementations should not produce nested `Multipart`
|
||||
/// values; consumers may `debug_assert!` against nesting.
|
||||
Multipart(Vec<ChannelContent>),
|
||||
}
|
||||
|
||||
/// A unified message from any channel.
|
||||
@@ -95,6 +113,60 @@ pub struct ChannelMessage {
|
||||
pub metadata: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
// Re-export the adapter allowlist from openfang-types so config validation
|
||||
// and routing share a single source of truth (no drift between the two).
|
||||
pub use openfang_types::config::CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL;
|
||||
|
||||
impl ChannelMessage {
|
||||
/// Return the platform-native channel/conversation ID for this message,
|
||||
/// suitable for matching against an `AgentBinding`'s `channel_id` field.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. For adapters in [`CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL`],
|
||||
/// `sender.platform_id` already *is* the channel ID (these adapters
|
||||
/// overload the field because it doubles as the send target).
|
||||
/// 2. Otherwise, fall back to `metadata["channel_id"]` if present (any
|
||||
/// adapter can opt in by populating that key).
|
||||
/// 3. Otherwise, `None`.
|
||||
///
|
||||
/// This is the central routing-time accessor — config validation and the
|
||||
/// router both consult it (directly or via the same allowlist) so the two
|
||||
/// cannot drift.
|
||||
pub fn channel_id(&self) -> Option<String> {
|
||||
// For builtin variants the string is already lowercase by construction.
|
||||
// For `Custom(s)`, adapters _should_ register lowercase names but we
|
||||
// case-fold here so a stray `Custom("Twitch")` cannot silently slip
|
||||
// past the allowlist (and out of step with the validation path, which
|
||||
// already lowercases user input). Allocates only on the Custom arm.
|
||||
let channel_str: std::borrow::Cow<'_, str> = match &self.channel {
|
||||
ChannelType::Telegram => "telegram".into(),
|
||||
ChannelType::Discord => "discord".into(),
|
||||
ChannelType::Slack => "slack".into(),
|
||||
ChannelType::WhatsApp => "whatsapp".into(),
|
||||
ChannelType::Signal => "signal".into(),
|
||||
ChannelType::Matrix => "matrix".into(),
|
||||
ChannelType::Email => "email".into(),
|
||||
ChannelType::Teams => "teams".into(),
|
||||
ChannelType::Mattermost => "mattermost".into(),
|
||||
ChannelType::WebChat => "webchat".into(),
|
||||
ChannelType::CLI => "cli".into(),
|
||||
ChannelType::Mqtt => "mqtt".into(),
|
||||
ChannelType::Custom(s) => s.to_lowercase().into(),
|
||||
};
|
||||
if CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL
|
||||
.iter()
|
||||
.any(|c| *c == channel_str.as_ref())
|
||||
{
|
||||
Some(self.sender.platform_id.clone())
|
||||
} else {
|
||||
self.metadata
|
||||
.get("channel_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent lifecycle phase for UX indicators.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -269,6 +341,24 @@ pub trait ChannelAdapter: Send + Sync {
|
||||
self.send(user, content).await
|
||||
}
|
||||
|
||||
/// Determine whether to auto-create a thread for an incoming message.
|
||||
/// Returns Some(thread_name) to create a thread, or None to reply directly.
|
||||
/// Default implementation returns None (no auto-threading).
|
||||
async fn should_auto_thread(&self, _message: &ChannelMessage) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Create a new thread (typically triggered after should_auto_thread returns Some).
|
||||
/// Returns the new thread ID on success.
|
||||
async fn create_thread(
|
||||
&self,
|
||||
_user: &ChannelUser,
|
||||
_message_id: &str,
|
||||
_thread_name: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
Err("Thread creation not supported for this adapter".into())
|
||||
}
|
||||
|
||||
/// Whether this adapter should suppress sending internal agent errors back to the user.
|
||||
///
|
||||
/// Returns `true` for public broadcast channels (e.g. Mastodon) where posting
|
||||
@@ -363,6 +453,34 @@ mod tests {
|
||||
assert_eq!(back, ChannelType::Email);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_id_custom_arm_is_case_insensitive() {
|
||||
// A stray capitalized Custom variant must still resolve through the
|
||||
// allowlist. The validation path lowercases user input; the routing
|
||||
// path needs the same case-fold to stay in sync.
|
||||
let make = |name: &str| ChannelMessage {
|
||||
channel: ChannelType::Custom(name.to_string()),
|
||||
platform_message_id: "m".to_string(),
|
||||
sender: ChannelUser {
|
||||
platform_id: "C123".to_string(),
|
||||
display_name: "x".to_string(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content: ChannelContent::Text("hi".to_string()),
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: false,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
};
|
||||
assert_eq!(make("twitch").channel_id().as_deref(), Some("C123"));
|
||||
assert_eq!(make("Twitch").channel_id().as_deref(), Some("C123"));
|
||||
assert_eq!(make("TWITCH").channel_id().as_deref(), Some("C123"));
|
||||
// Lark spelling (Feishu Intl) must also match.
|
||||
assert_eq!(make("lark").channel_id().as_deref(), Some("C123"));
|
||||
assert_eq!(make("Lark").channel_id().as_deref(), Some("C123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_content_variants() {
|
||||
let text = ChannelContent::Text("hello".to_string());
|
||||
|
||||
@@ -258,7 +258,8 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
"https://graph.facebook.com/v21.0/{}/messages",
|
||||
self.phone_number_id
|
||||
);
|
||||
let resp = self.client
|
||||
let resp = self
|
||||
.client
|
||||
.post(&api_url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
@@ -270,7 +271,7 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
return Err(format!("WhatsApp API error {status}: {body}").into());
|
||||
}
|
||||
}
|
||||
ChannelContent::File { url, filename } => {
|
||||
ChannelContent::File { url, filename, .. } => {
|
||||
let body = serde_json::json!({
|
||||
"messaging_product": "whatsapp",
|
||||
"to": user.platform_id,
|
||||
@@ -284,7 +285,8 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
"https://graph.facebook.com/v21.0/{}/messages",
|
||||
self.phone_number_id
|
||||
);
|
||||
let resp = self.client
|
||||
let resp = self
|
||||
.client
|
||||
.post(&api_url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
@@ -310,7 +312,8 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
"https://graph.facebook.com/v21.0/{}/messages",
|
||||
self.phone_number_id
|
||||
);
|
||||
let resp = self.client
|
||||
let resp = self
|
||||
.client
|
||||
.post(&api_url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
|
||||
@@ -229,14 +229,23 @@ async fn test_bridge_dispatch_text_message() {
|
||||
let sent = adapter_ref.get_sent();
|
||||
assert_eq!(sent.len(), 1, "Expected 1 response, got {}", sent.len());
|
||||
assert_eq!(sent[0].0, "user1");
|
||||
assert_eq!(sent[0].1, "Echo: Hello agent!");
|
||||
// The bridge prepends sender identity: [From: Name] or [From: Name <email>]
|
||||
assert!(
|
||||
sent[0].1.contains("Hello agent!"),
|
||||
"Response should contain original text, got: {}",
|
||||
sent[0].1
|
||||
);
|
||||
|
||||
// Verify: handle received the message
|
||||
// Verify: handle received the message (with sender prefix)
|
||||
{
|
||||
let received = handle.received.lock().unwrap();
|
||||
assert_eq!(received.len(), 1);
|
||||
assert_eq!(received[0].0, agent_id);
|
||||
assert_eq!(received[0].1, "Hello agent!");
|
||||
assert!(
|
||||
received[0].1.contains("Hello agent!"),
|
||||
"Handle should receive text containing original message, got: {}",
|
||||
received[0].1
|
||||
);
|
||||
}
|
||||
|
||||
manager.stop().await;
|
||||
@@ -486,7 +495,10 @@ async fn test_bridge_manager_lifecycle() {
|
||||
assert_eq!(sent.len(), 5, "Expected 5 responses, got {}", sent.len());
|
||||
|
||||
for (i, (_, text)) in sent.iter().enumerate() {
|
||||
assert_eq!(*text, format!("Echo: message {i}"));
|
||||
assert!(
|
||||
text.contains(&format!("message {i}")),
|
||||
"Expected 'message {i}' in: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
// Stop — should complete without hanging
|
||||
@@ -535,11 +547,19 @@ async fn test_bridge_multiple_adapters() {
|
||||
|
||||
let tg_sent = tg_ref.get_sent();
|
||||
assert_eq!(tg_sent.len(), 1);
|
||||
assert_eq!(tg_sent[0].1, "Echo: from telegram");
|
||||
assert!(
|
||||
tg_sent[0].1.contains("from telegram"),
|
||||
"Expected 'from telegram' in: {}",
|
||||
tg_sent[0].1
|
||||
);
|
||||
|
||||
let dc_sent = dc_ref.get_sent();
|
||||
assert_eq!(dc_sent.len(), 1);
|
||||
assert_eq!(dc_sent[0].1, "Echo: from discord");
|
||||
assert!(
|
||||
dc_sent[0].1.contains("from discord"),
|
||||
"Expected 'from discord' in: {}",
|
||||
dc_sent[0].1
|
||||
);
|
||||
|
||||
manager.stop().await;
|
||||
}
|
||||
|
||||
@@ -31,3 +31,4 @@ openfang-runtime = { path = "../openfang-runtime" }
|
||||
uuid = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
colored = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
+681
-53
@@ -237,6 +237,9 @@ enum Commands {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Dashboard authentication [*].
|
||||
#[command(subcommand)]
|
||||
Auth(AuthCommands),
|
||||
/// Security tools and audit trail [*].
|
||||
#[command(subcommand)]
|
||||
Security(SecurityCommands),
|
||||
@@ -403,6 +406,9 @@ enum HandCommands {
|
||||
Activate {
|
||||
/// Hand ID (e.g. "clip", "lead", "researcher").
|
||||
id: String,
|
||||
/// Optional instance name. Required to run multiple instances of the same hand.
|
||||
#[arg(long, short = 'n')]
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Deactivate an active hand instance.
|
||||
Deactivate {
|
||||
@@ -434,6 +440,27 @@ enum HandCommands {
|
||||
/// Instance ID (from `hand active`).
|
||||
id: String,
|
||||
},
|
||||
/// Get, set, or list settings for an active hand instance.
|
||||
///
|
||||
/// With no flags, prints the current settings. Use `--set KEY=VAL`
|
||||
/// (repeatable) to update values, `--unset KEY` to remove a value,
|
||||
/// or `--get KEY` to print a single value.
|
||||
Config {
|
||||
/// Hand ID (e.g. "browser", "clip").
|
||||
id: String,
|
||||
/// Print a single setting value.
|
||||
#[arg(long, value_name = "KEY", conflicts_with_all = ["set", "unset", "list"])]
|
||||
get: Option<String>,
|
||||
/// Set a setting value. Format: `KEY=VALUE`. May be repeated.
|
||||
#[arg(long, value_name = "KEY=VALUE")]
|
||||
set: Vec<String>,
|
||||
/// Unset a setting key. May be repeated.
|
||||
#[arg(long, value_name = "KEY")]
|
||||
unset: Vec<String>,
|
||||
/// List the current settings (default when no other flag is given).
|
||||
#[arg(long)]
|
||||
list: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -679,6 +706,12 @@ enum CronCommands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AuthCommands {
|
||||
/// Generate an Argon2id password hash for dashboard authentication.
|
||||
HashPassword,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SecurityCommands {
|
||||
/// Show security status summary.
|
||||
@@ -829,6 +862,7 @@ fn init_tracing_stderr() {
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
|
||||
)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
}
|
||||
|
||||
@@ -869,6 +903,21 @@ fn init_tracing_file() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `msg` to stdout, silently exiting with code 0 on BrokenPipe.
|
||||
/// Use this instead of `println!` for machine-readable (JSON) output that is
|
||||
/// commonly piped into other tools.
|
||||
fn write_stdout_safe(msg: &str) {
|
||||
let out = std::io::stdout();
|
||||
let mut lock = out.lock();
|
||||
if let Err(e) = writeln!(lock, "{}", msg) {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(0);
|
||||
}
|
||||
eprintln!("error: failed writing to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Load ~/.openfang/.env into process environment (system env takes priority).
|
||||
dotenv::load_dotenv();
|
||||
@@ -975,13 +1024,20 @@ fn main() {
|
||||
HandCommands::List => cmd_hand_list(),
|
||||
HandCommands::Active => cmd_hand_active(),
|
||||
HandCommands::Install { path } => cmd_hand_install(&path),
|
||||
HandCommands::Activate { id } => cmd_hand_activate(&id),
|
||||
HandCommands::Activate { id, name } => cmd_hand_activate(&id, name),
|
||||
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
|
||||
HandCommands::Info { id } => cmd_hand_info(&id),
|
||||
HandCommands::CheckDeps { id } => cmd_hand_check_deps(&id),
|
||||
HandCommands::InstallDeps { id } => cmd_hand_install_deps(&id),
|
||||
HandCommands::Pause { id } => cmd_hand_pause(&id),
|
||||
HandCommands::Resume { id } => cmd_hand_resume(&id),
|
||||
HandCommands::Config {
|
||||
id,
|
||||
get,
|
||||
set,
|
||||
unset,
|
||||
list,
|
||||
} => cmd_hand_config(&id, get.as_deref(), &set, &unset, list),
|
||||
},
|
||||
Some(Commands::Config(sub)) => match sub {
|
||||
ConfigCommands::Show => cmd_config_show(),
|
||||
@@ -1041,6 +1097,9 @@ fn main() {
|
||||
Some(Commands::Sessions { agent, json }) => cmd_sessions(agent.as_deref(), json),
|
||||
Some(Commands::Logs { lines, follow }) => cmd_logs(lines, follow),
|
||||
Some(Commands::Health { json }) => cmd_health(json),
|
||||
Some(Commands::Auth(sub)) => match sub {
|
||||
AuthCommands::HashPassword => cmd_auth_hash_password(),
|
||||
},
|
||||
Some(Commands::Security(sub)) => match sub {
|
||||
SecurityCommands::Status { json } => cmd_security_status(json),
|
||||
SecurityCommands::Audit { limit, json } => cmd_security_audit(limit, json),
|
||||
@@ -1417,9 +1476,30 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
|
||||
"openrouter/google/gemini-2.5-flash",
|
||||
"OpenRouter",
|
||||
),
|
||||
("minimax", "MINIMAX_API_KEY", "MiniMax-M2.7", "MiniMax"),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod provider_list_tests {
|
||||
use super::provider_list;
|
||||
|
||||
#[test]
|
||||
fn provider_list_includes_minimax() {
|
||||
let minimax = provider_list()
|
||||
.into_iter()
|
||||
.find(|(provider, _, _, _)| *provider == "minimax");
|
||||
assert!(
|
||||
minimax.is_some(),
|
||||
"MiniMax should be exposed by provider_list()"
|
||||
);
|
||||
let (_, env_var, model, display) = minimax.unwrap();
|
||||
assert_eq!(env_var, "MINIMAX_API_KEY");
|
||||
assert_eq!(model, "MiniMax-M2.7");
|
||||
assert_eq!(display, "MiniMax");
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick probe to check if Ollama is running on localhost.
|
||||
fn check_ollama_available() -> bool {
|
||||
std::net::TcpStream::connect_timeout(
|
||||
@@ -2115,18 +2195,14 @@ fn cmd_doctor(json: bool, repair: bool) {
|
||||
ui::check_ok(".env file (permissions fixed to 0600)");
|
||||
}
|
||||
repaired = true;
|
||||
} else {
|
||||
if !json {
|
||||
ui::check_warn(&format!(
|
||||
".env file has loose permissions ({:o}), should be 0600",
|
||||
mode
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !json {
|
||||
ui::check_ok(".env file");
|
||||
} else if !json {
|
||||
ui::check_warn(&format!(
|
||||
".env file has loose permissions ({:o}), should be 0600",
|
||||
mode
|
||||
));
|
||||
}
|
||||
} else if !json {
|
||||
ui::check_ok(".env file");
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
@@ -2402,6 +2478,7 @@ decay_rate = 0.05
|
||||
("TOGETHER_API_KEY", "Together", "together"),
|
||||
("MISTRAL_API_KEY", "Mistral", "mistral"),
|
||||
("FIREWORKS_API_KEY", "Fireworks", "fireworks"),
|
||||
("AWS_BEARER_TOKEN_BEDROCK", "AWS Bedrock", "bedrock"),
|
||||
];
|
||||
|
||||
let mut any_key_set = false;
|
||||
@@ -2414,11 +2491,14 @@ decay_rate = 0.05
|
||||
if !json {
|
||||
ui::provider_status(name, env_var, true);
|
||||
}
|
||||
} else if !json {
|
||||
ui::check_warn(&format!("{name} ({env_var}) - key rejected (401/403)"));
|
||||
} else {
|
||||
if !json {
|
||||
ui::check_fail(&format!("{name} ({env_var}) - key rejected (401/403)"));
|
||||
}
|
||||
all_ok = false;
|
||||
}
|
||||
any_key_set = true;
|
||||
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "warn" }, "live_test": !valid}));
|
||||
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "fail" }, "live_test": !valid}));
|
||||
} else {
|
||||
if !json {
|
||||
ui::provider_status(name, env_var, false);
|
||||
@@ -2427,6 +2507,20 @@ decay_rate = 0.05
|
||||
}
|
||||
}
|
||||
|
||||
// Check GitHub Copilot auth (separate from env var checks)
|
||||
{
|
||||
let openfang_dir = cli_openfang_home();
|
||||
if openfang_runtime::drivers::copilot::copilot_auth_available(&openfang_dir) {
|
||||
any_key_set = true;
|
||||
if !json {
|
||||
ui::check_ok("GitHub Copilot (authenticated via device flow)");
|
||||
}
|
||||
checks.push(
|
||||
serde_json::json!({"check": "provider", "name": "GitHub Copilot", "status": "ok"}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !any_key_set {
|
||||
if !json {
|
||||
println!();
|
||||
@@ -2580,7 +2674,8 @@ decay_rate = 0.05
|
||||
checks.push(serde_json::json!({"check": "mcp_server_config", "status": "warn", "name": server.name}));
|
||||
}
|
||||
}
|
||||
openfang_types::config::McpTransportEntry::Sse { url } => {
|
||||
openfang_types::config::McpTransportEntry::Sse { url }
|
||||
| openfang_types::config::McpTransportEntry::Http { url } => {
|
||||
if url.is_empty() {
|
||||
if !json {
|
||||
ui::check_warn(&format!(
|
||||
@@ -2626,9 +2721,7 @@ decay_rate = 0.05
|
||||
// Check workspace skills if home dir available
|
||||
if skills_dir.exists() {
|
||||
match skill_reg.load_workspace_skills(&skills_dir) {
|
||||
Ok(_) => {
|
||||
let total = skill_reg.count();
|
||||
let ws_count = total.saturating_sub(bundled_count);
|
||||
Ok(ws_count) => {
|
||||
if ws_count > 0 {
|
||||
if !json {
|
||||
ui::check_ok(&format!("Workspace skills loaded: {ws_count}"));
|
||||
@@ -2670,8 +2763,15 @@ decay_rate = 0.05
|
||||
}
|
||||
}
|
||||
}
|
||||
if injection_warnings > 0 {
|
||||
checks.push(serde_json::json!({"check": "skill_injection_scan", "status": "warn", "warnings": injection_warnings}));
|
||||
let blocked = skill_reg.blocked_count();
|
||||
if injection_warnings > 0 || blocked > 0 {
|
||||
let total_warnings = injection_warnings + blocked;
|
||||
if blocked > 0 && !json {
|
||||
ui::check_warn(&format!(
|
||||
"{blocked} workspace skill(s) were blocked for critical prompt injection"
|
||||
));
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "skill_injection_scan", "status": "warn", "warnings": total_warnings, "blocked": blocked}));
|
||||
} else {
|
||||
if !json {
|
||||
ui::check_ok("All skills pass prompt injection scan");
|
||||
@@ -2909,19 +3009,20 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
write_stdout_safe(
|
||||
&serde_json::to_string_pretty(&serde_json::json!({
|
||||
"all_ok": all_ok,
|
||||
"checks": checks,
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
} else {
|
||||
println!();
|
||||
if all_ok {
|
||||
ui::success("All checks passed! OpenFang is ready.");
|
||||
ui::hint("Start the daemon: openfang start");
|
||||
if find_daemon().is_none() {
|
||||
ui::hint("Start the daemon: openfang start");
|
||||
}
|
||||
} else if repaired {
|
||||
ui::success("Repairs applied. Re-run `openfang doctor` to verify.");
|
||||
} else {
|
||||
@@ -3488,6 +3589,7 @@ fn cmd_skill_install(source: &str) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("Installed OpenClaw skill: {}", manifest.skill.name);
|
||||
notify_daemon_skill_reload();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to convert OpenClaw skill: {e}");
|
||||
@@ -3517,6 +3619,86 @@ fn cmd_skill_install(source: &str) {
|
||||
"Installed skill: {} v{}",
|
||||
manifest.skill.name, manifest.skill.version
|
||||
);
|
||||
notify_daemon_skill_reload();
|
||||
} else if source.starts_with("https://")
|
||||
|| source.starts_with("http://")
|
||||
|| source.starts_with("git@")
|
||||
{
|
||||
// Git URL install — clone to temp dir then install from there
|
||||
ui::step(&format!("Cloning skill from {source}..."));
|
||||
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| {
|
||||
eprintln!("Failed to create temp directory: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let clone_path = tmp_dir.path().join("skill");
|
||||
let status = std::process::Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
source,
|
||||
clone_path.to_str().unwrap(),
|
||||
])
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {}
|
||||
Ok(_) => {
|
||||
eprintln!("Failed to clone repository: {source}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to run git: {e}");
|
||||
ui::hint("Make sure git is installed and available on your PATH.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse the local directory install logic on the cloned repo
|
||||
let manifest_path = clone_path.join("skill.toml");
|
||||
if !manifest_path.exists() {
|
||||
if openfang_skills::openclaw_compat::detect_openclaw_skill(&clone_path) {
|
||||
println!("Detected OpenClaw skill format. Converting...");
|
||||
match openfang_skills::openclaw_compat::convert_openclaw_skill(&clone_path) {
|
||||
Ok(manifest) => {
|
||||
let dest = skills_dir.join(&manifest.skill.name);
|
||||
copy_dir_recursive(&clone_path, &dest);
|
||||
if let Err(e) = openfang_skills::openclaw_compat::write_openfang_manifest(
|
||||
&dest, &manifest,
|
||||
) {
|
||||
eprintln!("Failed to write manifest: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("Installed OpenClaw skill: {}", manifest.skill.name);
|
||||
notify_daemon_skill_reload();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to convert OpenClaw skill: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
eprintln!("No skill.toml found in cloned repository: {source}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let toml_str = std::fs::read_to_string(&manifest_path).unwrap_or_else(|e| {
|
||||
eprintln!("Error reading skill.toml: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let manifest: openfang_skills::SkillManifest =
|
||||
toml::from_str(&toml_str).unwrap_or_else(|e| {
|
||||
eprintln!("Error parsing skill.toml: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let dest = skills_dir.join(&manifest.skill.name);
|
||||
copy_dir_recursive(&clone_path, &dest);
|
||||
println!(
|
||||
"Installed skill: {} v{}",
|
||||
manifest.skill.name, manifest.skill.version
|
||||
);
|
||||
notify_daemon_skill_reload();
|
||||
} else {
|
||||
// Remote install from FangHub
|
||||
println!("Installing {source} from FangHub...");
|
||||
@@ -3525,7 +3707,10 @@ fn cmd_skill_install(source: &str) {
|
||||
openfang_skills::marketplace::MarketplaceConfig::default(),
|
||||
);
|
||||
match rt.block_on(client.install(source, &skills_dir)) {
|
||||
Ok(version) => println!("Installed {source} {version}"),
|
||||
Ok(version) => {
|
||||
println!("Installed {source} {version}");
|
||||
notify_daemon_skill_reload();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install skill: {e}");
|
||||
std::process::exit(1);
|
||||
@@ -3534,6 +3719,25 @@ fn cmd_skill_install(source: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Notify the running daemon to hot-reload its skill registry after a CLI install.
|
||||
///
|
||||
/// If the daemon is not running, this is a no-op with a hint to the user.
|
||||
fn notify_daemon_skill_reload() {
|
||||
if let Some(base) = find_daemon() {
|
||||
let client = daemon_client();
|
||||
match client.post(format!("{base}/api/skills/reload")).send() {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
ui::step("Daemon notified — skill registry reloaded.");
|
||||
}
|
||||
_ => {
|
||||
ui::check_warn("Could not notify daemon. Restart with: openfang restart");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui::hint("Start the daemon to make this skill available to agents: openfang start");
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_skill_list() {
|
||||
let home = openfang_home();
|
||||
let skills_dir = home.join("skills");
|
||||
@@ -4231,23 +4435,37 @@ fn cmd_hand_active() {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_activate(id: &str) {
|
||||
fn cmd_hand_activate(id: &str, name: Option<String>) {
|
||||
let base = require_daemon("hand activate");
|
||||
let client = daemon_client();
|
||||
let request_body = match &name {
|
||||
Some(n) => serde_json::json!({ "instance_name": n }).to_string(),
|
||||
None => "{}".to_string(),
|
||||
};
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/{id}/activate"))
|
||||
.header("content-type", "application/json")
|
||||
.body("{}")
|
||||
.body(request_body)
|
||||
.send(),
|
||||
);
|
||||
if body.get("instance_id").is_some() {
|
||||
println!(
|
||||
"Hand '{}' activated (instance: {}, agent: {})",
|
||||
id,
|
||||
body["instance_id"].as_str().unwrap_or("?"),
|
||||
body["agent_name"].as_str().unwrap_or("?"),
|
||||
);
|
||||
if let Some(n) = &name {
|
||||
println!(
|
||||
"Hand '{}' activated (instance: {}, name: {}, agent: {})",
|
||||
id,
|
||||
body["instance_id"].as_str().unwrap_or("?"),
|
||||
n,
|
||||
body["agent_name"].as_str().unwrap_or("?"),
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Hand '{}' activated (instance: {}, agent: {})",
|
||||
id,
|
||||
body["instance_id"].as_str().unwrap_or("?"),
|
||||
body["agent_name"].as_str().unwrap_or("?"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
"Failed to activate hand '{}': {}",
|
||||
@@ -4396,6 +4614,167 @@ fn cmd_hand_resume(id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `KEY=VALUE` pair passed to `--set`.
|
||||
///
|
||||
/// Empty keys are rejected so `--set =foo` or `--set =bar` surface a clear
|
||||
/// error rather than silently writing a blank setting name.
|
||||
fn parse_hand_config_pair(pair: &str) -> Result<(String, String), String> {
|
||||
let (key, value) = pair
|
||||
.split_once('=')
|
||||
.ok_or_else(|| format!("Invalid --set '{pair}': expected KEY=VALUE"))?;
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
return Err(format!("Invalid --set '{pair}': empty key"));
|
||||
}
|
||||
Ok((key.to_string(), value.to_string()))
|
||||
}
|
||||
|
||||
fn cmd_hand_config(
|
||||
id: &str,
|
||||
get: Option<&str>,
|
||||
set_pairs: &[String],
|
||||
unset_keys: &[String],
|
||||
list: bool,
|
||||
) {
|
||||
let base = require_daemon("hand config");
|
||||
let client = daemon_client();
|
||||
|
||||
// Always fetch current state first so we can merge updates and print
|
||||
// a useful view even when the target hand has no active instance.
|
||||
let url = format!("{base}/api/hands/{id}/settings");
|
||||
let body = daemon_json(client.get(&url).send());
|
||||
|
||||
if let Some(err) = body.get("error").and_then(|v| v.as_str()) {
|
||||
ui::error(&format!("Hand '{id}': {err}"));
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mut current: std::collections::BTreeMap<String, serde_json::Value> = body
|
||||
.get("current_values")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let schema_defaults: std::collections::BTreeMap<String, String> = body
|
||||
.get("settings")
|
||||
.and_then(|v| v.get("settings"))
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|s| {
|
||||
let key = s.get("key").and_then(|v| v.as_str())?.to_string();
|
||||
let default = s
|
||||
.get("default")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some((key, default))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Pure read paths — no mutation, no daemon round-trip beyond the GET.
|
||||
if let Some(key) = get {
|
||||
match current
|
||||
.get(key)
|
||||
.map(value_to_display)
|
||||
.or_else(|| schema_defaults.get(key).cloned())
|
||||
{
|
||||
Some(val) => println!("{val}"),
|
||||
None => {
|
||||
ui::error(&format!("No setting '{key}' on hand '{id}'"));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let is_mutation = !set_pairs.is_empty() || !unset_keys.is_empty();
|
||||
if !is_mutation {
|
||||
print_hand_config(id, ¤t, &schema_defaults, list);
|
||||
return;
|
||||
}
|
||||
|
||||
for pair in set_pairs {
|
||||
match parse_hand_config_pair(pair) {
|
||||
Ok((k, v)) => {
|
||||
current.insert(k, serde_json::Value::String(v));
|
||||
}
|
||||
Err(e) => {
|
||||
ui::error(&e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in unset_keys {
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
ui::error("Invalid --unset: empty key");
|
||||
std::process::exit(1);
|
||||
}
|
||||
current.remove(key);
|
||||
}
|
||||
|
||||
let payload: serde_json::Map<String, serde_json::Value> = current.clone().into_iter().collect();
|
||||
let resp = daemon_json(
|
||||
client
|
||||
.put(&url)
|
||||
.json(&serde_json::Value::Object(payload))
|
||||
.send(),
|
||||
);
|
||||
if let Some(err) = resp.get("error").and_then(|v| v.as_str()) {
|
||||
ui::error(&format!("Failed to update hand '{id}' settings: {err}"));
|
||||
if err.contains("No active instance") {
|
||||
ui::hint(&format!(
|
||||
"Activate the hand first: openfang hand activate {id}"
|
||||
));
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
ui::success(&format!("Updated settings for hand '{id}'."));
|
||||
print_hand_config(id, ¤t, &schema_defaults, true);
|
||||
}
|
||||
|
||||
/// Human-readable display for a JSON setting value.
|
||||
fn value_to_display(v: &serde_json::Value) -> String {
|
||||
match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Null => String::new(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_hand_config(
|
||||
id: &str,
|
||||
current: &std::collections::BTreeMap<String, serde_json::Value>,
|
||||
schema_defaults: &std::collections::BTreeMap<String, String>,
|
||||
_list: bool,
|
||||
) {
|
||||
if current.is_empty() && schema_defaults.is_empty() {
|
||||
println!("No settings configured for hand '{id}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
println!("Settings for hand '{id}':");
|
||||
let mut keys: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
|
||||
for k in current.keys() {
|
||||
keys.insert(k.as_str());
|
||||
}
|
||||
for k in schema_defaults.keys() {
|
||||
keys.insert(k.as_str());
|
||||
}
|
||||
for key in keys {
|
||||
match current.get(key) {
|
||||
Some(v) => println!(" {key} = {}", value_to_display(v)),
|
||||
None => {
|
||||
let default = schema_defaults.get(key).map(|s| s.as_str()).unwrap_or("");
|
||||
println!(" {key} = {default} (default)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider / API key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -4467,6 +4846,9 @@ pub(crate) fn test_api_key(provider: &str, env_var: &str) -> bool {
|
||||
.get("https://openrouter.ai/api/v1/models")
|
||||
.bearer_auth(&key)
|
||||
.send(),
|
||||
// Bedrock bearer tokens are only valid against bedrock-runtime, not the
|
||||
// management plane. There is no cheap region-agnostic probe, so skip.
|
||||
"bedrock" => return true,
|
||||
_ => return true, // unknown provider — skip test
|
||||
};
|
||||
|
||||
@@ -4579,6 +4961,38 @@ fn cmd_config_edit() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of looking up a dotted key path in a parsed TOML config.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum ConfigGetOutcome {
|
||||
/// Scalar value formatted for display (may be the empty string).
|
||||
Value(String),
|
||||
/// Key exists but resolves to a non-scalar (table or array).
|
||||
NonScalar,
|
||||
/// Key path does not exist.
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// Look up a dotted key path inside a parsed TOML document and format the
|
||||
/// resulting scalar for display. Pure function so the behaviour can be tested
|
||||
/// without touching the filesystem.
|
||||
fn lookup_config_value(table: &toml::Value, key: &str) -> ConfigGetOutcome {
|
||||
let mut current = table;
|
||||
for part in key.split('.') {
|
||||
match current.get(part) {
|
||||
Some(v) => current = v,
|
||||
None => return ConfigGetOutcome::NotFound,
|
||||
}
|
||||
}
|
||||
match current {
|
||||
toml::Value::String(s) => ConfigGetOutcome::Value(s.clone()),
|
||||
toml::Value::Integer(i) => ConfigGetOutcome::Value(i.to_string()),
|
||||
toml::Value::Float(f) => ConfigGetOutcome::Value(f.to_string()),
|
||||
toml::Value::Boolean(b) => ConfigGetOutcome::Value(b.to_string()),
|
||||
toml::Value::Datetime(d) => ConfigGetOutcome::Value(d.to_string()),
|
||||
toml::Value::Array(_) | toml::Value::Table(_) => ConfigGetOutcome::NonScalar,
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_config_get(key: &str) {
|
||||
let home = openfang_home();
|
||||
let config_path = home.join("config.toml");
|
||||
@@ -4601,25 +5015,19 @@ fn cmd_config_get(key: &str) {
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// Navigate dotted path
|
||||
let mut current = &table;
|
||||
for part in key.split('.') {
|
||||
match current.get(part) {
|
||||
Some(v) => current = v,
|
||||
None => {
|
||||
ui::error(&format!("Key not found: {key}"));
|
||||
std::process::exit(1);
|
||||
}
|
||||
match lookup_config_value(&table, key) {
|
||||
ConfigGetOutcome::Value(s) => println!("{s}"),
|
||||
ConfigGetOutcome::NonScalar => {
|
||||
ui::error_with_fix(
|
||||
&format!("'{key}' is a section, not a scalar value"),
|
||||
"Use a deeper dotted key (e.g. `section.field`)",
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
ConfigGetOutcome::NotFound => {
|
||||
ui::error(&format!("Key not found: {key}"));
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Print value
|
||||
match current {
|
||||
toml::Value::String(s) => println!("{s}"),
|
||||
toml::Value::Integer(i) => println!("{i}"),
|
||||
toml::Value::Float(f) => println!("{f}"),
|
||||
toml::Value::Boolean(b) => println!("{b}"),
|
||||
other => println!("{other}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4815,6 +5223,29 @@ fn cmd_config_unset(key: &str) {
|
||||
}
|
||||
|
||||
fn cmd_config_set_key(provider: &str) {
|
||||
// GitHub Copilot uses OAuth device flow, not a simple API key paste.
|
||||
if provider == "github-copilot" || provider == "copilot" {
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
|
||||
ui::error(&format!("Failed to create async runtime: {e}"));
|
||||
std::process::exit(1);
|
||||
});
|
||||
match rt.block_on(openfang_runtime::drivers::copilot::run_interactive_setup(
|
||||
&openfang_dir,
|
||||
)) {
|
||||
Ok(_) => {
|
||||
ui::success("GitHub Copilot configured successfully");
|
||||
ui::hint("Restart the daemon: openfang stop && openfang start");
|
||||
}
|
||||
Err(e) => {
|
||||
ui::error(&format!("Copilot setup failed: {e}"));
|
||||
ui::hint("Check your Client ID/Secret and try again");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let env_var = provider_to_env_var(provider);
|
||||
|
||||
let key = prompt_input(&format!(" Paste your {provider} API key: "));
|
||||
@@ -5860,6 +6291,28 @@ fn cmd_health(json: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_auth_hash_password() {
|
||||
let password = prompt_input("Enter password: ");
|
||||
if password.is_empty() {
|
||||
ui::error("Empty password.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let confirm = prompt_input("Confirm password: ");
|
||||
if password != confirm {
|
||||
ui::error("Passwords do not match.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let hash = openfang_api::session_auth::hash_password(&password);
|
||||
println!();
|
||||
ui::success("Argon2id hash generated. Add this to your config.toml:");
|
||||
println!();
|
||||
println!(" [auth]");
|
||||
println!(" enabled = true");
|
||||
println!(" password_hash = \"{}\"", hash);
|
||||
println!();
|
||||
ui::hint("Restart the daemon after updating config.toml");
|
||||
}
|
||||
|
||||
fn cmd_security_status(json: bool) {
|
||||
let base = require_daemon("security status");
|
||||
let client = daemon_client();
|
||||
@@ -6808,8 +7261,183 @@ args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
assert_eq!(events.len(), 4);
|
||||
}
|
||||
|
||||
// --- Config get command unit tests ---
|
||||
|
||||
fn sample_config_with_base_url() -> &'static str {
|
||||
r#"api_listen = "127.0.0.1:4200"
|
||||
|
||||
[default_model]
|
||||
provider = "openai"
|
||||
model = "qwen3-coder-30b/qwen3-coder-30b"
|
||||
api_key_env = "OPENAI_API_KEY"
|
||||
base_url = "http://localhost:8991/v1"
|
||||
api_key = "sk-bf-test"
|
||||
|
||||
[memory]
|
||||
decay_rate = 0.05
|
||||
"#
|
||||
}
|
||||
|
||||
fn lookup(toml_str: &str, key: &str) -> super::ConfigGetOutcome {
|
||||
let table: toml::Value = toml::from_str(toml_str).expect("valid toml");
|
||||
super::lookup_config_value(&table, key)
|
||||
}
|
||||
|
||||
// Regression test for issue #905: `config get default_model.base_url`
|
||||
// must return the configured base_url string, not an empty string.
|
||||
#[test]
|
||||
fn config_get_returns_default_model_base_url() {
|
||||
let out = lookup(sample_config_with_base_url(), "default_model.base_url");
|
||||
assert_eq!(
|
||||
out,
|
||||
super::ConfigGetOutcome::Value("http://localhost:8991/v1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_returns_each_default_model_scalar() {
|
||||
let cfg = sample_config_with_base_url();
|
||||
assert_eq!(
|
||||
lookup(cfg, "default_model.provider"),
|
||||
super::ConfigGetOutcome::Value("openai".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(cfg, "default_model.model"),
|
||||
super::ConfigGetOutcome::Value("qwen3-coder-30b/qwen3-coder-30b".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(cfg, "default_model.api_key_env"),
|
||||
super::ConfigGetOutcome::Value("OPENAI_API_KEY".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(cfg, "default_model.api_key"),
|
||||
super::ConfigGetOutcome::Value("sk-bf-test".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_top_level_scalar() {
|
||||
assert_eq!(
|
||||
lookup(sample_config_with_base_url(), "api_listen"),
|
||||
super::ConfigGetOutcome::Value("127.0.0.1:4200".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_unset_base_url_is_not_found() {
|
||||
let cfg = r#"
|
||||
[default_model]
|
||||
provider = "openai"
|
||||
model = "gpt-4o"
|
||||
api_key_env = "OPENAI_API_KEY"
|
||||
"#;
|
||||
assert_eq!(
|
||||
lookup(cfg, "default_model.base_url"),
|
||||
super::ConfigGetOutcome::NotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_explicit_empty_string_round_trips_as_empty() {
|
||||
let cfg = r#"
|
||||
[default_model]
|
||||
provider = "openai"
|
||||
base_url = ""
|
||||
"#;
|
||||
assert_eq!(
|
||||
lookup(cfg, "default_model.base_url"),
|
||||
super::ConfigGetOutcome::Value(String::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_missing_key_returns_not_found() {
|
||||
assert_eq!(
|
||||
lookup(sample_config_with_base_url(), "default_model.nope"),
|
||||
super::ConfigGetOutcome::NotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_section_reports_non_scalar() {
|
||||
assert_eq!(
|
||||
lookup(sample_config_with_base_url(), "default_model"),
|
||||
super::ConfigGetOutcome::NonScalar
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_get_numeric_and_boolean_scalars() {
|
||||
let cfg = r#"
|
||||
retries = 3
|
||||
ratio = 0.25
|
||||
enabled = true
|
||||
"#;
|
||||
assert_eq!(
|
||||
lookup(cfg, "retries"),
|
||||
super::ConfigGetOutcome::Value("3".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(cfg, "ratio"),
|
||||
super::ConfigGetOutcome::Value("0.25".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(cfg, "enabled"),
|
||||
super::ConfigGetOutcome::Value("true".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// --- Uninstall command unit tests ---
|
||||
|
||||
// --- hand config command unit tests ---
|
||||
|
||||
#[test]
|
||||
fn test_hand_config_parse_pair_ok() {
|
||||
let (k, v) = super::parse_hand_config_pair("headless=true").unwrap();
|
||||
assert_eq!(k, "headless");
|
||||
assert_eq!(v, "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_config_parse_pair_value_may_contain_equals() {
|
||||
let (k, v) = super::parse_hand_config_pair("url=https://example.com?a=b").unwrap();
|
||||
assert_eq!(k, "url");
|
||||
assert_eq!(v, "https://example.com?a=b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_config_parse_pair_value_may_be_empty() {
|
||||
// Empty values are valid (useful to explicitly blank a setting before
|
||||
// PUT). Empty keys are the failure case.
|
||||
let (k, v) = super::parse_hand_config_pair("foo=").unwrap();
|
||||
assert_eq!(k, "foo");
|
||||
assert_eq!(v, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_config_parse_pair_rejects_empty_key() {
|
||||
assert!(super::parse_hand_config_pair("=bar").is_err());
|
||||
assert!(super::parse_hand_config_pair(" =bar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_config_parse_pair_requires_equals() {
|
||||
assert!(super::parse_hand_config_pair("headless").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hand_config_parse_multiple_pairs_round_trip() {
|
||||
let inputs = ["a=1", "b=two", "c=http://x.y"];
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
for pair in inputs {
|
||||
let (k, v) = super::parse_hand_config_pair(pair).unwrap();
|
||||
map.insert(k, v);
|
||||
}
|
||||
assert_eq!(map.get("a"), Some(&"1".to_string()));
|
||||
assert_eq!(map.get("b"), Some(&"two".to_string()));
|
||||
assert_eq!(map.get("c"), Some(&"http://x.y".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uninstall_path_line_filter() {
|
||||
use super::is_openfang_path_line;
|
||||
|
||||
@@ -153,6 +153,7 @@ impl StandaloneChat {
|
||||
name,
|
||||
result_preview,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
self.chat.tool_result(&name, &result_preview, is_error);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,11 @@ pub enum AppEvent {
|
||||
SkillUninstalled(String),
|
||||
/// MCP servers loaded.
|
||||
McpServersLoaded(Vec<McpServerInfo>),
|
||||
/// Skill config details loaded (installed skill `c` key).
|
||||
SkillConfigLoaded {
|
||||
skill: String,
|
||||
rows: Vec<crate::tui::screens::skills::SkillConfigVarDetail>,
|
||||
},
|
||||
/// Templates providers loaded (auth status).
|
||||
TemplateProvidersLoaded(Vec<ProviderAuth>),
|
||||
/// Security features loaded.
|
||||
@@ -1417,18 +1422,36 @@ pub fn spawn_fetch_skills(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
|
||||
let client = daemon_client();
|
||||
if let Ok(resp) = client.get(format!("{base_url}/api/skills")).send() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let skills: Vec<SkillInfo> = body
|
||||
.as_array()
|
||||
// API returns {"skills": [...], "total": N} — extract the inner array.
|
||||
// Fall back to bare array for backward compat.
|
||||
let items = body
|
||||
.get("skills")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| body.as_array());
|
||||
let skills: Vec<SkillInfo> = items
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|s| SkillInfo {
|
||||
name: s["name"].as_str().unwrap_or("").to_string(),
|
||||
runtime: s["runtime"].as_str().unwrap_or("").to_string(),
|
||||
source: s["source"].as_str().unwrap_or("").to_string(),
|
||||
// "source" is an object {"type": "..."} — extract the type string
|
||||
source: s["source"]["type"]
|
||||
.as_str()
|
||||
.or_else(|| s["source"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
description: s["description"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
config_declared: s["config_declared_count"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
as usize,
|
||||
config_resolved: s["config_resolved_count"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
as usize,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
@@ -1594,6 +1617,89 @@ pub fn spawn_fetch_mcp_servers(backend: BackendRef, tx: mpsc::Sender<AppEvent>)
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch declared + resolved config for a specific installed skill.
|
||||
///
|
||||
/// Pulls `GET /api/skills/{id}/config` and flattens the response into the
|
||||
/// `SkillConfigVarDetail` rows that the TUI details pane renders. Secret
|
||||
/// values are already redacted by the daemon so nothing sensitive crosses
|
||||
/// the wire here.
|
||||
pub fn spawn_fetch_skill_config(
|
||||
backend: BackendRef,
|
||||
skill_name: String,
|
||||
tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
use crate::tui::screens::skills::SkillConfigVarDetail;
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
let encoded: String = skill_name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' {
|
||||
c.to_string()
|
||||
} else {
|
||||
format!("%{:02X}", c as u32)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let url = format!("{base_url}/api/skills/{encoded}/config");
|
||||
if let Ok(resp) = client.get(&url).send() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let declared = body.get("declared").cloned().unwrap_or_default();
|
||||
let resolved = body.get("resolved").cloned().unwrap_or_default();
|
||||
let mut rows: Vec<SkillConfigVarDetail> = Vec::new();
|
||||
if let Some(obj) = declared.as_object() {
|
||||
// Sort keys for deterministic output.
|
||||
let mut keys: Vec<&String> = obj.keys().collect();
|
||||
keys.sort();
|
||||
for k in keys {
|
||||
let d = &obj[k];
|
||||
let r = resolved.get(k).cloned().unwrap_or_default();
|
||||
let value_hint = r
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
rows.push(SkillConfigVarDetail {
|
||||
name: k.clone(),
|
||||
description: d
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
required: d
|
||||
.get("required")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
source: r
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unresolved")
|
||||
.to_string(),
|
||||
value_hint,
|
||||
is_secret: r
|
||||
.get("is_secret")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
let _ = tx.send(AppEvent::SkillConfigLoaded {
|
||||
skill: skill_name,
|
||||
rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(_) => {
|
||||
let _ = tx.send(AppEvent::SkillConfigLoaded {
|
||||
skill: skill_name,
|
||||
rows: Vec::new(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch provider auth status for templates screen.
|
||||
pub fn spawn_fetch_template_providers(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
|
||||
std::thread::spawn(move || match backend {
|
||||
@@ -2216,13 +2322,22 @@ pub fn spawn_fetch_active_hands(backend: BackendRef, tx: mpsc::Sender<AppEvent>)
|
||||
}
|
||||
|
||||
/// Activate a hand.
|
||||
pub fn spawn_activate_hand(backend: BackendRef, hand_id: String, tx: mpsc::Sender<AppEvent>) {
|
||||
pub fn spawn_activate_hand(
|
||||
backend: BackendRef,
|
||||
hand_id: String,
|
||||
instance_name: Option<String>,
|
||||
tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
let payload = match &instance_name {
|
||||
Some(n) => serde_json::json!({ "instance_name": n }),
|
||||
None => serde_json::json!({}),
|
||||
};
|
||||
match client
|
||||
.post(format!("{base_url}/api/hands/{hand_id}/activate"))
|
||||
.json(&serde_json::json!({}))
|
||||
.json(&payload)
|
||||
.send()
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
@@ -2242,7 +2357,7 @@ pub fn spawn_activate_hand(backend: BackendRef, hand_id: String, tx: mpsc::Sende
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(kernel) => {
|
||||
match kernel.activate_hand(&hand_id, std::collections::HashMap::new()) {
|
||||
match kernel.activate_hand(&hand_id, std::collections::HashMap::new(), instance_name) {
|
||||
Ok(_) => {
|
||||
let _ = tx.send(AppEvent::HandActivated(hand_id));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use event::{AppEvent, BackendRef};
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::llm_driver::StreamEvent;
|
||||
use openfang_types::agent::AgentId;
|
||||
use openfang_types::commands::{self, Surfaces};
|
||||
use screens::{
|
||||
agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers,
|
||||
security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
|
||||
@@ -434,6 +435,10 @@ impl App {
|
||||
}
|
||||
self.skills.loading = false;
|
||||
}
|
||||
AppEvent::SkillConfigLoaded { skill, rows } => {
|
||||
self.skills.selected_config_details = Some((skill.clone(), rows));
|
||||
self.skills.status_msg = format!("Loaded config for '{skill}'");
|
||||
}
|
||||
AppEvent::TemplateProvidersLoaded(providers) => {
|
||||
self.templates.providers = providers;
|
||||
}
|
||||
@@ -1183,6 +1188,7 @@ impl App {
|
||||
name,
|
||||
result_preview,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
self.chat.tool_result(&name, &result_preview, is_error);
|
||||
}
|
||||
@@ -1561,6 +1567,11 @@ impl App {
|
||||
event::spawn_fetch_mcp_servers(backend, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
skills::SkillsAction::LoadSkillConfig(name) => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_fetch_skill_config(backend, name, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1597,9 +1608,14 @@ impl App {
|
||||
event::spawn_fetch_active_hands(backend, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
hands::HandsAction::ActivateHand(hand_id) => {
|
||||
hands::HandsAction::ActivateHand(hand_id, instance_name) => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_activate_hand(backend, hand_id, self.event_tx.clone());
|
||||
event::spawn_activate_hand(
|
||||
backend,
|
||||
hand_id,
|
||||
instance_name,
|
||||
self.event_tx.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
hands::HandsAction::DeactivateHand(instance_id) => {
|
||||
@@ -1997,23 +2013,18 @@ impl App {
|
||||
|
||||
fn handle_slash_command(&mut self, cmd: &str) {
|
||||
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
||||
match parts[0] {
|
||||
"/exit" | "/quit" => self.handle_chat_action(chat::ChatAction::Back),
|
||||
// Canonicalise through the unified command registry: `/quit` -> `exit`,
|
||||
// `/NEW` -> `new`, etc. Unregistered commands fall through unchanged so
|
||||
// this refactor does not break any existing surface-specific behaviour.
|
||||
let canonical_head: String = commands::resolve(parts[0])
|
||||
.filter(|def| def.surfaces.contains(Surfaces::CLI))
|
||||
.map(|def| format!("/{}", def.name))
|
||||
.unwrap_or_else(|| parts[0].to_string());
|
||||
match canonical_head.as_str() {
|
||||
"/exit" => self.handle_chat_action(chat::ChatAction::Back),
|
||||
"/help" => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
self.chat
|
||||
.push_message(chat::Role::System, commands::render_help(Surfaces::CLI));
|
||||
}
|
||||
"/status" => {
|
||||
let mut s = Vec::new();
|
||||
@@ -2179,9 +2190,10 @@ impl App {
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
let help = commands::render_help(Surfaces::CLI);
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Unknown command: {}. Type /help", parts[0]),
|
||||
format!("Unknown command: {}\n\n{}", parts[0], help),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,13 +576,11 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::CreateMethod;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.custom_name.is_empty() {
|
||||
if self.custom_desc.is_empty() {
|
||||
self.custom_desc = format!("A custom {} agent", self.custom_name);
|
||||
}
|
||||
self.sub = AgentSubScreen::CustomDesc;
|
||||
KeyCode::Enter if !self.custom_name.is_empty() => {
|
||||
if self.custom_desc.is_empty() {
|
||||
self.custom_desc = format!("A custom {} agent", self.custom_name);
|
||||
}
|
||||
self.sub = AgentSubScreen::CustomDesc;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.custom_name.push(c);
|
||||
@@ -641,15 +639,11 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::CustomPrompt;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.tool_cursor > 0 {
|
||||
self.tool_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.tool_cursor > 0 => {
|
||||
self.tool_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.tool_cursor < TOOL_OPTIONS.len() - 1 {
|
||||
self.tool_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if self.tool_cursor < TOOL_OPTIONS.len() - 1 => {
|
||||
self.tool_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
self.tool_checks[self.tool_cursor] = !self.tool_checks[self.tool_cursor];
|
||||
@@ -674,21 +668,15 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::CustomTools;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.skill_cursor > 0 {
|
||||
self.skill_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.skill_cursor > 0 => {
|
||||
self.skill_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 && self.skill_cursor < len - 1 {
|
||||
self.skill_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.skill_cursor < len - 1 => {
|
||||
self.skill_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if len > 0 {
|
||||
let checked = &mut self.available_skills[self.skill_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Char(' ') if len > 0 => {
|
||||
let checked = &mut self.available_skills[self.skill_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Advance to MCP server selection
|
||||
@@ -706,21 +694,15 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::CustomSkills;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.mcp_cursor > 0 {
|
||||
self.mcp_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.mcp_cursor > 0 => {
|
||||
self.mcp_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 && self.mcp_cursor < len - 1 {
|
||||
self.mcp_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.mcp_cursor < len - 1 => {
|
||||
self.mcp_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if len > 0 {
|
||||
let checked = &mut self.available_mcp[self.mcp_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Char(' ') if len > 0 => {
|
||||
let checked = &mut self.available_mcp[self.mcp_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let toml = self.build_custom_toml();
|
||||
@@ -737,21 +719,15 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::AgentDetail;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.skill_cursor > 0 {
|
||||
self.skill_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.skill_cursor > 0 => {
|
||||
self.skill_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 && self.skill_cursor < len - 1 {
|
||||
self.skill_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.skill_cursor < len - 1 => {
|
||||
self.skill_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if len > 0 {
|
||||
let checked = &mut self.available_skills[self.skill_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Char(' ') if len > 0 => {
|
||||
let checked = &mut self.available_skills[self.skill_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Save — collect checked skill names (none checked = "all")
|
||||
@@ -780,21 +756,15 @@ impl AgentSelectState {
|
||||
KeyCode::Esc => {
|
||||
self.sub = AgentSubScreen::AgentDetail;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.mcp_cursor > 0 {
|
||||
self.mcp_cursor -= 1;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if self.mcp_cursor > 0 => {
|
||||
self.mcp_cursor -= 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if len > 0 && self.mcp_cursor < len - 1 {
|
||||
self.mcp_cursor += 1;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if len > 0 && self.mcp_cursor < len - 1 => {
|
||||
self.mcp_cursor += 1;
|
||||
}
|
||||
KeyCode::Char(' ') => {
|
||||
if len > 0 {
|
||||
let checked = &mut self.available_mcp[self.mcp_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Char(' ') if len > 0 => {
|
||||
let checked = &mut self.available_mcp[self.mcp_cursor].1;
|
||||
*checked = !*checked;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Save — collect checked server names (none checked = "all")
|
||||
|
||||
@@ -164,19 +164,15 @@ impl AuditState {
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
self.action_filter = self.action_filter.next();
|
||||
|
||||
@@ -155,19 +155,19 @@ impl CommsState {
|
||||
self.task_field = 0;
|
||||
}
|
||||
KeyCode::Char('r') => return CommsAction::Refresh,
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k')
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() =>
|
||||
{
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % self.events.len();
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j')
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() =>
|
||||
{
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % self.events.len();
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -189,18 +189,17 @@ impl CommsState {
|
||||
self.send_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
KeyCode::Enter
|
||||
if !self.send_from.is_empty()
|
||||
&& !self.send_to.is_empty()
|
||||
&& !self.send_msg.is_empty()
|
||||
{
|
||||
self.show_send_modal = false;
|
||||
return CommsAction::SendMessage {
|
||||
from: self.send_from.clone(),
|
||||
to: self.send_to.clone(),
|
||||
msg: self.send_msg.clone(),
|
||||
};
|
||||
}
|
||||
&& !self.send_msg.is_empty() =>
|
||||
{
|
||||
self.show_send_modal = false;
|
||||
return CommsAction::SendMessage {
|
||||
from: self.send_from.clone(),
|
||||
to: self.send_to.clone(),
|
||||
msg: self.send_msg.clone(),
|
||||
};
|
||||
}
|
||||
KeyCode::Char(c) => match self.send_field {
|
||||
0 => self.send_from.push(c),
|
||||
@@ -238,15 +237,13 @@ impl CommsState {
|
||||
self.task_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.task_title.is_empty() {
|
||||
self.show_task_modal = false;
|
||||
return CommsAction::PostTask {
|
||||
title: self.task_title.clone(),
|
||||
desc: self.task_desc.clone(),
|
||||
assign: self.task_assign.clone(),
|
||||
};
|
||||
}
|
||||
KeyCode::Enter if !self.task_title.is_empty() => {
|
||||
self.show_task_modal = false;
|
||||
return CommsAction::PostTask {
|
||||
title: self.task_title.clone(),
|
||||
desc: self.task_desc.clone(),
|
||||
assign: self.task_assign.clone(),
|
||||
};
|
||||
}
|
||||
KeyCode::Char(c) => match self.task_field {
|
||||
0 => self.task_title.push(c),
|
||||
|
||||
@@ -152,12 +152,10 @@ impl ExtensionsState {
|
||||
self.sub = ExtSub::Health;
|
||||
return ExtensionsAction::RefreshHealth;
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
if self.sub == ExtSub::Browse {
|
||||
self.searching = true;
|
||||
self.search_query.clear();
|
||||
return ExtensionsAction::Continue;
|
||||
}
|
||||
KeyCode::Char('/') if self.sub == ExtSub::Browse => {
|
||||
self.searching = true;
|
||||
self.search_query.clear();
|
||||
return ExtensionsAction::Continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -172,19 +170,15 @@ impl ExtensionsState {
|
||||
fn handle_browse(&mut self, key: KeyEvent) -> ExtensionsAction {
|
||||
let total = self.filtered().len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.browse_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.browse_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let filtered = self.filtered();
|
||||
@@ -222,24 +216,18 @@ impl ExtensionsState {
|
||||
|
||||
let total = self.installed_list_data().len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete => {
|
||||
if self.installed_list.selected().is_some() {
|
||||
self.confirm_remove = true;
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete if self.installed_list.selected().is_some() => {
|
||||
self.confirm_remove = true;
|
||||
}
|
||||
KeyCode::Char('r') => return ExtensionsAction::RefreshAll,
|
||||
_ => {}
|
||||
@@ -250,19 +238,15 @@ impl ExtensionsState {
|
||||
fn handle_health(&mut self, key: KeyEvent) -> ExtensionsAction {
|
||||
let total = self.health_entries.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.health_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.health_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') | KeyCode::Enter => {
|
||||
if let Some(sel) = self.health_list.selected() {
|
||||
|
||||
@@ -55,7 +55,9 @@ pub enum HandsAction {
|
||||
Continue,
|
||||
RefreshDefinitions,
|
||||
RefreshActive,
|
||||
ActivateHand(String),
|
||||
/// Activate a hand. Second field is the optional instance name.
|
||||
/// TODO: add text-input modal for custom instance names (#878 follow-up).
|
||||
ActivateHand(String, Option<String>),
|
||||
DeactivateHand(String),
|
||||
PauseHand(String),
|
||||
ResumeHand(String),
|
||||
@@ -107,24 +109,21 @@ impl HandsState {
|
||||
fn handle_marketplace(&mut self, key: KeyEvent) -> HandsAction {
|
||||
let total = self.definitions.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.marketplace_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.marketplace_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char('a') => {
|
||||
if let Some(sel) = self.marketplace_list.selected() {
|
||||
if sel < self.definitions.len() {
|
||||
return HandsAction::ActivateHand(self.definitions[sel].id.clone());
|
||||
// TODO: add text-input modal for custom instance names (#878 follow-up)
|
||||
return HandsAction::ActivateHand(self.definitions[sel].id.clone(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,24 +153,18 @@ impl HandsState {
|
||||
|
||||
let total = self.instances.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.active_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.active_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete => {
|
||||
if self.active_list.selected().is_some() {
|
||||
self.confirm_deactivate = true;
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Delete if self.active_list.selected().is_some() => {
|
||||
self.confirm_deactivate = true;
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
if let Some(sel) = self.active_list.selected() {
|
||||
|
||||
@@ -148,6 +148,14 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "minimax",
|
||||
display: "MiniMax",
|
||||
env_var: "MINIMAX_API_KEY",
|
||||
default_model: "MiniMax-M2.7",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "huggingface",
|
||||
display: "Hugging Face",
|
||||
@@ -159,10 +167,10 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
ProviderInfo {
|
||||
name: "github-copilot",
|
||||
display: "GitHub Copilot",
|
||||
env_var: "GITHUB_TOKEN",
|
||||
default_model: "gpt-4o",
|
||||
needs_key: true,
|
||||
hint: "via PAT",
|
||||
env_var: "",
|
||||
default_model: "claude-sonnet-4.6",
|
||||
needs_key: false, // Auth handled via OAuth device flow after init
|
||||
hint: "free with subscription",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "replicate",
|
||||
@@ -188,6 +196,22 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "nvidia",
|
||||
display: "NVIDIA NIM",
|
||||
env_var: "NVIDIA_API_KEY",
|
||||
default_model: "nvidia/llama-3.1-nemotron-70b-instruct",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "bedrock",
|
||||
display: "AWS Bedrock",
|
||||
env_var: "AWS_BEARER_TOKEN_BEDROCK",
|
||||
default_model: "anthropic.claude-sonnet-4-6",
|
||||
needs_key: true,
|
||||
hint: "bearer token",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
display: "Claude Code",
|
||||
@@ -242,6 +266,23 @@ pub enum InitResult {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PROVIDERS;
|
||||
|
||||
#[test]
|
||||
fn init_wizard_lists_minimax_provider() {
|
||||
let minimax = PROVIDERS.iter().find(|provider| provider.name == "minimax");
|
||||
assert!(
|
||||
minimax.is_some(),
|
||||
"MiniMax should be selectable in openfang init"
|
||||
);
|
||||
let minimax = minimax.unwrap();
|
||||
assert_eq!(minimax.env_var, "MINIMAX_API_KEY");
|
||||
assert_eq!(minimax.default_model, "MiniMax-M2.7");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal state ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -249,6 +290,7 @@ enum Step {
|
||||
Welcome,
|
||||
Migration,
|
||||
Provider,
|
||||
CopilotAuth,
|
||||
ApiKey,
|
||||
Model,
|
||||
Routing,
|
||||
@@ -280,6 +322,29 @@ enum KeyTestState {
|
||||
Warn,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
enum CopilotAuthStatus {
|
||||
/// Requesting device code from GitHub.
|
||||
Starting,
|
||||
/// Waiting for user to authorize in browser.
|
||||
WaitingForUser,
|
||||
/// Authorized, fetching models.
|
||||
FetchingModels,
|
||||
/// Done — models loaded.
|
||||
Done,
|
||||
/// Error.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
enum CopilotAuthEvent {
|
||||
DeviceCode {
|
||||
user_code: String,
|
||||
verification_uri: String,
|
||||
},
|
||||
Authenticated,
|
||||
Models(Vec<String>),
|
||||
}
|
||||
|
||||
/// A model entry for list display.
|
||||
struct ModelEntry {
|
||||
id: String,
|
||||
@@ -340,6 +405,11 @@ struct State {
|
||||
daemon_url: String,
|
||||
daemon_error: String,
|
||||
saving_done: bool,
|
||||
|
||||
// Copilot auth
|
||||
copilot_user_code: String,
|
||||
copilot_verification_uri: String,
|
||||
copilot_auth_status: CopilotAuthStatus,
|
||||
save_error: String,
|
||||
}
|
||||
|
||||
@@ -378,6 +448,9 @@ impl State {
|
||||
daemon_error: String::new(),
|
||||
saving_done: false,
|
||||
save_error: String::new(),
|
||||
copilot_user_code: String::new(),
|
||||
copilot_verification_uri: String::new(),
|
||||
copilot_auth_status: CopilotAuthStatus::Starting,
|
||||
};
|
||||
s.build_provider_order();
|
||||
s.provider_list.select(Some(0));
|
||||
@@ -423,6 +496,7 @@ impl State {
|
||||
Step::Welcome => "1 of 7",
|
||||
Step::Migration => "2 of 7",
|
||||
Step::Provider => "3 of 7",
|
||||
Step::CopilotAuth => "4 of 7",
|
||||
Step::ApiKey => "4 of 7",
|
||||
Step::Model => "5 of 7",
|
||||
Step::Routing => "6 of 7",
|
||||
@@ -602,12 +676,52 @@ pub fn run() -> InitResult {
|
||||
let (test_tx, test_rx) = std::sync::mpsc::channel::<bool>();
|
||||
let (migrate_tx, migrate_rx) =
|
||||
std::sync::mpsc::channel::<Result<openfang_migrate::report::MigrationReport, String>>();
|
||||
let (copilot_tx, copilot_rx) = std::sync::mpsc::channel::<Result<CopilotAuthEvent, String>>();
|
||||
|
||||
let result = loop {
|
||||
terminal
|
||||
.draw(|f| draw(f, f.area(), &mut state))
|
||||
.expect("draw failed");
|
||||
|
||||
// Check for Copilot auth events
|
||||
if state.step == Step::CopilotAuth {
|
||||
while let Ok(event) = copilot_rx.try_recv() {
|
||||
match event {
|
||||
Ok(CopilotAuthEvent::DeviceCode {
|
||||
user_code,
|
||||
verification_uri,
|
||||
}) => {
|
||||
state.copilot_user_code = user_code;
|
||||
state.copilot_verification_uri = verification_uri;
|
||||
state.copilot_auth_status = CopilotAuthStatus::WaitingForUser;
|
||||
}
|
||||
Ok(CopilotAuthEvent::Authenticated) => {
|
||||
state.copilot_auth_status = CopilotAuthStatus::FetchingModels;
|
||||
}
|
||||
Ok(CopilotAuthEvent::Models(models)) => {
|
||||
state.copilot_auth_status = CopilotAuthStatus::Done;
|
||||
state.model_entries.clear();
|
||||
for model_id in &models {
|
||||
state.model_entries.push(ModelEntry {
|
||||
id: model_id.clone(),
|
||||
display_name: model_id.clone(),
|
||||
tier: "copilot",
|
||||
cost: "free".to_string(),
|
||||
});
|
||||
}
|
||||
if !state.model_entries.is_empty() {
|
||||
state.model_list.select(Some(0));
|
||||
}
|
||||
// Auto-advance to model picker
|
||||
state.step = Step::Model;
|
||||
}
|
||||
Err(e) => {
|
||||
state.copilot_auth_status = CopilotAuthStatus::Failed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for background key-test result
|
||||
if state.key_test == KeyTestState::Testing {
|
||||
if let Ok(ok) = test_rx.try_recv() {
|
||||
@@ -742,7 +856,117 @@ pub fn run() -> InitResult {
|
||||
state.selected_provider = Some(prov_idx);
|
||||
let p = &PROVIDERS[prov_idx];
|
||||
|
||||
if !p.needs_key {
|
||||
if p.name == "github-copilot" {
|
||||
// Start Copilot device flow in background
|
||||
state.copilot_auth_status = CopilotAuthStatus::Starting;
|
||||
state.api_key_from_env = false;
|
||||
state.step = Step::CopilotAuth;
|
||||
|
||||
// Kick off background auth
|
||||
let copilot_tx = copilot_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let openfang_dir = crate::cli_openfang_home();
|
||||
let rt = match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = copilot_tx
|
||||
.send(Err(format!("Runtime error: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
rt.block_on(async {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP error: {e}"));
|
||||
let http = match http {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
let _ = copilot_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Step 1: request device code
|
||||
use openfang_runtime::drivers::copilot;
|
||||
let device =
|
||||
match copilot::request_device_code(&http).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
let _ = copilot_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Send device code to TUI for display
|
||||
let _ =
|
||||
copilot_tx.send(Ok(CopilotAuthEvent::DeviceCode {
|
||||
user_code: device.user_code.clone(),
|
||||
verification_uri: device
|
||||
.verification_uri
|
||||
.clone(),
|
||||
}));
|
||||
|
||||
// Browser will be opened by user pressing Enter in TUI
|
||||
|
||||
// Step 2: poll for token
|
||||
let tokens = match copilot::poll_for_token(
|
||||
&http,
|
||||
&device.device_code,
|
||||
device.interval,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = copilot_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Save tokens
|
||||
if let Err(e) = tokens.save(&openfang_dir) {
|
||||
let _ = copilot_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = copilot_tx
|
||||
.send(Ok(CopilotAuthEvent::Authenticated));
|
||||
|
||||
// Step 3: fetch models
|
||||
let ct = match copilot::exchange_copilot_token(
|
||||
&http,
|
||||
&tokens.access_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ct) => ct,
|
||||
Err(e) => {
|
||||
let _ = copilot_tx
|
||||
.send(Err(format!("Token exchange: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match copilot::fetch_models(
|
||||
&http,
|
||||
&ct.base_url,
|
||||
&ct.token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(models) => {
|
||||
let _ = copilot_tx
|
||||
.send(Ok(CopilotAuthEvent::Models(models)));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = copilot_tx
|
||||
.send(Err(format!("Model fetch: {e}")));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if !p.needs_key {
|
||||
state.api_key_from_env = false;
|
||||
state.load_models_for_provider();
|
||||
state.step = Step::Model;
|
||||
@@ -761,6 +985,25 @@ pub fn run() -> InitResult {
|
||||
_ => {}
|
||||
},
|
||||
|
||||
Step::CopilotAuth => match key.code {
|
||||
KeyCode::Esc => {
|
||||
if matches!(state.copilot_auth_status, CopilotAuthStatus::Failed(_)) {
|
||||
state.step = Step::Provider;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter
|
||||
if matches!(
|
||||
state.copilot_auth_status,
|
||||
CopilotAuthStatus::WaitingForUser
|
||||
) && !state.copilot_verification_uri.is_empty() =>
|
||||
{
|
||||
let _ = openfang_runtime::drivers::copilot::open_verification_url(
|
||||
&state.copilot_verification_uri,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
||||
Step::ApiKey => {
|
||||
if matches!(state.key_test, KeyTestState::Ok | KeyTestState::Warn) {
|
||||
continue;
|
||||
@@ -771,41 +1014,36 @@ pub fn run() -> InitResult {
|
||||
state.key_test = KeyTestState::Idle;
|
||||
state.step = Step::Provider;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
KeyCode::Enter
|
||||
if !state.api_key_input.is_empty()
|
||||
&& state.key_test == KeyTestState::Idle
|
||||
{
|
||||
if let Some(p) = state.provider() {
|
||||
let _ = crate::dotenv::save_env_key(
|
||||
p.env_var,
|
||||
&state.api_key_input,
|
||||
);
|
||||
}
|
||||
state.key_test = KeyTestState::Testing;
|
||||
let provider_name = state
|
||||
.provider()
|
||||
.map(|p| p.name.to_string())
|
||||
.unwrap_or_default();
|
||||
let env_var = state
|
||||
.provider()
|
||||
.map(|p| p.env_var.to_string())
|
||||
.unwrap_or_default();
|
||||
let tx = test_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let ok = crate::test_api_key(&provider_name, &env_var);
|
||||
let _ = tx.send(ok);
|
||||
});
|
||||
&& state.key_test == KeyTestState::Idle =>
|
||||
{
|
||||
if let Some(p) = state.provider() {
|
||||
let _ = crate::dotenv::save_env_key(
|
||||
p.env_var,
|
||||
&state.api_key_input,
|
||||
);
|
||||
}
|
||||
state.key_test = KeyTestState::Testing;
|
||||
let provider_name = state
|
||||
.provider()
|
||||
.map(|p| p.name.to_string())
|
||||
.unwrap_or_default();
|
||||
let env_var = state
|
||||
.provider()
|
||||
.map(|p| p.env_var.to_string())
|
||||
.unwrap_or_default();
|
||||
let tx = test_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let ok = crate::test_api_key(&provider_name, &env_var);
|
||||
let _ = tx.send(ok);
|
||||
});
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if state.key_test == KeyTestState::Idle {
|
||||
state.api_key_input.push(c);
|
||||
}
|
||||
KeyCode::Char(c) if state.key_test == KeyTestState::Idle => {
|
||||
state.api_key_input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.key_test == KeyTestState::Idle {
|
||||
state.api_key_input.pop();
|
||||
}
|
||||
KeyCode::Backspace if state.key_test == KeyTestState::Idle => {
|
||||
state.api_key_input.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1234,6 +1472,7 @@ fn draw(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
Step::Welcome => draw_welcome(f, chunks[3]),
|
||||
Step::Migration => draw_migration(f, chunks[3], state),
|
||||
Step::Provider => draw_provider(f, chunks[3], state),
|
||||
Step::CopilotAuth => draw_copilot_auth(f, chunks[3], state),
|
||||
Step::ApiKey => draw_api_key(f, chunks[3], state),
|
||||
Step::Model => draw_model(f, chunks[3], state),
|
||||
Step::Routing => draw_routing(f, chunks[3], state),
|
||||
@@ -1735,6 +1974,12 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
} else {
|
||||
"no API key needed".to_string()
|
||||
}
|
||||
} else if p.name == "github-copilot" {
|
||||
if detected {
|
||||
format!("{} detected", p.env_var)
|
||||
} else {
|
||||
"run set-key after init".to_string()
|
||||
}
|
||||
} else if detected {
|
||||
format!("{} detected", p.env_var)
|
||||
} else if !p.needs_key {
|
||||
@@ -1764,6 +2009,110 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
f.render_widget(hints, chunks[2]);
|
||||
}
|
||||
|
||||
fn draw_copilot_auth(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // title
|
||||
Constraint::Length(1), // blank
|
||||
Constraint::Length(1), // status line 1
|
||||
Constraint::Length(1), // status line 2
|
||||
Constraint::Length(1), // blank
|
||||
Constraint::Length(1), // code label
|
||||
Constraint::Length(1), // code value
|
||||
Constraint::Length(1), // blank
|
||||
Constraint::Length(1), // url
|
||||
Constraint::Min(0), // spacer
|
||||
Constraint::Length(1), // hint
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let title = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" GitHub Copilot Authentication",
|
||||
Style::default().fg(theme::ACCENT),
|
||||
)]));
|
||||
f.render_widget(title, chunks[0]);
|
||||
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
|
||||
match &state.copilot_auth_status {
|
||||
CopilotAuthStatus::Starting => {
|
||||
let line = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(spinner, Style::default().fg(theme::ACCENT)),
|
||||
Span::raw(" Requesting device code..."),
|
||||
]));
|
||||
f.render_widget(line, chunks[2]);
|
||||
}
|
||||
CopilotAuthStatus::WaitingForUser => {
|
||||
let line1 = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(spinner, Style::default().fg(theme::ACCENT)),
|
||||
Span::raw(" Waiting for authorization..."),
|
||||
]));
|
||||
f.render_widget(line1, chunks[2]);
|
||||
|
||||
let code_label = Paragraph::new(Line::from(vec![Span::raw(" Enter this code:")]));
|
||||
f.render_widget(code_label, chunks[5]);
|
||||
|
||||
let code_value = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
&state.copilot_user_code,
|
||||
Style::default()
|
||||
.fg(theme::GREEN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
f.render_widget(code_value, chunks[6]);
|
||||
|
||||
let url = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" at "),
|
||||
Span::styled(&state.copilot_verification_uri, theme::dim_style()),
|
||||
]));
|
||||
f.render_widget(url, chunks[8]);
|
||||
|
||||
let hint = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [Enter] Open browser",
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
f.render_widget(hint, chunks[10]);
|
||||
}
|
||||
CopilotAuthStatus::FetchingModels => {
|
||||
let line = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)),
|
||||
Span::raw("Authenticated"),
|
||||
]));
|
||||
f.render_widget(line, chunks[2]);
|
||||
|
||||
let line2 = Paragraph::new(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(spinner, Style::default().fg(theme::ACCENT)),
|
||||
Span::raw(" Fetching available models..."),
|
||||
]));
|
||||
f.render_widget(line2, chunks[3]);
|
||||
}
|
||||
CopilotAuthStatus::Done => {
|
||||
let line = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" \u{2714} ", Style::default().fg(theme::GREEN)),
|
||||
Span::raw("Models loaded"),
|
||||
]));
|
||||
f.render_widget(line, chunks[2]);
|
||||
}
|
||||
CopilotAuthStatus::Failed(err) => {
|
||||
let line = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" \u{2718} ", Style::default().fg(theme::RED)),
|
||||
Span::raw(err.as_str()),
|
||||
]));
|
||||
f.render_widget(line, chunks[2]);
|
||||
|
||||
let hint = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" Esc to go back",
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
f.render_widget(hint, chunks[10]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_api_key(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
let p = match state.provider() {
|
||||
Some(p) => p,
|
||||
@@ -2335,6 +2684,23 @@ fn draw_complete(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bedrock credentials note ──
|
||||
if p.name == "bedrock" {
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
" AWS bearer token required \u{2014} set:",
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)]),
|
||||
Line::from(vec![Span::styled(
|
||||
" AWS_BEARER_TOKEN_BEDROCK",
|
||||
theme::dim_style(),
|
||||
)]),
|
||||
]),
|
||||
chunks[14],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bottom hints ──
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
|
||||
@@ -211,19 +211,15 @@ impl LogsState {
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('f') => {
|
||||
self.level_filter = self.level_filter.next();
|
||||
@@ -237,15 +233,11 @@ impl LogsState {
|
||||
self.auto_refresh = !self.auto_refresh;
|
||||
}
|
||||
KeyCode::Char('r') => return LogsAction::Refresh,
|
||||
KeyCode::End => {
|
||||
if total > 0 {
|
||||
self.list_state.select(Some(total - 1));
|
||||
}
|
||||
KeyCode::End if total > 0 => {
|
||||
self.list_state.select(Some(total - 1));
|
||||
}
|
||||
KeyCode::Home => {
|
||||
if total > 0 {
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
KeyCode::Home if total > 0 => {
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -106,19 +106,15 @@ impl MemoryState {
|
||||
fn handle_agent_select(&mut self, key: KeyEvent) -> MemoryAction {
|
||||
let total = self.agents.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.agent_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.agent_list_state.selected() {
|
||||
@@ -166,19 +162,15 @@ impl MemoryState {
|
||||
self.kv_pairs.clear();
|
||||
self.selected_agent = None;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.kv_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.kv_list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
self.sub = MemorySub::AddKey;
|
||||
@@ -196,10 +188,8 @@ impl MemoryState {
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if self.kv_list_state.selected().is_some() {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
KeyCode::Char('d') if self.kv_list_state.selected().is_some() => {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if let Some(agent) = &self.selected_agent {
|
||||
|
||||
@@ -62,19 +62,15 @@ impl PeersState {
|
||||
}
|
||||
let total = self.peers.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return PeersAction::Refresh,
|
||||
_ => {}
|
||||
|
||||
@@ -130,19 +130,15 @@ impl SessionsState {
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
@@ -155,10 +151,8 @@ impl SessionsState {
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
if self.list_state.selected().is_some() {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
KeyCode::Char('d') if self.list_state.selected().is_some() => {
|
||||
self.confirm_delete = true;
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
self.search_mode = true;
|
||||
|
||||
@@ -174,21 +174,17 @@ impl SettingsState {
|
||||
fn handle_providers(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
let total = self.providers.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.provider_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.provider_list.select(Some(next));
|
||||
self.test_result = None;
|
||||
}
|
||||
KeyCode::Char('e') => {
|
||||
if let Some(sel) = self.provider_list.selected() {
|
||||
@@ -223,19 +219,15 @@ impl SettingsState {
|
||||
fn handle_models(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
let total = self.models.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return SettingsAction::RefreshModels,
|
||||
_ => {}
|
||||
@@ -246,19 +238,15 @@ impl SettingsState {
|
||||
fn handle_tools(&mut self, key: KeyEvent) -> SettingsAction {
|
||||
let total = self.tools.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.tool_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.tool_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return SettingsAction::RefreshTools,
|
||||
_ => {}
|
||||
|
||||
@@ -16,6 +16,23 @@ pub struct SkillInfo {
|
||||
pub runtime: String,
|
||||
pub source: String,
|
||||
pub description: String,
|
||||
/// Number of config vars the skill's SKILL.md declares. Zero = no badge.
|
||||
pub config_declared: usize,
|
||||
/// Number of declared vars that are currently resolved (user override,
|
||||
/// env, or default). When `< config_declared` the badge shows a warning.
|
||||
pub config_resolved: usize,
|
||||
}
|
||||
|
||||
/// Declared + resolved config for a single variable, shown on the Skills
|
||||
/// detail pane when a skill with a non-empty `config:` section is selected.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SkillConfigVarDetail {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub required: bool,
|
||||
pub source: String, // "user" | "env" | "default" | "unresolved"
|
||||
pub value_hint: String,
|
||||
pub is_secret: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -82,6 +99,9 @@ pub struct SkillsState {
|
||||
pub tick: usize,
|
||||
pub confirm_uninstall: bool,
|
||||
pub status_msg: String,
|
||||
/// Config variable details for the currently selected installed skill.
|
||||
/// Keyed by skill name so refreshing the list doesn't strand stale data.
|
||||
pub selected_config_details: Option<(String, Vec<SkillConfigVarDetail>)>,
|
||||
}
|
||||
|
||||
pub enum SkillsAction {
|
||||
@@ -92,6 +112,10 @@ pub enum SkillsAction {
|
||||
InstallSkill(String),
|
||||
UninstallSkill(String),
|
||||
RefreshMcp,
|
||||
/// Fetch declared + resolved config for the named skill and display it
|
||||
/// in the details pane. Consumed by the caller which drives the API
|
||||
/// request.
|
||||
LoadSkillConfig(String),
|
||||
}
|
||||
|
||||
impl SkillsState {
|
||||
@@ -111,6 +135,7 @@ impl SkillsState {
|
||||
tick: 0,
|
||||
confirm_uninstall: false,
|
||||
status_msg: String::new(),
|
||||
selected_config_details: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,23 +192,30 @@ impl SkillsState {
|
||||
|
||||
let total = self.installed.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.installed_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.installed_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('u') => {
|
||||
if self.installed_list.selected().is_some() {
|
||||
self.confirm_uninstall = true;
|
||||
KeyCode::Char('u') if self.installed_list.selected().is_some() => {
|
||||
self.confirm_uninstall = true;
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
if let Some(sel) = self.installed_list.selected() {
|
||||
if sel < self.installed.len() {
|
||||
let name = self.installed[sel].name.clone();
|
||||
// Only skills that declare config are worth fetching.
|
||||
if self.installed[sel].config_declared > 0 {
|
||||
return SkillsAction::LoadSkillConfig(name);
|
||||
} else {
|
||||
self.status_msg = format!("'{}' declares no runtime config.", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => return SkillsAction::RefreshInstalled,
|
||||
@@ -217,19 +249,15 @@ impl SkillsState {
|
||||
|
||||
let total = self.clawhub_results.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.clawhub_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.clawhub_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('i') => {
|
||||
if let Some(sel) = self.clawhub_list.selected() {
|
||||
@@ -257,19 +285,15 @@ impl SkillsState {
|
||||
fn handle_mcp(&mut self, key: KeyEvent) -> SkillsAction {
|
||||
let total = self.mcp_servers.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.mcp_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.mcp_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return SkillsAction::RefreshMcp,
|
||||
_ => {}
|
||||
@@ -336,9 +360,11 @@ fn draw_sub_tabs(f: &mut Frame, area: Rect, active: SkillsSub) {
|
||||
}
|
||||
|
||||
fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
let chunks = Layout::vertical([
|
||||
// Two panes: list on the left, config details on the right, with a
|
||||
// single-line hint bar underneath.
|
||||
let outer = Layout::vertical([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Min(3), // list
|
||||
Constraint::Min(3), // list + details
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(area);
|
||||
@@ -346,14 +372,26 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" {:<20} {:<8} {:<12} {}",
|
||||
"Name", "Runtime", "Source", "Description"
|
||||
" {:<18} {:<7} {:<10} {:<11} {}",
|
||||
"Name", "Runtime", "Source", "Config", "Description"
|
||||
),
|
||||
theme::table_header(),
|
||||
)])),
|
||||
chunks[0],
|
||||
outer[0],
|
||||
);
|
||||
|
||||
// Only show the details pane when there's room and a selection with
|
||||
// declared config exists. Below ~80 cols we collapse to list-only.
|
||||
let has_details = state.selected_config_details.is_some();
|
||||
let body_chunks: Vec<Rect> = if has_details && outer[1].width >= 70 {
|
||||
Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(outer[1])
|
||||
.to_vec()
|
||||
} else {
|
||||
vec![outer[1]]
|
||||
};
|
||||
|
||||
// Skills list (left pane or full width)
|
||||
if state.loading {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
@@ -361,7 +399,7 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading skills\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
chunks[1],
|
||||
body_chunks[0],
|
||||
);
|
||||
} else if state.installed.is_empty() {
|
||||
f.render_widget(
|
||||
@@ -369,7 +407,7 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
" No skills installed. Press [2] to browse ClawHub.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
chunks[1],
|
||||
body_chunks[0],
|
||||
);
|
||||
} else {
|
||||
let items: Vec<ListItem> = state
|
||||
@@ -394,15 +432,29 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
"builtin" | "built-in" => Style::default().fg(theme::GREEN),
|
||||
_ => theme::dim_style(),
|
||||
};
|
||||
let (config_text, config_style) = if s.config_declared == 0 {
|
||||
(String::from("-"), theme::dim_style())
|
||||
} else if s.config_resolved >= s.config_declared {
|
||||
(
|
||||
format!("[{}/{}]", s.config_resolved, s.config_declared),
|
||||
Style::default().fg(theme::GREEN),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!("[{}/{} \u{26A0}]", s.config_resolved, s.config_declared),
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)
|
||||
};
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<20}", truncate(&s.name, 19)),
|
||||
format!(" {:<18}", truncate(&s.name, 17)),
|
||||
Style::default().fg(theme::CYAN),
|
||||
),
|
||||
Span::styled(format!(" {:<8}", runtime_badge), runtime_style),
|
||||
Span::styled(format!(" {:<12}", &s.source), source_style),
|
||||
Span::styled(format!(" {:<7}", runtime_badge), runtime_style),
|
||||
Span::styled(format!(" {:<10}", &s.source), source_style),
|
||||
Span::styled(format!(" {:<11}", config_text), config_style),
|
||||
Span::styled(
|
||||
format!(" {}", truncate(&s.description, 30)),
|
||||
format!(" {}", truncate(&s.description, 24)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
]))
|
||||
@@ -412,7 +464,12 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, chunks[1], &mut state.installed_list);
|
||||
f.render_stateful_widget(list, body_chunks[0], &mut state.installed_list);
|
||||
}
|
||||
|
||||
// Config details pane (right side)
|
||||
if has_details && body_chunks.len() > 1 {
|
||||
draw_skill_config_details(f, body_chunks[1], state);
|
||||
}
|
||||
|
||||
if state.confirm_uninstall {
|
||||
@@ -421,7 +478,7 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
" Uninstall this skill? [y] Yes [any] Cancel",
|
||||
Style::default().fg(theme::YELLOW),
|
||||
)])),
|
||||
chunks[2],
|
||||
outer[2],
|
||||
);
|
||||
} else if !state.status_msg.is_empty() {
|
||||
f.render_widget(
|
||||
@@ -429,19 +486,88 @@ fn draw_installed(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
format!(" {}", state.status_msg),
|
||||
Style::default().fg(theme::GREEN),
|
||||
)])),
|
||||
chunks[2],
|
||||
outer[2],
|
||||
);
|
||||
} else {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [\u{2191}\u{2193}] Navigate [u] Uninstall [r] Refresh",
|
||||
" [\u{2191}\u{2193}] Navigate [c] View config [u] Uninstall [r] Refresh",
|
||||
theme::hint_style(),
|
||||
)])),
|
||||
chunks[2],
|
||||
outer[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_skill_config_details(f: &mut Frame, area: Rect, state: &SkillsState) {
|
||||
let Some((name, rows)) = state.selected_config_details.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
format!(" Config: {name} "),
|
||||
Style::default().fg(theme::ACCENT),
|
||||
)]))
|
||||
.borders(Borders::LEFT)
|
||||
.border_style(theme::dim_style())
|
||||
.padding(Padding::horizontal(1));
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
if rows.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("No config declared.", theme::dim_style())),
|
||||
inner,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
for row in rows {
|
||||
let src_style = match row.source.as_str() {
|
||||
"user" => Style::default().fg(theme::GREEN),
|
||||
"env" => Style::default().fg(theme::CYAN),
|
||||
"default" => theme::dim_style(),
|
||||
_ => Style::default().fg(theme::RED),
|
||||
};
|
||||
let required_marker = if row.required { "*" } else { " " };
|
||||
let mut header = vec![
|
||||
Span::styled(
|
||||
format!("{required_marker} {}", truncate(&row.name, 22)),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("[{}]", row.source), src_style),
|
||||
];
|
||||
if row.is_secret {
|
||||
header.push(Span::raw(" "));
|
||||
header.push(Span::styled(
|
||||
"(secret)",
|
||||
Style::default()
|
||||
.fg(theme::YELLOW)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(header));
|
||||
if !row.value_hint.is_empty() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" = {}", truncate(&row.value_hint, 32)),
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
}
|
||||
if !row.description.is_empty() {
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" {}", truncate(&row.description, 36)),
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
}
|
||||
lines.push(Line::from(vec![Span::raw("")]));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
fn draw_clawhub(f: &mut Frame, area: Rect, state: &mut SkillsState) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1), // search / sort
|
||||
|
||||
@@ -194,19 +194,15 @@ impl TemplatesState {
|
||||
|
||||
let total = self.filtered.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(sel) = self.list_state.selected() {
|
||||
|
||||
@@ -156,19 +156,15 @@ impl TriggerState {
|
||||
self.create_step -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if self.create_step < 5 {
|
||||
self.create_step += 1;
|
||||
}
|
||||
KeyCode::Enter if self.create_step < 5 => {
|
||||
self.create_step += 1;
|
||||
}
|
||||
KeyCode::Char(c) => match self.create_step {
|
||||
0 => self.create_agent_id.push(c),
|
||||
2 => self.create_pattern_param.push(c),
|
||||
3 => self.create_prompt.push(c),
|
||||
4 => {
|
||||
if c.is_ascii_digit() {
|
||||
self.create_max_fires.push(c);
|
||||
}
|
||||
4 if c.is_ascii_digit() => {
|
||||
self.create_max_fires.push(c);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
||||
@@ -111,19 +111,15 @@ impl UsageState {
|
||||
UsageSub::ByModel => {
|
||||
let total = self.by_model.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.model_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.model_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return UsageAction::Refresh,
|
||||
_ => {}
|
||||
@@ -132,19 +128,15 @@ impl UsageState {
|
||||
UsageSub::ByAgent => {
|
||||
let total = self.by_agent.len();
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if total > 0 => {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = if i == 0 { total - 1 } else { i - 1 };
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if total > 0 {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if total > 0 => {
|
||||
let i = self.agent_list.selected().unwrap_or(0);
|
||||
let next = (i + 1) % total;
|
||||
self.agent_list.select(Some(next));
|
||||
}
|
||||
KeyCode::Char('r') => return UsageAction::Refresh,
|
||||
_ => {}
|
||||
|
||||
@@ -85,6 +85,12 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
default_model: "qwen-plus",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "minimax",
|
||||
env_var: "MINIMAX_API_KEY",
|
||||
default_model: "MiniMax-M2.7",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "perplexity",
|
||||
env_var: "PERPLEXITY_API_KEY",
|
||||
@@ -127,6 +133,12 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
default_model: "codegeex-4",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "nvidia",
|
||||
env_var: "NVIDIA_API_KEY",
|
||||
default_model: "nvidia/llama-3.1-nemotron-70b-instruct",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
env_var: "",
|
||||
@@ -316,13 +328,11 @@ impl WizardState {
|
||||
KeyCode::Esc => {
|
||||
self.step = WizardStep::Provider;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.api_key_input.is_empty() {
|
||||
if let Some(p) = self.selected_provider_info() {
|
||||
self.model_input = p.default_model.to_string();
|
||||
}
|
||||
self.step = WizardStep::Model;
|
||||
KeyCode::Enter if !self.api_key_input.is_empty() => {
|
||||
if let Some(p) = self.selected_provider_info() {
|
||||
self.model_input = p.default_model.to_string();
|
||||
}
|
||||
self.step = WizardStep::Model;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.api_key_input.push(c);
|
||||
@@ -683,3 +693,20 @@ fn draw_done(f: &mut Frame, area: Rect, state: &WizardState) {
|
||||
f.render_widget(cont, chunks[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PROVIDERS;
|
||||
|
||||
#[test]
|
||||
fn wizard_lists_minimax_provider() {
|
||||
let minimax = PROVIDERS.iter().find(|provider| provider.name == "minimax");
|
||||
assert!(
|
||||
minimax.is_some(),
|
||||
"MiniMax should be selectable in wizard provider list"
|
||||
);
|
||||
let minimax = minimax.unwrap();
|
||||
assert_eq!(minimax.env_var, "MINIMAX_API_KEY");
|
||||
assert_eq!(minimax.default_model, "MiniMax-M2.7");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenFang",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.9",
|
||||
"identifier": "ai.openfang.desktop",
|
||||
"build": {},
|
||||
"app": {
|
||||
|
||||
@@ -11,7 +11,7 @@ command = "npx"
|
||||
args = ["-y", "@notionhq/notion-mcp-server"]
|
||||
|
||||
[[required_env]]
|
||||
name = "NOTION_API_KEY"
|
||||
name = "NOTION_TOKEN"
|
||||
label = "Notion Integration Token"
|
||||
help = "An internal integration token created in your Notion workspace settings"
|
||||
is_secret = true
|
||||
@@ -24,5 +24,5 @@ unhealthy_threshold = 3
|
||||
setup_instructions = """
|
||||
1. Go to https://www.notion.so/my-integrations and click 'New integration'.
|
||||
2. Give it a name, select your workspace, and grant the required capabilities (Read/Update/Insert content).
|
||||
3. Copy the Internal Integration Token and paste it into the NOTION_API_KEY field above. Then share relevant pages with the integration in Notion.
|
||||
3. Copy the Internal Integration Token and paste it into the NOTION_TOKEN field above. Then share relevant pages with the integration in Notion.
|
||||
"""
|
||||
|
||||
@@ -146,6 +146,9 @@ mod tests {
|
||||
crate::McpTransportTemplate::Sse { .. } => {
|
||||
panic!("{} unexpectedly uses SSE transport", id);
|
||||
}
|
||||
crate::McpTransportTemplate::Http { .. } => {
|
||||
panic!("{} unexpectedly uses HTTP transport", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +327,7 @@ mod tests {
|
||||
|
||||
// Provide key directly
|
||||
let mut keys = HashMap::new();
|
||||
keys.insert("NOTION_API_KEY".to_string(), "ntn_test_key_123".to_string());
|
||||
keys.insert("NOTION_TOKEN".to_string(), "ntn_test_key_123".to_string());
|
||||
|
||||
let result = install_integration(&mut registry, &mut resolver, "notion", &keys).unwrap();
|
||||
assert_eq!(result.id, "notion");
|
||||
|
||||
@@ -88,6 +88,9 @@ pub enum McpTransportTemplate {
|
||||
Sse {
|
||||
url: String,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// An environment variable required by an integration.
|
||||
|
||||
@@ -191,6 +191,9 @@ impl IntegrationRegistry {
|
||||
crate::McpTransportTemplate::Sse { url } => {
|
||||
McpTransportEntry::Sse { url: url.clone() }
|
||||
}
|
||||
crate::McpTransportTemplate::Http { url } => {
|
||||
McpTransportEntry::Http { url: url.clone() }
|
||||
}
|
||||
};
|
||||
let env: Vec<String> = template
|
||||
.required_env
|
||||
@@ -202,6 +205,7 @@ impl IntegrationRegistry {
|
||||
transport,
|
||||
timeout_secs: 30,
|
||||
env,
|
||||
headers: Vec::new(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -15,6 +15,9 @@ tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user