mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4c6c038a0 | ||
|
|
864e957261 | ||
|
|
f9921780e2 | ||
|
|
1c049d06c1 | ||
|
|
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 | ||
|
|
a26f762635 | ||
|
|
62b6aa4eb8 | ||
|
|
09ec6f5549 | ||
|
|
47c743f17c | ||
|
|
489fc1312c | ||
|
|
0408d65d8f | ||
|
|
8d14d0c225 | ||
|
|
07963779be | ||
|
|
28d01acf91 | ||
|
|
3b21494867 | ||
|
|
a78299ed3d | ||
|
|
eebb83c79a | ||
|
|
0b59205b0c | ||
|
|
3f8ceabc51 | ||
|
|
4921ee5ece | ||
|
|
0c4769a07f | ||
|
|
167b37f10e | ||
|
|
545e710abb | ||
|
|
e421594185 | ||
|
|
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 | ||
|
|
cf38b49e4d | ||
|
|
8ba84ada9d |
@@ -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'
|
||||
|
||||
@@ -45,3 +45,6 @@ Thumbs.db
|
||||
*.swo
|
||||
*~
|
||||
.serena/
|
||||
|
||||
# Personal deploy scripts
|
||||
scripts/deploy-remote.sh
|
||||
|
||||
@@ -5,6 +5,16 @@ 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]
|
||||
|
||||
### 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
+151
-150
@@ -139,7 +139,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -159,15 +159,6 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
@@ -730,9 +721,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.57"
|
||||
version = "1.2.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@@ -813,16 +804,6 @@ dependencies = [
|
||||
"phf 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chumsky"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"stacker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -884,9 +865,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
@@ -912,7 +893,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1579,7 +1560,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1641,17 +1622,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.25.1"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d9c2e7f1d22d0f2ce07626d259b8a55f4a47cb0938d4006dd8ae037f17d585e"
|
||||
checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"cssparser 0.36.0",
|
||||
"foldhash 0.2.0",
|
||||
"html5ever 0.36.1",
|
||||
"html5ever 0.38.0",
|
||||
"precomputed-hash",
|
||||
"selectors 0.35.0",
|
||||
"tendril",
|
||||
"selectors 0.36.1",
|
||||
"tendril 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1739,9 +1720,9 @@ checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.7"
|
||||
version = "3.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f"
|
||||
checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
@@ -1829,7 +1810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2549,7 +2530,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2651,12 +2631,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.36.1"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e"
|
||||
checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever 0.36.1",
|
||||
"markup5ever 0.38.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2760,7 +2740,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3019,9 +2999,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.11"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d"
|
||||
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"indoc",
|
||||
@@ -3038,9 +3018,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.10"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
||||
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -3091,9 +3071,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "ittapi"
|
||||
@@ -3147,7 +3127,7 @@ dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
@@ -3156,9 +3136,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||
dependencies = [
|
||||
"jni-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
@@ -3250,13 +3252,12 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.19"
|
||||
version = "0.11.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
|
||||
checksum = "471816f3e24b85e820dee02cde962379ea1a669e5242f19c61bcbcffedf4c4fb"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chumsky",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
@@ -3338,9 +3339,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
@@ -3456,17 +3457,17 @@ dependencies = [
|
||||
"phf_codegen 0.11.3",
|
||||
"string_cache 0.8.9",
|
||||
"string_cache_codegen 0.5.4",
|
||||
"tendril",
|
||||
"tendril 0.4.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.36.1"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c"
|
||||
checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
"tendril 0.5.0",
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
@@ -3560,9 +3561,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3642,7 +3643,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
@@ -3662,7 +3663,7 @@ version = "0.6.0+11769913"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||
dependencies = [
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3735,14 +3736,14 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
@@ -3955,8 +3956,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
@@ -3976,6 +3978,7 @@ dependencies = [
|
||||
"openfang-skills",
|
||||
"openfang-types",
|
||||
"openfang-wire",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3995,7 +3998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"async-trait",
|
||||
@@ -4034,7 +4037,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4062,7 +4065,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -4088,7 +4091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4116,10 +4119,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"dirs 6.0.0",
|
||||
"openfang-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4133,7 +4137,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4155,6 +4159,7 @@ dependencies = [
|
||||
"openfang-wire",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.12.28",
|
||||
"rustls 0.23.37",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"subtle",
|
||||
@@ -4171,7 +4176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4191,7 +4196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4210,7 +4215,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4246,7 +4251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4269,7 +4274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4288,7 +4293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4391,7 +4396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4943,7 +4948,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.4+spec-1.1.0",
|
||||
"toml_edit 0.25.8+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5001,9 +5006,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.13.5"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
|
||||
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
@@ -5011,9 +5016,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.13.5"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -5022,16 +5027,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulley-interpreter"
|
||||
version = "41.0.4"
|
||||
@@ -5107,7 +5102,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.37",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -5145,7 +5140,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -5161,9 +5156,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
|
||||
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
@@ -5584,9 +5579,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f"
|
||||
checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -5710,7 +5705,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5805,7 +5800,7 @@ dependencies = [
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5980,9 +5975,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.35.0"
|
||||
version = "0.36.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2"
|
||||
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cssparser 0.36.0",
|
||||
@@ -6106,9 +6101,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
||||
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -6317,9 +6312,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.8"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
@@ -6375,7 +6370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6473,19 +6468,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
@@ -6643,9 +6625,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.34.6"
|
||||
version = "0.34.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb"
|
||||
checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
@@ -7124,7 +7106,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7138,6 +7120,16 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -7384,7 +7376,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.0.4",
|
||||
"serde_spanned 1.1.0",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
@@ -7411,9 +7403,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.0.0+spec-1.1.0"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
|
||||
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -7444,30 +7436,30 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.4+spec-1.1.0"
|
||||
version = "0.25.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
|
||||
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 1.0.0+spec-1.1.0",
|
||||
"toml_datetime 1.1.0+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 0.7.15",
|
||||
"winnow 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.9+spec-1.1.0"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
|
||||
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
|
||||
dependencies = [
|
||||
"winnow 0.7.15",
|
||||
"winnow 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
||||
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
@@ -7687,7 +7679,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7745,9 +7737,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.12.0"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-truncate"
|
||||
@@ -7851,9 +7843,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -8584,7 +8576,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9094,6 +9086,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.10.1"
|
||||
@@ -9227,9 +9228,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "wry"
|
||||
version = "0.54.3"
|
||||
version = "0.54.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a24eda84b5d488f99344e54b807138896cee8df0b2d16c793f1f6b80e6d8df1f"
|
||||
checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2",
|
||||
@@ -9325,7 +9326,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
@@ -9413,18 +9414,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.42"
|
||||
version = "0.8.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
|
||||
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.42"
|
||||
version = "0.8.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
|
||||
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+3
-2
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.1"
|
||||
version = "0.5.8"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -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,7 +76,7 @@ bytes = "1"
|
||||
|
||||
# Futures
|
||||
futures = "0.3"
|
||||
prost = "0.13"
|
||||
prost = "0.14"
|
||||
|
||||
# WebSocket client (for Discord/Slack gateway)
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -49,8 +49,8 @@ use openfang_channels::discourse::DiscourseAdapter;
|
||||
use openfang_channels::gitter::GitterAdapter;
|
||||
use openfang_channels::gotify::GotifyAdapter;
|
||||
use openfang_channels::linkedin::LinkedInAdapter;
|
||||
use openfang_channels::mumble::MumbleAdapter;
|
||||
use openfang_channels::mqtt::MqttAdapter;
|
||||
use openfang_channels::mumble::MumbleAdapter;
|
||||
use openfang_channels::ntfy::NtfyAdapter;
|
||||
use openfang_channels::webhook::WebhookAdapter;
|
||||
use openfang_channels::wecom::WeComAdapter;
|
||||
@@ -816,6 +816,19 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -3,6 +3,38 @@
|
||||
//! 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;
|
||||
|
||||
@@ -167,13 +167,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;
|
||||
|
||||
@@ -436,9 +436,16 @@ pub async fn send_message(
|
||||
}
|
||||
|
||||
/// GET /api/agents/:id/session — Get agent session (conversation history).
|
||||
///
|
||||
/// Query parameters:
|
||||
/// - `include_system` — when `true`, system-role messages are included in the
|
||||
/// response (intended for debugging only). Defaults to `false` so the
|
||||
/// internal system prompt is never leaked into the Web UI conversation
|
||||
/// history (issue #935).
|
||||
pub async fn get_agent_session(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let agent_id: AgentId = match id.parse() {
|
||||
Ok(id) => id,
|
||||
@@ -450,6 +457,14 @@ pub async fn get_agent_session(
|
||||
}
|
||||
};
|
||||
|
||||
// SECURITY (#935): Default to filtering out system-role messages so the
|
||||
// internal system prompt is never exposed in the Web UI conversation
|
||||
// history. Callers can opt-in via `?include_system=true` for debugging.
|
||||
let include_system = params
|
||||
.get("include_system")
|
||||
.map(|v| matches!(v.as_str(), "1" | "true" | "yes" | "TRUE" | "True"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let entry = match state.kernel.registry.get(agent_id) {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
@@ -462,6 +477,16 @@ pub async fn get_agent_session(
|
||||
|
||||
match state.kernel.memory.get_session(entry.session_id) {
|
||||
Ok(Some(session)) => {
|
||||
// Filter out system-role messages BEFORE any rendering / truncation
|
||||
// logic so the system prompt cannot leak into the response. The
|
||||
// raw message count is preserved separately for the API consumer.
|
||||
let raw_message_count = session.messages.len();
|
||||
let filtered_messages: Vec<&openfang_types::message::Message> = session
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| include_system || m.role != openfang_types::message::Role::System)
|
||||
.collect();
|
||||
|
||||
// Two-pass approach: ToolUse blocks live in Assistant messages while
|
||||
// ToolResult blocks arrive in subsequent User messages. Pass 1
|
||||
// collects all tool_use entries keyed by id; pass 2 attaches results.
|
||||
@@ -472,7 +497,8 @@ pub async fn get_agent_session(
|
||||
let mut tool_use_index: std::collections::HashMap<String, (usize, usize)> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for m in &session.messages {
|
||||
for m in &filtered_messages {
|
||||
let m = *m;
|
||||
let mut tools: Vec<serde_json::Value> = Vec::new();
|
||||
let mut msg_images: Vec<serde_json::Value> = Vec::new();
|
||||
let content = match &m.content {
|
||||
@@ -562,8 +588,8 @@ pub async fn get_agent_session(
|
||||
built_messages.push(msg);
|
||||
}
|
||||
|
||||
// Pass 2: walk messages again and attach ToolResult to the correct tool
|
||||
for m in &session.messages {
|
||||
// Pass 2: walk filtered messages again and attach ToolResult to the correct tool
|
||||
for m in &filtered_messages {
|
||||
if let openfang_types::message::MessageContent::Blocks(blocks) = &m.content {
|
||||
for b in blocks {
|
||||
if let openfang_types::message::ContentBlock::ToolResult {
|
||||
@@ -579,7 +605,8 @@ pub async fn get_agent_session(
|
||||
msg.get_mut("tools").and_then(|v| v.as_array_mut())
|
||||
{
|
||||
if let Some(tool_obj) = tools_arr.get_mut(tool_idx) {
|
||||
tool_obj["result"] = serde_json::Value::String(result.clone());
|
||||
tool_obj["result"] =
|
||||
serde_json::Value::String(result.clone());
|
||||
tool_obj["is_error"] =
|
||||
serde_json::Value::Bool(*is_error);
|
||||
}
|
||||
@@ -592,12 +619,16 @@ pub async fn get_agent_session(
|
||||
}
|
||||
|
||||
let messages = built_messages;
|
||||
// `message_count` reflects what the API actually returns (system
|
||||
// messages excluded by default). `raw_message_count` is exposed
|
||||
// for callers that need to know the underlying total.
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"session_id": session.id.0.to_string(),
|
||||
"agent_id": session.agent_id.0.to_string(),
|
||||
"message_count": session.messages.len(),
|
||||
"message_count": messages.len(),
|
||||
"raw_message_count": raw_message_count,
|
||||
"context_window_tokens": session.context_window_tokens,
|
||||
"label": session.label,
|
||||
"messages": messages,
|
||||
@@ -4037,12 +4068,18 @@ pub async fn list_active_hands(State(state): State<Arc<AppState>>) -> impl IntoR
|
||||
let items: Vec<serde_json::Value> = instances
|
||||
.iter()
|
||||
.map(|i| {
|
||||
// Effective agent name: custom instance_name takes priority, otherwise HAND.toml default.
|
||||
let effective_agent_name = i
|
||||
.instance_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| i.agent_name.clone());
|
||||
serde_json::json!({
|
||||
"instance_id": i.instance_id,
|
||||
"hand_id": i.hand_id,
|
||||
"instance_name": i.instance_name,
|
||||
"status": format!("{}", i.status),
|
||||
"agent_id": i.agent_id.map(|a| a.to_string()),
|
||||
"agent_name": i.agent_name,
|
||||
"agent_name": effective_agent_name,
|
||||
"activated_at": i.activated_at.to_rfc3339(),
|
||||
"updated_at": i.updated_at.to_rfc3339(),
|
||||
})
|
||||
@@ -4506,9 +4543,12 @@ pub async fn activate_hand(
|
||||
Path(hand_id): Path<String>,
|
||||
body: Option<Json<openfang_hands::ActivateHandRequest>>,
|
||||
) -> impl IntoResponse {
|
||||
let config = body.map(|b| b.0.config).unwrap_or_default();
|
||||
let (config, instance_name) = match body.map(|b| b.0) {
|
||||
Some(r) => (r.config, r.instance_name),
|
||||
None => (std::collections::HashMap::new(), None),
|
||||
};
|
||||
|
||||
match state.kernel.activate_hand(&hand_id, config) {
|
||||
match state.kernel.activate_hand(&hand_id, config, instance_name) {
|
||||
Ok(instance) => {
|
||||
// If the hand agent has a non-reactive schedule (autonomous hands),
|
||||
// start its background loop so it begins running immediately.
|
||||
@@ -4532,14 +4572,19 @@ pub async fn activate_hand(
|
||||
}
|
||||
}
|
||||
}
|
||||
let effective_agent_name = instance
|
||||
.instance_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| instance.agent_name.clone());
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"instance_id": instance.instance_id,
|
||||
"hand_id": instance.hand_id,
|
||||
"instance_name": instance.instance_name,
|
||||
"status": format!("{}", instance.status),
|
||||
"agent_id": instance.agent_id.map(|a| a.to_string()),
|
||||
"agent_name": instance.agent_name,
|
||||
"agent_name": effective_agent_name,
|
||||
"activated_at": instance.activated_at.to_rfc3339(),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -104,6 +104,15 @@ 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 auth_state = crate::middleware::AuthState {
|
||||
|
||||
@@ -55,20 +55,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 +85,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 +131,14 @@ 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,8 @@ pub async fn agent_ws(
|
||||
let query_auth = uri
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.map(|raw| crate::percent_decode(raw))
|
||||
.map(|token| ct_eq(&token, api_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !header_auth && !query_auth {
|
||||
|
||||
@@ -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": "Глобально"
|
||||
}
|
||||
@@ -2892,6 +2892,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
|
||||
<!-- ═══ Step 2: Configure ═══ -->
|
||||
<div class="hand-wizard-body" x-show="setupStep === 2">
|
||||
<!-- Optional instance name (for running multiple instances of the same hand) -->
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-dim mb-1" style="letter-spacing:0.5px;text-transform:uppercase">
|
||||
Instance name <span style="text-transform:none;letter-spacing:0;color:#888">(optional)</span>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2">Leave empty for a single instance of this hand. Set a name to run multiple instances in parallel.</div>
|
||||
<input type="text" class="form-input" x-model="setupWizard.instanceName" placeholder="e.g. clip-youtube, lead-q4-outreach" style="width:100%">
|
||||
</div>
|
||||
<template x-if="!setupHasSettings">
|
||||
<div class="text-sm text-dim" style="text-align:center;padding:20px 0">No configuration needed for this hand. Click Next to continue.</div>
|
||||
</template>
|
||||
@@ -3753,90 +3761,92 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<button class="btn btn-sm" @click="editMode ? saveBudget() : startEdit()" :disabled="saving" x-text="editMode ? (saving ? 'Saving...' : 'Save') : 'Edit Limits'" style="white-space:nowrap"></button>
|
||||
</div>
|
||||
<div x-show="budgetLoading" class="loading-state"><div class="spinner"></div><span>Loading budget...</span></div>
|
||||
<div x-show="!budgetLoading && budgetData">
|
||||
<!-- Global budget meters -->
|
||||
<div class="stats-row" style="margin-bottom:16px">
|
||||
<div class="stat-card" style="flex:1">
|
||||
<div class="stat-label">Hourly</div>
|
||||
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.hourly_spend || 0).toFixed(4)"></div>
|
||||
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.hourly_limit)"></div>
|
||||
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.hourly_limit > 0">
|
||||
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.hourly_pct*100,100)+'%', background: pctColor(budgetData.hourly_pct)}"></div>
|
||||
<template x-if="!budgetLoading && budgetData">
|
||||
<div>
|
||||
<!-- Global budget meters -->
|
||||
<div class="stats-row" style="margin-bottom:16px">
|
||||
<div class="stat-card" style="flex:1">
|
||||
<div class="stat-label">Hourly</div>
|
||||
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.hourly_spend || 0).toFixed(4)"></div>
|
||||
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.hourly_limit)"></div>
|
||||
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.hourly_limit > 0">
|
||||
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.hourly_pct*100,100)+'%', background: pctColor(budgetData.hourly_pct)}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" style="flex:1">
|
||||
<div class="stat-label">Daily</div>
|
||||
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.daily_spend || 0).toFixed(4)"></div>
|
||||
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.daily_limit)"></div>
|
||||
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.daily_limit > 0">
|
||||
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.daily_pct*100,100)+'%', background: pctColor(budgetData.daily_pct)}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" style="flex:1">
|
||||
<div class="stat-label">Monthly</div>
|
||||
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.monthly_spend || 0).toFixed(4)"></div>
|
||||
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.monthly_limit)"></div>
|
||||
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.monthly_limit > 0">
|
||||
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.monthly_pct*100,100)+'%', background: pctColor(budgetData.monthly_pct)}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" style="flex:1">
|
||||
<div class="stat-label">Daily</div>
|
||||
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.daily_spend || 0).toFixed(4)"></div>
|
||||
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.daily_limit)"></div>
|
||||
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.daily_limit > 0">
|
||||
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.daily_pct*100,100)+'%', background: pctColor(budgetData.daily_pct)}"></div>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-1" x-show="budgetData.alert_threshold > 0 && !editMode">
|
||||
Alert threshold: <span x-text="(budgetData.alert_threshold * 100).toFixed(0) + '%'"></span> of any limit
|
||||
</div>
|
||||
<div class="stat-card" style="flex:1">
|
||||
<div class="stat-label">Monthly</div>
|
||||
<div class="stat-value" style="font-size:18px" x-text="'$' + (budgetData.monthly_spend || 0).toFixed(4)"></div>
|
||||
<div class="text-xs text-dim" x-text="'of ' + fmtUsd(budgetData.monthly_limit)"></div>
|
||||
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;overflow:hidden" x-show="budgetData.monthly_limit > 0">
|
||||
<div style="height:100%;border-radius:2px;transition:width 0.3s" :style="{width: Math.min(budgetData.monthly_pct*100,100)+'%', background: pctColor(budgetData.monthly_pct)}"></div>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-3" x-show="!editMode">
|
||||
Hourly token limit (per agent): <span x-text="fmtTokens(budgetData.default_max_llm_tokens_per_hour || 0)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-1" x-show="budgetData.alert_threshold > 0 && !editMode">
|
||||
Alert threshold: <span x-text="(budgetData.alert_threshold * 100).toFixed(0) + '%'"></span> of any limit
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-3" x-show="!editMode">
|
||||
Hourly token limit (per agent): <span x-text="fmtTokens(budgetData.default_max_llm_tokens_per_hour || 0)"></span>
|
||||
</div>
|
||||
|
||||
<!-- Edit limits form -->
|
||||
<div x-show="editMode" class="card" style="margin:12px 0;padding:12px;border:1px solid var(--accent);border-radius:6px">
|
||||
<div class="stats-row" style="margin-bottom:8px;gap:8px">
|
||||
<div style="flex:1">
|
||||
<label class="text-xs text-dim">Hourly Limit ($)</label>
|
||||
<input type="number" step="0.1" min="0" x-model="editHourly" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
|
||||
<!-- Edit limits form -->
|
||||
<div x-show="editMode" class="card" style="margin:12px 0;padding:12px;border:1px solid var(--accent);border-radius:6px">
|
||||
<div class="stats-row" style="margin-bottom:8px;gap:8px">
|
||||
<div style="flex:1">
|
||||
<label class="text-xs text-dim">Hourly Limit ($)</label>
|
||||
<input type="number" step="0.1" min="0" x-model="editHourly" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label class="text-xs text-dim">Daily Limit ($)</label>
|
||||
<input type="number" step="1" min="0" x-model="editDaily" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label class="text-xs text-dim">Monthly Limit ($)</label>
|
||||
<input type="number" step="1" min="0" x-model="editMonthly" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
|
||||
</div>
|
||||
<div style="flex:0.6">
|
||||
<label class="text-xs text-dim">Alert (%)</label>
|
||||
<input type="number" step="5" min="0" max="100" x-model="editAlert" class="input" style="width:100%;margin-top:2px" placeholder="80">
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label class="text-xs text-dim">Daily Limit ($)</label>
|
||||
<input type="number" step="1" min="0" x-model="editDaily" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label class="text-xs text-dim">Monthly Limit ($)</label>
|
||||
<input type="number" step="1" min="0" x-model="editMonthly" class="input" style="width:100%;margin-top:2px" placeholder="0 = unlimited">
|
||||
</div>
|
||||
<div style="flex:0.6">
|
||||
<label class="text-xs text-dim">Alert (%)</label>
|
||||
<input type="number" step="5" min="0" max="100" x-model="editAlert" class="input" style="width:100%;margin-top:2px" placeholder="80">
|
||||
<div style="margin-bottom:8px">
|
||||
<label class="text-xs text-dim">Hourly Token Limit (per agent, 0 = use per-agent values)</label>
|
||||
<input type="number" step="100000" min="0" x-model="editTokenLimit" class="input" style="width:260px;margin-top:2px" placeholder="0 = per-agent default">
|
||||
</div>
|
||||
<div class="text-xs text-dim">Set to 0 for unlimited/per-agent default. Changes apply immediately (in-memory, not persisted to config.toml).</div>
|
||||
<button class="btn btn-sm mt-2" @click="editMode = false" style="margin-right:8px">Cancel</button>
|
||||
</div>
|
||||
<div style="margin-bottom:8px">
|
||||
<label class="text-xs text-dim">Hourly Token Limit (per agent, 0 = use per-agent values)</label>
|
||||
<input type="number" step="100000" min="0" x-model="editTokenLimit" class="input" style="width:260px;margin-top:2px" placeholder="0 = per-agent default">
|
||||
</div>
|
||||
<div class="text-xs text-dim">Set to 0 for unlimited/per-agent default. Changes apply immediately (in-memory, not persisted to config.toml).</div>
|
||||
<button class="btn btn-sm mt-2" @click="editMode = false" style="margin-right:8px">Cancel</button>
|
||||
</div>
|
||||
|
||||
<!-- Per-agent cost ranking -->
|
||||
<h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
|
||||
<div class="table-wrap" x-show="agentRanking.length">
|
||||
<table>
|
||||
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th><th>Token Limit/hr</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="a in agentRanking" :key="a.agent_id">
|
||||
<tr>
|
||||
<td class="font-bold" x-text="a.name"></td>
|
||||
<td x-text="'$' + (a.daily_cost_usd || 0).toFixed(4)"></td>
|
||||
<td class="text-dim" x-text="fmtUsd(a.hourly_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtUsd(a.daily_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtUsd(a.monthly_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtTokens(a.max_llm_tokens_per_hour || 0)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Per-agent cost ranking -->
|
||||
<h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
|
||||
<div class="table-wrap" x-show="agentRanking.length">
|
||||
<table>
|
||||
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th><th>Token Limit/hr</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="a in agentRanking" :key="a.agent_id">
|
||||
<tr>
|
||||
<td class="font-bold" x-text="a.name"></td>
|
||||
<td x-text="'$' + (a.daily_cost_usd || 0).toFixed(4)"></td>
|
||||
<td class="text-dim" x-text="fmtUsd(a.hourly_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtUsd(a.daily_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtUsd(a.monthly_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtTokens(a.max_llm_tokens_per_hour || 0)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-xs text-dim" x-show="!agentRanking.length">No spending recorded today.</div>
|
||||
</div>
|
||||
<div class="text-xs text-dim" x-show="!agentRanking.length">No spending recorded today.</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Migration tab -->
|
||||
@@ -3880,42 +3890,46 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="migStep === 'preview' && scanResult">
|
||||
<div class="card mb-4" style="border-left:3px solid var(--success, #22c55e)">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="font-bold" style="font-size:14px">OpenClaw Workspace Found</div>
|
||||
<span class="badge badge-connected">Ready to Migrate</span>
|
||||
<template x-if="migStep === 'preview' && scanResult">
|
||||
<div>
|
||||
<div class="card mb-4" style="border-left:3px solid var(--success, #22c55e)">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="font-bold" style="font-size:14px">OpenClaw Workspace Found</div>
|
||||
<span class="badge badge-connected">Ready to Migrate</span>
|
||||
</div>
|
||||
<div class="text-sm text-dim" style="font-family:monospace" x-text="scanResult.path"></div>
|
||||
</div>
|
||||
<div class="stats-row mb-4">
|
||||
<div class="stat-card"><div class="stat-value" x-text="scanResult.agents ? scanResult.agents.length : 0"></div><div class="stat-label">Agents</div></div>
|
||||
<div class="stat-card"><div class="stat-value" x-text="scanResult.channels ? scanResult.channels.length : 0"></div><div class="stat-label">Channels</div></div>
|
||||
<div class="stat-card"><div class="stat-value" x-text="scanResult.skills ? scanResult.skills.length : 0"></div><div class="stat-label">Skills</div></div>
|
||||
</div>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button class="btn btn-primary" @click="runMigration(false)" :disabled="migrating">
|
||||
<span x-show="!migrating">Migrate Now</span>
|
||||
<span x-show="migrating">Migrating...</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost" @click="runMigration(true)" :disabled="migrating">Dry Run</button>
|
||||
<button class="btn btn-ghost" @click="migStep = 'intro'; scanResult = null">Start Over</button>
|
||||
</div>
|
||||
<div class="text-sm text-dim" style="font-family:monospace" x-text="scanResult.path"></div>
|
||||
</div>
|
||||
<div class="stats-row mb-4">
|
||||
<div class="stat-card"><div class="stat-value" x-text="scanResult.agents ? scanResult.agents.length : 0"></div><div class="stat-label">Agents</div></div>
|
||||
<div class="stat-card"><div class="stat-value" x-text="scanResult.channels ? scanResult.channels.length : 0"></div><div class="stat-label">Channels</div></div>
|
||||
<div class="stat-card"><div class="stat-value" x-text="scanResult.skills ? scanResult.skills.length : 0"></div><div class="stat-label">Skills</div></div>
|
||||
</div>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button class="btn btn-primary" @click="runMigration(false)" :disabled="migrating">
|
||||
<span x-show="!migrating">Migrate Now</span>
|
||||
<span x-show="migrating">Migrating...</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost" @click="runMigration(true)" :disabled="migrating">Dry Run</button>
|
||||
<button class="btn btn-ghost" @click="migStep = 'intro'; scanResult = null">Start Over</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="migStep === 'result' && migResult">
|
||||
<div class="card mb-4" :style="'border-left:3px solid ' + (migResult.status === 'completed' ? 'var(--success, #22c55e)' : 'var(--error)')">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="font-bold" style="font-size:14px" x-text="migResult.dry_run ? 'Dry Run Complete' : 'Migration Complete!'"></div>
|
||||
<span class="badge" :class="migResult.status === 'completed' ? 'badge-connected' : 'badge-crashed'" x-text="migResult.status === 'completed' ? 'SUCCESS' : 'FAILED'"></span>
|
||||
<template x-if="migStep === 'result' && migResult">
|
||||
<div>
|
||||
<div class="card mb-4" :style="'border-left:3px solid ' + (migResult.status === 'completed' ? 'var(--success, #22c55e)' : 'var(--error)')">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="font-bold" style="font-size:14px" x-text="migResult.dry_run ? 'Dry Run Complete' : 'Migration Complete!'"></div>
|
||||
<span class="badge" :class="migResult.status === 'completed' ? 'badge-connected' : 'badge-crashed'" x-text="migResult.status === 'completed' ? 'SUCCESS' : 'FAILED'"></span>
|
||||
</div>
|
||||
<div class="text-sm text-dim" x-show="migResult.error" style="color:var(--error)" x-text="migResult.error"></div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" x-show="migResult.dry_run" @click="runMigration(false)" :disabled="migrating">Run Migration for Real</button>
|
||||
<button class="btn btn-ghost" @click="migStep = 'intro'; migResult = null; scanResult = null">Start New Migration</button>
|
||||
</div>
|
||||
<div class="text-sm text-dim" x-show="migResult.error" style="color:var(--error)" x-text="migResult.error"></div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" x-show="migResult.dry_run" @click="runMigration(false)" :disabled="migrating">Run Migration for Real</button>
|
||||
<button class="btn btn-ghost" @click="migStep = 'intro'; migResult = null; scanResult = null">Start New Migration</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="card" x-show="migStep === 'not_found'" style="border-left:3px solid var(--warning, #f59e0b)">
|
||||
<div class="font-bold mb-2" style="font-size:14px">OpenClaw Not Found</div>
|
||||
@@ -3980,7 +3994,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<td x-text="formatTokens(m.total_input_tokens)"></td>
|
||||
<td x-text="formatTokens(m.total_output_tokens)"></td>
|
||||
<td x-text="formatCost(m.total_cost_usd)"></td>
|
||||
<td><div style="background:var(--surface2);border-radius:4px;height:16px;overflow:hidden"><div style="height:100%;border-radius:4px;background:var(--accent);transition:width 0.3s" :style="'width:' + barWidth(m)"></div></div></td>
|
||||
<td><div style="background:var(--surface2);border-radius:4px;height:16px;overflow:hidden"><div style="height:100%;border-radius:4px;background:var(--accent);transition:width 0.3s" :style="{ width: barWidth(m) }"></div></div></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
@@ -4039,20 +4053,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div x-show="costByProvider().length > 0" class="donut-chart-wrap">
|
||||
<div class="donut-chart">
|
||||
<svg viewBox="0 0 160 160" width="160" height="160">
|
||||
<template x-for="(seg, idx) in donutSegments()" :key="seg.provider">
|
||||
<circle
|
||||
cx="80" cy="80" r="60"
|
||||
fill="none"
|
||||
:stroke="seg.color"
|
||||
stroke-width="24"
|
||||
:stroke-dasharray="seg.dasharray"
|
||||
:stroke-dashoffset="seg.dashoffset"
|
||||
transform="rotate(-90 80 80)"
|
||||
class="donut-segment"
|
||||
>
|
||||
<title x-text="seg.provider + ': ' + seg.percent + '% (' + formatCost(seg.cost) + ')'"></title>
|
||||
</circle>
|
||||
</template>
|
||||
<g x-html="donutSegmentsSvg()"></g>
|
||||
<!-- Center text -->
|
||||
<text x="80" y="76" text-anchor="middle" fill="var(--text)" style="font-size:14px;font-weight:700;font-family:var(--font-mono)" x-text="formatCost(summary.total_cost_usd)"></text>
|
||||
<text x="80" y="92" text-anchor="middle" fill="var(--text-muted)" style="font-size:9px;font-family:var(--font-mono)">TOTAL</text>
|
||||
@@ -4079,41 +4080,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<svg :viewBox="'0 0 ' + (barChartData().length * 50 + 20) + ' 180'" :width="barChartData().length * 50 + 20" height="180">
|
||||
<!-- Baseline -->
|
||||
<line x1="10" :x2="barChartData().length * 50 + 10" y1="150" y2="150" stroke="var(--border)" stroke-width="1"/>
|
||||
<template x-for="(bar, idx) in barChartData()" :key="bar.date">
|
||||
<g>
|
||||
<!-- Bar rect -->
|
||||
<rect
|
||||
:x="idx * 50 + 18"
|
||||
:y="150 - bar.barHeight"
|
||||
width="24"
|
||||
:height="bar.barHeight"
|
||||
rx="3"
|
||||
fill="var(--accent)"
|
||||
class="cost-bar"
|
||||
style="opacity:0.85"
|
||||
>
|
||||
<title x-text="bar.date + ': ' + formatCost(bar.cost) + ' (' + bar.calls + ' calls)'"></title>
|
||||
</rect>
|
||||
<!-- Day label -->
|
||||
<text
|
||||
:x="idx * 50 + 30"
|
||||
y="166"
|
||||
text-anchor="middle"
|
||||
fill="var(--text-muted)"
|
||||
style="font-size:9px;font-family:var(--font-mono)"
|
||||
x-text="bar.dayName"
|
||||
></text>
|
||||
<!-- Cost label on top -->
|
||||
<text
|
||||
:x="idx * 50 + 30"
|
||||
:y="150 - bar.barHeight - 4"
|
||||
text-anchor="middle"
|
||||
fill="var(--text-dim)"
|
||||
style="font-size:8px;font-family:var(--font-mono)"
|
||||
x-text="formatCost(bar.cost)"
|
||||
></text>
|
||||
</g>
|
||||
</template>
|
||||
<g x-html="barChartSvg()"></g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4152,7 +4119,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<td class="font-bold" x-text="formatCost(m.total_cost_usd)"></td>
|
||||
<td>
|
||||
<div style="background:var(--surface2);border-radius:4px;height:16px;overflow:hidden">
|
||||
<div style="height:100%;border-radius:4px;background:var(--accent);transition:width 0.3s" :style="'width:' + costBarWidth(m)"></div>
|
||||
<div style="height:100%;border-radius:4px;background:var(--accent);transition:width 0.3s" :style="{ width: costBarWidth(m) }"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -4524,7 +4491,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
|
||||
<!-- Send Message Modal -->
|
||||
<div x-show="showSendModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showSendModal=false" x-transition>
|
||||
<div style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showSendModal=false" :style="{ display: showSendModal ? 'flex' : 'none' }" x-transition>
|
||||
<div class="card" style="width:420px;max-width:90vw" @click.stop>
|
||||
<div class="card-header">Send Agent Message</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
|
||||
@@ -4562,7 +4529,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
|
||||
<!-- Post Task Modal -->
|
||||
<div x-show="showTaskModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showTaskModal=false" x-transition>
|
||||
<div style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" :style="{ display: showTaskModal ? 'flex' : 'none' }" @click.self="showTaskModal=false" x-transition>
|
||||
<div class="card" style="width:420px;max-width:90vw" @click.stop>
|
||||
<div class="card-header">Post Task</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
|
||||
|
||||
@@ -53,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',
|
||||
@@ -99,21 +108,31 @@ function agentsPage() {
|
||||
// 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: '' };
|
||||
},
|
||||
|
||||
@@ -197,6 +216,8 @@ function agentsPage() {
|
||||
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?';
|
||||
}
|
||||
@@ -240,61 +261,61 @@ function agentsPage() {
|
||||
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',
|
||||
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 = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.\n"""'
|
||||
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: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
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 = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.\n"""'
|
||||
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: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
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 = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.\n"""'
|
||||
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: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
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 = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.\n"""'
|
||||
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: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
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 = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.\n"""'
|
||||
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: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
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 = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.\n"""'
|
||||
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 || []
|
||||
];
|
||||
@@ -383,8 +404,8 @@ 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
|
||||
|
||||
@@ -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,6 +134,9 @@ function chatPage() {
|
||||
init() {
|
||||
var self = this;
|
||||
|
||||
// Initialize slash commands with i18n
|
||||
this.initSlashCommands();
|
||||
|
||||
// Start tip cycle
|
||||
this.startTipCycle();
|
||||
|
||||
@@ -264,19 +264,46 @@ 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;
|
||||
});
|
||||
},
|
||||
|
||||
// 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 dynamic slash commands from server
|
||||
fetchCommands: function() {
|
||||
var self = this;
|
||||
@@ -486,21 +513,13 @@ function chatPage() {
|
||||
this.currentAgent = agent;
|
||||
this.messages = [];
|
||||
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 +538,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 +583,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 +594,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'));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1006,8 +1033,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();
|
||||
@@ -1050,18 +1078,19 @@ 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');
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -34,27 +34,30 @@ function skillsPage() {
|
||||
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();
|
||||
@@ -264,15 +267,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,
|
||||
@@ -297,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] || '';
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -314,6 +314,118 @@ 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(),
|
||||
),
|
||||
},
|
||||
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() {
|
||||
|
||||
@@ -220,6 +220,13 @@ pub trait ChannelBridgeHandle: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// Get channel IDs that respond without requiring @mention (free response mode).
|
||||
///
|
||||
/// Returns an empty vector if the channel type is not configured or has no free response channels.
|
||||
async fn free_response_channels(&self, _channel_type: &str) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Record a delivery result for tracking (optional — default no-op).
|
||||
///
|
||||
/// `thread_id` preserves Telegram forum-topic context so cron/workflow
|
||||
@@ -517,6 +524,7 @@ fn default_output_format_for_channel(channel_type: &str) -> OutputFormat {
|
||||
"telegram" => OutputFormat::TelegramHtml,
|
||||
"slack" => OutputFormat::SlackMrkdwn,
|
||||
"wecom" => OutputFormat::PlainText,
|
||||
"signal" => OutputFormat::PlainText,
|
||||
_ => OutputFormat::Markdown,
|
||||
}
|
||||
}
|
||||
@@ -659,16 +667,23 @@ async fn dispatch_message(
|
||||
}
|
||||
}
|
||||
GroupPolicy::MentionOnly => {
|
||||
// Only allow messages where the bot was @mentioned or commands.
|
||||
let was_mentioned = message
|
||||
.metadata
|
||||
.get("was_mentioned")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_command = matches!(&message.content, ChannelContent::Command { .. });
|
||||
if !was_mentioned && !is_command {
|
||||
debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)");
|
||||
return;
|
||||
// Check if this channel is in the free_response list - if so, allow all messages
|
||||
let free_channels = handle.free_response_channels(ct_str).await;
|
||||
let channel_id = &message.sender.platform_id;
|
||||
let is_free_channel = free_channels.iter().any(|id| id == channel_id);
|
||||
|
||||
if !is_free_channel {
|
||||
// Only allow messages where the bot was @mentioned or commands.
|
||||
let was_mentioned = message
|
||||
.metadata
|
||||
.get("was_mentioned")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_command = matches!(&message.content, ChannelContent::Command { .. });
|
||||
if !was_mentioned && !is_command {
|
||||
debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
GroupPolicy::All => {}
|
||||
@@ -1932,6 +1947,10 @@ mod tests {
|
||||
default_output_format_for_channel("discord"),
|
||||
OutputFormat::Markdown
|
||||
);
|
||||
assert_eq!(
|
||||
default_output_format_for_channel("signal"),
|
||||
OutputFormat::PlainText
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -10,9 +10,11 @@ use async_trait::async_trait;
|
||||
use futures::{SinkExt, Stream, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tokio::sync::{mpsc, watch, Mutex, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
@@ -33,6 +35,18 @@ mod opcode {
|
||||
pub const HEARTBEAT_ACK: u64 = 11;
|
||||
}
|
||||
|
||||
/// Build a Discord gateway heartbeat (opcode 1) payload.
|
||||
///
|
||||
/// Per the Discord gateway spec, the payload `d` field is the last received
|
||||
/// dispatch sequence number, or `null` if no dispatch has been received yet.
|
||||
/// See: <https://discord.com/developers/docs/topics/gateway#sending-heartbeats>
|
||||
fn build_heartbeat_payload(last_sequence: Option<u64>) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"op": opcode::HEARTBEAT,
|
||||
"d": last_sequence,
|
||||
})
|
||||
}
|
||||
|
||||
/// Discord Gateway adapter using WebSocket.
|
||||
pub struct DiscordAdapter {
|
||||
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
|
||||
@@ -191,8 +205,15 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
backoff = INITIAL_BACKOFF;
|
||||
info!("Discord gateway connected");
|
||||
|
||||
let (mut ws_tx, mut ws_rx) = ws_stream.split();
|
||||
let mut _heartbeat_interval: Option<u64> = None;
|
||||
let (ws_tx_raw, mut ws_rx) = ws_stream.split();
|
||||
// Wrap the sink so the periodic heartbeat task and the inner
|
||||
// loop can both write to it.
|
||||
let ws_tx = Arc::new(Mutex::new(ws_tx_raw));
|
||||
let mut heartbeat_handle: Option<JoinHandle<()>> = None;
|
||||
// Tracks whether the most recent heartbeat we sent has been
|
||||
// ACKed (opcode 11). Initialized to `true` so the first
|
||||
// heartbeat is always allowed to fire.
|
||||
let heartbeat_acked = Arc::new(AtomicBool::new(true));
|
||||
|
||||
// Inner message loop — returns true if we should reconnect
|
||||
let should_reconnect = 'inner: loop {
|
||||
@@ -201,7 +222,10 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
info!("Discord shutdown requested");
|
||||
let _ = ws_tx.close().await;
|
||||
if let Some(h) = heartbeat_handle.take() {
|
||||
h.abort();
|
||||
}
|
||||
let _ = ws_tx.lock().await.close().await;
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
@@ -239,7 +263,8 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
|
||||
let op = payload["op"].as_u64().unwrap_or(999);
|
||||
|
||||
// Update sequence number
|
||||
// Update sequence number from any payload that carries one
|
||||
// (typically dispatch events, opcode 0).
|
||||
if let Some(s) = payload["s"].as_u64() {
|
||||
*sequence.write().await = Some(s);
|
||||
}
|
||||
@@ -248,9 +273,72 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
opcode::HELLO => {
|
||||
let interval =
|
||||
payload["d"]["heartbeat_interval"].as_u64().unwrap_or(45000);
|
||||
_heartbeat_interval = Some(interval);
|
||||
debug!("Discord HELLO: heartbeat_interval={interval}ms");
|
||||
|
||||
// Spawn the periodic heartbeat task BEFORE we send
|
||||
// IDENTIFY/RESUME, per the Discord gateway flow.
|
||||
// Abort any stale handle from a previous attempt
|
||||
// first (defensive — should normally be None here).
|
||||
if let Some(h) = heartbeat_handle.take() {
|
||||
h.abort();
|
||||
}
|
||||
heartbeat_acked.store(true, Ordering::Relaxed);
|
||||
let hb_sink = ws_tx.clone();
|
||||
let hb_seq = sequence.clone();
|
||||
let hb_acked = heartbeat_acked.clone();
|
||||
let mut hb_shutdown = shutdown.clone();
|
||||
heartbeat_handle = Some(tokio::spawn(async move {
|
||||
let mut ticker =
|
||||
tokio::time::interval(Duration::from_millis(interval));
|
||||
// Skip the immediate first tick — we want to
|
||||
// wait one full interval before the first beat.
|
||||
ticker.tick().await;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ticker.tick() => {}
|
||||
_ = hb_shutdown.changed() => {
|
||||
if *hb_shutdown.borrow() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If the previous heartbeat was never
|
||||
// ACKed, the connection is zombied — close
|
||||
// the sink so the read loop sees EOF and
|
||||
// triggers a reconnect (Discord spec).
|
||||
if !hb_acked.swap(false, Ordering::Relaxed) {
|
||||
warn!(
|
||||
"Discord: previous heartbeat not ACKed, \
|
||||
forcing reconnect"
|
||||
);
|
||||
let _ = hb_sink.lock().await.close().await;
|
||||
return;
|
||||
}
|
||||
|
||||
let seq = *hb_seq.read().await;
|
||||
let payload = build_heartbeat_payload(seq);
|
||||
let text = match serde_json::to_string(&payload) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Discord: failed to serialize heartbeat: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let send_res = hb_sink
|
||||
.lock()
|
||||
.await
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(text))
|
||||
.await;
|
||||
if let Err(e) = send_res {
|
||||
warn!("Discord: failed to send heartbeat: {e}");
|
||||
return;
|
||||
}
|
||||
debug!("Discord heartbeat sent (seq={:?})", seq);
|
||||
}
|
||||
}));
|
||||
|
||||
// Try RESUME if we have a session, otherwise IDENTIFY
|
||||
let has_session = session_id_store.read().await.is_some();
|
||||
let has_seq = sequence.read().await.is_some();
|
||||
@@ -284,6 +372,8 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
};
|
||||
|
||||
if let Err(e) = ws_tx
|
||||
.lock()
|
||||
.await
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(
|
||||
serde_json::to_string(&gateway_msg).unwrap(),
|
||||
))
|
||||
@@ -350,16 +440,23 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
opcode::HEARTBEAT => {
|
||||
// Server requests immediate heartbeat
|
||||
let seq = *sequence.read().await;
|
||||
let hb = serde_json::json!({ "op": opcode::HEARTBEAT, "d": seq });
|
||||
let hb = build_heartbeat_payload(seq);
|
||||
let _ = ws_tx
|
||||
.lock()
|
||||
.await
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(
|
||||
serde_json::to_string(&hb).unwrap(),
|
||||
))
|
||||
.await;
|
||||
// The server-requested heartbeat counts as a fresh
|
||||
// beat — reset the ACK gate so the periodic task
|
||||
// doesn't see a stale "unacked" flag.
|
||||
heartbeat_acked.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
opcode::HEARTBEAT_ACK => {
|
||||
debug!("Discord heartbeat ACK received");
|
||||
heartbeat_acked.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
opcode::RECONNECT => {
|
||||
@@ -385,6 +482,12 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
}
|
||||
};
|
||||
|
||||
// Tear down the heartbeat task before we either exit or
|
||||
// reconnect, so it doesn't outlive its WebSocket sink.
|
||||
if let Some(h) = heartbeat_handle.take() {
|
||||
h.abort();
|
||||
}
|
||||
|
||||
if !should_reconnect || *shutdown.borrow() {
|
||||
break;
|
||||
}
|
||||
@@ -889,6 +992,31 @@ mod tests {
|
||||
assert!(!msg.is_group);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_heartbeat_payload_with_sequence() {
|
||||
let payload = build_heartbeat_payload(Some(42));
|
||||
assert_eq!(payload["op"], 1);
|
||||
assert_eq!(payload["d"], 42);
|
||||
// Round-trip through serde_json::to_string and re-parse to assert
|
||||
// valid JSON matching {"op":1,"d":42} regardless of key ordering.
|
||||
let s = serde_json::to_string(&payload).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(parsed, serde_json::json!({"op": 1, "d": 42}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_heartbeat_payload_without_sequence() {
|
||||
let payload = build_heartbeat_payload(None);
|
||||
assert_eq!(payload["op"], 1);
|
||||
assert!(payload["d"].is_null());
|
||||
let s = serde_json::to_string(&payload).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(
|
||||
parsed,
|
||||
serde_json::json!({"op": 1, "d": serde_json::Value::Null})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_adapter_creation() {
|
||||
let adapter = DiscordAdapter::new(
|
||||
|
||||
@@ -48,8 +48,8 @@ pub mod discourse;
|
||||
pub mod gitter;
|
||||
pub mod gotify;
|
||||
pub mod linkedin;
|
||||
pub mod mumble;
|
||||
pub mod mqtt;
|
||||
pub mod mumble;
|
||||
pub mod ntfy;
|
||||
pub mod webhook;
|
||||
pub mod wecom;
|
||||
|
||||
@@ -108,7 +108,7 @@ impl LineAdapter {
|
||||
diff |= a ^ b;
|
||||
}
|
||||
if diff != 0 {
|
||||
let computed = base64::engine::general_purpose::STANDARD.encode(&result);
|
||||
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!(
|
||||
"{}...{}",
|
||||
@@ -381,8 +381,7 @@ 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::body::Bytes| {
|
||||
move |headers: axum::http::HeaderMap, body: axum::body::Bytes| {
|
||||
let secret = Arc::clone(&secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
async move {
|
||||
@@ -404,8 +403,7 @@ impl ChannelAdapter for LineAdapter {
|
||||
shutdown_rx: watch::channel(false).1,
|
||||
};
|
||||
|
||||
if !signature.is_empty()
|
||||
&& !adapter.verify_signature(&body, signature)
|
||||
if !signature.is_empty() && !adapter.verify_signature(&body, signature)
|
||||
{
|
||||
warn!("LINE: invalid webhook signature");
|
||||
return axum::http::StatusCode::UNAUTHORIZED;
|
||||
|
||||
@@ -152,7 +152,10 @@ impl MqttAdapter {
|
||||
}
|
||||
|
||||
/// Parse host:port string.
|
||||
fn parse_host_port(s: &str, default_port: u16) -> Result<(String, u16), Box<dyn std::error::Error>> {
|
||||
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();
|
||||
@@ -239,7 +242,8 @@ impl ChannelAdapter for MqttAdapter {
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>> {
|
||||
) -> 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);
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
/// Telegram long-polling timeout (seconds) — sent as the `timeout` parameter to getUpdates.
|
||||
const LONG_POLL_TIMEOUT: u64 = 30;
|
||||
/// Bound startup control-plane calls so a flaky Local Bot API cannot block daemon boot forever.
|
||||
const STARTUP_API_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Default Telegram Bot API base URL.
|
||||
const DEFAULT_API_URL: &str = "https://api.telegram.org";
|
||||
@@ -120,6 +122,7 @@ impl TelegramAdapter {
|
||||
let resp: serde_json::Value = self
|
||||
.client
|
||||
.post(&url)
|
||||
.timeout(STARTUP_API_TIMEOUT)
|
||||
.json(&serde_json::json!({ "commands": commands }))
|
||||
.send()
|
||||
.await?
|
||||
@@ -468,6 +471,7 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
.client
|
||||
.post(&delete_url)
|
||||
.json(&serde_json::json!({"drop_pending_updates": true}))
|
||||
.timeout(STARTUP_API_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
@@ -679,6 +685,12 @@ enum CronCommands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AuthCommands {
|
||||
/// Generate an Argon2id password hash for dashboard authentication.
|
||||
HashPassword,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SecurityCommands {
|
||||
/// Show security status summary.
|
||||
@@ -991,7 +1003,7 @@ 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),
|
||||
@@ -1057,6 +1069,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),
|
||||
@@ -2442,6 +2457,18 @@ 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!();
|
||||
@@ -4356,23 +4383,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 '{}': {}",
|
||||
@@ -4940,6 +4981,27 @@ 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: "));
|
||||
@@ -5985,6 +6047,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();
|
||||
|
||||
@@ -2226,13 +2226,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() => {
|
||||
@@ -2252,7 +2261,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));
|
||||
}
|
||||
|
||||
@@ -1598,9 +1598,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) => {
|
||||
|
||||
@@ -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),
|
||||
@@ -124,7 +126,8 @@ impl HandsState {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,10 +159,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",
|
||||
@@ -257,6 +257,7 @@ enum Step {
|
||||
Welcome,
|
||||
Migration,
|
||||
Provider,
|
||||
CopilotAuth,
|
||||
ApiKey,
|
||||
Model,
|
||||
Routing,
|
||||
@@ -288,6 +289,26 @@ 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,
|
||||
@@ -348,6 +369,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,
|
||||
}
|
||||
|
||||
@@ -386,6 +412,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));
|
||||
@@ -431,6 +460,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",
|
||||
@@ -610,12 +640,50 @@ 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() {
|
||||
@@ -750,7 +818,79 @@ 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;
|
||||
@@ -769,6 +909,24 @@ 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) {
|
||||
if !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;
|
||||
@@ -1242,6 +1400,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),
|
||||
@@ -1743,6 +1902,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 {
|
||||
@@ -1772,6 +1937,109 @@ 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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenFang",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.8",
|
||||
"identifier": "ai.openfang.desktop",
|
||||
"build": {},
|
||||
"app": {
|
||||
|
||||
@@ -15,6 +15,7 @@ tracing = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -330,6 +330,31 @@ pub fn parse_hand_toml(content: &str) -> Result<HandDefinition, toml::de::Error>
|
||||
Ok(wrapper.hand)
|
||||
}
|
||||
|
||||
/// Recursively copy a directory and all its contents.
|
||||
///
|
||||
/// Used by `HandRegistry::install_from_path` to persist a custom hand's
|
||||
/// source directory into `~/.openfang/hands/<hand_id>/` so installed hands
|
||||
/// survive daemon restarts (issue #984).
|
||||
pub(crate) fn copy_dir_all(
|
||||
src: impl AsRef<std::path::Path>,
|
||||
dst: impl AsRef<std::path::Path>,
|
||||
) -> std::io::Result<()> {
|
||||
let src = src.as_ref();
|
||||
let dst = dst.as_ref();
|
||||
std::fs::create_dir_all(dst)?;
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let ty = entry.file_type()?;
|
||||
let dst_path = dst.join(entry.file_name());
|
||||
if ty.is_dir() {
|
||||
copy_dir_all(entry.path(), &dst_path)?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), &dst_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Complete Hand definition — parsed from HAND.toml.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HandDefinition {
|
||||
@@ -396,6 +421,12 @@ pub struct HandInstance {
|
||||
pub instance_id: Uuid,
|
||||
/// Which hand definition this is an instance of.
|
||||
pub hand_id: String,
|
||||
/// Optional user-supplied instance label. When set, multiple instances of
|
||||
/// the same hand can coexist as long as each (hand_id, instance_name) pair
|
||||
/// is unique. When `None`, the legacy single-instance-per-hand rule
|
||||
/// applies.
|
||||
#[serde(default)]
|
||||
pub instance_name: Option<String>,
|
||||
/// Current status.
|
||||
pub status: HandStatus,
|
||||
/// The agent that was spawned for this hand.
|
||||
@@ -416,11 +447,13 @@ impl HandInstance {
|
||||
hand_id: &str,
|
||||
agent_name: &str,
|
||||
config: HashMap<String, serde_json::Value>,
|
||||
instance_name: Option<String>,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
instance_id: Uuid::new_v4(),
|
||||
hand_id: hand_id.to_string(),
|
||||
instance_name,
|
||||
status: HandStatus::Active,
|
||||
agent_id: None,
|
||||
agent_name: agent_name.to_string(),
|
||||
@@ -437,6 +470,10 @@ pub struct ActivateHandRequest {
|
||||
/// Optional configuration overrides.
|
||||
#[serde(default)]
|
||||
pub config: HashMap<String, serde_json::Value>,
|
||||
/// Optional unique instance label. Allows multiple instances of the same
|
||||
/// hand to coexist as long as each name is distinct.
|
||||
#[serde(default)]
|
||||
pub instance_name: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -462,11 +499,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hand_instance_new() {
|
||||
let instance = HandInstance::new("clip", "clip-hand", HashMap::new());
|
||||
let instance = HandInstance::new("clip", "clip-hand", HashMap::new(), None);
|
||||
assert_eq!(instance.hand_id, "clip");
|
||||
assert_eq!(instance.agent_name, "clip-hand");
|
||||
assert_eq!(instance.status, HandStatus::Active);
|
||||
assert!(instance.agent_id.is_none());
|
||||
assert!(instance.instance_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -124,6 +124,67 @@ impl HandRegistry {
|
||||
count
|
||||
}
|
||||
|
||||
/// Scan a directory for custom hand definitions and load them into the
|
||||
/// registry. Mirrors `SkillRegistry::load_workspace_skills` — each
|
||||
/// subdirectory containing a `HAND.toml` is treated as a hand, with an
|
||||
/// optional sibling `SKILL.md` attached as the skill content.
|
||||
///
|
||||
/// Parse failures on individual hands are logged and skipped so a single
|
||||
/// bad manifest cannot take down the whole registry.
|
||||
///
|
||||
/// Returns the number of hands successfully loaded. A non-existent
|
||||
/// `hands_dir` returns `Ok(0)` — this is the normal case on a fresh
|
||||
/// install where the user has not run `openfang hand install` yet.
|
||||
///
|
||||
/// Added for issue #984 — custom hands installed via `openfang hand
|
||||
/// install <path>` were only held in memory and lost on daemon restart.
|
||||
pub fn load_workspace_hands(&self, hands_dir: &std::path::Path) -> HandResult<usize> {
|
||||
if !hands_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut count = 0;
|
||||
let entries = std::fs::read_dir(hands_dir)
|
||||
.map_err(|e| HandError::Config(format!("read_dir {}: {e}", hands_dir.display())))?;
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Failed to read hands dir entry, skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let toml_path = path.join("HAND.toml");
|
||||
if !toml_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let contents = match std::fs::read_to_string(&toml_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(path = %toml_path.display(), error = %e, "Failed to read HAND.toml, skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let skill_path = path.join("SKILL.md");
|
||||
let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default();
|
||||
match bundled::parse_bundled("custom", &contents, &skill_content) {
|
||||
Ok(def) => {
|
||||
let hand_id = def.id.clone();
|
||||
info!(hand = %hand_id, path = %path.display(), "Loaded workspace hand");
|
||||
self.definitions.insert(hand_id, def);
|
||||
count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(path = %toml_path.display(), error = %e, "Invalid HAND.toml, skipping");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Install a hand from a directory containing HAND.toml (and optional SKILL.md).
|
||||
pub fn install_from_path(&self, path: &std::path::Path) -> HandResult<HandDefinition> {
|
||||
let toml_path = path.join("HAND.toml");
|
||||
@@ -145,6 +206,33 @@ impl HandRegistry {
|
||||
|
||||
info!(hand = %def.id, name = %def.name, path = %path.display(), "Installed hand from path");
|
||||
self.definitions.insert(def.id.clone(), def.clone());
|
||||
|
||||
// Persist the hand to the user's data dir so it survives daemon
|
||||
// restart (issue #984). Best-effort: failures are logged but do not
|
||||
// abort the install, because the hand is already registered in
|
||||
// memory and the user gets a working install for the current
|
||||
// session. On next restart, `load_workspace_hands` will pick it up
|
||||
// from disk.
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let dest_dir = home.join(".openfang").join("hands").join(&def.id);
|
||||
// Canonicalize both paths before comparing so we don't re-copy a
|
||||
// hand that is already being installed from its persistent
|
||||
// location (e.g. `openfang hand install ~/.openfang/hands/foo`).
|
||||
let same_path = match (path.canonicalize(), dest_dir.canonicalize()) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => path == dest_dir,
|
||||
};
|
||||
if !same_path {
|
||||
if let Err(e) = std::fs::create_dir_all(&dest_dir) {
|
||||
warn!(error = %e, dest = %dest_dir.display(), "Failed to create hands persistence dir");
|
||||
} else if let Err(e) = crate::copy_dir_all(path, &dest_dir) {
|
||||
warn!(error = %e, dest = %dest_dir.display(), "Failed to persist hand");
|
||||
} else {
|
||||
info!(hand = %def.id, dest = %dest_dir.display(), "Persisted hand to workspace");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(def)
|
||||
}
|
||||
|
||||
@@ -200,27 +288,42 @@ impl HandRegistry {
|
||||
}
|
||||
|
||||
/// Activate a hand — creates an instance (agent spawning is done by kernel).
|
||||
///
|
||||
/// `instance_name` is an optional user-supplied label. When set, multiple
|
||||
/// instances of the same hand can coexist as long as each
|
||||
/// (hand_id, instance_name) pair is unique. When `None`, the legacy
|
||||
/// single-instance-per-hand rule applies — a second unnamed activation of
|
||||
/// the same hand is rejected.
|
||||
pub fn activate(
|
||||
&self,
|
||||
hand_id: &str,
|
||||
config: HashMap<String, serde_json::Value>,
|
||||
instance_name: Option<String>,
|
||||
) -> HandResult<HandInstance> {
|
||||
let def = self
|
||||
.definitions
|
||||
.get(hand_id)
|
||||
.ok_or_else(|| HandError::NotFound(hand_id.to_string()))?;
|
||||
|
||||
// Check if already active
|
||||
// Reject only when the exact same (hand_id, instance_name) is already active.
|
||||
// This lets multiple uniquely-named instances of the same hand coexist.
|
||||
for entry in self.instances.iter() {
|
||||
if entry.hand_id == hand_id && entry.status == HandStatus::Active {
|
||||
return Err(HandError::AlreadyActive(hand_id.to_string()));
|
||||
if entry.hand_id == hand_id
|
||||
&& entry.instance_name == instance_name
|
||||
&& entry.status == HandStatus::Active
|
||||
{
|
||||
let label = match &instance_name {
|
||||
Some(name) => format!("{hand_id} (instance: {name})"),
|
||||
None => hand_id.to_string(),
|
||||
};
|
||||
return Err(HandError::AlreadyActive(label));
|
||||
}
|
||||
}
|
||||
|
||||
let instance = HandInstance::new(hand_id, &def.agent.name, config);
|
||||
let instance = HandInstance::new(hand_id, &def.agent.name, config, instance_name.clone());
|
||||
let id = instance.instance_id;
|
||||
self.instances.insert(id, instance.clone());
|
||||
info!(hand = %hand_id, instance = %id, "Hand activated");
|
||||
info!(hand = %hand_id, instance = %id, instance_name = ?instance_name, "Hand activated");
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
@@ -673,7 +776,7 @@ mod tests {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("clip", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("clip", HashMap::new(), None).unwrap();
|
||||
assert_eq!(instance.hand_id, "clip");
|
||||
assert_eq!(instance.status, HandStatus::Active);
|
||||
|
||||
@@ -681,7 +784,7 @@ mod tests {
|
||||
assert_eq!(instances.len(), 1);
|
||||
|
||||
// Can't activate again while active
|
||||
let err = reg.activate("clip", HashMap::new());
|
||||
let err = reg.activate("clip", HashMap::new(), None);
|
||||
assert!(err.is_err());
|
||||
|
||||
// Deactivate
|
||||
@@ -695,7 +798,7 @@ mod tests {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("clip", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("clip", HashMap::new(), None).unwrap();
|
||||
let id = instance.instance_id;
|
||||
|
||||
reg.pause(id).unwrap();
|
||||
@@ -714,7 +817,7 @@ mod tests {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("clip", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("clip", HashMap::new(), None).unwrap();
|
||||
let id = instance.instance_id;
|
||||
let agent_id = AgentId::new();
|
||||
|
||||
@@ -745,7 +848,7 @@ mod tests {
|
||||
fn not_found_errors() {
|
||||
let reg = HandRegistry::new();
|
||||
assert!(reg.get_definition("nonexistent").is_none());
|
||||
assert!(reg.activate("nonexistent", HashMap::new()).is_err());
|
||||
assert!(reg.activate("nonexistent", HashMap::new(), None).is_err());
|
||||
assert!(reg.check_requirements("nonexistent").is_err());
|
||||
assert!(reg.deactivate(Uuid::new_v4()).is_err());
|
||||
assert!(reg.pause(Uuid::new_v4()).is_err());
|
||||
@@ -757,7 +860,7 @@ mod tests {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("clip", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("clip", HashMap::new(), None).unwrap();
|
||||
let id = instance.instance_id;
|
||||
|
||||
reg.set_error(id, "something broke".to_string()).unwrap();
|
||||
@@ -830,7 +933,7 @@ mod tests {
|
||||
reg.load_bundled();
|
||||
|
||||
// Lead hand has no requirements — activate it
|
||||
let instance = reg.activate("lead", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("lead", HashMap::new(), None).unwrap();
|
||||
let r = reg.readiness("lead").unwrap();
|
||||
assert!(r.requirements_met);
|
||||
assert!(r.active);
|
||||
@@ -847,7 +950,7 @@ mod tests {
|
||||
// Browser hand requires python3 (non-optional) + chromium (optional).
|
||||
// requirements_met only reflects non-optional requirements.
|
||||
// degraded = active + any requirement (including optional) unsatisfied.
|
||||
let instance = reg.activate("browser", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("browser", HashMap::new(), None).unwrap();
|
||||
let r = reg.readiness("browser").unwrap();
|
||||
assert!(r.active);
|
||||
|
||||
@@ -874,7 +977,7 @@ mod tests {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("lead", HashMap::new()).unwrap();
|
||||
let instance = reg.activate("lead", HashMap::new(), None).unwrap();
|
||||
reg.pause(instance.instance_id).unwrap();
|
||||
|
||||
let r = reg.readiness("lead").unwrap();
|
||||
@@ -897,4 +1000,148 @@ mod tests {
|
||||
};
|
||||
assert!(!req.optional);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_workspace_hands_from_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let hands_dir = tmp.path();
|
||||
let hand_dir = hands_dir.join("test-custom-hand");
|
||||
std::fs::create_dir_all(&hand_dir).unwrap();
|
||||
let hand_toml = r#"
|
||||
id = "test-custom-hand"
|
||||
name = "Test Custom Hand"
|
||||
description = "A custom hand loaded from the workspace directory"
|
||||
category = "other"
|
||||
version = "0.1.0"
|
||||
author = "tester"
|
||||
|
||||
[agent]
|
||||
name = "test-agent"
|
||||
description = "A test agent"
|
||||
module = "builtin:chat"
|
||||
provider = "anthropic"
|
||||
model = "claude-sonnet-4-20250514"
|
||||
system_prompt = "You are a test agent."
|
||||
"#;
|
||||
std::fs::write(hand_dir.join("HAND.toml"), hand_toml).unwrap();
|
||||
|
||||
let registry = HandRegistry::new();
|
||||
let count = registry.load_workspace_hands(hands_dir).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert!(registry.get_definition("test-custom-hand").is_some());
|
||||
let def = registry.get_definition("test-custom-hand").unwrap();
|
||||
assert_eq!(def.name, "Test Custom Hand");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_workspace_hands_missing_dir_returns_zero() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let missing = tmp.path().join("does-not-exist");
|
||||
let registry = HandRegistry::new();
|
||||
let count = registry.load_workspace_hands(&missing).unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_workspace_hands_skips_invalid_toml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let hands_dir = tmp.path();
|
||||
|
||||
// Valid hand
|
||||
let good_dir = hands_dir.join("good-hand");
|
||||
std::fs::create_dir_all(&good_dir).unwrap();
|
||||
let good_toml = r#"
|
||||
id = "good-hand"
|
||||
name = "Good Hand"
|
||||
description = "..."
|
||||
category = "other"
|
||||
|
||||
[agent]
|
||||
name = "good-agent"
|
||||
description = "..."
|
||||
system_prompt = "You are good."
|
||||
"#;
|
||||
std::fs::write(good_dir.join("HAND.toml"), good_toml).unwrap();
|
||||
|
||||
// Invalid hand (missing required agent section)
|
||||
let bad_dir = hands_dir.join("bad-hand");
|
||||
std::fs::create_dir_all(&bad_dir).unwrap();
|
||||
std::fs::write(bad_dir.join("HAND.toml"), "not valid toml {[[[").unwrap();
|
||||
|
||||
// Directory without HAND.toml — should be silently skipped
|
||||
let empty_dir = hands_dir.join("empty-dir");
|
||||
std::fs::create_dir_all(&empty_dir).unwrap();
|
||||
|
||||
let registry = HandRegistry::new();
|
||||
let count = registry.load_workspace_hands(hands_dir).unwrap();
|
||||
assert_eq!(count, 1, "only the valid hand should load");
|
||||
assert!(registry.get_definition("good-hand").is_some());
|
||||
assert!(registry.get_definition("bad-hand").is_none());
|
||||
}
|
||||
|
||||
/// Build a `HandRegistry` pre-populated with a single dummy hand
|
||||
/// definition that has no requirements — used by the multi-instance
|
||||
/// activation tests below.
|
||||
fn test_registry_with_dummy_hand(hand_id: &str) -> HandRegistry {
|
||||
let toml_str = format!(
|
||||
r#"
|
||||
id = "{hand_id}"
|
||||
name = "Dummy Hand"
|
||||
description = "A dummy hand for tests"
|
||||
category = "other"
|
||||
tools = []
|
||||
|
||||
[agent]
|
||||
name = "dummy-agent"
|
||||
description = "dummy"
|
||||
system_prompt = "you are a dummy."
|
||||
|
||||
[dashboard]
|
||||
metrics = []
|
||||
"#
|
||||
);
|
||||
let def = crate::bundled::parse_bundled("dummy", &toml_str, "").unwrap();
|
||||
let reg = HandRegistry::new();
|
||||
reg.definitions.insert(def.id.clone(), def);
|
||||
reg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activate_same_hand_twice_with_different_instance_names_succeeds() {
|
||||
let reg = test_registry_with_dummy_hand("test-hand");
|
||||
let a = reg
|
||||
.activate("test-hand", HashMap::new(), Some("instance-a".into()))
|
||||
.unwrap();
|
||||
let b = reg
|
||||
.activate("test-hand", HashMap::new(), Some("instance-b".into()))
|
||||
.unwrap();
|
||||
assert_ne!(a.instance_id, b.instance_id);
|
||||
assert_eq!(a.instance_name, Some("instance-a".into()));
|
||||
assert_eq!(b.instance_name, Some("instance-b".into()));
|
||||
let active: Vec<_> = reg
|
||||
.list_instances()
|
||||
.into_iter()
|
||||
.filter(|i| i.status == HandStatus::Active)
|
||||
.collect();
|
||||
assert_eq!(active.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activate_same_hand_same_instance_name_rejects() {
|
||||
let reg = test_registry_with_dummy_hand("test-hand");
|
||||
reg.activate("test-hand", HashMap::new(), Some("same".into()))
|
||||
.unwrap();
|
||||
let err = reg
|
||||
.activate("test-hand", HashMap::new(), Some("same".into()))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, HandError::AlreadyActive(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activate_same_hand_unnamed_twice_still_rejects() {
|
||||
let reg = test_registry_with_dummy_hand("test-hand");
|
||||
reg.activate("test-hand", HashMap::new(), None).unwrap();
|
||||
let err = reg.activate("test-hand", HashMap::new(), None).unwrap_err();
|
||||
assert!(matches!(err, HandError::AlreadyActive(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ subtle = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
cron = "0.15"
|
||||
zeroize = { workspace = true }
|
||||
|
||||
|
||||
@@ -509,8 +509,48 @@ impl OpenFangKernel {
|
||||
Self::boot_with_config(config)
|
||||
}
|
||||
|
||||
/// Fetch live Copilot models by exchanging the persisted token and querying the API.
|
||||
/// Works both inside and outside a tokio runtime.
|
||||
fn fetch_copilot_models(openfang_dir: &Path) -> Result<Vec<String>, String> {
|
||||
use openfang_runtime::drivers::copilot;
|
||||
|
||||
let tokens = copilot::PersistedTokens::load(&openfang_dir.to_path_buf())
|
||||
.ok_or("No persisted Copilot tokens found")?;
|
||||
|
||||
let fetch = async {
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP client error: {e}"))?;
|
||||
|
||||
let ct = copilot::exchange_copilot_token(&http, &tokens.access_token).await?;
|
||||
copilot::fetch_models(&http, &ct.base_url, &ct.token).await
|
||||
};
|
||||
|
||||
// If we're already inside a tokio runtime (daemon start), use the existing one.
|
||||
// Otherwise (CLI commands), create a new one.
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
std::thread::scope(|s| {
|
||||
s.spawn(|| {
|
||||
handle.block_on(fetch)
|
||||
}).join().unwrap_or(Err("Thread panicked".to_string()))
|
||||
})
|
||||
} else {
|
||||
let rt = tokio::runtime::Runtime::new()
|
||||
.map_err(|e| format!("Failed to create runtime: {e}"))?;
|
||||
rt.block_on(fetch)
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot the kernel with an explicit configuration.
|
||||
pub fn boot_with_config(mut config: KernelConfig) -> KernelResult<Self> {
|
||||
if rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.is_err()
|
||||
{
|
||||
debug!("rustls crypto provider already installed, skipping");
|
||||
}
|
||||
|
||||
use openfang_types::config::KernelMode;
|
||||
|
||||
// Env var overrides — useful for Docker where config.toml is baked in.
|
||||
@@ -746,6 +786,21 @@ impl OpenFangKernel {
|
||||
// Load user's custom models from ~/.openfang/custom_models.json
|
||||
let custom_models_path = config.home_dir.join("custom_models.json");
|
||||
model_catalog.load_custom_models(&custom_models_path);
|
||||
|
||||
// Fetch live Copilot models if authenticated
|
||||
if openfang_runtime::drivers::copilot::copilot_auth_available(&config.home_dir) {
|
||||
let copilot_dir = config.home_dir.clone();
|
||||
match Self::fetch_copilot_models(&copilot_dir) {
|
||||
Ok(models) => {
|
||||
info!(count = models.len(), "Fetched live Copilot model catalog");
|
||||
model_catalog.merge_discovered_models("github-copilot", &models);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to fetch Copilot models (will use static catalog): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let available_count = model_catalog.available_models().len();
|
||||
let total_count = model_catalog.list_models().len();
|
||||
let local_count = model_catalog
|
||||
@@ -790,6 +845,23 @@ impl OpenFangKernel {
|
||||
info!("Loaded {hand_count} bundled hand(s)");
|
||||
}
|
||||
|
||||
// Load custom hands from the user's workspace (issue #984).
|
||||
// Hands installed via `openfang hand install <path>` are persisted to
|
||||
// `<home>/hands/<hand_id>/` so they survive daemon restarts.
|
||||
let workspace_hands_dir = config.home_dir.join("hands");
|
||||
match hand_registry.load_workspace_hands(&workspace_hands_dir) {
|
||||
Ok(n) if n > 0 => {
|
||||
info!(
|
||||
"Loaded {n} workspace hand(s) from {}",
|
||||
workspace_hands_dir.display()
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
warn!("Failed to load workspace hands: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize extension/integration registry
|
||||
let mut extension_registry =
|
||||
openfang_extensions::registry::IntegrationRegistry::new(&config.home_dir);
|
||||
@@ -877,40 +949,77 @@ impl OpenFangKernel {
|
||||
None
|
||||
}
|
||||
}
|
||||
} else if std::env::var("OPENAI_API_KEY").is_ok() {
|
||||
let model = if configured_model == "all-MiniLM-L6-v2" {
|
||||
default_embedding_model_for_provider("openai")
|
||||
} else {
|
||||
configured_model.as_str()
|
||||
};
|
||||
let openai_url = config.provider_urls.get("openai").map(|s| s.as_str());
|
||||
match create_embedding_driver("openai", model, "OPENAI_API_KEY", openai_url) {
|
||||
Ok(d) => {
|
||||
info!(model = %model, "Embedding driver auto-detected: OpenAI");
|
||||
Some(Arc::from(d))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "OpenAI embedding auto-detect failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Try Ollama (local, no key needed)
|
||||
let model = if configured_model == "all-MiniLM-L6-v2" {
|
||||
default_embedding_model_for_provider("ollama")
|
||||
// Auto-detect embedding provider by checking API key env vars in
|
||||
// priority order. First match wins.
|
||||
const API_KEY_PROVIDERS: &[(&str, &str)] = &[
|
||||
("OPENAI_API_KEY", "openai"),
|
||||
("GROQ_API_KEY", "groq"),
|
||||
("MISTRAL_API_KEY", "mistral"),
|
||||
("TOGETHER_API_KEY", "together"),
|
||||
("FIREWORKS_API_KEY", "fireworks"),
|
||||
("COHERE_API_KEY", "cohere"),
|
||||
];
|
||||
|
||||
let detected_from_key = API_KEY_PROVIDERS
|
||||
.iter()
|
||||
.find(|(env_var, _)| std::env::var(env_var).is_ok())
|
||||
.and_then(|(env_var, provider)| {
|
||||
let model = if configured_model == "all-MiniLM-L6-v2" {
|
||||
default_embedding_model_for_provider(provider)
|
||||
} else {
|
||||
configured_model.as_str()
|
||||
};
|
||||
let custom_url = config.provider_urls.get(*provider).map(|s| s.as_str());
|
||||
match create_embedding_driver(provider, model, env_var, custom_url) {
|
||||
Ok(d) => {
|
||||
info!(provider = %provider, model = %model, "Embedding driver auto-detected via {}", env_var);
|
||||
Some(Arc::from(d))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(provider = %provider, error = %e, "Embedding auto-detect failed for {}", provider);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if detected_from_key.is_some() {
|
||||
detected_from_key
|
||||
} else {
|
||||
configured_model.as_str()
|
||||
};
|
||||
let ollama_url = config.provider_urls.get("ollama").map(|s| s.as_str());
|
||||
match create_embedding_driver("ollama", model, "", ollama_url) {
|
||||
Ok(d) => {
|
||||
info!(model = %model, "Embedding driver auto-detected: Ollama (local)");
|
||||
Some(Arc::from(d))
|
||||
// No API key found — try local providers in order:
|
||||
// Ollama, vLLM, LM Studio (no key needed).
|
||||
const LOCAL_PROVIDERS: &[&str] = &["ollama", "vllm", "lmstudio"];
|
||||
|
||||
let mut local_result = None;
|
||||
for provider in LOCAL_PROVIDERS {
|
||||
let model = if configured_model == "all-MiniLM-L6-v2" {
|
||||
default_embedding_model_for_provider(provider)
|
||||
} else {
|
||||
configured_model.as_str()
|
||||
};
|
||||
let custom_url = config.provider_urls.get(*provider).map(|s| s.as_str());
|
||||
match create_embedding_driver(provider, model, "", custom_url) {
|
||||
Ok(d) => {
|
||||
info!(provider = %provider, model = %model, "Embedding driver auto-detected: {} (local)", provider);
|
||||
local_result = Some(Arc::from(d));
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(provider = %provider, error = %e, "Local embedding provider {} not available", provider);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("No embedding driver available (Ollama probe failed: {e}) — using text search fallback");
|
||||
None
|
||||
|
||||
if local_result.is_none() {
|
||||
warn!(
|
||||
"No embedding provider available. Memory recall will use text search only. \
|
||||
Configure [memory] embedding_provider in config.toml or set an API key \
|
||||
(OPENAI_API_KEY, GROQ_API_KEY, MISTRAL_API_KEY, TOGETHER_API_KEY, \
|
||||
FIREWORKS_API_KEY, COHERE_API_KEY)."
|
||||
);
|
||||
}
|
||||
|
||||
local_result
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1089,7 +1198,10 @@ impl OpenFangKernel {
|
||||
|| disk_manifest.tool_allowlist
|
||||
!= entry.manifest.tool_allowlist
|
||||
|| disk_manifest.tool_blocklist
|
||||
!= entry.manifest.tool_blocklist;
|
||||
!= entry.manifest.tool_blocklist
|
||||
|| disk_manifest.skills != entry.manifest.skills
|
||||
|| disk_manifest.mcp_servers
|
||||
!= entry.manifest.mcp_servers;
|
||||
if changed {
|
||||
info!(
|
||||
agent = %name,
|
||||
@@ -2889,20 +3001,16 @@ impl OpenFangKernel {
|
||||
model: &str,
|
||||
explicit_provider: Option<&str>,
|
||||
) -> KernelResult<()> {
|
||||
let catalog_entry = self
|
||||
.model_catalog
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|catalog| {
|
||||
// When the caller specifies a provider, use provider-aware lookup
|
||||
// so we resolve the model on the correct provider — not a builtin
|
||||
// from a different provider that happens to share the same name (#833).
|
||||
if let Some(ep) = explicit_provider {
|
||||
catalog.find_model_for_provider(model, ep).cloned()
|
||||
} else {
|
||||
catalog.find_model(model).cloned()
|
||||
}
|
||||
});
|
||||
let catalog_entry = self.model_catalog.read().ok().and_then(|catalog| {
|
||||
// When the caller specifies a provider, use provider-aware lookup
|
||||
// so we resolve the model on the correct provider — not a builtin
|
||||
// from a different provider that happens to share the same name (#833).
|
||||
if let Some(ep) = explicit_provider {
|
||||
catalog.find_model_for_provider(model, ep).cloned()
|
||||
} else {
|
||||
catalog.find_model(model).cloned()
|
||||
}
|
||||
});
|
||||
let provider = if let Some(ep) = explicit_provider {
|
||||
// User explicitly set the provider — use it as-is
|
||||
Some(ep.to_string())
|
||||
@@ -3280,6 +3388,7 @@ impl OpenFangKernel {
|
||||
&self,
|
||||
hand_id: &str,
|
||||
config: std::collections::HashMap<String, serde_json::Value>,
|
||||
instance_name: Option<String>,
|
||||
) -> KernelResult<openfang_hands::HandInstance> {
|
||||
use openfang_hands::HandError;
|
||||
|
||||
@@ -3296,7 +3405,7 @@ impl OpenFangKernel {
|
||||
// Create the instance in the registry
|
||||
let instance = self
|
||||
.hand_registry
|
||||
.activate(hand_id, config)
|
||||
.activate(hand_id, config, instance_name.clone())
|
||||
.map_err(|e| match e {
|
||||
HandError::AlreadyActive(id) => KernelError::OpenFang(OpenFangError::Internal(
|
||||
format!("Hand already active: {id}"),
|
||||
@@ -3317,8 +3426,15 @@ impl OpenFangKernel {
|
||||
def.agent.model.clone()
|
||||
};
|
||||
|
||||
// When a custom instance_name is provided, use it as the agent name so multiple
|
||||
// instances of the same hand type can coexist. Falls back to the HAND.toml name
|
||||
// for backward compatibility (single-instance mode).
|
||||
let agent_name = instance_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| def.agent.name.clone());
|
||||
|
||||
let mut manifest = AgentManifest {
|
||||
name: def.agent.name.clone(),
|
||||
name: agent_name.clone(),
|
||||
description: def.agent.description.clone(),
|
||||
module: def.agent.module.clone(),
|
||||
model: ModelConfig {
|
||||
@@ -3423,11 +3539,20 @@ impl OpenFangKernel {
|
||||
.registry
|
||||
.list()
|
||||
.into_iter()
|
||||
.find(|e| e.name == def.agent.name);
|
||||
.find(|e| e.name == agent_name);
|
||||
let old_agent_id = existing.as_ref().map(|e| e.id);
|
||||
let saved_triggers = old_agent_id
|
||||
.map(|id| self.triggers.take_agent_triggers(id))
|
||||
.unwrap_or_default();
|
||||
// Snapshot cron jobs before kill_agent destroys them. kill_agent calls
|
||||
// remove_agent_jobs() which deletes the jobs from memory and persists
|
||||
// an empty cron_jobs.json to disk. The reassign_agent_jobs() call below
|
||||
// would always be a no-op without this snapshot — same pattern as
|
||||
// saved_triggers above. Fixes the silent loss of cron jobs across
|
||||
// every daemon restart for hand-style agents.
|
||||
let saved_crons: Vec<openfang_types::scheduler::CronJob> = old_agent_id
|
||||
.map(|id| self.cron_scheduler.list_jobs(id))
|
||||
.unwrap_or_default();
|
||||
if let Some(old) = existing {
|
||||
info!(agent = %old.name, id = %old.id, "Removing existing hand agent for reactivation");
|
||||
let _ = self.kill_agent(old.id);
|
||||
@@ -3435,7 +3560,14 @@ impl OpenFangKernel {
|
||||
|
||||
// Spawn the agent with a fixed ID based on hand_id for stable identity across restarts.
|
||||
// This ensures triggers and cron jobs continue to work after daemon restart.
|
||||
let fixed_agent_id = AgentId::from_string(hand_id);
|
||||
// Named instances derive the UUID from instance_id so each coexists with a
|
||||
// unique stable agent id. Unnamed instances keep the legacy "derive from
|
||||
// hand_id" behavior for backward compatibility.
|
||||
let fixed_agent_id = if instance_name.is_some() {
|
||||
AgentId::from_string(&format!("hand_instance_{}", instance.instance_id))
|
||||
} else {
|
||||
AgentId::from_string(hand_id)
|
||||
};
|
||||
let agent_id = self.spawn_agent_with_parent(manifest, None, Some(fixed_agent_id))?;
|
||||
|
||||
// Restore triggers from the old agent under the new agent ID (#519).
|
||||
@@ -3451,9 +3583,38 @@ impl OpenFangKernel {
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate cron jobs from old agent to new agent so they survive restarts.
|
||||
// Without this, persisted cron jobs would reference the stale old UUID
|
||||
// and fail silently (issue #461).
|
||||
// Restore cron jobs that were snapshotted before kill_agent. They're
|
||||
// re-added under the new agent_id (which equals old.id when fixed_id is
|
||||
// derived from hand_id, but be explicit). Runtime state is reset so
|
||||
// jobs get a fresh start.
|
||||
if !saved_crons.is_empty() {
|
||||
let mut restored = 0usize;
|
||||
for mut job in saved_crons {
|
||||
job.agent_id = agent_id;
|
||||
job.next_run = None;
|
||||
job.last_run = None;
|
||||
if self.cron_scheduler.add_job(job, false).is_ok() {
|
||||
restored += 1;
|
||||
}
|
||||
}
|
||||
if restored > 0 {
|
||||
info!(
|
||||
agent = %agent_id,
|
||||
restored,
|
||||
"Restored cron jobs after hand reactivation"
|
||||
);
|
||||
if let Err(e) = self.cron_scheduler.persist() {
|
||||
warn!("Failed to persist cron jobs after restoration: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Belt-and-braces: also reassign any jobs that somehow still reference
|
||||
// the old UUID (shouldn't happen after the snapshot/restore above, but
|
||||
// kept as a safety net for edge cases like out-of-band cron creation
|
||||
// between kill and respawn). Removed reassign as primary path because
|
||||
// kill_agent's remove_agent_jobs always wipes saved_crons before this
|
||||
// could fire — see issue with #461's original fix.
|
||||
if let Some(old_id) = old_agent_id {
|
||||
let migrated = self.cron_scheduler.reassign_agent_jobs(old_id, agent_id);
|
||||
if migrated > 0 {
|
||||
@@ -3847,7 +4008,7 @@ impl OpenFangKernel {
|
||||
if !saved_hands.is_empty() {
|
||||
info!("Restoring {} persisted hand(s)", saved_hands.len());
|
||||
for (hand_id, config, old_agent_id) in saved_hands {
|
||||
match self.activate_hand(&hand_id, config) {
|
||||
match self.activate_hand(&hand_id, config, None) {
|
||||
Ok(inst) => {
|
||||
info!(hand = %hand_id, instance = %inst.instance_id, "Hand restored");
|
||||
// Reassign cron jobs and triggers from the pre-restart
|
||||
@@ -4641,62 +4802,108 @@ impl OpenFangKernel {
|
||||
}
|
||||
};
|
||||
|
||||
// If fallback models are configured, wrap in FallbackDriver
|
||||
if !manifest.fallback_models.is_empty() {
|
||||
// Primary driver uses the agent's own model name (already set in request)
|
||||
let mut chain: Vec<(
|
||||
std::sync::Arc<dyn openfang_runtime::llm_driver::LlmDriver>,
|
||||
String,
|
||||
)> = vec![(primary.clone(), String::new())];
|
||||
for fb in &manifest.fallback_models {
|
||||
// Resolve "default" provider/model to the kernel's configured defaults,
|
||||
// mirroring the overlay logic for the primary model.
|
||||
let dm = &self.config.default_model;
|
||||
let fb_provider = if fb.provider.is_empty() || fb.provider == "default" {
|
||||
dm.provider.clone()
|
||||
} else {
|
||||
fb.provider.clone()
|
||||
};
|
||||
let fb_model_name = if fb.model.is_empty() || fb.model == "default" {
|
||||
dm.model.clone()
|
||||
} else {
|
||||
fb.model.clone()
|
||||
};
|
||||
let _ = &fb_model_name; // used below in strip_provider_prefix
|
||||
// Build the complete fallback chain:
|
||||
// 1. Primary driver (from the agent manifest)
|
||||
// 2. Per-agent `manifest.fallback_models` (#845)
|
||||
// 3. Global `config.fallback_providers` (#1003) — applied to *every* agent
|
||||
//
|
||||
// Wrap in FallbackDriver whenever the chain has more than one entry. This
|
||||
// ensures that when a local provider (e.g. LM Studio) goes offline at
|
||||
// runtime, the agent loop transparently fails over to the next provider
|
||||
// instead of retrying the unreachable primary forever.
|
||||
//
|
||||
// Primary driver uses an empty model name so the request's `model` field
|
||||
// (which is the agent's own model) is used as-is.
|
||||
let mut chain: Vec<(
|
||||
std::sync::Arc<dyn openfang_runtime::llm_driver::LlmDriver>,
|
||||
String,
|
||||
)> = vec![(primary.clone(), String::new())];
|
||||
|
||||
let fb_api_key = if let Some(env) = &fb.api_key_env {
|
||||
std::env::var(env).ok()
|
||||
} else if fb_provider == dm.provider && !dm.api_key_env.is_empty() {
|
||||
std::env::var(&dm.api_key_env).ok()
|
||||
} else {
|
||||
// Resolve using provider_api_keys / convention for custom providers
|
||||
let env_var = self.config.resolve_api_key_env(&fb_provider);
|
||||
std::env::var(&env_var).ok()
|
||||
};
|
||||
let config = DriverConfig {
|
||||
provider: fb_provider.clone(),
|
||||
api_key: fb_api_key,
|
||||
base_url: fb
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| dm.base_url.clone())
|
||||
.or_else(|| self.lookup_provider_url(&fb_provider)),
|
||||
skip_permissions: true,
|
||||
};
|
||||
match drivers::create_driver(&config) {
|
||||
Ok(d) => chain.push((d, strip_provider_prefix(&fb_model_name, &fb_provider))),
|
||||
Err(e) => {
|
||||
warn!("Fallback driver '{}' failed to init: {e}", fb_provider);
|
||||
}
|
||||
// 2. Per-agent fallback models from the manifest.
|
||||
for fb in &manifest.fallback_models {
|
||||
// Resolve "default" provider/model to the kernel's configured defaults,
|
||||
// mirroring the overlay logic for the primary model.
|
||||
let dm = &self.config.default_model;
|
||||
let fb_provider = if fb.provider.is_empty() || fb.provider == "default" {
|
||||
dm.provider.clone()
|
||||
} else {
|
||||
fb.provider.clone()
|
||||
};
|
||||
let fb_model_name = if fb.model.is_empty() || fb.model == "default" {
|
||||
dm.model.clone()
|
||||
} else {
|
||||
fb.model.clone()
|
||||
};
|
||||
|
||||
let fb_api_key = if let Some(env) = &fb.api_key_env {
|
||||
self.resolve_credential(env)
|
||||
} else if fb_provider == dm.provider && !dm.api_key_env.is_empty() {
|
||||
self.resolve_credential(&dm.api_key_env)
|
||||
} else {
|
||||
// Resolve using provider_api_keys / convention for custom providers
|
||||
let env_var = self.config.resolve_api_key_env(&fb_provider);
|
||||
self.resolve_credential(&env_var)
|
||||
};
|
||||
let config = DriverConfig {
|
||||
provider: fb_provider.clone(),
|
||||
api_key: fb_api_key,
|
||||
base_url: fb
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| dm.base_url.clone())
|
||||
.or_else(|| self.lookup_provider_url(&fb_provider)),
|
||||
skip_permissions: true,
|
||||
};
|
||||
match drivers::create_driver(&config) {
|
||||
Ok(d) => chain.push((d, strip_provider_prefix(&fb_model_name, &fb_provider))),
|
||||
Err(e) => {
|
||||
warn!("Fallback driver '{}' failed to init: {e}", fb_provider);
|
||||
}
|
||||
}
|
||||
if chain.len() > 1 {
|
||||
return Ok(Arc::new(
|
||||
openfang_runtime::drivers::fallback::FallbackDriver::with_models(chain),
|
||||
));
|
||||
}
|
||||
|
||||
// 3. Global fallback providers from config.toml — `[[fallback_providers]]`.
|
||||
// These apply to every agent so that when the primary provider becomes
|
||||
// unreachable at runtime (network failure, daemon shutdown, etc.) the
|
||||
// agent loop fails over to the next provider in the chain. (#1003)
|
||||
for fb in &self.config.fallback_providers {
|
||||
let fb_api_key = {
|
||||
let env_var = if !fb.api_key_env.is_empty() {
|
||||
fb.api_key_env.clone()
|
||||
} else {
|
||||
self.config.resolve_api_key_env(&fb.provider)
|
||||
};
|
||||
self.resolve_credential(&env_var)
|
||||
};
|
||||
let fb_config = DriverConfig {
|
||||
provider: fb.provider.clone(),
|
||||
api_key: fb_api_key,
|
||||
base_url: fb
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| self.lookup_provider_url(&fb.provider)),
|
||||
skip_permissions: true,
|
||||
};
|
||||
match drivers::create_driver(&fb_config) {
|
||||
Ok(d) => {
|
||||
chain.push((d, strip_provider_prefix(&fb.model, &fb.provider)));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
provider = %fb.provider,
|
||||
error = %e,
|
||||
"Global fallback provider init failed — skipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if chain.len() > 1 {
|
||||
return Ok(Arc::new(
|
||||
openfang_runtime::drivers::fallback::FallbackDriver::with_models(chain),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(primary)
|
||||
}
|
||||
|
||||
@@ -5663,7 +5870,10 @@ fn apply_budget_defaults(
|
||||
fn default_embedding_model_for_provider(provider: &str) -> &'static str {
|
||||
match provider {
|
||||
"openai" => "text-embedding-3-small",
|
||||
"groq" => "nomic-embed-text",
|
||||
"mistral" => "mistral-embed",
|
||||
"together" => "togethercomputer/m2-bert-80M-8k-retrieval",
|
||||
"fireworks" => "nomic-ai/nomic-embed-text-v1.5",
|
||||
"cohere" => "embed-english-v3.0",
|
||||
// Local providers use nomic-embed-text as a good default
|
||||
"ollama" | "vllm" | "lmstudio" => "nomic-embed-text",
|
||||
@@ -6194,7 +6404,7 @@ impl KernelHandle for OpenFangKernel {
|
||||
config: std::collections::HashMap<String, serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let instance = self
|
||||
.activate_hand(hand_id, config)
|
||||
.activate_hand(hand_id, config, None)
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
@@ -6825,7 +7035,7 @@ mod tests {
|
||||
|
||||
let kernel = OpenFangKernel::boot_with_config(config).expect("Kernel should boot");
|
||||
let instance = kernel
|
||||
.activate_hand("browser", HashMap::new())
|
||||
.activate_hand("browser", HashMap::new(), None)
|
||||
.expect("browser hand should activate");
|
||||
let agent_id = instance.agent_id.expect("browser hand agent id");
|
||||
let entry = kernel
|
||||
|
||||
@@ -161,3 +161,39 @@ memory_write = ["self.*"]
|
||||
kernel.kill_agent(id2).unwrap();
|
||||
kernel.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agent_manifest_skills_parsing() {
|
||||
let toml_str = r#"
|
||||
name = "skills-test-agent"
|
||||
version = "0.1.0"
|
||||
description = "Test agent with skills"
|
||||
author = "test"
|
||||
module = "builtin:chat"
|
||||
|
||||
skills = ["Productivity", "web-search"]
|
||||
mcp_servers = ["github"]
|
||||
|
||||
[model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
|
||||
[capabilities]
|
||||
tools = ["file_read"]
|
||||
|
||||
[resources]
|
||||
max_llm_tokens_per_hour = 100000
|
||||
"#;
|
||||
|
||||
let manifest: AgentManifest = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(
|
||||
manifest.skills,
|
||||
vec!["Productivity", "web-search"],
|
||||
"Skills should be parsed correctly (must be at top level, not after [capabilities])"
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.mcp_servers,
|
||||
vec!["github"],
|
||||
"MCP servers should be parsed correctly (must be at top level)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,13 @@ fn phantom_action_detected(text: &str) -> bool {
|
||||
has_action && has_channel
|
||||
}
|
||||
|
||||
/// Returns true when the agent response text indicates an intentional silent completion.
|
||||
/// Matches `NO_REPLY` (exact) and `[SILENT]` (case-insensitive).
|
||||
fn is_silent_token(text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
trimmed == "NO_REPLY" || trimmed.eq_ignore_ascii_case("[silent]")
|
||||
}
|
||||
|
||||
/// Extra guidance injected after failed tool calls to prevent fabricated follow-up actions.
|
||||
const TOOL_ERROR_GUIDANCE: &str =
|
||||
"[System: One or more tool calls failed. Failed tools did not produce usable data. Do NOT invent missing results, cite nonexistent search results, or pretend failed tools succeeded. If your next steps depend on a failed tool, either retry with a materially different approach or explain the failure to the user and stop. Do not write files, store memory, or take downstream actions based on failed tool outputs.]";
|
||||
@@ -325,6 +332,10 @@ pub async fn run_agent_loop(
|
||||
|
||||
let mut total_usage = TokenUsage::default();
|
||||
let final_response;
|
||||
// Accumulate text from intermediate iterations (tool_use turns may include text
|
||||
// alongside tool calls — this text would otherwise be lost when the final
|
||||
// EndTurn iteration has empty text).
|
||||
let mut accumulated_text = String::new();
|
||||
|
||||
// Safety valve: trim excessively long message histories to prevent context overflow.
|
||||
// The full compaction system handles sophisticated summarization, but this prevents
|
||||
@@ -342,6 +353,10 @@ pub async fn run_agent_loop(
|
||||
// pair across the cut boundary, leaving orphaned blocks that cause the LLM
|
||||
// to return empty responses (input_tokens=0).
|
||||
messages = crate::session_repair::validate_and_repair(&messages);
|
||||
// Ensure history starts with a user turn: trimming may have left an
|
||||
// assistant turn at position 0, which strict providers (e.g. Gemini)
|
||||
// reject with INVALID_ARGUMENT on function-call turns.
|
||||
messages = crate::session_repair::ensure_starts_with_user(messages);
|
||||
}
|
||||
|
||||
// Use autonomous config max_iterations if set, else default
|
||||
@@ -381,6 +396,8 @@ pub async fn run_agent_loop(
|
||||
// which may have broken assistant→tool ordering invariants.
|
||||
if recovery != RecoveryStage::None {
|
||||
messages = crate::session_repair::validate_and_repair(&messages);
|
||||
// Ensure history starts with a user turn after overflow recovery.
|
||||
messages = crate::session_repair::ensure_starts_with_user(messages);
|
||||
}
|
||||
|
||||
// Context guard: compact oversized tool results before LLM call
|
||||
@@ -463,8 +480,9 @@ pub async fn run_agent_loop(
|
||||
crate::reply_directives::parse_directives(&text);
|
||||
let text = cleaned_text;
|
||||
|
||||
// NO_REPLY: agent intentionally chose not to reply
|
||||
if text.trim() == "NO_REPLY" || parsed_directives.silent {
|
||||
// NO_REPLY / [SILENT]: agent intentionally chose not to reply.
|
||||
// [SILENT] must not be stored literally — it reinforces silence in future turns.
|
||||
if is_silent_token(&text) || parsed_directives.silent {
|
||||
debug!(agent = %manifest.name, "Agent chose NO_REPLY/silent — silent completion");
|
||||
session
|
||||
.messages
|
||||
@@ -516,20 +534,30 @@ pub async fn run_agent_loop(
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against empty response — covers both iteration 0 and post-tool cycles
|
||||
// Guard against empty response — covers both iteration 0 and post-tool cycles.
|
||||
// Use accumulated_text from intermediate tool_use iterations as fallback.
|
||||
let text = if text.trim().is_empty() {
|
||||
warn!(
|
||||
agent = %manifest.name,
|
||||
iteration,
|
||||
input_tokens = total_usage.input_tokens,
|
||||
output_tokens = total_usage.output_tokens,
|
||||
messages_count = messages.len(),
|
||||
"Empty response from LLM — guard activated"
|
||||
);
|
||||
if any_tools_executed {
|
||||
"[Task completed — the agent executed tools but did not produce a text summary.]".to_string()
|
||||
if !accumulated_text.is_empty() {
|
||||
debug!(
|
||||
agent = %manifest.name,
|
||||
accumulated_len = accumulated_text.len(),
|
||||
"Using accumulated text from intermediate tool_use iterations"
|
||||
);
|
||||
accumulated_text.clone()
|
||||
} else {
|
||||
"[The model returned an empty response. This usually means the model is overloaded, the context is too large, or the API key lacks credits. Try again or check /status.]".to_string()
|
||||
warn!(
|
||||
agent = %manifest.name,
|
||||
iteration,
|
||||
input_tokens = total_usage.input_tokens,
|
||||
output_tokens = total_usage.output_tokens,
|
||||
messages_count = messages.len(),
|
||||
"Empty response from LLM — guard activated"
|
||||
);
|
||||
if any_tools_executed {
|
||||
"[Task completed — the agent executed tools but did not produce a text summary.]".to_string()
|
||||
} else {
|
||||
"[The model returned an empty response. This usually means the model is overloaded, the context is too large, or the API key lacks credits. Try again or check /status.]".to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
text
|
||||
@@ -650,6 +678,18 @@ pub async fn run_agent_loop(
|
||||
consecutive_max_tokens = 0;
|
||||
any_tools_executed = true;
|
||||
|
||||
// Capture any text content from this tool_use turn — the LLM may
|
||||
// produce text alongside tool calls (e.g., a message to the user
|
||||
// before calling memory_store). Without this, the text is lost if
|
||||
// the next iteration returns EndTurn with empty text.
|
||||
let intermediate_text = response.text();
|
||||
if !intermediate_text.trim().is_empty() {
|
||||
if !accumulated_text.is_empty() {
|
||||
accumulated_text.push_str("\n\n");
|
||||
}
|
||||
accumulated_text.push_str(intermediate_text.trim());
|
||||
}
|
||||
|
||||
// Execute tool calls
|
||||
let assistant_blocks = response.content.clone();
|
||||
|
||||
@@ -1489,6 +1529,7 @@ pub async fn run_agent_loop_streaming(
|
||||
|
||||
let mut total_usage = TokenUsage::default();
|
||||
let final_response;
|
||||
let mut accumulated_text = String::new();
|
||||
|
||||
// Safety valve: trim excessively long message histories to prevent context overflow.
|
||||
if messages.len() > MAX_HISTORY_MESSAGES {
|
||||
@@ -1504,6 +1545,10 @@ pub async fn run_agent_loop_streaming(
|
||||
// pair across the cut boundary, leaving orphaned blocks that cause the LLM
|
||||
// to return empty responses (input_tokens=0).
|
||||
messages = crate::session_repair::validate_and_repair(&messages);
|
||||
// Ensure history starts with a user turn: trimming may have left an
|
||||
// assistant turn at position 0, which strict providers (e.g. Gemini)
|
||||
// reject with INVALID_ARGUMENT on function-call turns.
|
||||
messages = crate::session_repair::ensure_starts_with_user(messages);
|
||||
}
|
||||
|
||||
// Use autonomous config max_iterations if set, else default
|
||||
@@ -1561,6 +1606,8 @@ pub async fn run_agent_loop_streaming(
|
||||
// be followed by tool messages" errors after context overflow recovery.)
|
||||
if recovery != RecoveryStage::None {
|
||||
messages = crate::session_repair::validate_and_repair(&messages);
|
||||
// Ensure history starts with a user turn after overflow recovery.
|
||||
messages = crate::session_repair::ensure_starts_with_user(messages);
|
||||
}
|
||||
|
||||
// Context guard: compact oversized tool results before LLM call
|
||||
@@ -1641,8 +1688,9 @@ pub async fn run_agent_loop_streaming(
|
||||
crate::reply_directives::parse_directives(&text);
|
||||
let text = cleaned_text_s;
|
||||
|
||||
// NO_REPLY: agent intentionally chose not to reply
|
||||
if text.trim() == "NO_REPLY" || parsed_directives_s.silent {
|
||||
// NO_REPLY / [SILENT]: agent intentionally chose not to reply.
|
||||
// [SILENT] must not be stored literally — it reinforces silence in future turns.
|
||||
if is_silent_token(&text) || parsed_directives_s.silent {
|
||||
debug!(agent = %manifest.name, "Agent chose NO_REPLY/silent (streaming) — silent completion");
|
||||
session
|
||||
.messages
|
||||
@@ -1694,20 +1742,29 @@ pub async fn run_agent_loop_streaming(
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against empty response — covers both iteration 0 and post-tool cycles
|
||||
// Guard against empty response — use accumulated text as fallback (streaming).
|
||||
let text = if text.trim().is_empty() {
|
||||
warn!(
|
||||
agent = %manifest.name,
|
||||
iteration,
|
||||
input_tokens = total_usage.input_tokens,
|
||||
output_tokens = total_usage.output_tokens,
|
||||
messages_count = messages.len(),
|
||||
"Empty response from LLM (streaming) — guard activated"
|
||||
);
|
||||
if any_tools_executed {
|
||||
"[Task completed — the agent executed tools but did not produce a text summary.]".to_string()
|
||||
if !accumulated_text.is_empty() {
|
||||
debug!(
|
||||
agent = %manifest.name,
|
||||
accumulated_len = accumulated_text.len(),
|
||||
"Using accumulated text from intermediate tool_use iterations (streaming)"
|
||||
);
|
||||
accumulated_text.clone()
|
||||
} else {
|
||||
"[The model returned an empty response. This usually means the model is overloaded, the context is too large, or the API key lacks credits. Try again or check /status.]".to_string()
|
||||
warn!(
|
||||
agent = %manifest.name,
|
||||
iteration,
|
||||
input_tokens = total_usage.input_tokens,
|
||||
output_tokens = total_usage.output_tokens,
|
||||
messages_count = messages.len(),
|
||||
"Empty response from LLM (streaming) — guard activated"
|
||||
);
|
||||
if any_tools_executed {
|
||||
"[Task completed — the agent executed tools but did not produce a text summary.]".to_string()
|
||||
} else {
|
||||
"[The model returned an empty response. This usually means the model is overloaded, the context is too large, or the API key lacks credits. Try again or check /status.]".to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
text
|
||||
@@ -1807,6 +1864,15 @@ pub async fn run_agent_loop_streaming(
|
||||
consecutive_max_tokens = 0;
|
||||
any_tools_executed = true;
|
||||
|
||||
// Capture text from intermediate tool_use turns (streaming path).
|
||||
let intermediate_text = response.text();
|
||||
if !intermediate_text.trim().is_empty() {
|
||||
if !accumulated_text.is_empty() {
|
||||
accumulated_text.push_str("\n\n");
|
||||
}
|
||||
accumulated_text.push_str(intermediate_text.trim());
|
||||
}
|
||||
|
||||
let assistant_blocks = response.content.clone();
|
||||
|
||||
session.messages.push(Message {
|
||||
@@ -2140,6 +2206,7 @@ pub async fn run_agent_loop_streaming(
|
||||
/// 11. `Action: tool\nAction Input: {"key":"value"}` — ReAct-style (LM Studio, GPT-OSS)
|
||||
/// 12. `tool_name\n{"key":"value"}` — bare name + JSON on next line (Llama 4 Scout)
|
||||
/// 13. `<tool_use>{"name":"tool","arguments":{...}}</tool_use>` — Llama 3.1+ variant
|
||||
/// 14. `<function=tool><parameter=name>value</parameter></function>` — nested XML parameter style
|
||||
///
|
||||
/// Validates tool names against available tools and returns synthetic `ToolCall` entries.
|
||||
fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Vec<ToolCall> {
|
||||
@@ -2177,13 +2244,16 @@ fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Ve
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse JSON input
|
||||
// Parse JSON input, or fall back to nested XML parameter blocks.
|
||||
let input: serde_json::Value = match serde_json::from_str(json_body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(tool = tool_name, error = %e, "Failed to parse text-based tool call JSON — skipping");
|
||||
continue;
|
||||
}
|
||||
Err(json_err) => match parse_xml_parameter_blocks(json_body) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
warn!(tool = tool_name, error = %json_err, "Failed to parse text-based tool call payload — skipping");
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
info!(
|
||||
@@ -2751,6 +2821,42 @@ fn parse_json_tool_call_object(
|
||||
Some((name.to_string(), args))
|
||||
}
|
||||
|
||||
fn unescape_xml_entities(text: &str) -> String {
|
||||
text.replace(""", "\"")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
fn parse_xml_parameter_blocks(text: &str) -> Option<serde_json::Value> {
|
||||
use regex_lite::Regex;
|
||||
|
||||
let re = Regex::new(r#"(?s)<parameter=([A-Za-z0-9_.:-]+)>\s*(.*?)\s*</parameter>"#).unwrap();
|
||||
let mut params = serde_json::Map::new();
|
||||
|
||||
for caps in re.captures_iter(text) {
|
||||
let Some(name) = caps.get(1).map(|m| m.as_str().trim()) else {
|
||||
continue;
|
||||
};
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw_value = caps.get(2).map(|m| m.as_str()).unwrap_or_default();
|
||||
let value_text = unescape_xml_entities(raw_value).trim().to_string();
|
||||
let value =
|
||||
serde_json::from_str(&value_text).unwrap_or(serde_json::Value::String(value_text));
|
||||
params.insert(name.to_string(), value);
|
||||
}
|
||||
|
||||
if params.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::Value::Object(params))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the custom arrow syntax used by some Ollama models:
|
||||
/// `{tool => "name", args => {--key "value"}}` or `{tool => "name", args => {"key":"value"}}`
|
||||
fn parse_arrow_syntax_tool_call(
|
||||
@@ -3639,6 +3745,44 @@ mod tests {
|
||||
assert!(calls[0].id.starts_with("recovered_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_text_tool_calls_xml_parameters() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "shell_exec".into(),
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = r#"<function=shell_exec><parameter=command>python3 "/tmp/run.py" --flag value</parameter></function>"#;
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "shell_exec");
|
||||
assert_eq!(
|
||||
calls[0].input["command"],
|
||||
r#"python3 "/tmp/run.py" --flag value"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_text_tool_calls_xml_parameters_with_wrapper() {
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "shell_exec".into(),
|
||||
description: "Execute".into(),
|
||||
input_schema: serde_json::json!({}),
|
||||
}];
|
||||
let text = r#"<tool_call>
|
||||
<function=shell_exec>
|
||||
<parameter=command>python3 "/tmp/poll.py" --job-id "abc123"</parameter>
|
||||
</function>
|
||||
</tool_call>"#;
|
||||
let calls = recover_text_tool_calls(text, &tools);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "shell_exec");
|
||||
assert_eq!(
|
||||
calls[0].input["command"],
|
||||
r#"python3 "/tmp/poll.py" --job-id "abc123""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_text_tool_calls_unknown_tool() {
|
||||
let tools = vec![ToolDefinition {
|
||||
@@ -4405,6 +4549,56 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock driver that emits nested XML parameter-style tool calls as plain text.
|
||||
struct NestedXmlTextToolCallDriver {
|
||||
call_count: AtomicU32,
|
||||
}
|
||||
|
||||
impl NestedXmlTextToolCallDriver {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
call_count: AtomicU32::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for NestedXmlTextToolCallDriver {
|
||||
async fn complete(
|
||||
&self,
|
||||
_request: CompletionRequest,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
let call = self.call_count.fetch_add(1, Ordering::Relaxed);
|
||||
if call == 0 {
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "<tool_call><function=web_search><parameter=query>rust async</parameter></function></tool_call>".to_string(),
|
||||
provider_metadata: None,
|
||||
}],
|
||||
stop_reason: StopReason::EndTurn,
|
||||
tool_calls: vec![],
|
||||
usage: TokenUsage {
|
||||
input_tokens: 18,
|
||||
output_tokens: 10,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
Ok(CompletionResponse {
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Recovered nested XML tool call successfully.".to_string(),
|
||||
provider_metadata: None,
|
||||
}],
|
||||
stop_reason: StopReason::EndTurn,
|
||||
tool_calls: vec![],
|
||||
usage: TokenUsage {
|
||||
input_tokens: 24,
|
||||
output_tokens: 8,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for TextToolCallDriver {
|
||||
async fn complete(
|
||||
@@ -4518,6 +4712,81 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_nested_xml_text_tool_call_recovery_e2e() {
|
||||
let memory = openfang_memory::MemorySubstrate::open_in_memory(0.01).unwrap();
|
||||
let agent_id = openfang_types::agent::AgentId::new();
|
||||
let mut session = openfang_memory::session::Session {
|
||||
id: openfang_types::agent::SessionId::new(),
|
||||
agent_id,
|
||||
messages: Vec::new(),
|
||||
context_window_tokens: 0,
|
||||
label: None,
|
||||
};
|
||||
let manifest = test_manifest();
|
||||
let driver: Arc<dyn LlmDriver> = Arc::new(NestedXmlTextToolCallDriver::new());
|
||||
|
||||
let tools = vec![ToolDefinition {
|
||||
name: "web_search".into(),
|
||||
description: "Search the web".into(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"}
|
||||
}
|
||||
}),
|
||||
}];
|
||||
|
||||
let result = run_agent_loop(
|
||||
&manifest,
|
||||
"Search for rust async programming",
|
||||
&mut session,
|
||||
&memory,
|
||||
driver,
|
||||
&tools,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Agent loop should recover nested XML tool calls");
|
||||
|
||||
assert!(
|
||||
!result.response.contains("<tool_call>"),
|
||||
"Response should not contain raw tool_call tags, got: {:?}",
|
||||
result.response
|
||||
);
|
||||
assert!(
|
||||
!result.response.contains("<function="),
|
||||
"Response should not contain raw function tags, got: {:?}",
|
||||
result.response
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.response
|
||||
.contains("Recovered nested XML tool call successfully."),
|
||||
"Expected final response text, got: {:?}",
|
||||
result.response
|
||||
);
|
||||
assert!(
|
||||
result.iterations >= 2,
|
||||
"Should have at least 2 iterations (tool call + final response), got: {}",
|
||||
result.iterations
|
||||
);
|
||||
}
|
||||
|
||||
/// Mock driver that returns NO text-based tool calls — just normal text.
|
||||
/// Verifies recovery does NOT interfere with normal flow.
|
||||
#[tokio::test]
|
||||
@@ -4648,4 +4917,36 @@ mod tests {
|
||||
}
|
||||
assert!(!events.is_empty(), "Should have received stream events");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_silent_detection_uppercase() {
|
||||
assert!(is_silent_token("[SILENT]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_silent_detection_lowercase() {
|
||||
assert!(is_silent_token("[silent]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_silent_detection_mixed_case() {
|
||||
assert!(is_silent_token("[Silent]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_silent_detection_with_whitespace() {
|
||||
assert!(is_silent_token(" [SILENT] "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_silent_detection_no_reply() {
|
||||
assert!(is_silent_token("NO_REPLY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_silent_detection_rejects_normal_text() {
|
||||
assert!(!is_silent_token("Hello, how can I help?"));
|
||||
assert!(!is_silent_token("SILENT"));
|
||||
assert!(!is_silent_token(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -877,7 +877,7 @@ pub async fn tool_browser_navigate(
|
||||
agent_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let url = input["url"].as_str().ok_or("Missing 'url' parameter")?;
|
||||
crate::web_fetch::check_ssrf(url)?;
|
||||
crate::web_fetch::check_ssrf(url, &[])?;
|
||||
|
||||
let resp = mgr
|
||||
.send_command(
|
||||
|
||||
@@ -435,11 +435,10 @@ async fn summarize_messages(
|
||||
let safe_start = if conversation_text.is_char_boundary(start) {
|
||||
start
|
||||
} else {
|
||||
conversation_text[start..]
|
||||
.char_indices()
|
||||
.next()
|
||||
.map(|(i, _)| start + i)
|
||||
.unwrap_or(conversation_text.len())
|
||||
// Find the nearest valid character boundary moving upward
|
||||
(start..conversation_text.len())
|
||||
.find(|&i| conversation_text.is_char_boundary(i))
|
||||
.unwrap_or(conversation_text.len())
|
||||
};
|
||||
conversation_text = conversation_text[safe_start..].to_string();
|
||||
}
|
||||
@@ -1478,7 +1477,10 @@ mod tests {
|
||||
Message::assistant("Done reading."),
|
||||
];
|
||||
let adjusted = adjust_split_for_tool_pairs(&messages, 2);
|
||||
assert_eq!(adjusted, 1, "Should pull back split to keep ToolUse + ToolResult together");
|
||||
assert_eq!(
|
||||
adjusted, 1,
|
||||
"Should pull back split to keep ToolUse + ToolResult together"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1489,7 +1491,10 @@ mod tests {
|
||||
Message::user("c"),
|
||||
];
|
||||
let adjusted = adjust_split_for_tool_pairs(&messages, 1);
|
||||
assert_eq!(adjusted, 1, "Should not change split for plain text messages");
|
||||
assert_eq!(
|
||||
adjusted, 1,
|
||||
"Should not change split for plain text messages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -32,10 +32,14 @@ fn safe_drain_boundary(messages: &[Message], mut boundary: usize) -> usize {
|
||||
// is in the last drained message (boundary - 1). Pull boundary back by 1.
|
||||
if messages[boundary].role == Role::User {
|
||||
if let MessageContent::Blocks(blocks) = &messages[boundary].content {
|
||||
let has_tool_result = blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. }));
|
||||
let has_tool_result = blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::ToolResult { .. }));
|
||||
if has_tool_result && boundary > 0 && messages[boundary - 1].role == Role::Assistant {
|
||||
if let MessageContent::Blocks(asst_blocks) = &messages[boundary - 1].content {
|
||||
let has_tool_use = asst_blocks.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. }));
|
||||
let has_tool_use = asst_blocks
|
||||
.iter()
|
||||
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
|
||||
if has_tool_use {
|
||||
boundary -= 1;
|
||||
debug!(
|
||||
@@ -135,7 +139,8 @@ pub fn recover_from_overflow(
|
||||
debug!(
|
||||
estimated_tokens = estimated,
|
||||
removing = remove,
|
||||
"Stage 1: moderate trim to last {} messages", messages.len() - remove
|
||||
"Stage 1: moderate trim to last {} messages",
|
||||
messages.len() - remove
|
||||
);
|
||||
messages.drain(..remove);
|
||||
// Re-check after trim
|
||||
@@ -156,7 +161,8 @@ pub fn recover_from_overflow(
|
||||
warn!(
|
||||
estimated_tokens = estimate_tokens(messages, system_prompt, tools),
|
||||
removing = remove,
|
||||
"Stage 2: aggressive overflow compaction to last {} messages", messages.len() - remove
|
||||
"Stage 2: aggressive overflow compaction to last {} messages",
|
||||
messages.len() - remove
|
||||
);
|
||||
let summary = Message::user(format!(
|
||||
"[System: {} earlier messages were removed due to context overflow. \
|
||||
@@ -373,7 +379,10 @@ mod tests {
|
||||
];
|
||||
// Boundary 2 would cut between the assistant(ToolUse) at [1] and user(ToolResult) at [2].
|
||||
let adjusted = safe_drain_boundary(&msgs, 2);
|
||||
assert_eq!(adjusted, 1, "Should pull boundary back to keep the ToolUse/ToolResult pair together");
|
||||
assert_eq!(
|
||||
adjusted, 1,
|
||||
"Should pull boundary back to keep the ToolUse/ToolResult pair together"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -385,7 +394,10 @@ mod tests {
|
||||
Message::assistant("d"),
|
||||
];
|
||||
let adjusted = safe_drain_boundary(&msgs, 2);
|
||||
assert_eq!(adjusted, 2, "Should not change boundary for plain text messages");
|
||||
assert_eq!(
|
||||
adjusted, 2,
|
||||
"Should not change boundary for plain text messages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -245,4 +245,75 @@ mod tests {
|
||||
// All drivers rate-limited — error should bubble up
|
||||
assert!(matches!(result, Err(LlmError::RateLimited { .. })));
|
||||
}
|
||||
|
||||
/// Regression test for #1003: when the primary driver returns a network /
|
||||
/// connection error (e.g. LM Studio shut down → reqwest connection refused),
|
||||
/// the FallbackDriver MUST escalate to the next driver in the chain instead
|
||||
/// of bubbling the error up to the agent loop (which would then retry the
|
||||
/// dead primary forever).
|
||||
#[tokio::test]
|
||||
async fn test_network_error_falls_through_to_secondary() {
|
||||
struct NetworkFailDriver;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for NetworkFailDriver {
|
||||
async fn complete(
|
||||
&self,
|
||||
_req: CompletionRequest,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
// Simulates `reqwest::Error` from a connection refused — exactly
|
||||
// what an offline LM Studio looks like in production.
|
||||
Err(LlmError::Http(
|
||||
"error sending request: connection refused (os error 10061)".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let driver = FallbackDriver::new(vec![
|
||||
Arc::new(NetworkFailDriver) as Arc<dyn LlmDriver>,
|
||||
Arc::new(OkDriver) as Arc<dyn LlmDriver>,
|
||||
]);
|
||||
let result = driver.complete(test_request()).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"FallbackDriver should escalate network errors to the next driver"
|
||||
);
|
||||
assert_eq!(result.unwrap().text(), "OK");
|
||||
}
|
||||
|
||||
/// Same as above but for streaming. The streaming path is what the agent
|
||||
/// loop hits in practice for LM Studio etc., so it must also fall through.
|
||||
#[tokio::test]
|
||||
async fn test_network_error_falls_through_streaming() {
|
||||
struct NetworkFailDriver;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for NetworkFailDriver {
|
||||
async fn complete(
|
||||
&self,
|
||||
_req: CompletionRequest,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
Err(LlmError::Http("connection refused".to_string()))
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_req: CompletionRequest,
|
||||
_tx: tokio::sync::mpsc::Sender<StreamEvent>,
|
||||
) -> Result<CompletionResponse, LlmError> {
|
||||
Err(LlmError::Http("connection refused".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
let driver = FallbackDriver::new(vec![
|
||||
Arc::new(NetworkFailDriver) as Arc<dyn LlmDriver>,
|
||||
Arc::new(OkDriver) as Arc<dyn LlmDriver>,
|
||||
]);
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(16);
|
||||
let result = driver.stream(test_request(), tx).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"FallbackDriver::stream should also escalate network errors"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +370,11 @@ fn sanitize_gemini_turns(contents: Vec<GeminiContent>) -> Vec<GeminiContent> {
|
||||
}
|
||||
|
||||
// Step 2: Drop orphaned functionCall parts from model turns.
|
||||
// A model turn with functionCall must be followed by a user turn with functionResponse.
|
||||
// A model turn with functionCall must be:
|
||||
// (a) followed by a user turn with functionResponse, AND
|
||||
// (b) preceded by a user turn (i.e. not at position 0).
|
||||
// Gemini rejects with INVALID_ARGUMENT if a functionCall turn is at
|
||||
// position 0 with no preceding user turn, even when (a) is satisfied.
|
||||
let len = merged.len();
|
||||
for i in 0..len {
|
||||
let is_model = merged[i].role.as_deref() == Some("model");
|
||||
@@ -394,7 +398,9 @@ fn sanitize_gemini_turns(contents: Vec<GeminiContent>) -> Vec<GeminiContent> {
|
||||
.iter()
|
||||
.any(|p| matches!(p, GeminiPart::FunctionResponse { .. }));
|
||||
|
||||
if !next_has_response {
|
||||
// After Step 1 merge, i > 0 guarantees a user turn precedes this model
|
||||
// turn (alternating roles). i == 0 means no preceding user turn.
|
||||
if i == 0 || !next_has_response {
|
||||
// Drop the functionCall parts from this model turn (keep text parts)
|
||||
merged[i]
|
||||
.parts
|
||||
|
||||
@@ -139,8 +139,8 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
}),
|
||||
"github-copilot" | "copilot" => Some(ProviderDefaults {
|
||||
base_url: copilot::GITHUB_COPILOT_BASE_URL,
|
||||
api_key_env: "GITHUB_TOKEN",
|
||||
key_required: true,
|
||||
api_key_env: "COPILOT_CLIENT_ID",
|
||||
key_required: false, // Auth handled via OAuth device flow, not simple API key
|
||||
}),
|
||||
"codex" | "openai-codex" => Some(ProviderDefaults {
|
||||
base_url: OPENAI_BASE_URL,
|
||||
@@ -334,27 +334,23 @@ pub fn create_driver(config: &DriverConfig) -> Result<Arc<dyn LlmDriver>, LlmErr
|
||||
)));
|
||||
}
|
||||
|
||||
// GitHub Copilot — wraps OpenAI-compatible driver with automatic token exchange.
|
||||
// The CopilotDriver exchanges the GitHub PAT for a Copilot API token on demand,
|
||||
// caches it, and refreshes when expired.
|
||||
// GitHub Copilot — OAuth device flow + OpenAI-compatible completions.
|
||||
// Authentication is handled automatically via persisted tokens from the device flow.
|
||||
// Run `openfang config set-key github-copilot` to authenticate.
|
||||
if provider == "github-copilot" || provider == "copilot" {
|
||||
let github_token = config
|
||||
.api_key
|
||||
.clone()
|
||||
.or_else(|| std::env::var("GITHUB_TOKEN").ok())
|
||||
.ok_or_else(|| {
|
||||
LlmError::MissingApiKey(
|
||||
"Set GITHUB_TOKEN environment variable for GitHub Copilot".to_string(),
|
||||
)
|
||||
})?;
|
||||
let base_url = config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| copilot::GITHUB_COPILOT_BASE_URL.to_string());
|
||||
return Ok(Arc::new(copilot::CopilotDriver::new(
|
||||
github_token,
|
||||
base_url,
|
||||
)));
|
||||
let openfang_dir = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.map(|h| std::path::PathBuf::from(h).join(".openfang"))
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(".openfang"));
|
||||
|
||||
if !copilot::copilot_auth_available(&openfang_dir) {
|
||||
return Err(LlmError::MissingApiKey(
|
||||
"Copilot not authenticated. Run `openfang config set-key github-copilot` to sign in."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
return Ok(Arc::new(copilot::CopilotDriver::new(openfang_dir)));
|
||||
}
|
||||
|
||||
// Azure OpenAI — deployment-based URL with `api-key` header
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::think_filter::{FilterAction, StreamingThinkFilter};
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage};
|
||||
use openfang_types::model_catalog::MOONSHOT_KIMI_BASE_URL;
|
||||
use openfang_types::tool::ToolCall;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
@@ -84,7 +85,17 @@ impl OpenAIDriver {
|
||||
AZURE_API_VERSION,
|
||||
)
|
||||
} else {
|
||||
format!("{}/chat/completions", self.base_url)
|
||||
// Kimi K2/K2.5 models live on api.moonshot.cn, not api.moonshot.ai.
|
||||
// When the moonshot provider is configured with the default .ai URL
|
||||
// but the model is a kimi-k2* model, redirect to the .cn endpoint.
|
||||
let effective_url = if self.base_url.contains("api.moonshot.ai")
|
||||
&& model.to_lowercase().starts_with("kimi-k2")
|
||||
{
|
||||
MOONSHOT_KIMI_BASE_URL
|
||||
} else {
|
||||
&self.base_url
|
||||
};
|
||||
format!("{}/chat/completions", effective_url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +273,22 @@ struct OaiUsage {
|
||||
completion_tokens: u64,
|
||||
}
|
||||
|
||||
/// Strip trailing empty assistant messages without tool calls.
|
||||
/// Some API proxies reject empty assistant messages as "prefill".
|
||||
fn strip_trailing_empty_assistant(messages: &mut Vec<OaiMessage>) {
|
||||
while messages.last().map_or(false, |m| {
|
||||
m.role == "assistant"
|
||||
&& m.tool_calls.is_none()
|
||||
&& match &m.content {
|
||||
None => true,
|
||||
Some(OaiMessageContent::Text(t)) => t.trim().is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}) {
|
||||
messages.pop();
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for OpenAIDriver {
|
||||
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
@@ -417,6 +444,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
strip_trailing_empty_assistant(&mut oai_messages);
|
||||
|
||||
let oai_tools: Vec<OaiTool> = request
|
||||
.tools
|
||||
.iter()
|
||||
@@ -444,6 +473,7 @@ impl LlmDriver for OpenAIDriver {
|
||||
} else {
|
||||
(Some(request.max_tokens), None)
|
||||
};
|
||||
|
||||
let mut oai_request = OaiRequest {
|
||||
model: request.model.clone(),
|
||||
messages: oai_messages,
|
||||
@@ -873,6 +903,8 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
strip_trailing_empty_assistant(&mut oai_messages);
|
||||
|
||||
let oai_tools: Vec<OaiTool> = request
|
||||
.tools
|
||||
.iter()
|
||||
@@ -1317,6 +1349,16 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
|
||||
for (id, name, arguments) in &tool_accum {
|
||||
// Skip malformed tool calls (empty ID or name can happen if
|
||||
// streaming chunks arrive out of order or are dropped by proxy).
|
||||
if id.is_empty() || name.is_empty() {
|
||||
warn!(
|
||||
tool_id = %id,
|
||||
tool_name = %name,
|
||||
"Skipping tool call with empty ID or name from streaming response"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
|
||||
content.push(ContentBlock::ToolUse {
|
||||
@@ -1836,4 +1878,24 @@ mod tests {
|
||||
let url = driver.chat_url("gpt-4o");
|
||||
assert_eq!(url, "https://api.openai.com/v1/chat/completions");
|
||||
}
|
||||
|
||||
/// Regression test for #970: kimi-k2.5 on moonshot.ai should redirect to moonshot.cn
|
||||
#[test]
|
||||
fn test_kimi_k2_redirects_to_moonshot_cn() {
|
||||
let driver = OpenAIDriver::new(
|
||||
"test-key".to_string(),
|
||||
"https://api.moonshot.ai/v1".to_string(),
|
||||
);
|
||||
// kimi-k2.5 must go to the .cn endpoint
|
||||
let url = driver.chat_url("kimi-k2.5");
|
||||
assert_eq!(url, "https://api.moonshot.cn/v1/chat/completions");
|
||||
|
||||
// kimi-k2 must also redirect
|
||||
let url = driver.chat_url("kimi-k2");
|
||||
assert_eq!(url, "https://api.moonshot.cn/v1/chat/completions");
|
||||
|
||||
// moonshot-v1-128k should NOT redirect (stays on .ai)
|
||||
let url = driver.chat_url("moonshot-v1-128k");
|
||||
assert_eq!(url, "https://api.moonshot.ai/v1/chat/completions");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ use rmcp::service::RunningService;
|
||||
use rmcp::{RoleClient, ServiceExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -307,11 +306,10 @@ impl McpConnection {
|
||||
}
|
||||
}
|
||||
|
||||
let config = StreamableHttpClientTransportConfig {
|
||||
uri: Arc::from(url),
|
||||
custom_headers,
|
||||
..Default::default()
|
||||
};
|
||||
// rmcp 1.3+ marks StreamableHttpClientTransportConfig as #[non_exhaustive].
|
||||
// Use the official builder API (credit: @jefflower, PR #986).
|
||||
let config =
|
||||
StreamableHttpClientTransportConfig::with_uri(url).custom_headers(custom_headers);
|
||||
|
||||
let transport = StreamableHttpClientTransport::from_config(config);
|
||||
|
||||
|
||||
@@ -75,6 +75,21 @@ impl ModelCatalog {
|
||||
continue;
|
||||
}
|
||||
|
||||
// GitHub Copilot: check for persisted OAuth tokens
|
||||
if provider.id == "github-copilot" || provider.id == "copilot" {
|
||||
let openfang_dir = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.map(|h| std::path::PathBuf::from(h).join(".openfang"))
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(".openfang"));
|
||||
provider.auth_status =
|
||||
if crate::drivers::copilot::copilot_auth_available(&openfang_dir) {
|
||||
AuthStatus::Configured
|
||||
} else {
|
||||
AuthStatus::Missing
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if !provider.key_required {
|
||||
provider.auth_status = AuthStatus::NotRequired;
|
||||
continue;
|
||||
@@ -372,8 +387,8 @@ impl ModelCatalog {
|
||||
display_name: display,
|
||||
provider: provider.to_string(),
|
||||
tier: ModelTier::Local,
|
||||
context_window: 32_768,
|
||||
max_output_tokens: 4_096,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 16_384,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
@@ -961,11 +976,10 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("command-r", "command-r-plus"),
|
||||
("command", "command-a"),
|
||||
// GitHub Copilot aliases
|
||||
("copilot", "copilot/gpt-4o"),
|
||||
("copilot-4o", "copilot/gpt-4o"),
|
||||
("copilot-4", "copilot/gpt-4"),
|
||||
("copilot-gpt4o", "copilot/gpt-4o"),
|
||||
("copilot-gpt4", "copilot/gpt-4"),
|
||||
("copilot", "gpt-4o"),
|
||||
("copilot-4o", "gpt-4o"),
|
||||
("copilot-opus", "claude-opus-4.6"),
|
||||
("copilot-sonnet", "claude-sonnet-4.6"),
|
||||
// Chinese model aliases
|
||||
("qwen", "qwen-plus"),
|
||||
("glm", "glm-5-20250605"),
|
||||
@@ -2904,36 +2918,9 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// GitHub Copilot (2) — free for subscribers
|
||||
// GitHub Copilot — models fetched dynamically at runtime.
|
||||
// No static entries needed; see kernel.rs fetch_copilot_models().
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "copilot/gpt-4o".into(),
|
||||
display_name: "GPT-4o (Copilot)".into(),
|
||||
provider: "github-copilot".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 4_096,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["copilot-gpt4o".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "copilot/gpt-4".into(),
|
||||
display_name: "GPT-4 (Copilot)".into(),
|
||||
provider: "github-copilot".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 4_096,
|
||||
input_cost_per_m: 0.0,
|
||||
output_cost_per_m: 0.0,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["copilot-gpt4".into()],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Qwen / Alibaba (6)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -38,9 +38,17 @@ pub struct RepairStats {
|
||||
/// 1. Drops orphaned ToolResult blocks that have no matching ToolUse
|
||||
/// 2. Drops empty messages
|
||||
/// - 2b. Reorders misplaced ToolResults to follow their matching ToolUse
|
||||
/// - 2c. Inserts synthetic error results for unmatched ToolUse blocks
|
||||
/// - 2d. Deduplicates ToolResults with the same tool_use_id
|
||||
/// - 2c. Deduplicates ToolResults with the same tool_use_id
|
||||
/// - 2d. Inserts synthetic error results for unmatched ToolUse blocks
|
||||
/// 3. Merges consecutive same-role messages
|
||||
///
|
||||
/// Note: dedup MUST run before synthetic insertion. Some providers (e.g., Moonshot)
|
||||
/// reuse `tool_use_id` values across turns (`function_name:index` format). After
|
||||
/// compaction, multiple ToolUse blocks may share the same id with only one matching
|
||||
/// ToolResult. If synthetic insertion ran first it would see the id as "matched" and
|
||||
/// skip it; dedup would then leave one ToolUse orphaned. By deduping first, we
|
||||
/// guarantee that each unique id has at most one result, and synthetic insertion
|
||||
/// can correctly count uses vs. results to top up missing pairings.
|
||||
pub fn validate_and_repair(messages: &[Message]) -> Vec<Message> {
|
||||
validate_and_repair_with_stats(messages).0
|
||||
}
|
||||
@@ -117,14 +125,25 @@ pub fn validate_and_repair_with_stats(messages: &[Message]) -> (Vec<Message>, Re
|
||||
let reordered_count = reorder_tool_results(&mut cleaned);
|
||||
stats.results_reordered = reordered_count;
|
||||
|
||||
// Phase 2c: Insert synthetic error results for unmatched ToolUse blocks
|
||||
let synthetic_count = insert_synthetic_results(&mut cleaned);
|
||||
stats.synthetic_results_inserted = synthetic_count;
|
||||
|
||||
// Phase 2d: Deduplicate ToolResults
|
||||
// Phase 2c: Deduplicate ToolResults FIRST.
|
||||
//
|
||||
// This must run before synthetic insertion (issue #1013). Providers like
|
||||
// Moonshot reuse tool_use_ids across turns in `function_name:index` form
|
||||
// (e.g. "memory_store:0"). After compaction we may have multiple ToolUse
|
||||
// blocks sharing the same id with multiple ToolResult blocks for the same
|
||||
// id. If synthetic insertion ran first, it would see the id as "matched"
|
||||
// and not insert any synthetic. Dedup would then strip the duplicate
|
||||
// result, leaving a ToolUse orphaned and producing an API 400.
|
||||
// Dedup only removes duplicate ToolResult blocks; ToolUse blocks are
|
||||
// untouched, so the next phase can pair any leftover orphaned ToolUses
|
||||
// with synthetic results.
|
||||
let dedup_count = deduplicate_tool_results(&mut cleaned);
|
||||
stats.duplicates_removed = dedup_count;
|
||||
|
||||
// Phase 2d: Insert synthetic error results for unmatched ToolUse blocks.
|
||||
let synthetic_count = insert_synthetic_results(&mut cleaned);
|
||||
stats.synthetic_results_inserted = synthetic_count;
|
||||
|
||||
// Phase 2e: Skip aborted/errored assistant messages
|
||||
// An assistant message with no content blocks (or only empty text) followed by
|
||||
// a user message containing ToolResults indicates an interrupted tool-use.
|
||||
@@ -177,6 +196,33 @@ pub fn validate_and_repair_with_stats(messages: &[Message]) -> (Vec<Message>, Re
|
||||
(merged, stats)
|
||||
}
|
||||
|
||||
/// Ensure the message history starts with a user turn.
|
||||
///
|
||||
/// After context trimming the drain boundary may land on an assistant turn,
|
||||
/// leaving it at position 0. Providers (especially Gemini) require the first
|
||||
/// message to be from the user. This function drops leading assistant messages
|
||||
/// and re-validates to clean up newly-orphaned ToolResults.
|
||||
///
|
||||
/// The loop handles the edge case where the first user turn consisted entirely
|
||||
/// of ToolResult blocks that became orphaned (dropped by `validate_and_repair`),
|
||||
/// which would re-expose another leading assistant turn.
|
||||
pub fn ensure_starts_with_user(mut messages: Vec<Message>) -> Vec<Message> {
|
||||
loop {
|
||||
match messages.iter().position(|m| m.role == Role::User) {
|
||||
Some(0) | None => break,
|
||||
Some(i) => {
|
||||
warn!(
|
||||
dropped = i,
|
||||
"Dropping leading assistant turn(s) to ensure history starts with user"
|
||||
);
|
||||
messages.drain(..i);
|
||||
messages = validate_and_repair(&messages);
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
/// Phase 2b: Reorder misplaced ToolResults -- ensure each result follows its use.
|
||||
///
|
||||
/// Builds a map of tool_use_id to the index of the assistant message containing it.
|
||||
@@ -319,35 +365,44 @@ fn reorder_tool_results(messages: &mut Vec<Message>) -> usize {
|
||||
reorder_count
|
||||
}
|
||||
|
||||
/// Phase 2c: Insert synthetic error results for unmatched ToolUse blocks.
|
||||
/// Phase 2d: Insert synthetic error results for unmatched ToolUse blocks.
|
||||
///
|
||||
/// If an assistant message contains a ToolUse block but there is no matching
|
||||
/// ToolResult anywhere in the history, a synthetic error result is inserted
|
||||
/// immediately after the assistant message to prevent API validation errors.
|
||||
///
|
||||
/// This counts ToolUse and ToolResult occurrences per id (not just presence)
|
||||
/// so it correctly handles providers like Moonshot that reuse tool_use_ids
|
||||
/// across turns (e.g. "memory_store:0" called multiple times). If two ToolUses
|
||||
/// share an id but only one ToolResult exists, one synthetic will be inserted
|
||||
/// for the still-orphaned use.
|
||||
fn insert_synthetic_results(messages: &mut Vec<Message>) -> usize {
|
||||
// Collect all existing ToolResult IDs
|
||||
let existing_result_ids: HashSet<String> = messages
|
||||
.iter()
|
||||
.flat_map(|m| match &m.content {
|
||||
MessageContent::Blocks(blocks) => blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
_ => vec![],
|
||||
})
|
||||
.collect();
|
||||
// Count existing ToolResult IDs (occurrences, not just presence).
|
||||
let mut available_result_counts: HashMap<String, usize> = HashMap::new();
|
||||
for msg in messages.iter() {
|
||||
if let MessageContent::Blocks(blocks) = &msg.content {
|
||||
for b in blocks {
|
||||
if let ContentBlock::ToolResult { tool_use_id, .. } = b {
|
||||
*available_result_counts
|
||||
.entry(tool_use_id.clone())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find ToolUse blocks without matching results
|
||||
// Walk ToolUse blocks in order; consume one available result per id and
|
||||
// mark any leftover ToolUses as orphaned.
|
||||
let mut orphaned_uses: Vec<(usize, String)> = Vec::new(); // (assistant_msg_idx, tool_use_id)
|
||||
for (idx, msg) in messages.iter().enumerate() {
|
||||
if msg.role == Role::Assistant {
|
||||
if let MessageContent::Blocks(blocks) = &msg.content {
|
||||
for block in blocks {
|
||||
if let ContentBlock::ToolUse { id, .. } = block {
|
||||
if !existing_result_ids.contains(id) {
|
||||
let remaining = available_result_counts.entry(id.clone()).or_insert(0);
|
||||
if *remaining > 0 {
|
||||
*remaining -= 1;
|
||||
} else {
|
||||
orphaned_uses.push((idx, id.clone()));
|
||||
}
|
||||
}
|
||||
@@ -409,10 +464,15 @@ fn insert_synthetic_results(messages: &mut Vec<Message>) -> usize {
|
||||
count
|
||||
}
|
||||
|
||||
/// Phase 2d: Drop duplicate ToolResults for the same tool_use_id.
|
||||
/// Phase 2c: Drop duplicate ToolResults for the same tool_use_id.
|
||||
///
|
||||
/// If multiple ToolResult blocks exist for the same tool_use_id across the
|
||||
/// message history, only the first one is kept. Returns the count of duplicates removed.
|
||||
///
|
||||
/// Note: this only removes duplicate ToolResult blocks. ToolUse blocks are
|
||||
/// untouched, so the subsequent synthetic-insertion phase can pair any
|
||||
/// orphaned ToolUses (e.g. from Moonshot's repeated tool_use_ids) with
|
||||
/// synthetic results.
|
||||
fn deduplicate_tool_results(messages: &mut Vec<Message>) -> usize {
|
||||
let mut seen_ids: HashSet<String> = HashSet::new();
|
||||
let mut removed = 0usize;
|
||||
@@ -928,6 +988,152 @@ mod tests {
|
||||
assert_eq!(result_count, 1, "Should keep only the first ToolResult");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_moonshot_duplicate_tool_ids_gets_synthetic_after_dedup_1013() {
|
||||
// Regression for issue #1013.
|
||||
//
|
||||
// Moonshot returns tool_use_ids in `function_name:index` format
|
||||
// (e.g. "memory_store:0") which repeat across turns when the same tool
|
||||
// is called multiple times. After compaction we may keep multiple
|
||||
// turns containing the same id. Phase ordering used to insert
|
||||
// synthetic results BEFORE deduping, so duplicate-id ToolResults
|
||||
// looked "matched" and dedup later stripped one, leaving an orphan
|
||||
// and producing an API 400.
|
||||
//
|
||||
// After the fix, dedup runs first, then synthetic insertion counts
|
||||
// ToolUse vs ToolResult occurrences per id and tops up the missing
|
||||
// pairing.
|
||||
let messages = vec![
|
||||
Message::user("Remember this fact"),
|
||||
// First turn: assistant calls memory_store with id "memory_store:0".
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
|
||||
id: "memory_store:0".to_string(),
|
||||
name: "memory_store".to_string(),
|
||||
input: serde_json::json!({"key": "fact1", "value": "hello"}),
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
},
|
||||
// Matching ToolResult for the first call.
|
||||
Message {
|
||||
role: Role::User,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "memory_store:0".to_string(),
|
||||
tool_name: "memory_store".to_string(),
|
||||
content: "stored".to_string(),
|
||||
is_error: false,
|
||||
}]),
|
||||
},
|
||||
// Second turn: assistant calls memory_store again with the SAME id
|
||||
// because Moonshot reuses the `function_name:index` format.
|
||||
Message {
|
||||
role: Role::Assistant,
|
||||
content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
|
||||
id: "memory_store:0".to_string(),
|
||||
name: "memory_store".to_string(),
|
||||
input: serde_json::json!({"key": "fact2", "value": "world"}),
|
||||
provider_metadata: None,
|
||||
}]),
|
||||
},
|
||||
// No matching ToolResult for the second call (e.g. lost during
|
||||
// compaction or interrupted mid-execution).
|
||||
Message::user("Did it work?"),
|
||||
];
|
||||
|
||||
let (repaired, stats) = validate_and_repair_with_stats(&messages);
|
||||
|
||||
// Count ToolUse blocks and ToolResult blocks for "memory_store:0".
|
||||
let mut tool_use_count = 0usize;
|
||||
let mut tool_result_count = 0usize;
|
||||
for m in &repaired {
|
||||
if let MessageContent::Blocks(blocks) = &m.content {
|
||||
for b in blocks {
|
||||
match b {
|
||||
ContentBlock::ToolUse { id, .. } if id == "memory_store:0" => {
|
||||
tool_use_count += 1;
|
||||
}
|
||||
ContentBlock::ToolResult { tool_use_id, .. }
|
||||
if tool_use_id == "memory_store:0" =>
|
||||
{
|
||||
tool_result_count += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Both ToolUses must be preserved.
|
||||
assert_eq!(
|
||||
tool_use_count, 2,
|
||||
"Both ToolUse blocks should be preserved after repair"
|
||||
);
|
||||
// Every ToolUse must have a corresponding ToolResult.
|
||||
assert_eq!(
|
||||
tool_result_count, tool_use_count,
|
||||
"Every ToolUse should have exactly one corresponding ToolResult \
|
||||
(uses={tool_use_count}, results={tool_result_count})"
|
||||
);
|
||||
|
||||
// A synthetic result must have been inserted for the orphaned use.
|
||||
assert_eq!(
|
||||
stats.synthetic_results_inserted, 1,
|
||||
"Exactly one synthetic result should be inserted for the orphaned ToolUse"
|
||||
);
|
||||
|
||||
// No ToolResult should be orphaned (every ToolResult must have a
|
||||
// matching ToolUse id).
|
||||
let mut tool_use_ids: HashSet<String> = HashSet::new();
|
||||
for m in &repaired {
|
||||
if let MessageContent::Blocks(blocks) = &m.content {
|
||||
for b in blocks {
|
||||
if let ContentBlock::ToolUse { id, .. } = b {
|
||||
tool_use_ids.insert(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in &repaired {
|
||||
if let MessageContent::Blocks(blocks) = &m.content {
|
||||
for b in blocks {
|
||||
if let ContentBlock::ToolResult { tool_use_id, .. } = b {
|
||||
assert!(
|
||||
tool_use_ids.contains(tool_use_id),
|
||||
"ToolResult {tool_use_id} has no matching ToolUse"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the synthetic result is marked as an error result and
|
||||
// contains the interrupted-tool message.
|
||||
let synthetic_present = repaired.iter().any(|m| {
|
||||
if let MessageContent::Blocks(blocks) = &m.content {
|
||||
blocks.iter().any(|b| match b {
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
is_error,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
tool_use_id == "memory_store:0"
|
||||
&& *is_error
|
||||
&& content.contains("interrupted")
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
assert!(
|
||||
synthetic_present,
|
||||
"A synthetic error ToolResult for memory_store:0 should be present"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_tool_result_details() {
|
||||
let short = "Normal tool output";
|
||||
|
||||
@@ -893,6 +893,41 @@ mod tests {
|
||||
assert!(validate_command_allowlist(&cjk_cmd, &policy).is_err());
|
||||
}
|
||||
|
||||
/// Regression test for GitHub issue #919.
|
||||
///
|
||||
/// User reported that `rm /home/jcl/test/test.txt` succeeds in Allowlist
|
||||
/// mode even when `rm` is NOT in `allowed_commands`. The bypass turned out
|
||||
/// to be the `process_start` tool, which spawned subprocesses without
|
||||
/// consulting `exec_policy` at all (fixed in tool_runner.rs).
|
||||
///
|
||||
/// This test pins down the contract on the validator itself: given the
|
||||
/// EXACT policy from the bug report, `rm /tmp/test.txt` MUST be rejected
|
||||
/// with "not in the exec allowlist" so that any future tool path which
|
||||
/// spawns subprocesses can call it and get a correct answer.
|
||||
#[test]
|
||||
fn test_issue_919_rm_blocked_when_not_in_allowlist() {
|
||||
let policy = ExecPolicy {
|
||||
mode: ExecSecurityMode::Allowlist,
|
||||
allowed_commands: vec!["ls".to_string(), "echo".to_string()],
|
||||
..ExecPolicy::default()
|
||||
};
|
||||
// The exact command from the bug report.
|
||||
let result = validate_command_allowlist("rm /tmp/test.txt", &policy);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"rm must be blocked when not in allowed_commands (issue #919)"
|
||||
);
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("not in the exec allowlist"),
|
||||
"Error message must indicate allowlist rejection, got: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains("rm"),
|
||||
"Error message must name the rejected command, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_all_commands_cjk_separators() {
|
||||
// Ensure extract_all_commands handles CJK content between separators
|
||||
|
||||
@@ -21,8 +21,11 @@ const MAX_AGENT_CALL_DEPTH: u32 = 5;
|
||||
/// Check if a tool name refers to a shell execution tool.
|
||||
///
|
||||
/// Used to determine whether exec_policy settings should bypass the approval gate.
|
||||
/// SECURITY (#919): `process_start` is also a shell execution path — it spawns
|
||||
/// arbitrary subprocesses via the persistent process manager. It must be gated
|
||||
/// by the same approval rules as `shell_exec`.
|
||||
fn is_shell_tool(name: &str) -> bool {
|
||||
name == "shell_exec"
|
||||
matches!(name, "shell_exec" | "process_start")
|
||||
}
|
||||
|
||||
/// Check if a shell command should be blocked by taint tracking.
|
||||
@@ -353,7 +356,9 @@ pub async fn execute_tool(
|
||||
"channel_send" => tool_channel_send(input, kernel, workspace_root).await,
|
||||
|
||||
// Persistent process tools
|
||||
"process_start" => tool_process_start(input, process_manager, caller_agent_id).await,
|
||||
"process_start" => {
|
||||
tool_process_start(input, process_manager, caller_agent_id, exec_policy).await
|
||||
}
|
||||
"process_poll" => tool_process_poll(input, process_manager).await,
|
||||
"process_write" => tool_process_write(input, process_manager).await,
|
||||
"process_kill" => tool_process_kill(input, process_manager).await,
|
||||
@@ -2477,7 +2482,7 @@ async fn tool_a2a_discover(input: &serde_json::Value) -> Result<String, String>
|
||||
let url = input["url"].as_str().ok_or("Missing 'url' parameter")?;
|
||||
|
||||
// SSRF protection: block private/metadata IPs
|
||||
if crate::web_fetch::check_ssrf(url).is_err() {
|
||||
if crate::web_fetch::check_ssrf(url, &[]).is_err() {
|
||||
return Err("SSRF blocked: URL resolves to a private or metadata address".to_string());
|
||||
}
|
||||
|
||||
@@ -2500,7 +2505,7 @@ async fn tool_a2a_send(
|
||||
// Resolve agent URL: either directly provided or looked up by name
|
||||
let url = if let Some(url) = input["agent_url"].as_str() {
|
||||
// SSRF protection
|
||||
if crate::web_fetch::check_ssrf(url).is_err() {
|
||||
if crate::web_fetch::check_ssrf(url, &[]).is_err() {
|
||||
return Err("SSRF blocked: URL resolves to a private or metadata address".to_string());
|
||||
}
|
||||
url.to_string()
|
||||
@@ -3085,10 +3090,18 @@ async fn tool_docker_exec(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Start a long-running process (REPL, server, watcher).
|
||||
///
|
||||
/// SECURITY (#919): process_start previously spawned subprocesses with NO
|
||||
/// exec policy enforcement, allowing an LLM in Allowlist mode to bypass
|
||||
/// allowed_commands entirely. For example, process_start with command="rm"
|
||||
/// args=["/some/file"] would delete the file even though "rm" was not
|
||||
/// in the allowlist. This function now performs the same checks as
|
||||
/// shell_exec: metacharacter rejection plus exec_policy validation.
|
||||
async fn tool_process_start(
|
||||
input: &serde_json::Value,
|
||||
pm: Option<&crate::process_manager::ProcessManager>,
|
||||
caller_agent_id: Option<&str>,
|
||||
exec_policy: Option<&openfang_types::config::ExecPolicy>,
|
||||
) -> Result<String, String> {
|
||||
let pm = pm.ok_or("Process manager not available")?;
|
||||
let agent_id = caller_agent_id.unwrap_or("default");
|
||||
@@ -3104,6 +3117,41 @@ async fn tool_process_start(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// SECURITY: Reject shell metacharacters in the command name itself.
|
||||
// The command field must be a single binary token.
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(command) {
|
||||
return Err(format!(
|
||||
"process_start blocked: command contains {reason}. \
|
||||
Shell metacharacters are never allowed in the command field."
|
||||
));
|
||||
}
|
||||
// Also reject metacharacters anywhere in the arguments. While direct
|
||||
// spawn does not interpret these, blocking them prevents an LLM from
|
||||
// smuggling a chained command past the allowlist via an argument.
|
||||
for arg in &args {
|
||||
if let Some(reason) = crate::subprocess_sandbox::contains_shell_metacharacters(arg) {
|
||||
return Err(format!(
|
||||
"process_start blocked: argument contains {reason}. \
|
||||
Shell metacharacters are not allowed in process arguments."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// SECURITY (#919): Enforce exec policy against the base command. The
|
||||
// shared validate_command_allowlist handles Deny / Full / Allowlist and
|
||||
// falls through to allow commands listed in safe_bins or allowed_commands.
|
||||
if let Some(policy) = exec_policy {
|
||||
if let Err(reason) = crate::subprocess_sandbox::validate_command_allowlist(command, policy)
|
||||
{
|
||||
return Err(format!(
|
||||
"process_start blocked: {reason}. Current exec_policy.mode = '{:?}'. \
|
||||
To allow this command, add it to exec_policy.allowed_commands or \
|
||||
set exec_policy.mode = 'full'.",
|
||||
policy.mode
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let proc_id = pm.start(agent_id, command, &args).await?;
|
||||
Ok(serde_json::json!({
|
||||
"process_id": proc_id,
|
||||
@@ -3677,13 +3725,12 @@ mod tests {
|
||||
None, // process_manager
|
||||
)
|
||||
.await;
|
||||
// Should NOT be the capability-check denial — it should normalize to file_write
|
||||
// and pass the capability check. It may fail for other reasons (path validation,
|
||||
// OS-level errors), but not the agent capability gate.
|
||||
// Should NOT be the capability-enforcement "Permission denied" — it should
|
||||
// normalize to file_write and pass the capability check. It may still fail
|
||||
// for filesystem reasons (e.g. OS "Permission denied (os error 13)"), so we
|
||||
// check specifically for the capability-gate message.
|
||||
assert!(
|
||||
!result
|
||||
.content
|
||||
.contains("does not have capability to use tool"),
|
||||
!result.content.contains("Permission denied: agent"),
|
||||
"fs-write should normalize to file_write and pass capability check, got: {}",
|
||||
result.content
|
||||
);
|
||||
@@ -4018,4 +4065,130 @@ mod tests {
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
// ── Regression: GitHub issue #919 — rm bypass via process_start ──────
|
||||
//
|
||||
// Before the fix, an LLM in Allowlist mode could call process_start
|
||||
// with command="rm" and args=["/some/file"] to delete files even though
|
||||
// "rm" was not in exec_policy.allowed_commands. tool_process_start
|
||||
// spawned the subprocess directly without ever consulting exec_policy.
|
||||
//
|
||||
// These tests pin down the new contract:
|
||||
// 1. process_start with a non-allowlisted binary returns Err.
|
||||
// 2. The Err message identifies allowlist rejection (so callers and
|
||||
// logs can distinguish it from a generic spawn failure).
|
||||
// 3. process_start with an allowlisted binary still works.
|
||||
// 4. is_shell_tool() now reports process_start as a shell tool so
|
||||
// the approval-gate path treats it the same as shell_exec.
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_919_process_start_rm_blocked_in_allowlist() {
|
||||
use openfang_types::config::{ExecPolicy, ExecSecurityMode};
|
||||
|
||||
let pm = crate::process_manager::ProcessManager::new(5);
|
||||
let policy = ExecPolicy {
|
||||
mode: ExecSecurityMode::Allowlist,
|
||||
allowed_commands: vec!["ls".to_string(), "echo".to_string()],
|
||||
..ExecPolicy::default()
|
||||
};
|
||||
let input = serde_json::json!({
|
||||
"command": "rm",
|
||||
"args": ["/tmp/openfang_test_should_not_be_deleted.txt"],
|
||||
});
|
||||
|
||||
let result = tool_process_start(&input, Some(&pm), Some("test-agent"), Some(&policy)).await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"process_start must reject 'rm' when not in allowlist (issue #919). Got: {:?}",
|
||||
result
|
||||
);
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("not in the exec allowlist"),
|
||||
"Error must indicate allowlist rejection, got: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains("process_start blocked"),
|
||||
"Error must identify process_start as the blocking tool, got: {err}"
|
||||
);
|
||||
assert_eq!(
|
||||
pm.count(),
|
||||
0,
|
||||
"No process must have been spawned when allowlist rejects the command"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_919_process_start_metachar_in_command_blocked() {
|
||||
use openfang_types::config::{ExecPolicy, ExecSecurityMode};
|
||||
|
||||
let pm = crate::process_manager::ProcessManager::new(5);
|
||||
let policy = ExecPolicy {
|
||||
mode: ExecSecurityMode::Full,
|
||||
..ExecPolicy::default()
|
||||
};
|
||||
// Even in Full mode, smuggling shell metacharacters into the command
|
||||
// field must be rejected — process_start does direct exec, not shell.
|
||||
let input = serde_json::json!({
|
||||
"command": "rm; cat /etc/passwd",
|
||||
"args": [],
|
||||
});
|
||||
let result = tool_process_start(&input, Some(&pm), Some("test-agent"), Some(&policy)).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("metacharacter") || pm.count() == 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_919_process_start_metachar_in_arg_blocked() {
|
||||
use openfang_types::config::{ExecPolicy, ExecSecurityMode};
|
||||
|
||||
let pm = crate::process_manager::ProcessManager::new(5);
|
||||
let policy = ExecPolicy {
|
||||
mode: ExecSecurityMode::Allowlist,
|
||||
allowed_commands: vec!["echo".to_string()],
|
||||
..ExecPolicy::default()
|
||||
};
|
||||
// Smuggling a chained command via an argument: echo "$(rm -rf /)"
|
||||
let input = serde_json::json!({
|
||||
"command": "echo",
|
||||
"args": ["$(rm -rf /)"],
|
||||
});
|
||||
let result = tool_process_start(&input, Some(&pm), Some("test-agent"), Some(&policy)).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"process_start must reject metacharacters in args"
|
||||
);
|
||||
assert_eq!(pm.count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_issue_919_process_start_deny_mode_blocks_everything() {
|
||||
use openfang_types::config::{ExecPolicy, ExecSecurityMode};
|
||||
|
||||
let pm = crate::process_manager::ProcessManager::new(5);
|
||||
let policy = ExecPolicy {
|
||||
mode: ExecSecurityMode::Deny,
|
||||
..ExecPolicy::default()
|
||||
};
|
||||
let input = serde_json::json!({
|
||||
"command": "echo",
|
||||
"args": ["hello"],
|
||||
});
|
||||
let result = tool_process_start(&input, Some(&pm), Some("test-agent"), Some(&policy)).await;
|
||||
assert!(result.is_err(), "Deny mode must block process_start");
|
||||
assert!(result.unwrap_err().to_lowercase().contains("disabled"));
|
||||
assert_eq!(pm.count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issue_919_is_shell_tool_includes_process_start() {
|
||||
// process_start must be treated as a shell tool by the approval gate
|
||||
// so #772 (full-mode approval bypass) and #919 (allowlist enforcement)
|
||||
// both apply consistently.
|
||||
assert!(is_shell_tool("shell_exec"));
|
||||
assert!(is_shell_tool("process_start"));
|
||||
assert!(!is_shell_tool("file_read"));
|
||||
assert!(!is_shell_tool("web_fetch"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ impl WebFetchEngine {
|
||||
let method_upper = method.to_uppercase();
|
||||
|
||||
// Step 1: SSRF protection — BEFORE any network I/O
|
||||
check_ssrf(url)?;
|
||||
check_ssrf(url, &self.config.ssrf_allowed_hosts)?;
|
||||
|
||||
// Step 2: Cache lookup (only for GET)
|
||||
let cache_key = format!("fetch:{}:{}", method_upper, url);
|
||||
@@ -185,7 +185,14 @@ fn is_html(content_type: &str, body: &str) -> bool {
|
||||
/// Check if a URL targets a private/internal network resource.
|
||||
/// Blocks localhost, metadata endpoints, and private IPs.
|
||||
/// Must run BEFORE any network I/O.
|
||||
pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
|
||||
///
|
||||
/// The `allowed_hosts` slice lets self-hosted deployments bypass the
|
||||
/// private-IP check for specific hosts. Entries can be exact hostnames
|
||||
/// (`"n8n.local"`), wildcard domains (`"*.olares.com"`), or CIDR ranges
|
||||
/// (`"10.0.0.0/8"`).
|
||||
///
|
||||
/// **Cloud metadata endpoints are NEVER allowed regardless of the allowlist.**
|
||||
pub(crate) fn check_ssrf(url: &str, allowed_hosts: &[String]) -> Result<(), String> {
|
||||
// Only allow http:// and https:// schemes
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return Err("Only http:// and https:// URLs are allowed".to_string());
|
||||
@@ -200,6 +207,7 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
|
||||
};
|
||||
|
||||
// Hostname-based blocklist (catches metadata endpoints)
|
||||
// These are UNCONDITIONALLY blocked — no allowlist can override them.
|
||||
let blocked = [
|
||||
"localhost",
|
||||
"ip6-localhost",
|
||||
@@ -217,13 +225,28 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
|
||||
return Err(format!("SSRF blocked: {hostname} is a restricted hostname"));
|
||||
}
|
||||
|
||||
// Check if the hostname is explicitly allowed before doing DNS resolution.
|
||||
if is_host_allowed(hostname, allowed_hosts) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Resolve DNS and check every returned IP
|
||||
let port = if url.starts_with("https") { 443 } else { 80 };
|
||||
let socket_addr = format!("{hostname}:{port}");
|
||||
if let Ok(addrs) = socket_addr.to_socket_addrs() {
|
||||
for addr in addrs {
|
||||
let ip = addr.ip();
|
||||
if is_metadata_ip(&ip) {
|
||||
// Metadata IPs are NEVER allowed, even via allowlist.
|
||||
return Err(format!(
|
||||
"SSRF blocked: {hostname} resolves to metadata IP {ip}"
|
||||
));
|
||||
}
|
||||
if ip.is_loopback() || ip.is_unspecified() || is_private_ip(&ip) {
|
||||
// Check if the resolved IP matches a CIDR in the allowlist.
|
||||
if is_ip_allowed(&ip, allowed_hosts) {
|
||||
continue;
|
||||
}
|
||||
return Err(format!(
|
||||
"SSRF blocked: {hostname} resolves to private IP {ip}"
|
||||
));
|
||||
@@ -234,6 +257,97 @@ pub(crate) fn check_ssrf(url: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if an IP is a cloud metadata endpoint address.
|
||||
fn is_metadata_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
let octets = v4.octets();
|
||||
// 169.254.169.254 (AWS/GCP/Azure IMDS)
|
||||
octets == [169, 254, 169, 254]
|
||||
// 100.100.100.200 (Alibaba Cloud IMDS)
|
||||
|| octets == [100, 100, 100, 200]
|
||||
// 192.0.0.192 (Azure IMDS alternative)
|
||||
|| octets == [192, 0, 0, 192]
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a hostname matches any entry in the allowlist.
|
||||
/// Supports exact match and wildcard domains (`*.example.com`).
|
||||
fn is_host_allowed(hostname: &str, allowed_hosts: &[String]) -> bool {
|
||||
let lower = hostname.to_lowercase();
|
||||
for entry in allowed_hosts {
|
||||
let entry_lower = entry.to_lowercase();
|
||||
// Exact match
|
||||
if entry_lower == lower {
|
||||
return true;
|
||||
}
|
||||
// Wildcard domain: *.example.com matches sub.example.com
|
||||
if let Some(suffix) = entry_lower.strip_prefix("*.") {
|
||||
if lower.ends_with(&format!(".{suffix}")) || lower == suffix {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if an IP address matches any CIDR entry in the allowlist.
|
||||
fn is_ip_allowed(ip: &IpAddr, allowed_hosts: &[String]) -> bool {
|
||||
for entry in allowed_hosts {
|
||||
if let Some(pos) = entry.find('/') {
|
||||
// Parse as CIDR: base_ip/prefix_len
|
||||
let base_str = &entry[..pos];
|
||||
let prefix_str = &entry[pos + 1..];
|
||||
if let (Ok(base_ip), Ok(prefix_len)) =
|
||||
(base_str.parse::<IpAddr>(), prefix_str.parse::<u32>())
|
||||
{
|
||||
if ip_in_cidr(ip, &base_ip, prefix_len) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if let Ok(entry_ip) = entry.parse::<IpAddr>() {
|
||||
// Exact IP match
|
||||
if *ip == entry_ip {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if `ip` falls within the CIDR block `base/prefix_len`.
|
||||
fn ip_in_cidr(ip: &IpAddr, base: &IpAddr, prefix_len: u32) -> bool {
|
||||
match (ip, base) {
|
||||
(IpAddr::V4(ip4), IpAddr::V4(base4)) => {
|
||||
if prefix_len > 32 {
|
||||
return false;
|
||||
}
|
||||
if prefix_len == 0 {
|
||||
return true;
|
||||
}
|
||||
let ip_bits = u32::from_be_bytes(ip4.octets());
|
||||
let base_bits = u32::from_be_bytes(base4.octets());
|
||||
let mask = !0u32 << (32 - prefix_len);
|
||||
(ip_bits & mask) == (base_bits & mask)
|
||||
}
|
||||
(IpAddr::V6(ip6), IpAddr::V6(base6)) => {
|
||||
if prefix_len > 128 {
|
||||
return false;
|
||||
}
|
||||
if prefix_len == 0 {
|
||||
return true;
|
||||
}
|
||||
let ip_bits = u128::from_be_bytes(ip6.octets());
|
||||
let base_bits = u128::from_be_bytes(base6.octets());
|
||||
let mask = !0u128 << (128 - prefix_len);
|
||||
(ip_bits & mask) == (base_bits & mask)
|
||||
}
|
||||
_ => false, // mismatched families
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an IP address is in a private range.
|
||||
fn is_private_ip(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
@@ -308,8 +422,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_localhost() {
|
||||
assert!(check_ssrf("http://localhost/admin").is_err());
|
||||
assert!(check_ssrf("http://localhost:8080/api").is_err());
|
||||
assert!(check_ssrf("http://localhost/admin", &[]).is_err());
|
||||
assert!(check_ssrf("http://localhost:8080/api", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -323,8 +437,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_metadata() {
|
||||
assert!(check_ssrf("http://169.254.169.254/latest/meta-data/").is_err());
|
||||
assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/").is_err());
|
||||
assert!(check_ssrf("http://169.254.169.254/latest/meta-data/", &[]).is_err());
|
||||
assert!(check_ssrf("http://metadata.google.internal/computeMetadata/v1/", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -339,28 +453,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_non_http() {
|
||||
assert!(check_ssrf("file:///etc/passwd").is_err());
|
||||
assert!(check_ssrf("ftp://internal.corp/data").is_err());
|
||||
assert!(check_ssrf("gopher://evil.com").is_err());
|
||||
assert!(check_ssrf("file:///etc/passwd", &[]).is_err());
|
||||
assert!(check_ssrf("ftp://internal.corp/data", &[]).is_err());
|
||||
assert!(check_ssrf("gopher://evil.com", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_cloud_metadata() {
|
||||
// Alibaba Cloud IMDS
|
||||
assert!(check_ssrf("http://100.100.100.200/latest/meta-data/").is_err());
|
||||
assert!(check_ssrf("http://100.100.100.200/latest/meta-data/", &[]).is_err());
|
||||
// Azure IMDS alternative
|
||||
assert!(check_ssrf("http://192.0.0.192/metadata/instance").is_err());
|
||||
assert!(check_ssrf("http://192.0.0.192/metadata/instance", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_zero_ip() {
|
||||
assert!(check_ssrf("http://0.0.0.0/").is_err());
|
||||
assert!(check_ssrf("http://0.0.0.0/", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_blocks_ipv6_localhost() {
|
||||
assert!(check_ssrf("http://[::1]/admin").is_err());
|
||||
assert!(check_ssrf("http://[::1]:8080/api").is_err());
|
||||
assert!(check_ssrf("http://[::1]/admin", &[]).is_err());
|
||||
assert!(check_ssrf("http://[::1]:8080/api", &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -374,4 +488,51 @@ mod tests {
|
||||
let h3 = extract_host("http://[::1]/path");
|
||||
assert_eq!(h3, "[::1]:80");
|
||||
}
|
||||
|
||||
// ── SSRF allowlist tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_allowlist_permits_private_ip() {
|
||||
// A CIDR allowlist entry should permit an otherwise-blocked private IP.
|
||||
let allow = vec!["10.0.0.0/8".to_string()];
|
||||
assert!(check_ssrf("http://10.1.2.3", &allow).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_allowlist_still_blocks_metadata() {
|
||||
// Even if the allowlist covers the entire link-local range,
|
||||
// cloud metadata endpoints must NEVER be permitted.
|
||||
let allow = vec!["169.254.0.0/16".to_string()];
|
||||
assert!(check_ssrf("http://169.254.169.254/latest/meta-data/", &allow).is_err());
|
||||
// Also verify hostname-based metadata blocks
|
||||
let allow2 = vec!["metadata.google.internal".to_string()];
|
||||
assert!(check_ssrf(
|
||||
"http://metadata.google.internal/computeMetadata/v1/",
|
||||
&allow2
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_allowlist_wildcard_domain() {
|
||||
let allow = vec!["*.example.com".to_string()];
|
||||
assert!(check_ssrf("http://api.example.com", &allow).is_ok());
|
||||
// Non-matching domain should still go through normal checks
|
||||
assert!(!is_host_allowed("other.net", &allow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssrf_allowlist_exact_hostname() {
|
||||
let allow = vec!["n8n.local".to_string()];
|
||||
assert!(check_ssrf("http://n8n.local/webhook", &allow).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cidr_matching() {
|
||||
let ip_in: IpAddr = "10.1.2.3".parse().unwrap();
|
||||
let ip_out: IpAddr = "11.0.0.1".parse().unwrap();
|
||||
let base: IpAddr = "10.0.0.0".parse().unwrap();
|
||||
assert!(ip_in_cidr(&ip_in, &base, 8));
|
||||
assert!(!ip_in_cidr(&ip_out, &base, 8));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ impl WebSearchEngine {
|
||||
SearchProvider::Tavily => self.search_tavily(query, max_results).await,
|
||||
SearchProvider::Perplexity => self.search_perplexity(query).await,
|
||||
SearchProvider::DuckDuckGo => self.search_duckduckgo(query, max_results).await,
|
||||
SearchProvider::Searxng => self.search_searxng(query, max_results, None, 1).await,
|
||||
SearchProvider::Auto => self.search_auto(query, max_results).await,
|
||||
};
|
||||
|
||||
@@ -67,7 +68,7 @@ impl WebSearchEngine {
|
||||
}
|
||||
|
||||
/// Auto-select provider based on available API keys.
|
||||
/// Priority: Tavily → Brave → Perplexity → DuckDuckGo
|
||||
/// Priority: Tavily → Brave → Perplexity → Searxng → DuckDuckGo
|
||||
async fn search_auto(&self, query: &str, max_results: usize) -> Result<String, String> {
|
||||
// Tavily first (AI-agent-native)
|
||||
if resolve_api_key(&self.config.tavily.api_key_env).is_some() {
|
||||
@@ -96,6 +97,15 @@ impl WebSearchEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Searxng fourth (self-hosted, no API key needed)
|
||||
if !self.config.searxng.url.is_empty() {
|
||||
debug!("Auto: trying Searxng");
|
||||
match self.search_searxng(query, max_results, None, 1).await {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) => warn!("Searxng failed, falling back: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// DuckDuckGo always available as zero-config fallback
|
||||
debug!("Auto: falling back to DuckDuckGo");
|
||||
self.search_duckduckgo(query, max_results).await
|
||||
@@ -313,6 +323,168 @@ impl WebSearchEngine {
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Search via SearXNG self-hosted instance.
|
||||
async fn search_searxng(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
category: Option<&str>,
|
||||
page: u32,
|
||||
) -> Result<String, String> {
|
||||
if self.config.searxng.url.is_empty() {
|
||||
return Err("SearXNG URL is not configured".to_string());
|
||||
}
|
||||
|
||||
let category = category.unwrap_or("general");
|
||||
|
||||
// Validate category against SearXNG instance
|
||||
match self.list_searxng_categories().await {
|
||||
Ok(cats) => {
|
||||
if !cats.iter().any(|c| c == category) {
|
||||
return Err(format!(
|
||||
"Invalid SearXNG category '{}'. Available: {}",
|
||||
category,
|
||||
cats.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Could not validate SearXNG category: {e}"),
|
||||
}
|
||||
|
||||
let limit = max_results;
|
||||
|
||||
debug!(query, "Searching via SearXNG");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/search",
|
||||
self.config.searxng.url.trim_end_matches('/')
|
||||
))
|
||||
.query(&[
|
||||
("q", query),
|
||||
("format", "json"),
|
||||
("categories", category),
|
||||
("page", &page.to_string()),
|
||||
])
|
||||
.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("SearXNG request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("SearXNG API returned {}", resp.status()));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SearxngResponse {
|
||||
results: Vec<SearxngResult>,
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SearxngResult {
|
||||
url: String,
|
||||
title: String,
|
||||
content: Option<String>,
|
||||
#[serde(alias = "pubdate")]
|
||||
published_date: Option<String>,
|
||||
}
|
||||
|
||||
let data: SearxngResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("SearXNG JSON parse failed: {e}"))?;
|
||||
|
||||
if data.results.is_empty() {
|
||||
return Err(format!("No results found for '{query}' (SearXNG)."));
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct OutputResult<'a> {
|
||||
title: &'a str,
|
||||
url: &'a str,
|
||||
content: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
published_date: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct Output<'a> {
|
||||
query: &'a str,
|
||||
results: Vec<OutputResult<'a>>,
|
||||
}
|
||||
|
||||
let results: Vec<OutputResult> = data
|
||||
.results
|
||||
.iter()
|
||||
.take(limit)
|
||||
.map(|r| OutputResult {
|
||||
title: &r.title,
|
||||
url: &r.url,
|
||||
content: r.content.as_deref().unwrap_or(""),
|
||||
published_date: r.published_date.as_deref(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let output = Output {
|
||||
query: &data.query,
|
||||
results,
|
||||
};
|
||||
|
||||
serde_json::to_string(&output)
|
||||
.map_err(|e| format!("Failed to serialize SearXNG results: {e}"))
|
||||
}
|
||||
|
||||
/// List available search categories from the SearXNG instance.
|
||||
///
|
||||
/// Fetches the `/config` endpoint and returns the list of categories
|
||||
/// the instance supports (e.g., "general", "images", "news", "videos").
|
||||
/// Returns an error if SearXNG URL is not configured or the request fails.
|
||||
pub async fn list_searxng_categories(&self) -> Result<Vec<String>, String> {
|
||||
if self.config.searxng.url.is_empty() {
|
||||
return Err("SearXNG URL is not configured".to_string());
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SearxngConfig {
|
||||
categories: Vec<String>,
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!(
|
||||
"{}/config",
|
||||
self.config.searxng.url.trim_end_matches('/')
|
||||
))
|
||||
.header("User-Agent", "Mozilla/5.0 (compatible; OpenFangAgent/0.1)")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("SearXNG config request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("SearXNG config API returned {}", resp.status()));
|
||||
}
|
||||
|
||||
let data: SearxngConfig = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("SearXNG config JSON parse failed: {e}"))?;
|
||||
|
||||
Ok(data.categories)
|
||||
}
|
||||
|
||||
/// Return all non-Auto search providers (for provider listing/discovery).
|
||||
pub fn all_providers() -> Vec<SearchProvider> {
|
||||
vec![
|
||||
SearchProvider::Brave,
|
||||
SearchProvider::Tavily,
|
||||
SearchProvider::Perplexity,
|
||||
SearchProvider::DuckDuckGo,
|
||||
SearchProvider::Searxng,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: searxng
|
||||
description: Privacy-respecting metasearch specialist using SearXNG instances
|
||||
---
|
||||
# SearXNG Search Specialist
|
||||
|
||||
You are a privacy-respecting web search specialist using SearXNG, a self-hosted metasearch engine that aggregates results from multiple search engines without tracking.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- Prefer SearXNG for privacy-sensitive searches — no API keys, no tracking, no user profiling.
|
||||
- Always cite sources with URLs so the user can verify information.
|
||||
- Prefer primary sources (official docs, research papers) over secondary ones (blog posts, forums).
|
||||
- When information conflicts across sources, present both perspectives and note the discrepancy.
|
||||
- State the date of information when recency matters.
|
||||
|
||||
## SearXNG Capabilities
|
||||
|
||||
SearXNG supports 30+ search categories. Use the right category for the task:
|
||||
|
||||
| Category | Use Case |
|
||||
|----------|----------|
|
||||
| `general` | Default web search |
|
||||
| `images` | Image search |
|
||||
| `news` | News articles |
|
||||
| `videos` | Video results |
|
||||
| `music` | Music and audio |
|
||||
| `files` | File search |
|
||||
| `it` | IT and programming |
|
||||
| `science` | Scientific content |
|
||||
| `books` | Book search |
|
||||
| `maps` | Map and location |
|
||||
| `q&a` | Q&A sites (Stack Overflow, etc.) |
|
||||
| `social media` | Social media posts |
|
||||
| `wikimedia` | Wikipedia and Wikimedia |
|
||||
| `dictionaries` | Dictionary definitions |
|
||||
| `currency` | Currency conversion |
|
||||
| `weather` | Weather information |
|
||||
| `translate` | Translation results |
|
||||
|
||||
## Search Techniques
|
||||
|
||||
- **Category selection**: Always specify a category when the topic is clear. Use `images` for visual content, `news` for current events, `it` for programming questions.
|
||||
- **Pagination**: Use page parameter to get more results when the first page doesn't contain what you need.
|
||||
- **Engine syntax**: SearXNG supports `!engine` syntax to target specific engines (e.g., `!wikipedia rust programming`).
|
||||
- **Site search**: Use `site:example.com` in queries to search within a specific domain.
|
||||
- **Exact phrases**: Use quotes for exact phrase matching (e.g., `"rust borrow checker"`).
|
||||
- **Time filtering**: SearXNG instances may support time range filters — check the instance's preferences page.
|
||||
|
||||
## Query Formulation
|
||||
|
||||
- Start with specific, targeted queries. Use exact phrases for precise matches.
|
||||
- Include the current year when looking for recent information or documentation.
|
||||
- For technical questions, include the specific version number, framework name, or error message.
|
||||
- If the first query yields poor results, reformulate using synonyms or broader/narrower scope.
|
||||
|
||||
## Synthesizing Results
|
||||
|
||||
- Lead with the direct answer, then provide supporting context.
|
||||
- Organize findings by relevance, not by the order you found them.
|
||||
- Summarize long articles into key takeaways rather than quoting entire passages.
|
||||
- When comparing options, use structured comparisons with pros and cons.
|
||||
- Flag information that may be outdated or from unreliable sources.
|
||||
|
||||
## Pitfalls to Avoid
|
||||
|
||||
- Never present information from a single source as definitive without corroboration.
|
||||
- Do not include URLs you have not verified — broken links erode trust.
|
||||
- Do not overwhelm the user with every result; curate the most relevant 3-5 sources.
|
||||
- Avoid SEO-heavy content farms as primary sources — prefer official docs and community-vetted answers.
|
||||
@@ -13,6 +13,7 @@ pub fn bundled_skills() -> Vec<(&'static str, &'static str)> {
|
||||
("github", include_str!("../bundled/github/SKILL.md")),
|
||||
("docker", include_str!("../bundled/docker/SKILL.md")),
|
||||
("web-search", include_str!("../bundled/web-search/SKILL.md")),
|
||||
("searxng", include_str!("../bundled/searxng/SKILL.md")),
|
||||
(
|
||||
"code-reviewer",
|
||||
include_str!("../bundled/code-reviewer/SKILL.md"),
|
||||
@@ -195,7 +196,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_bundled_skills_count() {
|
||||
let skills = bundled_skills();
|
||||
assert_eq!(skills.len(), 60, "Expected 60 bundled skills");
|
||||
assert_eq!(skills.len(), 61, "Expected 61 bundled skills");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -173,7 +173,9 @@ pub enum SearchProvider {
|
||||
Perplexity,
|
||||
/// DuckDuckGo HTML (no API key needed).
|
||||
DuckDuckGo,
|
||||
/// Auto-select based on available API keys (Tavily → Brave → Perplexity → DuckDuckGo).
|
||||
/// SearXNG self-hosted search (no API key needed).
|
||||
Searxng,
|
||||
/// Auto-select based on available API keys (Tavily → Brave → Perplexity → Searxng → DuckDuckGo).
|
||||
#[default]
|
||||
Auto,
|
||||
}
|
||||
@@ -192,6 +194,8 @@ pub struct WebConfig {
|
||||
pub tavily: TavilySearchConfig,
|
||||
/// Perplexity Search configuration.
|
||||
pub perplexity: PerplexitySearchConfig,
|
||||
/// SearXNG Search configuration.
|
||||
pub searxng: SearxngSearchConfig,
|
||||
/// Web fetch configuration.
|
||||
pub fetch: WebFetchConfig,
|
||||
}
|
||||
@@ -204,6 +208,7 @@ impl Default for WebConfig {
|
||||
brave: BraveSearchConfig::default(),
|
||||
tavily: TavilySearchConfig::default(),
|
||||
perplexity: PerplexitySearchConfig::default(),
|
||||
searxng: SearxngSearchConfig::default(),
|
||||
fetch: WebFetchConfig::default(),
|
||||
}
|
||||
}
|
||||
@@ -281,6 +286,14 @@ impl Default for PerplexitySearchConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// SearXNG self-hosted search configuration.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SearxngSearchConfig {
|
||||
/// Base URL of the SearXNG instance (e.g., "https://search.example.com").
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Web fetch configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -293,6 +306,15 @@ pub struct WebFetchConfig {
|
||||
pub timeout_secs: u64,
|
||||
/// Enable HTML→Markdown readability extraction.
|
||||
pub readability: bool,
|
||||
/// SSRF allowlist for self-hosted environments.
|
||||
///
|
||||
/// Entries can be exact hostnames (`"n8n.local"`), wildcard domains
|
||||
/// (`"*.olares.com"`), or CIDR ranges (`"10.0.0.0/8"`).
|
||||
///
|
||||
/// Allowlisted hosts bypass the private-IP check but **never** bypass
|
||||
/// cloud metadata endpoint blocking (169.254.169.254, metadata.google.internal, etc.).
|
||||
#[serde(default)]
|
||||
pub ssrf_allowed_hosts: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for WebFetchConfig {
|
||||
@@ -302,6 +324,7 @@ impl Default for WebFetchConfig {
|
||||
max_response_bytes: 10 * 1024 * 1024, // 10 MB
|
||||
timeout_secs: 30,
|
||||
readability: true,
|
||||
ssrf_allowed_hosts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1138,7 +1161,7 @@ pub struct AuthConfig {
|
||||
pub enabled: bool,
|
||||
/// Admin username.
|
||||
pub username: String,
|
||||
/// SHA256 hash of the password (hex-encoded).
|
||||
/// Argon2id password hash (PHC string format).
|
||||
/// Generate with: openfang auth hash-password
|
||||
pub password_hash: String,
|
||||
/// Session token lifetime in hours (default: 168 = 7 days).
|
||||
@@ -1769,6 +1792,10 @@ pub struct DiscordConfig {
|
||||
/// Default channel ID for outgoing messages when no recipient is specified.
|
||||
#[serde(default)]
|
||||
pub default_channel_id: Option<String>,
|
||||
/// Channel IDs that respond without requiring @mention (free response mode).
|
||||
/// In these channels, the bot responds to all group messages without needing to be mentioned.
|
||||
#[serde(default, deserialize_with = "deserialize_string_or_int_vec")]
|
||||
pub free_response_channels: Vec<String>,
|
||||
/// Per-channel behavior overrides.
|
||||
#[serde(default)]
|
||||
pub overrides: ChannelOverrides,
|
||||
@@ -1784,6 +1811,7 @@ impl Default for DiscordConfig {
|
||||
intents: 37376,
|
||||
ignore_bots: true,
|
||||
default_channel_id: None,
|
||||
free_response_channels: vec![],
|
||||
overrides: ChannelOverrides::default(),
|
||||
}
|
||||
}
|
||||
@@ -3588,6 +3616,13 @@ impl KernelConfig {
|
||||
));
|
||||
}
|
||||
}
|
||||
SearchProvider::Searxng => {
|
||||
if self.web.searxng.url.is_empty() {
|
||||
warnings.push(
|
||||
"Searxng search selected but searxng.url is not configured".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
SearchProvider::DuckDuckGo | SearchProvider::Auto => {}
|
||||
}
|
||||
|
||||
@@ -3676,6 +3711,26 @@ mod tests {
|
||||
assert!(dc2.ignore_bots);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_config_free_response_channels_deserialization() {
|
||||
// Test with free_response_channels as list of strings
|
||||
let toml_str = r#"
|
||||
bot_token_env = "DISCORD_BOT_TOKEN"
|
||||
free_response_channels = ["123456789", "987654321"]
|
||||
"#;
|
||||
let dc: DiscordConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(dc.free_response_channels.len(), 2);
|
||||
assert_eq!(dc.free_response_channels[0], "123456789");
|
||||
assert_eq!(dc.free_response_channels[1], "987654321");
|
||||
|
||||
// Test default (empty list)
|
||||
let toml_str2 = r#"
|
||||
bot_token_env = "DISCORD_BOT_TOKEN"
|
||||
"#;
|
||||
let dc2: DiscordConfig = toml::from_str(toml_str2).unwrap();
|
||||
assert!(dc2.free_response_channels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slack_config_defaults() {
|
||||
let sl = SlackConfig::default();
|
||||
|
||||
@@ -142,9 +142,10 @@ impl MessageContent {
|
||||
ContentBlock::Text { text, .. } => text.len(),
|
||||
ContentBlock::ToolResult { content, .. } => content.len(),
|
||||
ContentBlock::Thinking { thinking } => thinking.len(),
|
||||
ContentBlock::ToolUse { .. }
|
||||
| ContentBlock::Image { .. }
|
||||
| ContentBlock::Unknown => 0,
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
name.len() + input.to_string().len()
|
||||
}
|
||||
ContentBlock::Image { .. } | ContentBlock::Unknown => 0,
|
||||
})
|
||||
.sum(),
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paa
|
||||
pub const ZAI_BASE_URL: &str = "https://api.z.ai/api/paas/v4";
|
||||
pub const ZAI_CODING_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
|
||||
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
|
||||
/// Kimi K2/K2.5 models live on the `.cn` platform, not `.ai`.
|
||||
pub const MOONSHOT_KIMI_BASE_URL: &str = "https://api.moonshot.cn/v1";
|
||||
pub const KIMI_CODING_BASE_URL: &str = "https://api.kimi.com/coding";
|
||||
pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
|
||||
pub const VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3";
|
||||
|
||||
@@ -169,6 +169,19 @@ fn normalize_schema_recursive(schema: &serde_json::Value) -> serde_json::Value {
|
||||
result.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
// Gemini requires `items` for every array-typed parameter.
|
||||
// JSON Schema allows arrays without `items`, but the Gemini API rejects
|
||||
// such schemas with INVALID_ARGUMENT. Inject a default string items schema
|
||||
// so MCP tools (and any other source) don't break Gemini requests.
|
||||
if result.get("type").and_then(|t| t.as_str()) == Some("array")
|
||||
&& !result.contains_key("items")
|
||||
{
|
||||
result.insert(
|
||||
"items".to_string(),
|
||||
serde_json::json!({"type": "string"}),
|
||||
);
|
||||
}
|
||||
|
||||
serde_json::Value::Object(result)
|
||||
}
|
||||
|
||||
@@ -616,6 +629,38 @@ mod tests {
|
||||
assert!(payload_prop.get("anyOf").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_injects_items_for_array_without_items() {
|
||||
// MCP tools often send array params without `items` — Gemini rejects these.
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fields": { "type": "array", "description": "List of fields" },
|
||||
"filters": { "type": "array" }
|
||||
}
|
||||
});
|
||||
let result = normalize_schema_for_provider(&schema, "gemini");
|
||||
// Both array properties must have `items` injected
|
||||
assert_eq!(result["properties"]["fields"]["items"]["type"], "string");
|
||||
assert_eq!(result["properties"]["filters"]["items"]["type"], "string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_preserves_existing_items() {
|
||||
// If `items` already exists, it must not be overwritten
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
});
|
||||
let result = normalize_schema_for_provider(&schema, "gemini");
|
||||
assert_eq!(result["properties"]["ids"]["items"]["type"], "integer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_combined_issue_488() {
|
||||
// Real-world schema combining multiple #488 issues
|
||||
|
||||
+46
-1
@@ -318,6 +318,37 @@ shared_secret = "my-cluster-secret"
|
||||
|
||||
---
|
||||
|
||||
### `[auth]`
|
||||
|
||||
Configures dashboard login with username/password authentication. Disabled by default.
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
enabled = true
|
||||
username = "admin"
|
||||
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$..." # generate with: openfang auth hash-password
|
||||
session_ttl_hours = 168
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Enable username/password authentication for the dashboard. |
|
||||
| `username` | string | `"admin"` | Admin username. |
|
||||
| `password_hash` | string | `""` (empty) | Argon2id password hash in PHC string format. Generate with `openfang auth hash-password`. |
|
||||
| `session_ttl_hours` | u64 | `168` (7 days) | Session token lifetime in hours. |
|
||||
|
||||
**Generating a password hash:**
|
||||
|
||||
```bash
|
||||
openfang auth hash-password
|
||||
```
|
||||
|
||||
This prompts for a password and outputs an Argon2id PHC string to paste into `config.toml`.
|
||||
|
||||
> **Breaking change (v0.5.0):** Password hashes must be in Argon2id format. Older SHA256 hex hashes from versions prior to v0.5.0 are no longer accepted. Re-run `openfang auth hash-password` to generate a new hash.
|
||||
|
||||
---
|
||||
|
||||
### `[web]`
|
||||
|
||||
Configures web search and web fetch capabilities used by agent tools.
|
||||
@@ -337,10 +368,11 @@ cache_ttl_minutes = 15
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `auto` | Cascading fallback: tries Tavily, then Brave, then Perplexity, then DuckDuckGo, based on which API keys are available. |
|
||||
| `auto` | Cascading fallback: tries Tavily, then Brave, then Perplexity, then SearXNG, then DuckDuckGo, based on which API keys/configs are available. |
|
||||
| `brave` | Brave Search API. Requires `BRAVE_API_KEY`. |
|
||||
| `tavily` | Tavily AI-native search. Requires `TAVILY_API_KEY`. |
|
||||
| `perplexity` | Perplexity AI search. Requires `PERPLEXITY_API_KEY`. |
|
||||
| `searxng` | Self-hosted search engine aggregator. No API key required, just point to your SearXNG instance. |
|
||||
| `duck_duck_go` | DuckDuckGo HTML scraping. No API key needed. |
|
||||
|
||||
#### `[web.brave]`
|
||||
@@ -392,6 +424,19 @@ model = "sonar"
|
||||
| `api_key_env` | string | `"PERPLEXITY_API_KEY"` | Environment variable name holding the Perplexity API key. |
|
||||
| `model` | string | `"sonar"` | Perplexity model to use for search queries. |
|
||||
|
||||
#### `[web.searxng]`
|
||||
|
||||
**SearXNG** — Self-hosted search engine aggregator. No API key required, just point to your SearXNG instance. Supports 30+ search categories (general, images, news, videos, etc.) and pagination.
|
||||
|
||||
```toml
|
||||
[web.searxng]
|
||||
url = "https://searxng.example.com" # SearXNG instance URL (required)
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `url` | string | (required) | Full URL of your SearXNG instance (e.g., `https://searxng.example.com`). Must be accessible. |
|
||||
|
||||
#### `[web.fetch]`
|
||||
|
||||
```toml
|
||||
|
||||
+15
-10
@@ -583,19 +583,24 @@ docker run -d --name openfang \
|
||||
|
||||
### How do I protect the dashboard with a password?
|
||||
|
||||
OpenFang doesn't have built-in login. Use a reverse proxy with basic auth:
|
||||
OpenFang has built-in dashboard authentication. Enable it in `~/.openfang/config.toml`:
|
||||
|
||||
**Caddy example:**
|
||||
```
|
||||
ai.yourdomain.com {
|
||||
basicauth {
|
||||
username $2a$14$YOUR_HASHED_PASSWORD
|
||||
}
|
||||
reverse_proxy localhost:4200
|
||||
}
|
||||
```toml
|
||||
[auth]
|
||||
enabled = true
|
||||
username = "admin"
|
||||
password_hash = "$argon2id$..." # see below
|
||||
```
|
||||
|
||||
Generate a password hash: `caddy hash-password`
|
||||
Generate the password hash:
|
||||
|
||||
```bash
|
||||
openfang auth hash-password
|
||||
```
|
||||
|
||||
Paste the output into the `password_hash` field and restart the daemon.
|
||||
|
||||
For public-facing deployments, you should also place a reverse proxy (Caddy, nginx) in front for TLS termination.
|
||||
|
||||
### How do I configure the embedding model for memory?
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
rust-project.defaults.perCrate.crane.args.buildInputs = with pkgs; [
|
||||
clang
|
||||
openssl
|
||||
perl
|
||||
pkg-config
|
||||
];
|
||||
rust-project.crates.openfang-desktop.crane.args.buildInputs = with pkgs; [
|
||||
|
||||
+12
-2
@@ -46,13 +46,23 @@ install() {
|
||||
echo " =================="
|
||||
echo ""
|
||||
|
||||
# Get latest version
|
||||
# Get latest version with binary assets
|
||||
if [ -n "${OPENFANG_VERSION:-}" ]; then
|
||||
VERSION="$OPENFANG_VERSION"
|
||||
echo " Using specified version: $VERSION"
|
||||
else
|
||||
echo " Fetching latest release..."
|
||||
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed 's/.*"tag_name": *"//' | sed 's/".*//')
|
||||
# Find the most recent release that has binary assets (skip empty tag-only releases)
|
||||
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=10" | \
|
||||
grep -E '"tag_name"|"assets":\[' | \
|
||||
paste - - | \
|
||||
grep -v '"assets":\[\]' | \
|
||||
head -1 | \
|
||||
sed 's/.*"tag_name": *"//' | sed 's/".*//')
|
||||
# Fallback to /releases/latest if the above fails
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed 's/.*"tag_name": *"//' | sed 's/".*//')
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
|
||||
Reference in New Issue
Block a user