mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aa1508f54 | ||
|
|
5447bf7f1d | ||
|
|
b77ebfb897 | ||
|
|
2323cd5e67 | ||
|
|
df29e5e8b9 | ||
|
|
8b411c21c4 | ||
|
|
b00af5eddd | ||
|
|
36177425c4 | ||
|
|
6bed6c04ff | ||
|
|
4c496be02f | ||
|
|
7f7b071528 | ||
|
|
2d1fb8171c | ||
|
|
e683acc565 | ||
|
|
5396889ff1 | ||
|
|
c9e8d31571 | ||
|
|
d8ad91572e | ||
|
|
c27bfebd17 | ||
|
|
f05ba5e42f | ||
|
|
d9e72abb4b | ||
|
|
505a8e8080 | ||
|
|
5cc865e6e6 | ||
|
|
6a1ce40d86 | ||
|
|
fbb7936234 | ||
|
|
569e76c79a | ||
|
|
88ad029999 | ||
|
|
838836b29c | ||
|
|
8564872181 | ||
|
|
efbefa1682 | ||
|
|
bdcd440cd6 | ||
|
|
25516c7f87 | ||
|
|
ae2706bdab | ||
|
|
32299bb506 | ||
|
|
6f8463fc91 | ||
|
|
6b03cb2e9d | ||
|
|
8cb7541678 | ||
|
|
6b5b7674d3 | ||
|
|
247dca508b | ||
|
|
90d16e52be | ||
|
|
68bde60fac | ||
|
|
a422058049 | ||
|
|
6ba0bfb7ef | ||
|
|
7699b86037 | ||
|
|
e31216d5ec | ||
|
|
538e943d3d | ||
|
|
94fca22124 | ||
|
|
37e2043ed7 | ||
|
|
31eb833cdf | ||
|
|
15da248faf | ||
|
|
c27a6f3609 | ||
|
|
f792f1a14b | ||
|
|
5e228336e4 | ||
|
|
8b10930e40 | ||
|
|
5c1b1508a2 | ||
|
|
701fcd8e2e | ||
|
|
118eacea64 | ||
|
|
aaad1fdf32 | ||
|
|
dd8c53026e | ||
|
|
218f2dba1f | ||
|
|
24aca4e31d | ||
|
|
3cce1eb3fb | ||
|
|
c89958b66d | ||
|
|
b0a92456bf | ||
|
|
67bbcc623d | ||
|
|
a91bfc0e9c | ||
|
|
948117d5de | ||
|
|
2dedab2a8b | ||
|
|
8642c4d442 | ||
|
|
46a6eb33d9 | ||
|
|
99b4ce2931 | ||
|
|
87932f5da0 | ||
|
|
9130811433 | ||
|
|
325734c6aa | ||
|
|
faf2cf9211 | ||
|
|
15ed29c667 | ||
|
|
1d1bf0fb09 | ||
|
|
d3363142b2 | ||
|
|
76929a41aa | ||
|
|
fe34a37e6f | ||
|
|
4b63eb18cc | ||
|
|
fe21d4b4df | ||
|
|
f52bc53e47 | ||
|
|
10f7ee1885 | ||
|
|
7bc6591338 | ||
|
|
53f2066945 | ||
|
|
c69dd84184 | ||
|
|
c1356fc95d | ||
|
|
aabf83b351 | ||
|
|
da6b567ac3 | ||
|
|
ccdd7943a2 | ||
|
|
7fe87babe6 | ||
|
|
9c0e1637a5 | ||
|
|
fd450dbfc2 | ||
|
|
81176dc626 | ||
|
|
ef9096f7c5 | ||
|
|
fbb5bb1ae9 | ||
|
|
c435a6adcd | ||
|
|
f67c4e8754 | ||
|
|
3b237ac526 | ||
|
|
96c572df32 | ||
|
|
17e0d519ca | ||
|
|
37c233d489 | ||
|
|
92f7e996de | ||
|
|
b1c4061247 | ||
|
|
79aa34c77a | ||
|
|
4ae2961b1c | ||
|
|
6a90aa08df | ||
|
|
356500bb1e | ||
|
|
40bd7e2c11 | ||
|
|
a7197d7b97 | ||
|
|
bc26d5e8c3 | ||
|
|
84d90ad342 | ||
|
|
9fee63d58c | ||
|
|
40903cceee | ||
|
|
5a86141677 | ||
|
|
e97eb6fff3 | ||
|
|
93b57bdd52 | ||
|
|
0227ff1790 | ||
|
|
525d7d844a | ||
|
|
f2587995a2 | ||
|
|
e6bab993ae | ||
|
|
a39a675ba9 | ||
|
|
0ce390e09f | ||
|
|
88eeaa6a4d | ||
|
|
3db5d3a825 | ||
|
|
5a1f372612 | ||
|
|
a9f15b2d23 | ||
|
|
45e7ea7948 | ||
|
|
de8a692036 | ||
|
|
8d3d77dd99 | ||
|
|
d3d9fa842d | ||
|
|
ff44cfbe87 | ||
|
|
ce89d05987 | ||
|
|
00c0ff60de | ||
|
|
07af248a07 | ||
|
|
6ab07d155e | ||
|
|
e2b0a54720 | ||
|
|
6f519b9122 | ||
|
|
983519c8e8 | ||
|
|
c9701627a9 | ||
|
|
643a22b295 | ||
|
|
890ab8c177 | ||
|
|
1b9a68dc0b | ||
|
|
2b6286e469 | ||
|
|
589f32c8e5 | ||
|
|
528a7b9ff7 | ||
|
|
6851bbab09 | ||
|
|
9323edccf4 | ||
|
|
0bccba10cf | ||
|
|
2daaf92ad7 | ||
|
|
80359b4487 | ||
|
|
b866bb6b21 | ||
|
|
3fa8c6c527 | ||
|
|
e322afbebd | ||
|
|
a4c6c038a0 | ||
|
|
864e957261 | ||
|
|
f9921780e2 | ||
|
|
1c049d06c1 | ||
|
|
6f86f7a857 | ||
|
|
c47e15c1ce | ||
|
|
185ebc429f | ||
|
|
87626ae1c9 | ||
|
|
4a8af88fe8 | ||
|
|
2ef5704132 | ||
|
|
747314b19b | ||
|
|
3787c13fb1 | ||
|
|
8136281e68 | ||
|
|
779389e68d | ||
|
|
4e3569778e | ||
|
|
6b19bac049 | ||
|
|
2671791742 | ||
|
|
1ca86c4284 | ||
|
|
a603dc9d96 | ||
|
|
8a2197126c | ||
|
|
c743a72481 | ||
|
|
605ce747ec | ||
|
|
34a27de85e | ||
|
|
3e7d57b095 | ||
|
|
e988572c82 | ||
|
|
2d94963627 | ||
|
|
d94508e72e | ||
|
|
af51f6c971 | ||
|
|
e1790b5380 | ||
|
|
be1c4dba47 | ||
|
|
51a5f7983c | ||
|
|
760f35f9de | ||
|
|
64b1ec5a7e | ||
|
|
dc6119d2cc | ||
|
|
a8b4d3b48d | ||
|
|
36ba675f02 | ||
|
|
546816f692 | ||
|
|
80af18a174 | ||
|
|
abeaaf5446 | ||
|
|
3854e3eb89 | ||
|
|
73d50c0284 | ||
|
|
6403871aa1 | ||
|
|
df22a3db64 | ||
|
|
c1a14e884e | ||
|
|
77bef773e5 | ||
|
|
a26f762635 | ||
|
|
62b6aa4eb8 | ||
|
|
c000392093 | ||
|
|
ae32af1b06 | ||
|
|
d3972b23c0 | ||
|
|
ded95f3180 | ||
|
|
09ec6f5549 | ||
|
|
47c743f17c | ||
|
|
489fc1312c | ||
|
|
0408d65d8f | ||
|
|
8d14d0c225 | ||
|
|
07963779be | ||
|
|
28d01acf91 | ||
|
|
be149597e6 | ||
|
|
3b21494867 | ||
|
|
4565988e2e | ||
|
|
4714efb9e4 | ||
|
|
365bec868c | ||
|
|
a78299ed3d | ||
|
|
eebb83c79a | ||
|
|
0b59205b0c | ||
|
|
3f8ceabc51 | ||
|
|
4921ee5ece | ||
|
|
0c4769a07f | ||
|
|
167b37f10e | ||
|
|
545e710abb | ||
|
|
e421594185 | ||
|
|
2fe926c3b9 | ||
|
|
618e83714c | ||
|
|
8b925d8a04 | ||
|
|
449a29418d | ||
|
|
46eac44635 | ||
|
|
9372cc6ff2 | ||
|
|
9cf37eab22 | ||
|
|
79ca1cda32 | ||
|
|
50c51dd6b7 | ||
|
|
d75a56a0f6 | ||
|
|
ce3344a994 | ||
|
|
656e2734ce | ||
|
|
3c221dc3ca | ||
|
|
cfda9b9bfc | ||
|
|
a428b1cd66 | ||
|
|
51d358f9d9 | ||
|
|
1c61b869c0 | ||
|
|
fc902a9ceb | ||
|
|
a3cefa424c | ||
|
|
06d0479419 | ||
|
|
613a7d4a3b | ||
|
|
6ed6d3ac3b | ||
|
|
9f72d921c3 | ||
|
|
4b5aba28cf | ||
|
|
bbed72b491 | ||
|
|
55395c80db | ||
|
|
946363e919 | ||
|
|
64631a31e6 | ||
|
|
cf38b49e4d | ||
|
|
8ba84ada9d | ||
|
|
9fef6d6c91 | ||
|
|
f98bc330d4 | ||
|
|
86694dd926 | ||
|
|
f8da17719e | ||
|
|
a72e6087d8 | ||
|
|
3a64e322ad | ||
|
|
9e2853a5f8 | ||
|
|
feecb60442 | ||
|
|
e53e238e81 | ||
|
|
991aea85ee | ||
|
|
bfbf0bb892 | ||
|
|
b6cb4cc2d9 | ||
|
|
827481633c | ||
|
|
ad780b9cb4 | ||
|
|
4582ed16b0 | ||
|
|
f56505258d | ||
|
|
ddd1536bcb | ||
|
|
9fa5234061 | ||
|
|
e21efa61ef | ||
|
|
3f72c5d918 | ||
|
|
c286b88d54 | ||
|
|
22c08c2325 | ||
|
|
f6493e8843 | ||
|
|
1d2bfff8ea | ||
|
|
b967852891 | ||
|
|
d95d9583b0 | ||
|
|
8c0cce3ac5 | ||
|
|
f036bd54e3 | ||
|
|
77da90f3f8 | ||
|
|
b0b6f84492 | ||
|
|
0da8e32a51 | ||
|
|
7410faa96d | ||
|
|
e880dfa3e7 | ||
|
|
7791b3f170 | ||
|
|
e58039c83e | ||
|
|
9b0a7d2f61 | ||
|
|
86fe4929e9 | ||
|
|
9993718d9c | ||
|
|
0bf2f61ab1 | ||
|
|
51eff0d75f | ||
|
|
a30cce129e | ||
|
|
54885d8a1c | ||
|
|
617b4f81d8 | ||
|
|
a0f829383c | ||
|
|
6083c24484 | ||
|
|
1964545f35 | ||
|
|
fc7e971d7e | ||
|
|
86309c8e40 | ||
|
|
282ad3a960 | ||
|
|
d6f857eee2 | ||
|
|
751b420b39 | ||
|
|
5b2be80399 | ||
|
|
6ae8dd4cfd | ||
|
|
25a66df41b | ||
|
|
5212730773 | ||
|
|
9b7496947b | ||
|
|
895e94ccac | ||
|
|
da9d8a84f1 | ||
|
|
e7b9143423 | ||
|
|
604e4ea7e3 | ||
|
|
c926372d81 | ||
|
|
da12f47369 | ||
|
|
b7c81965a1 | ||
|
|
f65dc775eb | ||
|
|
c59041a09d | ||
|
|
715f37effc | ||
|
|
570e1941b2 | ||
|
|
4c700c8d2d | ||
|
|
5ae554ed51 | ||
|
|
62b697c90e | ||
|
|
4b3b602457 | ||
|
|
d7bd5c6636 | ||
|
|
66e6eb2509 | ||
|
|
1365fc9635 | ||
|
|
78669863b7 | ||
|
|
316bbe11c3 | ||
|
|
ff00499e8e | ||
|
|
1a5ae4e3ce | ||
|
|
bf9066a602 | ||
|
|
acf51e02a8 | ||
|
|
1c9d53df11 | ||
|
|
b298c4c273 | ||
|
|
f407a41a98 | ||
|
|
173c843107 | ||
|
|
b3787e07ea | ||
|
|
935c8cad88 | ||
|
|
d95270da5a | ||
|
|
7a2211d0f4 | ||
|
|
e14885fa80 | ||
|
|
ccbaf90a24 | ||
|
|
f66c2525cb | ||
|
|
842d932ae6 | ||
|
|
37d1c822f2 | ||
|
|
865fd28704 | ||
|
|
43a92a764f | ||
|
|
17f783073e | ||
|
|
db86ff4ce3 | ||
|
|
ee042769e2 | ||
|
|
41ffb8537a | ||
|
|
80658c94e3 | ||
|
|
c35301e155 | ||
|
|
14f0421e7b | ||
|
|
3f772b5b27 | ||
|
|
a12547081a | ||
|
|
63f4befe80 | ||
|
|
0f25386e2e | ||
|
|
7f752dde99 | ||
|
|
93ef98a429 | ||
|
|
b71bd801fb | ||
|
|
9badeb243e | ||
|
|
9a683ec511 | ||
|
|
eaa89defd1 | ||
|
|
d245059a01 | ||
|
|
c30bf3e557 | ||
|
|
dd95f24980 | ||
|
|
1cf36241e4 | ||
|
|
2ab31f3d3e | ||
|
|
2915cb2113 | ||
|
|
7b1057df0c | ||
|
|
0b99ac4071 | ||
|
|
19260945bd | ||
|
|
93ea832394 | ||
|
|
38d42c4d9b | ||
|
|
2d02ba22fb | ||
|
|
91d8734198 | ||
|
|
b676b2975a | ||
|
|
44f37711cb | ||
|
|
cea4c3f452 | ||
|
|
12ab5f1a93 | ||
|
|
a5bc9f916a | ||
|
|
ad472d657e | ||
|
|
b4383b1626 | ||
|
|
ea287093c4 | ||
|
|
3688d86ef8 | ||
|
|
9f9903797e | ||
|
|
3cd8847a95 | ||
|
|
88bb55c8f2 | ||
|
|
935f3fac8e | ||
|
|
ad90d417cc | ||
|
|
4eae7502d2 | ||
|
|
d6326d8967 | ||
|
|
8ec3766da3 | ||
|
|
90fc171e26 | ||
|
|
972a52ff9c | ||
|
|
6b78838416 | ||
|
|
13051b2f06 | ||
|
|
aed4bf62ae | ||
|
|
a039e395f5 | ||
|
|
67b30c1549 | ||
|
|
40bdf4316b | ||
|
|
e3c05a9d47 | ||
|
|
6ab77612f5 | ||
|
|
a3073007a1 | ||
|
|
a95fb4a96b | ||
|
|
f1ca52714d | ||
|
|
77ed954d18 | ||
|
|
4fa2f9474b | ||
|
|
75c9c80679 | ||
|
|
3eaa9e02c9 | ||
|
|
f165263296 | ||
|
|
c122e1ddd7 | ||
|
|
d2ea030f03 | ||
|
|
eb87e3fd42 | ||
|
|
6d742e9081 | ||
|
|
317b947608 | ||
|
|
1cb8b989d1 | ||
|
|
bde1f5414c | ||
|
|
d15207fa51 | ||
|
|
e808ca0d08 | ||
|
|
c554adae0d | ||
|
|
590121d5f3 | ||
|
|
58a7a07942 | ||
|
|
72b87dfaa9 | ||
|
|
b8fb6987e0 | ||
|
|
dec081a326 | ||
|
|
52647b2996 | ||
|
|
905cfd2c21 | ||
|
|
7ad7489860 | ||
|
|
77c4c9add8 | ||
|
|
07019f764e | ||
|
|
80eed53305 | ||
|
|
14c4c1d1f5 | ||
|
|
fa7dd277e6 | ||
|
|
0e589e3f9a | ||
|
|
59703d50d6 | ||
|
|
5413269943 | ||
|
|
c5582ceb1e | ||
|
|
36dc62745c | ||
|
|
cb6e6909d4 | ||
|
|
b3be3f4940 | ||
|
|
7505007d8c | ||
|
|
fdd6c1a1f7 | ||
|
|
135c37fbf7 | ||
|
|
a7a96a7b0f | ||
|
|
52bacf0946 | ||
|
|
d55e1b8545 | ||
|
|
0c059d1dc1 | ||
|
|
b6b8b4ebe1 | ||
|
|
14f4845170 | ||
|
|
be8a589986 | ||
|
|
951e8d0feb | ||
|
|
98f8d1ca79 | ||
|
|
24f5717ae9 | ||
|
|
f10eefdc0e | ||
|
|
edd0fed518 | ||
|
|
86b50070e8 | ||
|
|
c6aab08faa | ||
|
|
62ec09d0ae | ||
|
|
f6f9cf7e9f | ||
|
|
56aeb499c9 | ||
|
|
b4e6a693f5 | ||
|
|
48d5418c91 | ||
|
|
cc93ef4571 | ||
|
|
3e069798f9 | ||
|
|
ad10aa5e80 | ||
|
|
385aee8e56 | ||
|
|
a00327abe9 | ||
|
|
487555a5e5 | ||
|
|
9d51426cb4 | ||
|
|
6fab720843 | ||
|
|
4667f497ef | ||
|
|
eba9198827 | ||
|
|
f2413949bc | ||
|
|
9e230f423e | ||
|
|
8138b7e0e8 | ||
|
|
cfae867908 | ||
|
|
6857e3cf06 | ||
|
|
772cbdbe38 | ||
|
|
b2e2b1a038 | ||
|
|
d237ecf161 | ||
|
|
4a3d570155 | ||
|
|
ebcdc17c13 |
@@ -0,0 +1,34 @@
|
||||
# Ignored advisories — all are transitive dependencies we cannot upgrade directly.
|
||||
#
|
||||
# time 0.3.45: pinned by mac-notification-sys (tauri dependency), awaiting upstream fix
|
||||
# GTK3/glib/pango/etc: tauri uses gtk-rs GTK3 bindings which are unmaintained
|
||||
# paste, proc-macro-error, fxhash: unmaintained transitive deps
|
||||
# lexical-core: unmaintained, pulled by tauri dep chain
|
||||
# serde_cbor: unmaintained, pulled by tao (tauri)
|
||||
# cocoa/cocoa-foundation: unmaintained, pulled by tauri/tao
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
"RUSTSEC-2026-0009", # time DoS — pinned by mac-notification-sys
|
||||
"RUSTSEC-2024-0370", # proc-macro-error unmaintained
|
||||
"RUSTSEC-2024-0411", # gtk-rs GTK3 unmaintained (gdk-pixbuf)
|
||||
"RUSTSEC-2024-0412", # gtk-rs GTK3 unmaintained (gdk)
|
||||
"RUSTSEC-2024-0413", # gtk-rs GTK3 unmaintained (atk)
|
||||
"RUSTSEC-2024-0414", # gtk-rs GTK3 unmaintained (pango)
|
||||
"RUSTSEC-2024-0415", # gtk-rs GTK3 unmaintained (gio)
|
||||
"RUSTSEC-2024-0416", # gtk-rs GTK3 unmaintained (atk-sys)
|
||||
"RUSTSEC-2024-0417", # gtk-rs GTK3 unmaintained (gdk-pixbuf-sys)
|
||||
"RUSTSEC-2024-0418", # gtk-rs GTK3 unmaintained (gdk-sys)
|
||||
"RUSTSEC-2024-0419", # gtk-rs GTK3 unmaintained (gtk3-macros)
|
||||
"RUSTSEC-2024-0420", # gtk-rs GTK3 unmaintained (pango-sys)
|
||||
"RUSTSEC-2024-0429", # gtk-rs GTK3 unmaintained (gtk-sys)
|
||||
"RUSTSEC-2024-0436", # paste unmaintained
|
||||
"RUSTSEC-2025-0057", # fxhash unmaintained
|
||||
"RUSTSEC-2025-0075", # glib unmaintained
|
||||
"RUSTSEC-2025-0080", # cocoa unmaintained
|
||||
"RUSTSEC-2025-0081", # cocoa-foundation unmaintained
|
||||
"RUSTSEC-2025-0098", # lexical-core unmaintained
|
||||
"RUSTSEC-2025-0100", # gio-sys unmaintained
|
||||
"RUSTSEC-2026-0002", # serde_cbor unmaintained
|
||||
"RUSTSEC-2023-0086", # lexopt unmaintained (if present)
|
||||
]
|
||||
@@ -32,6 +32,9 @@
|
||||
# Fireworks AI
|
||||
# FIREWORKS_API_KEY=...
|
||||
|
||||
# Novita AI (multi-model gateway)
|
||||
# NOVITA_API_KEY=...
|
||||
|
||||
# ─── Local LLM Providers (no API key needed) ─────────────────────────
|
||||
|
||||
# Ollama (default: http://localhost:11434)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What happened?
|
||||
placeholder: Describe the bug clearly and concisely.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this?
|
||||
placeholder: |
|
||||
1. Run `openfang start`
|
||||
2. Open dashboard at http://localhost:4200
|
||||
3. Click ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OpenFang Version
|
||||
description: Output of `openfang -V`
|
||||
placeholder: "0.3.23"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- Linux (x86_64)
|
||||
- Linux (aarch64/ARM64)
|
||||
- macOS (Apple Silicon)
|
||||
- macOS (Intel)
|
||||
- Windows
|
||||
- Android (Termux)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Screenshots
|
||||
description: Paste relevant logs or attach screenshots.
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What feature would you like?
|
||||
placeholder: Describe the feature and why it would be useful.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you tried any workarounds?
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context, screenshots, or references.
|
||||
@@ -0,0 +1,17 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "ci"
|
||||
@@ -0,0 +1,19 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? Link related issues with "Fixes #123". -->
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- Brief list of what changed. -->
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes
|
||||
- [ ] `cargo test --workspace` passes
|
||||
- [ ] Live integration tested (if applicable)
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] No new unsafe code
|
||||
- [ ] No secrets or API keys in diff
|
||||
- [ ] User input validated at boundaries
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
@@ -87,17 +87,19 @@ jobs:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- run: cargo fmt --check
|
||||
# Gate every workspace crate on rustfmt to keep `cargo fmt --all --check` clean.
|
||||
# See issue #1121.
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
audit:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install cargo-audit
|
||||
@@ -109,7 +111,7 @@ jobs:
|
||||
name: Secrets Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install trufflehog
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install system deps (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
@@ -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
|
||||
@@ -162,25 +165,30 @@ jobs:
|
||||
archive: zip
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- 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'
|
||||
run: |
|
||||
xattr -cr target/${{ matrix.target }}/release/openfang || true
|
||||
codesign --force --sign - target/${{ matrix.target }}/release/openfang
|
||||
- name: Package (Unix)
|
||||
if: matrix.archive == 'tar.gz'
|
||||
run: |
|
||||
@@ -196,7 +204,7 @@ jobs:
|
||||
$hash = (Get-FileHash "openfang-${{ matrix.target }}.zip" -Algorithm SHA256).Hash.ToLower()
|
||||
"$hash openfang-${{ matrix.target }}.zip" | Out-File -Encoding ASCII "openfang-${{ matrix.target }}.zip.sha256"
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: openfang-${{ matrix.target }}.*
|
||||
env:
|
||||
@@ -207,22 +215,22 @@ jobs:
|
||||
name: Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU (for arm64 emulation)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -230,5 +238,17 @@ jobs:
|
||||
tags: |
|
||||
ghcr.io/rightnow-ai/openfang:latest
|
||||
ghcr.io/rightnow-ai/openfang:${{ steps.version.outputs.version }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/RightNow-AI/openfang
|
||||
org.opencontainers.image.licenses=MIT
|
||||
org.opencontainers.image.description=OpenFang Agent OS — single-binary Rust agent framework
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Set GHCR package visibility to public
|
||||
run: |
|
||||
curl -fsSL -X PATCH \
|
||||
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/orgs/RightNow-AI/packages/container/openfang \
|
||||
-d '{"visibility":"public"}'
|
||||
|
||||
@@ -34,6 +34,7 @@ BUILD_LOG.md
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
._*
|
||||
Thumbs.db
|
||||
|
||||
# IDE & tools
|
||||
@@ -43,3 +44,7 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.serena/
|
||||
|
||||
# Personal deploy scripts
|
||||
scripts/deploy-remote.sh
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
## Health Stack
|
||||
|
||||
- typecheck: cargo build --workspace --lib
|
||||
- lint: cargo clippy --workspace --all-targets -- -D warnings
|
||||
- test: cargo test --workspace
|
||||
- shell: shellcheck scripts/install.sh
|
||||
@@ -5,6 +5,37 @@ All notable changes to OpenFang will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.10] - 2026-04-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Non-loopback requests with no `api_key` configured now return 401 by default. Opt out with `OPENFANG_ALLOW_NO_AUTH=1`. Fixes the B1/B2 authentication bypass from #1034.
|
||||
- Agent `context.md` is re-read on every turn so external updates take effect mid-session. Opt out per agent with `cache_context = true` on the manifest. Fixes #843.
|
||||
- `openfang config get default_model.base_url` now prints the configured URL instead of an empty string. Missing keys return a clear "not found" error. Fixes #905.
|
||||
- `schedule_create`, `schedule_list`, and `schedule_delete` tools plus the `/api/schedules` routes now use the kernel cron scheduler, so scheduled jobs actually fire. One-shot idempotent migration imports legacy shared-memory entries at startup. Fixes #1069.
|
||||
- Multimodal user messages now combine text and image blocks into a single message so the LLM sees both. Fixes #1043.
|
||||
|
||||
### Added
|
||||
|
||||
- `openfang hand config <id>` subcommand: get, set, unset, and list settings on an active hand instance. Fixes #809.
|
||||
- Optional per-channel `prefix_agent_name` setting (`off` / `bracket` / `bold_bracket`). Wraps outbound agent responses so users in multi-agent channels can see which agent replied. Default is off, byte-identical to prior behavior. Fixes #980.
|
||||
|
||||
### Closed as invalid
|
||||
|
||||
- #818 and #819. Both reference a knowledge-domain API that does not exist on `main`. Filed against an unmerged feature branch (`plan/013-audit-remediation`). Close with a note to build the proposed validation and stale-timestamp surfacing into that feature when it lands.
|
||||
|
||||
## [0.5.9] - 2026-04-10
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING:** Dashboard password hashing switched from SHA256 to Argon2id. Existing `password_hash` values in `config.toml` must be regenerated with `openfang auth hash-password`. Only affects users with `[auth] enabled = true`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Dashboard passwords were hashed with plain SHA256 (no salt), making them vulnerable to rainbow table and GPU-accelerated brute force attacks. Now uses Argon2id with random salts.
|
||||
|
||||
## [0.1.0] - 2026-02-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -56,6 +56,16 @@ Tests that require a real LLM key will skip gracefully if the env var is absent.
|
||||
cargo build --workspace
|
||||
```
|
||||
|
||||
### Fast Release Build (for development)
|
||||
|
||||
The default `--release` profile uses full LTO and single-codegen-unit, which produces the smallest/fastest binary but is slow to compile. For iterating locally, use the `release-fast` profile instead:
|
||||
|
||||
```bash
|
||||
cargo build --profile release-fast -p openfang-cli
|
||||
```
|
||||
|
||||
This cuts link time significantly (thin LTO, 8 codegen units, `opt-level=2`) while still producing a binary fast enough to run integration tests against. Use `--release` only for final binaries or CI.
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+1059
-634
File diff suppressed because it is too large
Load Diff
+35
-10
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.23"
|
||||
version = "0.6.8"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -32,7 +32,7 @@ tokio-stream = "0.1"
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
toml = "0.9"
|
||||
rmp-serde = "1"
|
||||
|
||||
# Error handling
|
||||
@@ -49,9 +49,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
|
||||
# IDs
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||
|
||||
# Database
|
||||
rusqlite = { version = "0.31", features = ["bundled", "serde_json"] }
|
||||
@@ -61,7 +62,8 @@ clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
|
||||
# HTTP client (for LLM drivers)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
|
||||
# Async trait
|
||||
async-trait = "0.1"
|
||||
@@ -74,16 +76,17 @@ bytes = "1"
|
||||
|
||||
# Futures
|
||||
futures = "0.3"
|
||||
prost = "0.14"
|
||||
|
||||
# WebSocket client (for Discord/Slack gateway)
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
url = "2"
|
||||
|
||||
# WASM sandbox
|
||||
wasmtime = "41"
|
||||
wasmtime = "43"
|
||||
|
||||
# HTTP server (for API daemon)
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "compression-br"] }
|
||||
|
||||
@@ -101,6 +104,9 @@ walkdir = "2"
|
||||
|
||||
# Security
|
||||
sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
subtle = "2"
|
||||
@@ -109,7 +115,7 @@ rand = "0.8"
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
# Rate limiting
|
||||
governor = "0.8"
|
||||
governor = "0.10"
|
||||
|
||||
# Interactive CLI
|
||||
ratatui = "0.29"
|
||||
@@ -119,20 +125,32 @@ colored = "3"
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
|
||||
# HTML entity decoding
|
||||
html-escape = "0.2"
|
||||
|
||||
# Lightweight regex
|
||||
regex-lite = "0.1"
|
||||
|
||||
# MCP SDK (official Rust implementation)
|
||||
rmcp = { version = "1.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "reqwest"] }
|
||||
|
||||
# Socket options (SO_REUSEADDR)
|
||||
socket2 = "0.5"
|
||||
|
||||
# Zip archive extraction
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "4", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Email (SMTP + IMAP)
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
|
||||
imap = "2"
|
||||
native-tls = "0.2"
|
||||
mailparse = "0.15"
|
||||
native-tls = { version = "0.2", features = ["vendored"] }
|
||||
mailparse = "0.16"
|
||||
|
||||
# MQTT client
|
||||
rumqttc = { version = "0.25", default-features = false, features = ["use-native-tls"] }
|
||||
|
||||
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
# Testing
|
||||
tokio-test = "0.4"
|
||||
@@ -143,3 +161,10 @@ lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
opt-level = 3
|
||||
|
||||
[profile.release-fast]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
codegen-units = 8
|
||||
opt-level = 2
|
||||
strip = false
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
+17
-3
@@ -1,16 +1,30 @@
|
||||
# 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
|
||||
COPY agents ./agents
|
||||
COPY packages ./packages
|
||||
# Optional build args for dev environments to speed up compilation
|
||||
# Example: docker build --build-arg LTO=false --build-arg CODEGEN_UNITS=16 .
|
||||
ARG LTO=true
|
||||
ARG CODEGEN_UNITS=1
|
||||
ENV CARGO_PROFILE_RELEASE_LTO=${LTO} \
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=${CODEGEN_UNITS}
|
||||
RUN cargo build --release --bin openfang
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
FROM rust:1-slim-bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /build/target/release/openfang /usr/local/bin/
|
||||
COPY --from=builder /build/agents /opt/openfang/agents
|
||||
EXPOSE 4200
|
||||
|
||||
@@ -19,25 +19,25 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
|
||||
<img src="https://img.shields.io/badge/version-0.1.0-green?style=flat-square" alt="v0.1.0" />
|
||||
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/version-0.6.8-green?style=flat-square" alt="v0.6.8" />
|
||||
<img src="https://img.shields.io/badge/tests-2,669%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> **v0.1.0 — First Release (February 2026)**
|
||||
> **v0.5.10 (April 2026)**
|
||||
>
|
||||
> OpenFang is feature-complete but this is the first public release. You may encounter instability, rough edges, or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
> OpenFang is feature complete but still pre-1.0. Expect rough edges and breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
|
||||
---
|
||||
|
||||
## What is OpenFang?
|
||||
|
||||
OpenFang is an **open-source Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust.
|
||||
OpenFang is an **open-source Agent Operating System**. Not a chatbot framework. Not a Python wrapper around an LLM. Not a "multi-agent orchestrator." A full operating system for autonomous agents, built from scratch in Rust.
|
||||
|
||||
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you** — on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
|
||||
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you**: on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
|
||||
|
||||
The entire system compiles to a **single ~32MB binary**. One install, one command, your agents are live.
|
||||
|
||||
@@ -65,13 +65,13 @@ openfang start
|
||||
|
||||
<p align="center"><em>"Traditional agents wait for you to type. Hands work <strong>for</strong> you."</em></p>
|
||||
|
||||
**Hands** are OpenFang's core innovation — pre-built autonomous capability packages that run independently, on schedules, without you having to prompt them. This is not a chatbot. This is an agent that wakes up at 6 AM, researches your competitors, builds a knowledge graph, scores the findings, and delivers a report to your Telegram before you've had coffee.
|
||||
**Hands** are OpenFang's core innovation. Pre-built autonomous capability packages that run independently, on schedules, without you having to prompt them. This is not a chatbot. This is an agent that wakes up at 6 AM, researches your competitors, builds a knowledge graph, scores the findings, and delivers a report to your Telegram before you've had coffee.
|
||||
|
||||
Each Hand bundles:
|
||||
- **HAND.toml** — Manifest declaring tools, settings, requirements, and dashboard metrics
|
||||
- **System Prompt** — Multi-phase operational playbook (not a one-liner — these are 500+ word expert procedures)
|
||||
- **SKILL.md** — Domain expertise reference injected into context at runtime
|
||||
- **Guardrails** — Approval gates for sensitive actions (e.g. Browser Hand requires approval before any purchase)
|
||||
- **HAND.toml**: manifest declaring tools, settings, requirements, and dashboard metrics.
|
||||
- **System Prompt**: multi-phase operational playbook. Not a one-liner. These are 500+ word expert procedures.
|
||||
- **SKILL.md**: domain expertise reference injected into context at runtime.
|
||||
- **Guardrails**: approval gates for sensitive actions (e.g. Browser Hand requires approval before any purchase).
|
||||
|
||||
All compiled into the binary. No downloading, no pip install, no Docker pull.
|
||||
|
||||
@@ -81,14 +81,14 @@ All compiled into the binary. No downloading, no pip install, no Docker pull.
|
||||
|------|----------------------|
|
||||
| **Clip** | Takes a YouTube URL, downloads it, identifies the best moments, cuts them into vertical shorts with captions and thumbnails, optionally adds AI voice-over, and publishes to Telegram and WhatsApp. 8-phase pipeline. FFmpeg + yt-dlp + 5 STT backends. |
|
||||
| **Lead** | Runs daily. Discovers prospects matching your ICP, enriches them with web research, scores 0-100, deduplicates against your existing database, and delivers qualified leads in CSV/JSON/Markdown. Builds ICP profiles over time. |
|
||||
| **Collector** | OSINT-grade intelligence. You give it a target (company, person, topic). It monitors continuously — change detection, sentiment tracking, knowledge graph construction, and critical alerts when something important shifts. |
|
||||
| **Collector** | OSINT grade intelligence. You give it a target (company, person, topic). It monitors continuously: change detection, sentiment tracking, knowledge graph construction, and critical alerts when something important shifts. |
|
||||
| **Predictor** | Superforecasting engine. Collects signals from multiple sources, builds calibrated reasoning chains, makes predictions with confidence intervals, and tracks its own accuracy using Brier scores. Has a contrarian mode that deliberately argues against consensus. |
|
||||
| **Researcher** | Deep autonomous researcher. Cross-references multiple sources, evaluates credibility using CRAAP criteria (Currency, Relevance, Authority, Accuracy, Purpose), generates cited reports with APA formatting, supports multiple languages. |
|
||||
| **Twitter** | Autonomous Twitter/X account manager. Creates content in 7 rotating formats, schedules posts for optimal engagement, responds to mentions, tracks performance metrics. Has an approval queue — nothing posts without your OK. |
|
||||
| **Browser** | Web automation agent. Navigates sites, fills forms, clicks buttons, handles multi-step workflows. Uses Playwright bridge with session persistence. **Mandatory purchase approval gate** — it will never spend your money without explicit confirmation. |
|
||||
| **Twitter** | Autonomous Twitter/X account manager. Creates content in 7 rotating formats, schedules posts for optimal engagement, responds to mentions, tracks performance metrics. Has an approval queue, so nothing posts without your OK. |
|
||||
| **Browser** | Web automation agent. Navigates sites, fills forms, clicks buttons, handles multi-step workflows. Uses Playwright bridge with session persistence. **Mandatory purchase approval gate**: it will never spend your money without explicit confirmation. |
|
||||
|
||||
```bash
|
||||
# Activate the Researcher Hand — it starts working immediately
|
||||
# Activate the Researcher Hand. It starts working immediately.
|
||||
openfang hand activate researcher
|
||||
|
||||
# Check its progress anytime
|
||||
@@ -116,7 +116,7 @@ openfang hand list
|
||||
|
||||
### Benchmarks: Measured, Not Marketed
|
||||
|
||||
All data from official documentation and public repositories — February 2026.
|
||||
All data from official documentation and public repositories, February 2026.
|
||||
|
||||
#### Cold Start Time (lower is better)
|
||||
|
||||
@@ -203,7 +203,7 @@ AutoGen ███████████░░░░░░░░░░░░
|
||||
|
||||
---
|
||||
|
||||
## 16 Security Systems — Defense in Depth
|
||||
## 16 Security Systems: Defense in Depth
|
||||
|
||||
OpenFang doesn't bolt security on after the fact. Every layer is independently testable and operates without a single point of failure.
|
||||
|
||||
@@ -211,19 +211,19 @@ OpenFang doesn't bolt security on after the fact. Every layer is independently t
|
||||
|---|--------|-------------|
|
||||
| 1 | **WASM Dual-Metered Sandbox** | Tool code runs in WebAssembly with fuel metering + epoch interruption. A watchdog thread kills runaway code. |
|
||||
| 2 | **Merkle Hash-Chain Audit Trail** | Every action is cryptographically linked to the previous one. Tamper with one entry and the entire chain breaks. |
|
||||
| 3 | **Information Flow Taint Tracking** | Labels propagate through execution — secrets are tracked from source to sink. |
|
||||
| 3 | **Information Flow Taint Tracking** | Labels propagate through execution. Secrets are tracked from source to sink. |
|
||||
| 4 | **Ed25519 Signed Agent Manifests** | Every agent identity and capability set is cryptographically signed. |
|
||||
| 5 | **SSRF Protection** | Blocks private IPs, cloud metadata endpoints, and DNS rebinding attacks. |
|
||||
| 6 | **Secret Zeroization** | `Zeroizing<String>` auto-wipes API keys from memory the instant they're no longer needed. |
|
||||
| 7 | **OFP Mutual Authentication** | HMAC-SHA256 nonce-based, constant-time verification for P2P networking. |
|
||||
| 8 | **Capability Gates** | Role-based access control — agents declare required tools, the kernel enforces it. |
|
||||
| 8 | **Capability Gates** | Role based access control. Agents declare required tools, the kernel enforces it. |
|
||||
| 9 | **Security Headers** | CSP, X-Frame-Options, HSTS, X-Content-Type-Options on every response. |
|
||||
| 10 | **Health Endpoint Redaction** | Public health check returns minimal info. Full diagnostics require authentication. |
|
||||
| 11 | **Subprocess Sandbox** | `env_clear()` + selective variable passthrough. Process tree isolation with cross-platform kill. |
|
||||
| 12 | **Prompt Injection Scanner** | Detects override attempts, data exfiltration patterns, and shell reference injection in skills. |
|
||||
| 13 | **Loop Guard** | SHA256-based tool call loop detection with circuit breaker. Handles ping-pong patterns. |
|
||||
| 14 | **Session Repair** | 7-phase message history validation and automatic recovery from corruption. |
|
||||
| 15 | **Path Traversal Prevention** | Canonicalization with symlink escape prevention. `../` doesn't work here. |
|
||||
| 15 | **Path Traversal Prevention** | Canonicalization with symlink escape prevention. ``../`` doesn't work here. |
|
||||
| 16 | **GCRA Rate Limiter** | Cost-aware token bucket rate limiting with per-IP tracking and stale cleanup. |
|
||||
|
||||
---
|
||||
@@ -266,7 +266,98 @@ Each adapter supports per-channel model overrides, DM/group policies, rate limit
|
||||
|
||||
---
|
||||
|
||||
## 27 LLM Providers — 123+ Models
|
||||
## WhatsApp Web Gateway (QR Code)
|
||||
|
||||
Connect your personal WhatsApp account to OpenFang via QR code, just like WhatsApp Web. No Meta Business account required.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js >= 18** installed ([download](https://nodejs.org/))
|
||||
- OpenFang installed and initialized
|
||||
|
||||
### Setup
|
||||
|
||||
**1. Install the gateway dependencies:**
|
||||
|
||||
```bash
|
||||
cd packages/whatsapp-gateway
|
||||
npm install
|
||||
```
|
||||
|
||||
**2. Configure `config.toml`:**
|
||||
|
||||
```toml
|
||||
[channels.whatsapp]
|
||||
mode = "web"
|
||||
default_agent = "assistant"
|
||||
```
|
||||
|
||||
**3. Set the gateway URL (choose one):**
|
||||
|
||||
Add to your shell profile for persistence:
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
echo 'export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
Or set it inline when starting the gateway:
|
||||
|
||||
```bash
|
||||
export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"
|
||||
```
|
||||
|
||||
**4. Start the gateway:**
|
||||
|
||||
```bash
|
||||
node packages/whatsapp-gateway/index.js
|
||||
```
|
||||
|
||||
The gateway listens on port `3009` by default. Override with `WHATSAPP_GATEWAY_PORT`.
|
||||
|
||||
**5. Start OpenFang:**
|
||||
|
||||
```bash
|
||||
openfang start
|
||||
# Dashboard at http://localhost:4200
|
||||
```
|
||||
|
||||
**6. Scan the QR code:**
|
||||
|
||||
Open the dashboard → **Channels** → **WhatsApp**. A QR code will appear. Scan it with your phone:
|
||||
|
||||
> **WhatsApp** → **Settings** → **Linked Devices** → **Link a Device**
|
||||
|
||||
Once scanned, the status changes to `connected` and incoming messages are routed to your configured agent.
|
||||
|
||||
### Gateway Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `WHATSAPP_WEB_GATEWAY_URL` | Gateway URL for OpenFang to connect to | _(empty = disabled)_ |
|
||||
| `WHATSAPP_GATEWAY_PORT` | Port the gateway listens on | `3009` |
|
||||
| `OPENFANG_URL` | OpenFang API URL the gateway reports to | `http://127.0.0.1:4200` |
|
||||
| `OPENFANG_DEFAULT_AGENT` | Agent that handles incoming messages | `assistant` |
|
||||
|
||||
### Gateway API Endpoints
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| `POST` | `/login/start` | Generate QR code (returns base64 PNG) |
|
||||
| `GET` | `/login/status` | Connection status (`disconnected`, `qr_ready`, `connected`) |
|
||||
| `POST` | `/message/send` | Send a message (`{ "to": "5511999999999", "text": "Hello" }`) |
|
||||
| `GET` | `/health` | Health check |
|
||||
|
||||
### Alternative: WhatsApp Cloud API
|
||||
|
||||
For production workloads, use the [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api) with a Meta Business account. See the [Cloud API configuration docs](https://openfang.sh/docs/channels/whatsapp).
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 27 LLM Providers, 123+ Models
|
||||
|
||||
3 native drivers (Anthropic, Gemini, OpenAI-compatible) route to 27 providers:
|
||||
|
||||
@@ -281,7 +372,7 @@ Intelligent routing with task complexity scoring, automatic fallback, cost track
|
||||
Already running OpenClaw? One command:
|
||||
|
||||
```bash
|
||||
# Migrate everything — agents, memory, skills, configs
|
||||
# Migrate everything: agents, memory, skills, configs.
|
||||
openfang migrate --from openclaw
|
||||
|
||||
# Migrate from a specific path
|
||||
@@ -319,7 +410,7 @@ curl -X POST localhost:4200/v1/chat/completions \
|
||||
# 1. Install (macOS/Linux)
|
||||
curl -fsSL https://openfang.sh/install | sh
|
||||
|
||||
# 2. Initialize — walks you through provider setup
|
||||
# 2. Initialize. Walks you through provider setup.
|
||||
openfang init
|
||||
|
||||
# 3. Start the daemon
|
||||
@@ -327,7 +418,7 @@ openfang start
|
||||
|
||||
# 4. Dashboard is live at http://localhost:4200
|
||||
|
||||
# 5. Activate a Hand — it starts working for you
|
||||
# 5. Activate a Hand. It starts working for you.
|
||||
openfang hand activate researcher
|
||||
|
||||
# 6. Chat with an agent
|
||||
@@ -371,20 +462,26 @@ cargo fmt --all -- --check
|
||||
|
||||
## Stability Notice
|
||||
|
||||
OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
OpenFang v0.5.10 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is deep. That said:
|
||||
|
||||
- **Breaking changes** may occur between minor versions until v1.0
|
||||
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
|
||||
- **Edge cases** exist — if you find one, [open an issue](https://github.com/RightNow-AI/openfang/issues)
|
||||
- **Pin to a specific commit** for production deployments until v1.0
|
||||
- **Breaking changes** may occur between minor versions until v1.0.
|
||||
- **Some Hands** are more mature than others. Browser and Researcher are the most battle tested.
|
||||
- **Edge cases** exist. If you find one, [open an issue](https://github.com/RightNow-AI/openfang/issues).
|
||||
- **Pin to a specific commit** for production deployments until v1.0.
|
||||
|
||||
We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
|
||||
We ship fast and fix fast. The goal is a rock solid v1.0 by mid 2026.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
To report a security vulnerability, email **jaber@rightnowai.co**. We take all reports seriously and will respond within 48 hours.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — use it however you want.
|
||||
MIT. Use it however you want.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---------|--------------------|
|
||||
| 0.1.x | :white_check_mark: |
|
||||
| 0.3.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -14,7 +14,7 @@ If you discover a security vulnerability in OpenFang, please report it responsib
|
||||
|
||||
### How to Report
|
||||
|
||||
1. Email: **security@openfang.ai**
|
||||
1. Email: **jaber@rightnowai.co**
|
||||
2. Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
|
||||
@@ -76,3 +76,6 @@ memory_read = ["*"]
|
||||
memory_write = ["self.*", "shared.*"]
|
||||
agent_message = ["*"]
|
||||
shell = ["python *", "cargo *", "git *", "npm *"]
|
||||
|
||||
[autonomous]
|
||||
max_iterations = 100
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
LangChain Code Review Agent — core review logic.
|
||||
|
||||
Supports OpenAI, Ollama, and any LangChain-compatible LLM.
|
||||
"""
|
||||
|
||||
import os
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
|
||||
SYSTEM_PROMPT = """\
|
||||
You are a principal-level code reviewer with 15+ years of production experience \
|
||||
across multiple languages (Python, Rust, TypeScript, Java, Go, C/C++).
|
||||
You receive code snippets, diffs, or pull request descriptions and produce a \
|
||||
structured, actionable review report.
|
||||
|
||||
You MUST respond in **中文**, but keep code snippets, variable names, and \
|
||||
technical terms in their original language.
|
||||
|
||||
# ── 审核维度(按优先级排序) ──────────────────────────────
|
||||
|
||||
## 1. 正确性 (Correctness)
|
||||
- 逻辑错误、off-by-one、边界条件
|
||||
- 空指针 / None / undefined 未处理
|
||||
- 错误处理不完整(吞异常、漏 catch、panic 路径)
|
||||
- 并发问题:竞态条件、死锁、数据竞争
|
||||
- 类型安全:隐式转换、溢出、精度丢失
|
||||
- 资源泄漏:未关闭的文件/连接/锁
|
||||
|
||||
## 2. 安全性 (Security)
|
||||
- SQL / NoSQL / OS 命令注入
|
||||
- XSS、CSRF、SSRF
|
||||
- 硬编码密钥、token、密码
|
||||
- 不安全的反序列化
|
||||
- 路径穿越(Path Traversal)
|
||||
- 缺少输入校验 / 输出编码
|
||||
- 权限检查缺失或绕过
|
||||
- 敏感数据明文日志
|
||||
|
||||
## 3. 性能 (Performance)
|
||||
- 算法复杂度不合理(O(n²) 可优化为 O(n))
|
||||
- 不必要的内存分配 / 拷贝
|
||||
- N+1 查询、缺少批量操作
|
||||
- 阻塞 I/O 在异步上下文中
|
||||
- 缺少缓存 / 索引
|
||||
- 热路径上的正则编译 / 反射
|
||||
|
||||
## 4. 可维护性 (Maintainability)
|
||||
- 命名不清晰、缩写歧义
|
||||
- 函数过长(>50行建议拆分)
|
||||
- 重复代码(DRY 违反)
|
||||
- 职责不单一(SRP 违反)
|
||||
- 缺少必要注释(复杂业务逻辑、非显而易见的决策)
|
||||
- 魔法数字 / 字符串
|
||||
- 耦合过紧、依赖方向不合理
|
||||
|
||||
## 5. 测试 (Testing)
|
||||
- 关键路径缺少单元测试
|
||||
- 测试覆盖了 happy path 但遗漏了 edge case
|
||||
- 测试中有硬编码依赖(时间、文件路径、网络)
|
||||
- Mock 过度导致测试失去意义
|
||||
|
||||
## 6. 风格 (Style)
|
||||
- 不符合语言惯例(Pythonic、Rust idiom 等)
|
||||
- 格式不一致(应由 formatter 处理的除外)
|
||||
- 不必要的复杂写法
|
||||
|
||||
# ── 严重级别 ──────────────────────────────────────────
|
||||
|
||||
| 级别 | 含义 | 是否阻塞合并 |
|
||||
|------|------|-------------|
|
||||
| 🔴 **[必须修复]** | 存在 bug、安全漏洞或数据丢失风险 | 是 |
|
||||
| 🟡 **[建议修复]** | 不影响功能但会影响可维护性或性能 | 否,但强烈建议 |
|
||||
| 🔵 **[小建议]** | 风格、命名等微小改进 | 否 |
|
||||
| 🟢 **[亮点]** | 写得好的地方,值得肯定 | — |
|
||||
|
||||
# ── 输出格式 ──────────────────────────────────────────
|
||||
|
||||
严格按以下 Markdown 格式输出:
|
||||
|
||||
```
|
||||
## 📋 总结
|
||||
**结论**: [✅ 通过 / ⚠️ 需要修改 / 💬 仅评论]
|
||||
**概述**: [1-2 句话总体评价]
|
||||
**发现统计**: 🔴 X 个必须修复 | 🟡 X 个建议修复 | 🔵 X 个小建议 | 🟢 X 个亮点
|
||||
|
||||
---
|
||||
|
||||
## 🔍 详细发现
|
||||
|
||||
### 🔴 [必须修复] 问题标题
|
||||
- **位置**: `文件名` 第 X-Y 行
|
||||
- **问题**: 具体描述
|
||||
- **原因**: 为什么这是个问题,可能造成什么后果
|
||||
- **修复建议**:
|
||||
(给出修复后的代码)
|
||||
|
||||
### 🟡 [建议修复] 问题标题
|
||||
...
|
||||
|
||||
### 🔵 [小建议] 问题标题
|
||||
...
|
||||
|
||||
### 🟢 [亮点] 优点标题
|
||||
- **位置**: `文件名` 第 X-Y 行
|
||||
- **说明**: 为什么这段代码写得好
|
||||
|
||||
---
|
||||
|
||||
## 📊 评分
|
||||
| 维度 | 分数 | 说明 |
|
||||
|------|------|------|
|
||||
| 正确性 | X/10 | 一句话说明 |
|
||||
| 安全性 | X/10 | 一句话说明 |
|
||||
| 性能 | X/10 | 一句话说明 |
|
||||
| 可维护性 | X/10 | 一句话说明 |
|
||||
| 测试 | X/10 | 一句话说明 |
|
||||
| **综合** | **X/10** | 一句话总结 |
|
||||
```
|
||||
|
||||
# ── 审核原则 ──────────────────────────────────────────
|
||||
|
||||
1. **先肯定,再指出问题** — 不要只挑毛病,好的代码也要指出来
|
||||
2. **解释 WHY,不仅是 WHAT** — 每个问题都要说清楚「为什么不好」和「可能导致什么后果」
|
||||
3. **给出具体修复代码** — 不要只说"这里有问题",要给出改好后的写法
|
||||
4. **区分严重级别** — 不要把小问题标成必须修复,也不要把严重 bug 标成小建议
|
||||
5. **尊重作者** — 用建设性的语气,避免 "这是错的" 这种措辞,用 "这里可以改进为..."
|
||||
6. **不纠结格式** — 如果项目有 formatter/linter,格式问题跳过
|
||||
7. **关注变更本身** — 如果是 diff,只审核变更的部分,不要评论未修改的代码
|
||||
8. **没有代码时** — 直接要求提交代码,不要编造审核结果"""
|
||||
|
||||
|
||||
def _build_llm():
|
||||
"""Build the LLM based on environment configuration."""
|
||||
use_ollama = os.getenv("USE_OLLAMA", "").lower() in ("1", "true", "yes")
|
||||
|
||||
if use_ollama:
|
||||
from langchain_ollama import ChatOllama
|
||||
model = os.getenv("OLLAMA_MODEL", "qwen2.5")
|
||||
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
return ChatOllama(model=model, base_url=base_url, temperature=0.2)
|
||||
|
||||
provider = os.getenv("LLM_PROVIDER", "openai").lower()
|
||||
|
||||
if provider == "deepseek":
|
||||
from langchain_openai import ChatOpenAI
|
||||
return ChatOpenAI(
|
||||
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
|
||||
api_key=os.getenv("DEEPSEEK_API_KEY"),
|
||||
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
|
||||
temperature=0.2,
|
||||
max_tokens=4096,
|
||||
)
|
||||
|
||||
from langchain_openai import ChatOpenAI
|
||||
return ChatOpenAI(
|
||||
model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
|
||||
temperature=0.2,
|
||||
max_tokens=4096,
|
||||
)
|
||||
|
||||
|
||||
class CodeReviewAgent:
|
||||
"""LangChain-based code review agent."""
|
||||
|
||||
def __init__(self):
|
||||
self.llm = _build_llm()
|
||||
self.prompt = ChatPromptTemplate.from_messages([
|
||||
("system", SYSTEM_PROMPT),
|
||||
("human", "{input}"),
|
||||
])
|
||||
self.chain = self.prompt | self.llm | StrOutputParser()
|
||||
|
||||
def review(self, code_or_diff: str) -> str:
|
||||
"""
|
||||
Review the given code or diff.
|
||||
|
||||
Args:
|
||||
code_or_diff: Source code, git diff, or PR description to review.
|
||||
|
||||
Returns:
|
||||
Structured review report as markdown text.
|
||||
"""
|
||||
if not code_or_diff.strip():
|
||||
return "No code provided. Please submit code or a diff to review."
|
||||
|
||||
return self.chain.invoke({"input": code_or_diff})
|
||||
@@ -0,0 +1,10 @@
|
||||
# Add this section to your ~/.openfang/config.toml
|
||||
# to register the LangChain code review agent.
|
||||
|
||||
[a2a]
|
||||
enabled = true
|
||||
listen_path = "/a2a"
|
||||
|
||||
[[a2a.external_agents]]
|
||||
name = "langchain-code-reviewer"
|
||||
url = "http://127.0.0.1:9100"
|
||||
@@ -0,0 +1,6 @@
|
||||
langchain>=0.3
|
||||
langchain-openai>=0.3
|
||||
langchain-core>=0.3
|
||||
langchain-ollama>=0.3
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.34
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
LangChain Code Review Agent — A2A-compatible server.
|
||||
|
||||
Exposes a code review agent via Google's A2A protocol so that
|
||||
OpenFang workflows can call it as an external agent.
|
||||
|
||||
Start:
|
||||
OPENAI_API_KEY=sk-xxx python server.py
|
||||
# or with Ollama (no key needed):
|
||||
USE_OLLAMA=1 python server.py
|
||||
|
||||
Endpoints:
|
||||
GET /.well-known/agent.json — A2A Agent Card
|
||||
POST /a2a — JSON-RPC task endpoint
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
import uvicorn
|
||||
|
||||
from agent import CodeReviewAgent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HOST = os.getenv("HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("PORT", "9100"))
|
||||
BASE_URL = os.getenv("BASE_URL", f"http://127.0.0.1:{PORT}")
|
||||
|
||||
app = FastAPI(title="LangChain Code Review Agent")
|
||||
agent = CodeReviewAgent()
|
||||
|
||||
# In-memory task store
|
||||
tasks: dict[str, dict] = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A2A Agent Card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AGENT_CARD = {
|
||||
"name": "langchain-code-reviewer",
|
||||
"description": (
|
||||
"LangChain-powered code review agent. "
|
||||
"Analyzes code for bugs, security issues, performance problems, "
|
||||
"and style violations. Returns structured review with severity levels."
|
||||
),
|
||||
"url": f"{BASE_URL}/a2a",
|
||||
"version": "0.1.0",
|
||||
"capabilities": {
|
||||
"streaming": False,
|
||||
"pushNotifications": False,
|
||||
"stateTransitionHistory": True,
|
||||
},
|
||||
"skills": [
|
||||
{
|
||||
"id": "code-review",
|
||||
"name": "Code Review",
|
||||
"description": "Review code for correctness, security, performance, and style",
|
||||
"tags": ["code", "review", "security", "quality"],
|
||||
"examples": [
|
||||
"Review this Python function for bugs",
|
||||
"Check this Rust code for security issues",
|
||||
"Analyze this PR diff for performance problems",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "pr-review",
|
||||
"name": "Pull Request Review",
|
||||
"description": "Review a git diff / pull request",
|
||||
"tags": ["pr", "diff", "git"],
|
||||
"examples": [
|
||||
"Review this PR diff",
|
||||
"Analyze these changes",
|
||||
],
|
||||
},
|
||||
],
|
||||
"defaultInputModes": ["text"],
|
||||
"defaultOutputModes": ["text"],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/.well-known/agent.json")
|
||||
async def agent_card():
|
||||
return JSONResponse(content=AGENT_CARD)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A2A JSON-RPC Endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/a2a")
|
||||
async def a2a_endpoint(request: Request):
|
||||
body = await request.json()
|
||||
|
||||
jsonrpc = body.get("jsonrpc", "2.0")
|
||||
req_id = body.get("id", 1)
|
||||
method = body.get("method", "")
|
||||
params = body.get("params", {})
|
||||
|
||||
if method == "tasks/send":
|
||||
return await handle_tasks_send(jsonrpc, req_id, params)
|
||||
elif method == "tasks/get":
|
||||
return handle_tasks_get(jsonrpc, req_id, params)
|
||||
elif method == "tasks/cancel":
|
||||
return handle_tasks_cancel(jsonrpc, req_id, params)
|
||||
else:
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
||||
})
|
||||
|
||||
|
||||
async def handle_tasks_send(jsonrpc: str, req_id: int, params: dict):
|
||||
message = params.get("message", {})
|
||||
session_id = params.get("sessionId")
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
text_parts = [
|
||||
p["text"] for p in message.get("parts", []) if p.get("type") == "text"
|
||||
]
|
||||
user_input = "\n".join(text_parts)
|
||||
|
||||
task = {
|
||||
"id": task_id,
|
||||
"sessionId": session_id,
|
||||
"status": {"state": "working", "message": None},
|
||||
"messages": [message],
|
||||
"artifacts": [],
|
||||
}
|
||||
tasks[task_id] = task
|
||||
|
||||
try:
|
||||
review_result = await asyncio.to_thread(agent.review, user_input)
|
||||
|
||||
agent_message = {
|
||||
"role": "agent",
|
||||
"parts": [{"type": "text", "text": review_result}],
|
||||
}
|
||||
task["messages"].append(agent_message)
|
||||
task["status"] = {"state": "completed", "message": None}
|
||||
task["artifacts"] = [
|
||||
{
|
||||
"name": "code-review-report",
|
||||
"description": "Structured code review report",
|
||||
"parts": [{"type": "text", "text": review_result}],
|
||||
"index": 0,
|
||||
"lastChunk": True,
|
||||
}
|
||||
]
|
||||
except Exception as e:
|
||||
task["status"] = {"state": "failed", "message": str(e)}
|
||||
task["messages"].append({
|
||||
"role": "agent",
|
||||
"parts": [{"type": "text", "text": f"Review failed: {e}"}],
|
||||
})
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"result": task,
|
||||
})
|
||||
|
||||
|
||||
def handle_tasks_get(jsonrpc: str, req_id: int, params: dict):
|
||||
task_id = params.get("id", "")
|
||||
task = tasks.get(task_id)
|
||||
|
||||
if task is None:
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"error": {"code": -32000, "message": f"Task not found: {task_id}"},
|
||||
})
|
||||
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"result": task,
|
||||
})
|
||||
|
||||
|
||||
def handle_tasks_cancel(jsonrpc: str, req_id: int, params: dict):
|
||||
task_id = params.get("id", "")
|
||||
task = tasks.get(task_id)
|
||||
|
||||
if task is None:
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"error": {"code": -32000, "message": f"Task not found: {task_id}"},
|
||||
})
|
||||
|
||||
task["status"] = {"state": "cancelled", "message": None}
|
||||
return JSONResponse(content={
|
||||
"jsonrpc": jsonrpc,
|
||||
"id": req_id,
|
||||
"result": task,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "agent": "langchain-code-reviewer", "tasks": len(tasks)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Starting LangChain Code Review Agent on {HOST}:{PORT}")
|
||||
print(f"Agent Card: {BASE_URL}/.well-known/agent.json")
|
||||
print(f"A2A endpoint: {BASE_URL}/a2a")
|
||||
uvicorn.run(app, host=HOST, port=PORT)
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"name": "langchain-code-review-pipeline",
|
||||
"description": "Code review pipeline: uses LangChain external agent for deep review, then OpenFang Writer agent to format the final report.",
|
||||
"created_at": "2026-03-16T00:00:00Z",
|
||||
"steps": [
|
||||
{
|
||||
"name": "review-code",
|
||||
"agent": { "name": "a2a-proxy" },
|
||||
"prompt_template": "Use the a2a_send tool to send the following code to the external agent for code review. Set agent_name to langchain-code-reviewer and set message to the code below. Return the complete review result:\n\n{{input}}",
|
||||
"mode": "sequential",
|
||||
"timeout_secs": 300,
|
||||
"output_var": "review_result"
|
||||
},
|
||||
{
|
||||
"name": "format-report",
|
||||
"agent": { "name": "Writer" },
|
||||
"prompt_template": "Format the following code review into a clean, professional report. Preserve all severity levels and scores. Add a brief executive summary at the top:\n\n{{review_result}}",
|
||||
"mode": "sequential",
|
||||
"timeout_secs": 120
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -33,8 +33,13 @@ governor = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -30,6 +30,7 @@ use openfang_channels::messenger::MessengerAdapter;
|
||||
use openfang_channels::reddit::RedditAdapter;
|
||||
use openfang_channels::revolt::RevoltAdapter;
|
||||
use openfang_channels::viber::ViberAdapter;
|
||||
use openfang_types::config::FeishuMode;
|
||||
// Wave 4
|
||||
use openfang_channels::flock::FlockAdapter;
|
||||
use openfang_channels::guilded::GuildedAdapter;
|
||||
@@ -43,19 +44,25 @@ use openfang_channels::webex::WebexAdapter;
|
||||
// Wave 5
|
||||
use async_trait::async_trait;
|
||||
use openfang_channels::dingtalk::DingTalkAdapter;
|
||||
use openfang_channels::dingtalk_stream::DingTalkStreamAdapter;
|
||||
use openfang_channels::discourse::DiscourseAdapter;
|
||||
use openfang_channels::gitter::GitterAdapter;
|
||||
use openfang_channels::gotify::GotifyAdapter;
|
||||
use openfang_channels::linkedin::LinkedInAdapter;
|
||||
use openfang_channels::mqtt::MqttAdapter;
|
||||
use openfang_channels::mumble::MumbleAdapter;
|
||||
use openfang_channels::ntfy::NtfyAdapter;
|
||||
use openfang_channels::webhook::WebhookAdapter;
|
||||
use openfang_channels::wecom::WeComAdapter;
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::kernel_handle::KernelHandle;
|
||||
use openfang_types::agent::AgentId;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use openfang_runtime::str_utils::safe_truncate_str;
|
||||
|
||||
/// Wraps `OpenFangKernel` to implement `ChannelBridgeHandle`.
|
||||
pub struct KernelBridgeAdapter {
|
||||
kernel: Arc<OpenFangKernel>,
|
||||
@@ -70,6 +77,37 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.send_message(agent_id, message)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
// Silent/NO_REPLY responses should not be forwarded to channels
|
||||
if result.silent {
|
||||
return Ok(String::new());
|
||||
}
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
async fn send_message_with_blocks(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
blocks: Vec<openfang_types::message::ContentBlock>,
|
||||
) -> Result<String, String> {
|
||||
// Extract text for the message parameter (used for memory recall / logging)
|
||||
let text: String = blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
openfang_types::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let text = if text.is_empty() {
|
||||
"[Image]".to_string()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let result = self
|
||||
.kernel
|
||||
.send_message_with_blocks(agent_id, &text, blocks)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
@@ -351,7 +389,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.map(|e| e.name.clone())
|
||||
.unwrap_or_else(|| t.agent_id.to_string());
|
||||
let status = if t.enabled { "on" } else { "off" };
|
||||
let id_short = &t.id.0.to_string()[..8];
|
||||
let id_str = t.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
msg.push_str(&format!(
|
||||
" [{}] {} -> {} ({:?}) fires:{} [{}]\n",
|
||||
id_short,
|
||||
@@ -390,7 +429,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.kernel
|
||||
.triggers
|
||||
.register(agent.id, pattern, prompt.to_string(), 0);
|
||||
let id_short = &trigger_id.0.to_string()[..8];
|
||||
let id_str = trigger_id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Trigger created [{id_short}] for agent '{agent_name}'.")
|
||||
}
|
||||
|
||||
@@ -405,7 +445,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
1 => {
|
||||
let t = matched[0];
|
||||
if self.kernel.triggers.remove(t.id) {
|
||||
format!("Trigger [{}] removed.", &t.id.0.to_string()[..8])
|
||||
let id_str = t.id.0.to_string();
|
||||
format!("Trigger [{}] removed.", safe_truncate_str(&id_str, 8))
|
||||
} else {
|
||||
"Failed to remove trigger.".to_string()
|
||||
}
|
||||
@@ -428,7 +469,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.map(|e| e.name.clone())
|
||||
.unwrap_or_else(|| job.agent_id.to_string());
|
||||
let status = if job.enabled { "on" } else { "off" };
|
||||
let id_short = &job.id.0.to_string()[..8];
|
||||
let id_str = job.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
let sched = match &job.schedule {
|
||||
openfang_types::scheduler::CronSchedule::Cron { expr, .. } => expr.clone(),
|
||||
openfang_types::scheduler::CronSchedule::Every { every_secs } => {
|
||||
@@ -450,6 +492,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
msg
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn manage_schedule_text(&self, action: &str, args: &[String]) -> String {
|
||||
match action {
|
||||
"add" => {
|
||||
@@ -481,6 +524,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
timeout_secs: None,
|
||||
},
|
||||
delivery: openfang_types::scheduler::CronDelivery::None,
|
||||
delivery_targets: Vec::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
last_run: None,
|
||||
next_run: None,
|
||||
@@ -488,7 +532,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
|
||||
match self.kernel.cron_scheduler.add_job(job, false) {
|
||||
Ok(id) => {
|
||||
let id_short = &id.0.to_string()[..8];
|
||||
let id_str = id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Job [{id_short}] created: '{cron_expr}' -> {agent_name}: \"{message}\"")
|
||||
}
|
||||
Err(e) => format!("Failed to create job: {e}"),
|
||||
@@ -510,7 +555,12 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
let j = matched[0];
|
||||
match self.kernel.cron_scheduler.remove_job(j.id) {
|
||||
Ok(_) => {
|
||||
format!("Job [{}] '{}' removed.", &j.id.0.to_string()[..8], j.name)
|
||||
let id_str = j.id.0.to_string();
|
||||
format!(
|
||||
"Job [{}] '{}' removed.",
|
||||
safe_truncate_str(&id_str, 8),
|
||||
j.name
|
||||
)
|
||||
}
|
||||
Err(e) => format!("Failed to remove job: {e}"),
|
||||
}
|
||||
@@ -539,10 +589,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
openfang_types::scheduler::CronAction::SystemEvent { text } => {
|
||||
text.clone()
|
||||
}
|
||||
openfang_types::scheduler::CronAction::WorkflowRun {
|
||||
workflow_id,
|
||||
input,
|
||||
..
|
||||
} => {
|
||||
format!(
|
||||
"Run workflow {workflow_id}{}",
|
||||
input
|
||||
.as_deref()
|
||||
.map(|i| format!(" with input: {i}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
};
|
||||
match self.kernel.send_message(j.agent_id, &message).await {
|
||||
Ok(result) => {
|
||||
let id_short = &j.id.0.to_string()[..8];
|
||||
let id_str = j.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Job [{id_short}] ran:\n{}", result.response)
|
||||
}
|
||||
Err(e) => format!("Failed to run job: {e}"),
|
||||
@@ -562,7 +626,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
}
|
||||
let mut msg = format!("Pending approvals ({}):\n", pending.len());
|
||||
for req in &pending {
|
||||
let id_short = &req.id.to_string()[..8];
|
||||
let id_str = req.id.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds();
|
||||
let age = if age_secs >= 60 {
|
||||
format!("{}m", age_secs / 60)
|
||||
@@ -603,10 +668,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
) {
|
||||
Ok(_) => {
|
||||
let verb = if approve { "Approved" } else { "Rejected" };
|
||||
let id_str = req.id.to_string();
|
||||
format!(
|
||||
"{} [{}] {} — {}",
|
||||
verb,
|
||||
&req.id.to_string()[..8],
|
||||
safe_truncate_str(&id_str, 8),
|
||||
req.tool_name,
|
||||
req.agent_id
|
||||
)
|
||||
@@ -646,9 +712,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
));
|
||||
}
|
||||
self.kernel
|
||||
.set_agent_model(agent_id, model)
|
||||
.set_agent_model(agent_id, model, None)
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(format!("Model switched to: {model}"))
|
||||
// Read back resolved model+provider from registry
|
||||
let entry = self
|
||||
.kernel
|
||||
.registry
|
||||
.get(agent_id)
|
||||
.ok_or_else(|| "Agent not found after model switch".to_string())?;
|
||||
Ok(format!(
|
||||
"Model switched to: {} (provider: {})",
|
||||
entry.manifest.model.model, entry.manifest.model.provider
|
||||
))
|
||||
}
|
||||
|
||||
async fn stop_run(&self, agent_id: AgentId) -> Result<String, String> {
|
||||
@@ -727,16 +802,35 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
// Wave 5
|
||||
"mumble" => channels.mumble.as_ref().map(|c| c.overrides.clone()),
|
||||
"dingtalk" => channels.dingtalk.as_ref().map(|c| c.overrides.clone()),
|
||||
"dingtalk_stream" => channels
|
||||
.dingtalk_stream
|
||||
.as_ref()
|
||||
.map(|c| c.overrides.clone()),
|
||||
"discourse" => channels.discourse.as_ref().map(|c| c.overrides.clone()),
|
||||
"gitter" => channels.gitter.as_ref().map(|c| c.overrides.clone()),
|
||||
"ntfy" => channels.ntfy.as_ref().map(|c| c.overrides.clone()),
|
||||
"gotify" => channels.gotify.as_ref().map(|c| c.overrides.clone()),
|
||||
"webhook" => channels.webhook.as_ref().map(|c| c.overrides.clone()),
|
||||
"linkedin" => channels.linkedin.as_ref().map(|c| c.overrides.clone()),
|
||||
"wecom" => channels.wecom.as_ref().map(|c| c.overrides.clone()),
|
||||
"mqtt" => channels.mqtt.as_ref().map(|c| c.overrides.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -774,6 +868,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
recipient: &str,
|
||||
success: bool,
|
||||
error: Option<&str>,
|
||||
thread_id: Option<&str>,
|
||||
) {
|
||||
let receipt = if success {
|
||||
openfang_kernel::DeliveryTracker::sent_receipt(channel, recipient)
|
||||
@@ -786,9 +881,13 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
};
|
||||
self.kernel.delivery_tracker.record(agent_id, receipt);
|
||||
|
||||
// Persist last channel for cron CronDelivery::LastChannel
|
||||
// Persist last channel for cron CronDelivery::LastChannel.
|
||||
// Include thread_id when present so forum-topic context survives restarts.
|
||||
if success {
|
||||
let kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
|
||||
let mut kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
|
||||
if let Some(tid) = thread_id {
|
||||
kv_val["thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let _ = self
|
||||
.kernel
|
||||
.memory
|
||||
@@ -796,6 +895,23 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_channel_message(
|
||||
&self,
|
||||
channel_type: &str,
|
||||
recipient: &str,
|
||||
message: &str,
|
||||
) -> Result<(), String> {
|
||||
<OpenFangKernel as KernelHandle>::send_channel_message(
|
||||
&self.kernel,
|
||||
channel_type,
|
||||
recipient,
|
||||
message,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn check_auto_reply(&self, agent_id: AgentId, message: &str) -> Option<String> {
|
||||
// Check if auto-reply should fire for this message
|
||||
let channel_type = "bridge"; // Generic; the bridge layer handles specifics
|
||||
@@ -859,7 +975,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
return "OFP peer network is disabled. Set network_enabled = true in config.toml."
|
||||
.to_string();
|
||||
}
|
||||
match &self.kernel.peer_registry {
|
||||
match self.kernel.peer_registry.get() {
|
||||
Some(registry) => {
|
||||
let peers = registry.all_peers();
|
||||
if peers.is_empty() {
|
||||
@@ -934,16 +1050,39 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a token from an env var, returning None with a warning if missing/empty.
|
||||
fn read_token(env_var: &str, adapter_name: &str) -> Option<String> {
|
||||
match std::env::var(env_var) {
|
||||
/// Resolve a token: if the value looks like an actual secret (contains `:`,
|
||||
/// starts with `xoxb-`, `xapp-`, `sk-`, etc.), use it directly.
|
||||
/// Otherwise treat it as an env var name and look it up.
|
||||
fn read_token(env_var_or_token: &str, adapter_name: &str) -> Option<String> {
|
||||
// Heuristic: actual tokens contain `:` (Telegram, Discord) or start with
|
||||
// known prefixes. Env var names are uppercase ASCII identifiers.
|
||||
let looks_like_token = env_var_or_token.contains(':')
|
||||
|| env_var_or_token.starts_with("xoxb-")
|
||||
|| env_var_or_token.starts_with("xapp-")
|
||||
|| env_var_or_token.starts_with("sk-")
|
||||
|| env_var_or_token.starts_with("Bearer ")
|
||||
|| env_var_or_token.len() > 80; // Long random strings are tokens, not env var names
|
||||
|
||||
if looks_like_token {
|
||||
warn!(
|
||||
"{adapter_name}: config field contains what looks like an actual token \
|
||||
rather than an env var name — using it directly. \
|
||||
Tip: store the token in an env var and use the var name instead for security."
|
||||
);
|
||||
return Some(env_var_or_token.to_string());
|
||||
}
|
||||
|
||||
match std::env::var(env_var_or_token) {
|
||||
Ok(t) if !t.is_empty() => Some(t),
|
||||
Ok(_) => {
|
||||
warn!("{adapter_name} bot token env var '{env_var}' is empty, skipping");
|
||||
warn!("{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("{adapter_name} bot token env var '{env_var}' not set, skipping");
|
||||
warn!(
|
||||
"{adapter_name} token env var '{env_var_or_token}' not set, skipping. \
|
||||
Set it with: export {env_var_or_token}=<your-token>"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1003,6 +1142,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Wave 5
|
||||
|| config.mumble.is_some()
|
||||
|| config.dingtalk.is_some()
|
||||
|| config.dingtalk_stream.is_some()
|
||||
|| config.discourse.is_some()
|
||||
|| config.gitter.is_some()
|
||||
|| config.ntfy.is_some()
|
||||
@@ -1026,10 +1166,12 @@ pub async fn start_channel_bridge_with_config(
|
||||
if let Some(ref tg_config) = config.telegram {
|
||||
if let Some(token) = read_token(&tg_config.bot_token_env, "Telegram") {
|
||||
let poll_interval = Duration::from_secs(tg_config.poll_interval_secs);
|
||||
let adapter = Arc::new(TelegramAdapter::new(
|
||||
let adapter = Arc::new(TelegramAdapter::with_thread_routes(
|
||||
token,
|
||||
tg_config.allowed_users.clone(),
|
||||
poll_interval,
|
||||
tg_config.api_url.clone(),
|
||||
tg_config.thread_routes.clone(),
|
||||
));
|
||||
adapters.push((adapter, tg_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1042,7 +1184,9 @@ pub async fn start_channel_bridge_with_config(
|
||||
token,
|
||||
dc_config.allowed_guilds.clone(),
|
||||
dc_config.allowed_users.clone(),
|
||||
dc_config.ignore_bots,
|
||||
dc_config.intents,
|
||||
dc_config.auto_thread.clone(),
|
||||
));
|
||||
adapters.push((adapter, dc_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1056,6 +1200,9 @@ pub async fn start_channel_bridge_with_config(
|
||||
app_token,
|
||||
bot_token,
|
||||
sl_config.allowed_channels.clone(),
|
||||
sl_config.auto_thread_reply,
|
||||
sl_config.thread_ttl_hours,
|
||||
sl_config.unfurl_links,
|
||||
));
|
||||
adapters.push((adapter, sl_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1065,7 +1212,9 @@ pub async fn start_channel_bridge_with_config(
|
||||
// WhatsApp — supports Cloud API mode (access token) or Web/QR mode (gateway URL)
|
||||
if let Some(ref wa_config) = config.whatsapp {
|
||||
let cloud_token = read_token(&wa_config.access_token_env, "WhatsApp");
|
||||
let gateway_url = std::env::var(&wa_config.gateway_url_env).ok().filter(|u| !u.is_empty());
|
||||
let gateway_url = std::env::var(&wa_config.gateway_url_env)
|
||||
.ok()
|
||||
.filter(|u| !u.is_empty());
|
||||
|
||||
if cloud_token.is_some() || gateway_url.is_some() {
|
||||
let token = cloud_token.unwrap_or_default();
|
||||
@@ -1102,11 +1251,19 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Matrix
|
||||
if let Some(ref mx_config) = config.matrix {
|
||||
if let Some(token) = read_token(&mx_config.access_token_env, "Matrix") {
|
||||
let adapter = Arc::new(MatrixAdapter::new(
|
||||
// MSC2918 refresh-token support: optional env var, when present the
|
||||
// adapter auto-recovers from M_UNKNOWN_TOKEN 401s.
|
||||
let refresh = mx_config
|
||||
.refresh_token_env
|
||||
.as_deref()
|
||||
.and_then(|env| read_token(env, "Matrix refresh"));
|
||||
let adapter = Arc::new(MatrixAdapter::with_refresh_token(
|
||||
mx_config.homeserver_url.clone(),
|
||||
mx_config.user_id.clone(),
|
||||
token,
|
||||
refresh,
|
||||
mx_config.allowed_rooms.clone(),
|
||||
mx_config.auto_accept_invites,
|
||||
));
|
||||
adapters.push((adapter, mx_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1313,11 +1470,28 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Feishu/Lark
|
||||
if let Some(ref fs_config) = config.feishu {
|
||||
if let Some(secret) = read_token(&fs_config.app_secret_env, "Feishu") {
|
||||
let adapter = Arc::new(FeishuAdapter::new(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
fs_config.webhook_port,
|
||||
));
|
||||
let region = openfang_channels::feishu::FeishuRegion::parse_region(&fs_config.region);
|
||||
let encrypt_key = fs_config
|
||||
.encrypt_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| read_token(env, "Feishu encrypt_key"));
|
||||
let adapter = match fs_config.mode {
|
||||
FeishuMode::Webhook => Arc::new(FeishuAdapter::with_config(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
fs_config.webhook_port,
|
||||
region,
|
||||
Some(fs_config.webhook_path.clone()),
|
||||
fs_config.verification_token.clone(),
|
||||
encrypt_key,
|
||||
fs_config.bot_names.clone(),
|
||||
)),
|
||||
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket_with_region(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
region,
|
||||
)),
|
||||
};
|
||||
adapters.push((adapter, fs_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
@@ -1325,8 +1499,30 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Revolt
|
||||
if let Some(ref rv_config) = config.revolt {
|
||||
if let Some(token) = read_token(&rv_config.bot_token_env, "Revolt") {
|
||||
let adapter = Arc::new(RevoltAdapter::new(token));
|
||||
adapters.push((adapter, rv_config.default_agent.clone()));
|
||||
let mut adapter = RevoltAdapter::with_urls(
|
||||
token,
|
||||
rv_config.api_url.clone(),
|
||||
rv_config.ws_url.clone(),
|
||||
);
|
||||
if !rv_config.allowed_channels.is_empty() {
|
||||
adapter.set_allowed_channels(rv_config.allowed_channels.clone());
|
||||
}
|
||||
adapters.push((Arc::new(adapter), rv_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// WeCom/WeChat Work
|
||||
if let Some(ref wc_config) = config.wecom {
|
||||
if let Some(secret) = read_token(&wc_config.secret_env, "WeCom") {
|
||||
let adapter = Arc::new(WeComAdapter::with_verification(
|
||||
wc_config.corp_id.clone(),
|
||||
wc_config.agent_id.clone(),
|
||||
secret,
|
||||
wc_config.webhook_port,
|
||||
wc_config.encoding_aes_key.clone(),
|
||||
wc_config.token.clone(),
|
||||
));
|
||||
adapters.push((adapter, wc_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1436,7 +1632,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// DingTalk
|
||||
// DingTalk (webhook mode)
|
||||
if let Some(ref dt_config) = config.dingtalk {
|
||||
if let Some(token) = read_token(&dt_config.access_token_env, "DingTalk") {
|
||||
let secret = read_token(&dt_config.secret_env, "DingTalk (secret)").unwrap_or_default();
|
||||
@@ -1445,6 +1641,21 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// DingTalk (stream mode)
|
||||
if let Some(ref ds_config) = config.dingtalk_stream {
|
||||
if let Some(app_key) = read_token(&ds_config.app_key_env, "DingTalk Stream (app_key)") {
|
||||
if let Some(app_secret) =
|
||||
read_token(&ds_config.app_secret_env, "DingTalk Stream (app_secret)")
|
||||
{
|
||||
let robot_code =
|
||||
read_token(&ds_config.robot_code_env, "DingTalk Stream (robot_code)")
|
||||
.unwrap_or_else(|| app_key.clone());
|
||||
let adapter = Arc::new(DingTalkStreamAdapter::new(app_key, app_secret, robot_code));
|
||||
adapters.push((adapter, ds_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discourse
|
||||
if let Some(ref dc_config) = config.discourse {
|
||||
if let Some(api_key) = read_token(&dc_config.api_key_env, "Discourse") {
|
||||
@@ -1518,6 +1729,25 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// MQTT
|
||||
if let Some(ref mq_config) = config.mqtt {
|
||||
let username = read_token(&mq_config.username_env, "MQTT (username)");
|
||||
let password = read_token(&mq_config.password_env, "MQTT (password)");
|
||||
let adapter = Arc::new(MqttAdapter::new(
|
||||
mq_config.broker_url.clone(),
|
||||
mq_config.client_id.clone(),
|
||||
mq_config.subscribe_topic.clone(),
|
||||
mq_config.publish_topic.clone(),
|
||||
username,
|
||||
password,
|
||||
mq_config.use_tls,
|
||||
mq_config.keep_alive_secs,
|
||||
mq_config.clean_session,
|
||||
mq_config.qos,
|
||||
));
|
||||
adapters.push((adapter, mq_config.default_agent.clone()));
|
||||
}
|
||||
|
||||
if adapters.is_empty() {
|
||||
return (None, Vec::new());
|
||||
}
|
||||
@@ -1549,7 +1779,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
|
||||
adapter.name()
|
||||
);
|
||||
router.set_channel_default(channel_key, agent_id);
|
||||
router.set_channel_default_with_name(channel_key, agent_id, name.clone());
|
||||
// First configured default also becomes system-wide fallback
|
||||
if !system_default_set {
|
||||
router.set_default(agent_id);
|
||||
@@ -1722,4 +1952,35 @@ mod tests {
|
||||
assert!(config.channels.webhook.is_none());
|
||||
assert!(config.channels.linkedin.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feishu_bridge_mode_defaults_to_websocket() {
|
||||
let config: openfang_types::config::KernelConfig = toml::from_str(
|
||||
r#"
|
||||
[channels.feishu]
|
||||
app_id = "cli_test"
|
||||
app_secret_env = "FEISHU_APP_SECRET"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let feishu = config.channels.feishu.expect("feishu config should exist");
|
||||
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feishu_bridge_mode_supports_websocket() {
|
||||
let config: openfang_types::config::KernelConfig = toml::from_str(
|
||||
r#"
|
||||
[channels.feishu]
|
||||
app_id = "cli_test"
|
||||
app_secret_env = "FEISHU_APP_SECRET"
|
||||
mode = "websocket"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let feishu = config.channels.feishu.expect("feishu config should exist");
|
||||
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,42 @@
|
||||
//! 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;
|
||||
pub mod rate_limiter;
|
||||
pub mod routes;
|
||||
pub mod server;
|
||||
pub mod session_auth;
|
||||
pub mod stream_chunker;
|
||||
pub mod stream_dedup;
|
||||
pub mod types;
|
||||
|
||||
@@ -43,76 +43,132 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
|
||||
response
|
||||
}
|
||||
|
||||
/// Authentication state passed to the auth middleware.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub api_key: String,
|
||||
pub auth_enabled: bool,
|
||||
pub session_secret: String,
|
||||
/// Set from `OPENFANG_ALLOW_NO_AUTH=1` to permit running without an api_key
|
||||
/// on a non-loopback bind. Off by default so empty keys fail closed.
|
||||
pub allow_no_auth: bool,
|
||||
}
|
||||
|
||||
/// Bearer token authentication middleware.
|
||||
///
|
||||
/// When `api_key` is non-empty, all requests must include
|
||||
/// `Authorization: Bearer <api_key>`. If the key is empty, auth is bypassed.
|
||||
/// When `api_key` is non-empty (after trimming), requests to non-public
|
||||
/// endpoints must include `Authorization: Bearer <api_key>`.
|
||||
///
|
||||
/// When `api_key` is empty (no key configured) the server defaults to
|
||||
/// fail-closed for any request that does NOT originate from loopback.
|
||||
/// Loopback traffic (127.0.0.1 / ::1) is always allowed through with no
|
||||
/// key so single-user local setups keep zero-config UX. To explicitly
|
||||
/// run a no-auth server on a LAN/WAN address, set
|
||||
/// `OPENFANG_ALLOW_NO_AUTH=1`; this opts out of fail-closed and is
|
||||
/// reported loudly at startup.
|
||||
///
|
||||
/// When dashboard auth is enabled, session cookies are also accepted.
|
||||
pub async fn auth(
|
||||
axum::extract::State(api_key): axum::extract::State<String>,
|
||||
axum::extract::State(auth_state): axum::extract::State<AuthState>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
// If no API key configured, skip authentication entirely (open access).
|
||||
if api_key.is_empty() {
|
||||
// SECURITY: Capture method early for method-aware public endpoint checks.
|
||||
let method = request.method().clone();
|
||||
|
||||
let is_loopback = request
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(false); // SECURITY: default-deny; unknown origin is NOT loopback
|
||||
|
||||
// Shutdown is loopback-only (CLI on same machine). Skip token auth only
|
||||
// when the request is from loopback.
|
||||
let path = request.uri().path();
|
||||
if path == "/api/shutdown" && is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Shutdown is loopback-only (CLI on same machine) — skip token auth
|
||||
let path = request.uri().path();
|
||||
if path == "/api/shutdown" {
|
||||
let is_loopback = request
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(true); // default true for unix sockets / tests
|
||||
if is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth (dashboard needs these)
|
||||
if path == "/"
|
||||
// Public endpoints that don't require auth (dashboard needs these).
|
||||
// SECURITY: /api/agents is GET-only (listing). POST (spawn) requires auth.
|
||||
// SECURITY: Public endpoints are GET-only unless explicitly noted.
|
||||
// POST/PUT/DELETE to any endpoint ALWAYS requires auth to prevent
|
||||
// unauthenticated writes (cron job creation, skill install, etc.).
|
||||
let is_get = method == axum::http::Method::GET;
|
||||
let is_public = path == "/"
|
||||
|| path == "/logo.png"
|
||||
|| path == "/favicon.ico"
|
||||
|| path == "/.well-known/agent.json"
|
||||
|| path.starts_with("/a2a/")
|
||||
|| (path == "/.well-known/agent.json" && is_get)
|
||||
|| (path.starts_with("/a2a/") && is_get)
|
||||
|| path == "/api/health"
|
||||
|| path == "/api/health/detail"
|
||||
|| path == "/api/status"
|
||||
|| path == "/api/version"
|
||||
|| path == "/api/agents"
|
||||
|| path == "/api/profiles"
|
||||
|| path == "/api/config"
|
||||
|| path.starts_with("/api/uploads/")
|
||||
|| (path == "/api/agents" && is_get)
|
||||
|| (path == "/api/profiles" && is_get)
|
||||
|| (path == "/api/config" && is_get)
|
||||
|| (path == "/api/config/schema" && is_get)
|
||||
|| (path.starts_with("/api/uploads/") && is_get)
|
||||
// Dashboard read endpoints — allow unauthenticated so the SPA can
|
||||
// render before the user enters their API key.
|
||||
|| path == "/api/models"
|
||||
|| path == "/api/models/aliases"
|
||||
|| path == "/api/providers"
|
||||
|| path == "/api/budget"
|
||||
|| path == "/api/budget/agents"
|
||||
|| path.starts_with("/api/budget/agents/")
|
||||
|| path == "/api/network/status"
|
||||
|| path == "/api/a2a/agents"
|
||||
|| path == "/api/approvals"
|
||||
|| path.starts_with("/api/approvals/")
|
||||
|| path == "/api/channels"
|
||||
|| path == "/api/hands"
|
||||
|| path == "/api/hands/active"
|
||||
|| path.starts_with("/api/hands/")
|
||||
|| path == "/api/skills"
|
||||
|| path == "/api/sessions"
|
||||
|| path == "/api/integrations"
|
||||
|| path == "/api/integrations/available"
|
||||
|| path == "/api/integrations/health"
|
||||
|| path == "/api/workflows"
|
||||
|| path == "/api/logs/stream"
|
||||
|| path.starts_with("/api/cron/")
|
||||
|| (path == "/api/models" && is_get)
|
||||
|| (path == "/api/models/aliases" && is_get)
|
||||
|| (path == "/api/providers" && is_get)
|
||||
|| (path == "/api/budget" && is_get)
|
||||
|| (path == "/api/budget/agents" && is_get)
|
||||
|| (path.starts_with("/api/budget/agents/") && is_get)
|
||||
|| (path == "/api/network/status" && is_get)
|
||||
|| (path == "/api/a2a/agents" && is_get)
|
||||
|| (path == "/api/approvals" && is_get)
|
||||
|| (path.starts_with("/api/approvals/") && is_get)
|
||||
|| (path == "/api/channels" && is_get)
|
||||
|| (path == "/api/hands" && is_get)
|
||||
|| (path == "/api/hands/active" && is_get)
|
||||
|| (path.starts_with("/api/hands/") && is_get)
|
||||
|| (path == "/api/skills" && is_get)
|
||||
|| (path.starts_with("/api/skills/") && path.ends_with("/config") && is_get)
|
||||
|| (path == "/api/sessions" && is_get)
|
||||
|| (path == "/api/integrations" && is_get)
|
||||
|| (path == "/api/integrations/available" && is_get)
|
||||
|| (path == "/api/integrations/health" && is_get)
|
||||
|| (path == "/api/workflows" && is_get)
|
||||
|| path == "/api/logs/stream" // SSE stream, read-only
|
||||
|| (path.starts_with("/api/cron/") && is_get)
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/")
|
||||
{
|
||||
|| path == "/api/auth/login"
|
||||
|| path == "/api/auth/logout"
|
||||
|| (path == "/api/auth/check" && is_get);
|
||||
|
||||
if is_public {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// If no API key configured and no dashboard login is active, fail closed
|
||||
// for anything that did not come from loopback. Opting out of this
|
||||
// behavior requires setting `OPENFANG_ALLOW_NO_AUTH=1`, which is logged
|
||||
// loudly at startup.
|
||||
//
|
||||
// See issue #1034 (B1/B2): empty api_key previously bypassed auth for
|
||||
// all origins, exposing agent config, channel tokens, and LLM keys on
|
||||
// any LAN-reachable bind.
|
||||
let api_key_trimmed = auth_state.api_key.trim().to_string();
|
||||
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
|
||||
if is_loopback || auth_state.allow_no_auth {
|
||||
return next.run(request).await;
|
||||
}
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("www-authenticate", "Bearer")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"error": "API key required for non-loopback requests. Set OPENFANG_API_KEY or bind to 127.0.0.1."
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
let api_key = api_key_trimmed.as_str();
|
||||
|
||||
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
|
||||
let bearer_token = request
|
||||
.headers()
|
||||
@@ -138,13 +194,14 @@ pub async fn auth(
|
||||
|
||||
// Also check ?token= query parameter (for EventSource/SSE clients that
|
||||
// cannot set custom headers, same approach as WebSocket auth).
|
||||
let query_token = request
|
||||
let query_token_decoded = request
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")));
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(crate::percent_decode);
|
||||
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks.
|
||||
let query_auth = query_token.map(|token| {
|
||||
let query_auth = query_token_decoded.as_deref().map(|token| {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != api_key.len() {
|
||||
return false;
|
||||
@@ -157,6 +214,17 @@ pub async fn auth(
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Check session cookie (dashboard login sessions)
|
||||
if auth_state.auth_enabled {
|
||||
if let Some(token) = crate::session_auth::extract_session_cookie(request.headers()) {
|
||||
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
|
||||
.is_some()
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine error message: was a credential provided but wrong, or missing entirely?
|
||||
let credential_provided = header_auth.is_some() || query_auth.is_some();
|
||||
let error_msg = if credential_provided {
|
||||
@@ -181,13 +249,16 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
headers.insert("x-content-type-options", "nosniff".parse().unwrap());
|
||||
headers.insert("x-frame-options", "DENY".parse().unwrap());
|
||||
headers.insert("x-xss-protection", "1; mode=block".parse().unwrap());
|
||||
// All JS/CSS is bundled inline — only external resource is Google Fonts.
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
// The dashboard handler (webchat_page) sets its own nonce-based CSP.
|
||||
// For all other responses (API endpoints), apply a strict default.
|
||||
if !headers.contains_key("content-security-policy") {
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
headers.insert(
|
||||
"referrer-policy",
|
||||
"strict-origin-when-cross-origin".parse().unwrap(),
|
||||
@@ -196,15 +267,133 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
"cache-control",
|
||||
"no-store, no-cache, must-revalidate".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"strict-transport-security",
|
||||
"max-age=63072000; includeSubDomains".parse().unwrap(),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{Method, Request};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use std::net::SocketAddr;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[test]
|
||||
fn test_request_id_header_constant() {
|
||||
assert_eq!(REQUEST_ID_HEADER, "x-request-id");
|
||||
}
|
||||
|
||||
fn auth_state_empty() -> AuthState {
|
||||
AuthState {
|
||||
api_key: String::new(),
|
||||
auth_enabled: false,
|
||||
session_secret: String::new(),
|
||||
allow_no_auth: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_state_with_key(key: &str) -> AuthState {
|
||||
AuthState {
|
||||
api_key: key.to_string(),
|
||||
auth_enabled: false,
|
||||
session_secret: key.to_string(),
|
||||
allow_no_auth: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn ok_handler() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
fn router(state: AuthState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/agents/1", get(ok_handler))
|
||||
.route_layer(axum::middleware::from_fn_with_state(state, auth))
|
||||
}
|
||||
|
||||
fn req_from(ip: &str) -> Request<Body> {
|
||||
let addr: SocketAddr = format!("{ip}:40000").parse().unwrap();
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/agents/1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(addr));
|
||||
req
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_allows_loopback() {
|
||||
let app = router(auth_state_empty());
|
||||
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_blocks_lan_origin() {
|
||||
// Issue #1034 B1: previously 192.168/10/... could hit every non-public
|
||||
// endpoint when api_key was unset. Must now be 401.
|
||||
let app = router(auth_state_empty());
|
||||
let resp = app.oneshot(req_from("192.168.1.50")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_blocks_public_origin() {
|
||||
let app = router(auth_state_empty());
|
||||
let resp = app.oneshot(req_from("203.0.113.5")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_blocks_unknown_connect_info() {
|
||||
// Paranoia: if ConnectInfo is missing for any reason, we must fail
|
||||
// closed, not open.
|
||||
let app = router(auth_state_empty());
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/agents/1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_key_with_allow_no_auth_opens_everything() {
|
||||
let mut s = auth_state_empty();
|
||||
s.allow_no_auth = true;
|
||||
let app = router(s);
|
||||
let resp = app.oneshot(req_from("10.0.0.9")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_key_rejects_missing_token_from_loopback() {
|
||||
let app = router(auth_state_with_key("secret"));
|
||||
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_key_accepts_bearer() {
|
||||
let app = router(auth_state_with_key("secret"));
|
||||
let addr: SocketAddr = "127.0.0.1:40000".parse().unwrap();
|
||||
let mut req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/agents/1")
|
||||
.header("authorization", "Bearer secret")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(addr));
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +202,10 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
|
||||
let blocks: Vec<ContentBlock> = parts
|
||||
.iter()
|
||||
.filter_map(|part| match part {
|
||||
OaiContentPart::Text { text } => {
|
||||
Some(ContentBlock::Text { text: text.clone() })
|
||||
}
|
||||
OaiContentPart::Text { text } => Some(ContentBlock::Text {
|
||||
text: text.clone(),
|
||||
provider_metadata: None,
|
||||
}),
|
||||
OaiContentPart::ImageUrl { image_url } => {
|
||||
// Parse data URI: data:{media_type};base64,{data}
|
||||
if let Some(rest) = image_url.url.strip_prefix("data:") {
|
||||
@@ -234,7 +235,12 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
|
||||
OaiContent::Null => return None,
|
||||
};
|
||||
|
||||
Some(Message { role, content })
|
||||
Some(Message {
|
||||
msg_id: uuid::Uuid::new_v4().to_string(),
|
||||
provider_msg_id: None,
|
||||
role,
|
||||
content,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -322,7 +328,7 @@ pub async fn chat_completions(
|
||||
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
|
||||
match state
|
||||
.kernel
|
||||
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle))
|
||||
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle), None, None)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -378,7 +384,7 @@ async fn stream_response(
|
||||
|
||||
let (mut rx, _handle) = state
|
||||
.kernel
|
||||
.send_message_streaming(agent_id, message, Some(kernel_handle))
|
||||
.send_message_streaming(agent_id, message, Some(kernel_handle), None, None, None)
|
||||
.map_err(|e| format!("Streaming setup failed: {e}"))?;
|
||||
|
||||
let (tx, stream_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(64);
|
||||
|
||||
@@ -29,6 +29,16 @@ pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
|
||||
("POST", p) if p.contains("/run") => NonZeroU32::new(100).unwrap(),
|
||||
("POST", "/api/skills/install") => NonZeroU32::new(50).unwrap(),
|
||||
("POST", "/api/skills/uninstall") => NonZeroU32::new(10).unwrap(),
|
||||
("POST", "/api/skills/reload") => NonZeroU32::new(5).unwrap(),
|
||||
("GET", p) if p.starts_with("/api/skills/") && p.ends_with("/config") => {
|
||||
NonZeroU32::new(3).unwrap()
|
||||
}
|
||||
("PUT", p) if p.starts_with("/api/skills/") && p.ends_with("/config") => {
|
||||
NonZeroU32::new(10).unwrap()
|
||||
}
|
||||
("DELETE", p) if p.starts_with("/api/skills/") && p.contains("/config/") => {
|
||||
NonZeroU32::new(10).unwrap()
|
||||
}
|
||||
("POST", "/api/migrate") => NonZeroU32::new(100).unwrap(),
|
||||
("PUT", p) if p.contains("/update") => NonZeroU32::new(10).unwrap(),
|
||||
_ => NonZeroU32::new(5).unwrap(),
|
||||
|
||||
+3522
-673
File diff suppressed because it is too large
Load Diff
@@ -45,16 +45,22 @@ pub async fn build_router(
|
||||
let state = Arc::new(AppState {
|
||||
kernel: kernel.clone(),
|
||||
started_at: Instant::now(),
|
||||
peer_registry: kernel.peer_registry.as_ref().map(|r| Arc::new(r.clone())),
|
||||
peer_registry: kernel.peer_registry.get().map(|r| Arc::new(r.clone())),
|
||||
bridge_manager: tokio::sync::Mutex::new(bridge),
|
||||
channels_config: tokio::sync::RwLock::new(channels_config),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(kernel.config.budget.clone())),
|
||||
});
|
||||
|
||||
// Start WS cron broadcaster — subscribes to kernel event bus and pushes
|
||||
// cron job results to all connected WebSocket clients in real-time.
|
||||
ws::start_ws_cron_broadcaster(kernel.clone());
|
||||
|
||||
// CORS: allow localhost origins by default. If API key is set, the API
|
||||
// is protected anyway. For development, permissive CORS is convenient.
|
||||
let cors = if state.kernel.config.api_key.is_empty() {
|
||||
let cors = if state.kernel.config.api_key.trim().is_empty() {
|
||||
// No auth → restrict CORS to localhost origins (include both 127.0.0.1 and localhost)
|
||||
let port = listen_addr.port();
|
||||
let mut origins: Vec<axum::http::HeaderValue> = vec![
|
||||
@@ -102,13 +108,63 @@ pub async fn build_router(
|
||||
.allow_headers(tower_http::cors::Any)
|
||||
};
|
||||
|
||||
let api_key = state.kernel.config.api_key.clone();
|
||||
// Warn if dashboard auth is enabled but the password hash is not Argon2id.
|
||||
let ph = &state.kernel.config.auth.password_hash;
|
||||
if state.kernel.config.auth.enabled && !ph.is_empty() && !ph.starts_with("$argon2") {
|
||||
tracing::warn!(
|
||||
"Dashboard auth password_hash is not in Argon2id format. \
|
||||
Login will fail. Regenerate with: openfang auth hash-password"
|
||||
);
|
||||
}
|
||||
|
||||
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
let allow_no_auth = std::env::var("OPENFANG_ALLOW_NO_AUTH")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "on"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Fail-closed warning: if no api_key and no dashboard auth, and the
|
||||
// server is bound to a non-loopback address without an explicit opt-in,
|
||||
// shout about it. The middleware will reject non-loopback traffic.
|
||||
let bind_is_loopback = listen_addr.ip().is_loopback();
|
||||
if api_key.is_empty() && !state.kernel.config.auth.enabled && !bind_is_loopback {
|
||||
if allow_no_auth {
|
||||
tracing::warn!(
|
||||
"OPENFANG_ALLOW_NO_AUTH=1 is set. Running WITHOUT authentication on {}. \
|
||||
Anyone reachable at this address can read/write agents, channels, and keys.",
|
||||
listen_addr
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"No api_key configured and server is bound to {} (non-loopback). \
|
||||
Non-loopback requests will be rejected with 401. \
|
||||
Set OPENFANG_API_KEY (or api_key in config.toml), or bind to 127.0.0.1, \
|
||||
or set OPENFANG_ALLOW_NO_AUTH=1 to explicitly run open.",
|
||||
listen_addr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let auth_state = crate::middleware::AuthState {
|
||||
api_key: api_key.clone(),
|
||||
auth_enabled: state.kernel.config.auth.enabled,
|
||||
session_secret: if !api_key.is_empty() {
|
||||
api_key.clone()
|
||||
} else if state.kernel.config.auth.enabled {
|
||||
state.kernel.config.auth.password_hash.clone()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
allow_no_auth,
|
||||
};
|
||||
let gcra_limiter = rate_limiter::create_rate_limiter();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", axum::routing::get(webchat::webchat_page))
|
||||
.route("/logo.png", axum::routing::get(webchat::logo_png))
|
||||
.route("/favicon.ico", axum::routing::get(webchat::favicon_ico))
|
||||
.route("/manifest.json", axum::routing::get(webchat::manifest_json))
|
||||
.route("/sw.js", axum::routing::get(webchat::sw_js))
|
||||
.route(
|
||||
"/api/metrics",
|
||||
axum::routing::get(routes::prometheus_metrics),
|
||||
@@ -126,13 +182,33 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}",
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent),
|
||||
axum::routing::get(routes::get_agent)
|
||||
.delete(routes::kill_agent)
|
||||
.patch(routes::patch_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/uninstall",
|
||||
axum::routing::delete(routes::uninstall_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/mode",
|
||||
axum::routing::put(routes::set_agent_mode),
|
||||
)
|
||||
.route("/api/profiles", axum::routing::get(routes::list_profiles))
|
||||
.route(
|
||||
"/api/agents/{id}/restart",
|
||||
axum::routing::post(routes::restart_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/start",
|
||||
axum::routing::post(routes::restart_agent),
|
||||
)
|
||||
.route(
|
||||
// Issue #890 — alias so dashboards and external orchestrators can
|
||||
// wake an inactive agent via a verb that matches the agent_activate tool.
|
||||
"/api/agents/{id}/activate",
|
||||
axum::routing::post(routes::restart_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/message",
|
||||
axum::routing::post(routes::send_message),
|
||||
@@ -281,11 +357,21 @@ pub async fn build_router(
|
||||
"/api/schedules/{id}/run",
|
||||
axum::routing::post(routes::run_schedule),
|
||||
)
|
||||
.route(
|
||||
"/api/schedules/{id}/delivery-log",
|
||||
axum::routing::get(routes::schedule_delivery_log),
|
||||
)
|
||||
// Workflow endpoints
|
||||
.route(
|
||||
"/api/workflows",
|
||||
axum::routing::get(routes::list_workflows).post(routes::create_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/workflows/{id}",
|
||||
axum::routing::get(routes::get_workflow)
|
||||
.put(routes::update_workflow)
|
||||
.delete(routes::delete_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/workflows/{id}/run",
|
||||
axum::routing::post(routes::run_workflow),
|
||||
@@ -304,6 +390,23 @@ pub async fn build_router(
|
||||
"/api/skills/uninstall",
|
||||
axum::routing::post(routes::uninstall_skill),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/reload",
|
||||
axum::routing::post(routes::reload_skills),
|
||||
)
|
||||
// Audit trail (issue #1174 — instance-side wrapper integration)
|
||||
.route(
|
||||
"/api/audit/append",
|
||||
axum::routing::post(routes::audit_append),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/{id}/config",
|
||||
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/{id}/config/{var_name}",
|
||||
axum::routing::delete(routes::delete_skill_config_var),
|
||||
)
|
||||
.route(
|
||||
"/api/marketplace/search",
|
||||
axum::routing::get(routes::marketplace_search),
|
||||
@@ -335,6 +438,10 @@ pub async fn build_router(
|
||||
"/api/hands/install",
|
||||
axum::routing::post(routes::install_hand),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/upsert",
|
||||
axum::routing::post(routes::upsert_hand),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/active",
|
||||
axum::routing::get(routes::list_active_hands),
|
||||
@@ -354,8 +461,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/hands/{hand_id}/settings",
|
||||
axum::routing::get(routes::get_hand_settings)
|
||||
.put(routes::update_hand_settings),
|
||||
axum::routing::get(routes::get_hand_settings).put(routes::update_hand_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/instances/{id}/pause",
|
||||
@@ -412,14 +518,11 @@ pub async fn build_router(
|
||||
"/api/comms/events/stream",
|
||||
axum::routing::get(routes::comms_events_stream),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/send",
|
||||
axum::routing::post(routes::comms_send),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/task",
|
||||
axum::routing::post(routes::comms_task),
|
||||
)
|
||||
.route("/api/comms/send", axum::routing::post(routes::comms_send))
|
||||
.route("/api/comms/task", axum::routing::post(routes::comms_task));
|
||||
|
||||
// Split into a second router chunk to stay within axum's type nesting limit.
|
||||
let app = app
|
||||
// Tools endpoint
|
||||
.route("/api/tools", axum::routing::get(routes::list_tools))
|
||||
// Config endpoints
|
||||
@@ -464,8 +567,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/budget/agents/{id}",
|
||||
axum::routing::get(routes::agent_budget_status)
|
||||
.put(routes::update_agent_budget),
|
||||
axum::routing::get(routes::agent_budget_status).put(routes::update_agent_budget),
|
||||
)
|
||||
// Session endpoints
|
||||
.route("/api/sessions", axum::routing::get(routes::list_sessions))
|
||||
@@ -556,6 +658,10 @@ pub async fn build_router(
|
||||
"/api/cron/jobs/{id}/status",
|
||||
axum::routing::get(routes::cron_job_status),
|
||||
)
|
||||
.route(
|
||||
"/api/cron/jobs/{id}/run",
|
||||
axum::routing::post(routes::run_cron_job),
|
||||
)
|
||||
// Webhook trigger endpoints (external event injection)
|
||||
.route("/hooks/wake", axum::routing::post(routes::webhook_wake))
|
||||
.route("/hooks/agent", axum::routing::post(routes::webhook_agent))
|
||||
@@ -669,8 +775,12 @@ pub async fn build_router(
|
||||
"/v1/models",
|
||||
axum::routing::get(crate::openai_compat::list_models),
|
||||
)
|
||||
// Dashboard authentication endpoints
|
||||
.route("/api/auth/login", axum::routing::post(routes::auth_login))
|
||||
.route("/api/auth/logout", axum::routing::post(routes::auth_logout))
|
||||
.route("/api/auth/check", axum::routing::get(routes::auth_check))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
api_key,
|
||||
auth_state,
|
||||
middleware::auth,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
@@ -787,8 +897,7 @@ pub async fn run_daemon(
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(1024)?;
|
||||
let listener =
|
||||
tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
|
||||
let listener = tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
|
||||
|
||||
// Run server with graceful shutdown.
|
||||
// SECURITY: `into_make_service_with_connect_info` injects the peer
|
||||
@@ -919,11 +1028,8 @@ fn is_daemon_responding(addr: &str) -> bool {
|
||||
.or_else(|| addr.strip_prefix("https://"))
|
||||
.unwrap_or(addr);
|
||||
if let Ok(sock_addr) = addr_only.parse::<std::net::SocketAddr>() {
|
||||
std::net::TcpStream::connect_timeout(
|
||||
&sock_addr,
|
||||
std::time::Duration::from_millis(500),
|
||||
)
|
||||
.is_ok()
|
||||
std::net::TcpStream::connect_timeout(&sock_addr, std::time::Duration::from_millis(500))
|
||||
.is_ok()
|
||||
} else {
|
||||
// Fallback: try connecting to hostname
|
||||
std::net::TcpStream::connect(addr_only)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Stateless session token authentication for the dashboard.
|
||||
//! Tokens are HMAC-SHA256 signed and contain username + expiry.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Create a session token: base64(username:expiry_unix:hmac_hex)
|
||||
pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> String {
|
||||
use base64::Engine;
|
||||
let expiry = chrono::Utc::now().timestamp() + (ttl_hours as i64 * 3600);
|
||||
let payload = format!("{username}:{expiry}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key");
|
||||
mac.update(payload.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
|
||||
}
|
||||
|
||||
/// Extract the `openfang_session` cookie value from a `Cookie` header string.
|
||||
///
|
||||
/// Returns `None` if the header is absent or the cookie is not present.
|
||||
/// Used by both the HTTP auth middleware and the WebSocket upgrade handler so
|
||||
/// that browser sessions established via `sessionLogin()` are honored on both
|
||||
/// surfaces (issue #1085).
|
||||
pub fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies.split(';').find_map(|c| {
|
||||
c.trim()
|
||||
.strip_prefix("openfang_session=")
|
||||
.map(|v| v.to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify a session token. Returns the username if valid and not expired.
|
||||
pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
|
||||
use base64::Engine;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(token)
|
||||
.ok()?;
|
||||
let decoded_str = String::from_utf8(decoded).ok()?;
|
||||
let parts: Vec<&str> = decoded_str.splitn(3, ':').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let (username, expiry_str, provided_sig) = (parts[0], parts[1], parts[2]);
|
||||
|
||||
let expiry: i64 = expiry_str.parse().ok()?;
|
||||
if chrono::Utc::now().timestamp() > expiry {
|
||||
return None;
|
||||
}
|
||||
|
||||
let payload = format!("{username}:{expiry_str}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
|
||||
mac.update(payload.as_bytes());
|
||||
let expected_sig = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
if provided_sig.len() != expected_sig.len() {
|
||||
return None;
|
||||
}
|
||||
if provided_sig
|
||||
.as_bytes()
|
||||
.ct_eq(expected_sig.as_bytes())
|
||||
.into()
|
||||
{
|
||||
Some(username.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash a password with 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 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 Argon2id hash (PHC string format).
|
||||
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
|
||||
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
|
||||
let Ok(parsed) = PasswordHash::new(stored_hash) else {
|
||||
return false;
|
||||
};
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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);
|
||||
let user = verify_session_token(&token, "my-secret");
|
||||
assert_eq!(user, Some("admin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_wrong_secret() {
|
||||
let token = create_session_token("admin", "my-secret", 1);
|
||||
let user = verify_session_token(&token, "wrong-secret");
|
||||
assert_eq!(user, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_invalid_base64() {
|
||||
let user = verify_session_token("not-valid-base64!!!", "secret");
|
||||
assert_eq!(user, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_garbage_input() {
|
||||
assert!(!verify_password("x", "short"));
|
||||
assert!(!verify_password("x", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_malformed_argon2_hash() {
|
||||
// Starts with $argon2 but is not a valid PHC string.
|
||||
assert!(!verify_password("x", "$argon2id$garbage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_cookie_present() {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert(
|
||||
"cookie",
|
||||
"foo=bar; openfang_session=abc.def.ghi; baz=qux"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(extract_session_cookie(&h).as_deref(), Some("abc.def.ghi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_cookie_absent() {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert("cookie", "foo=bar; baz=qux".parse().unwrap());
|
||||
assert_eq!(extract_session_cookie(&h), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_cookie_no_header() {
|
||||
let h = axum::http::HeaderMap::new();
|
||||
assert_eq!(extract_session_cookie(&h), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_session_cookie_only_value() {
|
||||
let mut h = axum::http::HeaderMap::new();
|
||||
h.insert("cookie", "openfang_session=lonely".parse().unwrap());
|
||||
assert_eq!(extract_session_cookie(&h).as_deref(), Some("lonely"));
|
||||
}
|
||||
}
|
||||
@@ -140,9 +140,23 @@ impl StreamChunker {
|
||||
}
|
||||
|
||||
/// Find the last occurrence of a pattern within a byte range.
|
||||
///
|
||||
/// Both `range.start` and `range.end` are clamped to the nearest valid UTF-8
|
||||
/// char boundary so that slicing never panics on multi-byte content.
|
||||
fn find_last_in_range(text: &str, pattern: &str, range: &std::ops::Range<usize>) -> Option<usize> {
|
||||
let search_text = &text[range.start..range.end.min(text.len())];
|
||||
search_text.rfind(pattern).map(|pos| range.start + pos)
|
||||
let len = text.len();
|
||||
// Clamp end to text length and walk back to a char boundary
|
||||
let mut end = range.end.min(len);
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
// Walk start forward to the nearest char boundary (never past end)
|
||||
let mut start = range.start.min(end);
|
||||
while start < end && !text.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
let search_text = &text[start..end];
|
||||
search_text.rfind(pattern).map(|pos| start + pos)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Request to spawn an agent from a TOML manifest string.
|
||||
/// Request to spawn an agent from a TOML manifest string or a template name.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SpawnRequest {
|
||||
/// Agent manifest as TOML string.
|
||||
/// Agent manifest as TOML string (optional if `template` is provided).
|
||||
#[serde(default)]
|
||||
pub manifest_toml: String,
|
||||
/// Template name from `~/.openfang/agents/{template}/agent.toml`.
|
||||
/// When provided and `manifest_toml` is empty, the template is loaded automatically.
|
||||
#[serde(default)]
|
||||
pub template: Option<String>,
|
||||
/// Optional Ed25519 signed manifest envelope (JSON).
|
||||
/// When present, the signature is verified before spawning.
|
||||
#[serde(default)]
|
||||
@@ -37,6 +42,12 @@ pub struct MessageRequest {
|
||||
/// Optional file attachments (uploaded via /upload endpoint).
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<AttachmentRef>,
|
||||
/// Sender identity (e.g. WhatsApp phone number, Telegram user ID).
|
||||
#[serde(default)]
|
||||
pub sender_id: Option<String>,
|
||||
/// Sender display name.
|
||||
#[serde(default)]
|
||||
pub sender_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from sending a message.
|
||||
@@ -54,6 +65,15 @@ pub struct MessageResponse {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SkillInstallRequest {
|
||||
pub name: String,
|
||||
/// When true, reject the install unless the bundle ships a valid
|
||||
/// Ed25519 SignedManifest envelope bound to the on-disk manifest.
|
||||
/// Maps to `InstallOptions::require_signed` (issue #1170).
|
||||
#[serde(default)]
|
||||
pub require_signed: bool,
|
||||
/// Optional hex-encoded allow-list of acceptable signer public keys.
|
||||
/// Empty = TOFU (any valid signature accepted).
|
||||
#[serde(default)]
|
||||
pub allowed_signer_keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Request to uninstall a skill.
|
||||
@@ -96,3 +116,105 @@ pub struct ClawHubInstallRequest {
|
||||
/// ClawHub skill slug (e.g., "github-helper").
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
/// Query parameters for `GET /api/commands`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CommandsQuery {
|
||||
/// Surface filter: `web` (default), `cli`, `channel`, or `all`.
|
||||
#[serde(default)]
|
||||
pub surface: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/audit/append` (issue #1174).
|
||||
///
|
||||
/// Lets external (instance-side) wrappers append entries to the Merkle hash
|
||||
/// chain audit log. The handler maps `event_type` to an `AuditAction` and
|
||||
/// records the entry through `kernel.audit_log`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuditAppendRequest {
|
||||
/// Operator-supplied event category. Case-insensitive, matched against the
|
||||
/// `AuditAction` enum variants (e.g. `tool_invoke`, `ConfigChange`,
|
||||
/// `agent_message`). Unknown values fall back to `ToolInvoke`.
|
||||
pub event_type: String,
|
||||
/// Agent or wrapper identifier responsible for the event. When empty,
|
||||
/// recorded as `"external-wrapper"`.
|
||||
#[serde(default)]
|
||||
pub agent_id: String,
|
||||
/// Free-form detail string (e.g. tool name, URL, file path).
|
||||
#[serde(default)]
|
||||
pub detail: String,
|
||||
/// Optional arbitrary payload. When present it is serialised to JSON and
|
||||
/// appended onto the entry's detail so the wrapper retains structured
|
||||
/// context without changing the on-chain schema.
|
||||
#[serde(default)]
|
||||
pub payload: Option<serde_json::Value>,
|
||||
/// Optional outcome string (`"ok"`, `"denied"`, or an error). Defaults to
|
||||
/// `"ok"` when omitted.
|
||||
#[serde(default)]
|
||||
pub outcome: Option<String>,
|
||||
/// Optional operator-supplied signing context (e.g. wrapper identity, key
|
||||
/// fingerprint). Mixed into the detail when present so the chain captures
|
||||
/// who attested to the event.
|
||||
#[serde(default)]
|
||||
pub signing_context: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skill_install_request_defaults_back_compat() {
|
||||
// Existing callers send `{"name": "..."}` only. New optional fields
|
||||
// must default cleanly (issue #1170).
|
||||
let req: SkillInstallRequest =
|
||||
serde_json::from_str(r#"{"name":"github-helper"}"#).unwrap();
|
||||
assert_eq!(req.name, "github-helper");
|
||||
assert!(!req.require_signed);
|
||||
assert!(req.allowed_signer_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_install_request_parses_require_signed() {
|
||||
let req: SkillInstallRequest = serde_json::from_str(
|
||||
r#"{"name":"x","require_signed":true,"allowed_signer_keys":["abc123"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(req.require_signed);
|
||||
assert_eq!(req.allowed_signer_keys, vec!["abc123".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_append_request_required_only() {
|
||||
// Only `event_type` is required; everything else must default.
|
||||
let req: AuditAppendRequest =
|
||||
serde_json::from_str(r#"{"event_type":"ToolInvoke"}"#).unwrap();
|
||||
assert_eq!(req.event_type, "ToolInvoke");
|
||||
assert!(req.agent_id.is_empty());
|
||||
assert!(req.detail.is_empty());
|
||||
assert!(req.payload.is_none());
|
||||
assert!(req.outcome.is_none());
|
||||
assert!(req.signing_context.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_append_request_full_payload() {
|
||||
let body = r#"{
|
||||
"event_type": "config_change",
|
||||
"agent_id": "wrapper-1",
|
||||
"detail": "rotated key",
|
||||
"payload": {"key_id": "k-42", "ts": 1700000000},
|
||||
"outcome": "ok",
|
||||
"signing_context": "ed25519:deadbeef"
|
||||
}"#;
|
||||
let req: AuditAppendRequest = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(req.event_type, "config_change");
|
||||
assert_eq!(req.agent_id, "wrapper-1");
|
||||
assert_eq!(req.detail, "rotated key");
|
||||
assert_eq!(req.outcome.as_deref(), Some("ok"));
|
||||
assert_eq!(req.signing_context.as_deref(), Some("ed25519:deadbeef"));
|
||||
let payload = req.payload.expect("payload present");
|
||||
assert_eq!(payload["key_id"], "k-42");
|
||||
assert_eq!(payload["ts"], 1_700_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
use axum::http::header;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
/// Nonce placeholder in compile-time HTML, replaced at request time.
|
||||
const NONCE_PLACEHOLDER: &str = "__NONCE__";
|
||||
|
||||
/// Compile-time ETag based on the crate version.
|
||||
/// Not used for the dashboard page (nonce prevents caching) but retained
|
||||
/// for potential future use by static asset handlers.
|
||||
#[allow(dead_code)]
|
||||
const ETAG: &str = concat!("\"openfang-", env!("CARGO_PKG_VERSION"), "\"");
|
||||
|
||||
/// Embedded logo PNG for single-binary deployment.
|
||||
@@ -46,20 +52,65 @@ pub async fn favicon_ico() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
/// GET / — Serve the OpenFang Dashboard single-page application.
|
||||
///
|
||||
/// Returns the full SPA with ETag header based on package version for caching.
|
||||
pub async fn webchat_page() -> impl IntoResponse {
|
||||
/// Embedded PWA manifest for installable web app support.
|
||||
const MANIFEST_JSON: &str = include_str!("../static/manifest.json");
|
||||
|
||||
/// Embedded service worker for PWA support.
|
||||
const SW_JS: &str = include_str!("../static/sw.js");
|
||||
|
||||
/// GET /manifest.json — Serve the PWA web app manifest.
|
||||
pub async fn manifest_json() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
|
||||
(header::ETAG, ETAG),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
"public, max-age=3600, must-revalidate",
|
||||
),
|
||||
(header::CONTENT_TYPE, "application/manifest+json"),
|
||||
(header::CACHE_CONTROL, "public, max-age=86400, immutable"),
|
||||
],
|
||||
WEBCHAT_HTML,
|
||||
MANIFEST_JSON,
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /sw.js — Serve the PWA service worker.
|
||||
pub async fn sw_js() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
SW_JS,
|
||||
)
|
||||
}
|
||||
|
||||
/// GET / — Serve the OpenFang Dashboard single-page application.
|
||||
///
|
||||
/// Generates a unique CSP nonce on every request and injects it into both
|
||||
/// the `<script>` tags and the `Content-Security-Policy` header. This
|
||||
/// replaces `'unsafe-inline'` so only our own scripts execute.
|
||||
pub async fn webchat_page() -> impl IntoResponse {
|
||||
let nonce = uuid::Uuid::new_v4().to_string();
|
||||
let html = WEBCHAT_HTML.replace(NONCE_PLACEHOLDER, &nonce);
|
||||
let csp = format!(
|
||||
"default-src 'self'; \
|
||||
script-src 'self' 'nonce-{nonce}' 'unsafe-eval' https://cdn.jsdelivr.net; \
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; \
|
||||
img-src 'self' data: blob:; \
|
||||
connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:* https://cdn.jsdelivr.net; \
|
||||
font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; \
|
||||
media-src 'self' blob:; \
|
||||
frame-src 'self' blob:; \
|
||||
object-src 'none'; \
|
||||
base-uri 'self'; \
|
||||
form-action 'self'"
|
||||
);
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
|
||||
(
|
||||
header::HeaderName::from_static("content-security-policy"),
|
||||
csp,
|
||||
),
|
||||
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||
],
|
||||
html,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,6 +120,7 @@ pub async fn webchat_page() -> impl IntoResponse {
|
||||
/// All vendor libraries (Alpine.js, marked.js, highlight.js) are bundled
|
||||
/// locally — no CDN dependency. Alpine.js is included LAST because it
|
||||
/// immediately processes x-data directives and fires alpine:init on load.
|
||||
/// KaTeX is loaded dynamically from jsdelivr CDN when needed for LaTeX rendering.
|
||||
const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/index_head.html"),
|
||||
"<style>\n",
|
||||
@@ -81,21 +133,26 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/vendor/github-dark.min.css"),
|
||||
"\n</style>\n",
|
||||
include_str!("../static/index_body.html"),
|
||||
// Vendor libs: marked + highlight first (used by app.js)
|
||||
"<script>\n",
|
||||
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/marked.min.js"),
|
||||
"\n</script>\n",
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/highlight.min.js"),
|
||||
"\n</script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/chart.umd.min.js"),
|
||||
"\n</script>\n",
|
||||
// App code
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/js/api.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/app.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/overview.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/katex.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/chat.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/agents.js"),
|
||||
@@ -129,7 +186,7 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/js/pages/runtime.js"),
|
||||
"\n</script>\n",
|
||||
// Alpine.js MUST be last — it processes x-data and fires alpine:init
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/alpine.min.js"),
|
||||
"\n</script>\n",
|
||||
"</body></html>"
|
||||
|
||||
+869
-116
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: var(--shadow-xs), var(--shadow-inset);
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-dim); box-shadow: var(--shadow-sm), var(--shadow-accent); transform: translateY(-1px); }
|
||||
@@ -69,6 +69,32 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Card-based flex containers for agent chips and similar inline layouts */
|
||||
.card-flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Nested list indentation inside cards, detail panels, and modals */
|
||||
.card ul, .card ol,
|
||||
.detail-grid ul, .detail-grid ol,
|
||||
.modal ul, .modal ol,
|
||||
.info-card ul, .info-card ol {
|
||||
padding-left: 18px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.card ul ul, .card ol ol,
|
||||
.modal ul ul, .modal ol ol {
|
||||
padding-left: 16px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.card li, .modal li, .info-card li {
|
||||
margin-bottom: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Glow effect on card hover */
|
||||
.card-glow {
|
||||
overflow: hidden;
|
||||
@@ -90,13 +116,17 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge + .badge { margin-left: 4px; }
|
||||
|
||||
.badge-running { background: rgba(74,222,128,0.12); color: var(--success); }
|
||||
.badge-suspended { background: rgba(245,158,11,0.12); color: var(--warning); }
|
||||
@@ -110,7 +140,7 @@
|
||||
.badge-error { background: rgba(239,68,68,0.12); color: var(--error); }
|
||||
.badge-muted { background: rgba(148,163,184,0.12); color: var(--text-dim); }
|
||||
.badge-info { background: rgba(59,130,246,0.12); color: var(--info); }
|
||||
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; }
|
||||
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; padding: 2px 6px; }
|
||||
.text-danger { color: var(--error); }
|
||||
|
||||
/* Tables */
|
||||
@@ -282,6 +312,12 @@ tr:hover td { background: var(--surface2); }
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
/* Issue #1026: live indicator for agents currently calling the LLM */
|
||||
@keyframes agent-inferencing-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 var(--accent); }
|
||||
50% { transform: scale(1.25); opacity: 0.85; box-shadow: 0 0 0 4px rgba(255, 92, 0, 0); }
|
||||
}
|
||||
|
||||
.message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
@@ -508,7 +544,7 @@ tr:hover td { background: var(--surface2); }
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
@@ -617,6 +653,11 @@ mark.search-highlight {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.message-bubble.markdown-body ul,
|
||||
.message-bubble.markdown-body ol {
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -829,7 +870,7 @@ mark.search-highlight {
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -949,6 +990,14 @@ mark.search-highlight {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-switcher-search select {
|
||||
max-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.model-switcher-search select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.model-switcher-search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
@@ -1236,8 +1285,11 @@ mark.search-highlight {
|
||||
/* Utility */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.grid { display: grid; }
|
||||
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
@@ -1245,6 +1297,7 @@ mark.search-highlight {
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mb-2 { margin-bottom: 8px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.text-sm { font-size: 11px; }
|
||||
.text-xs { font-size: 10px; }
|
||||
@@ -1947,7 +2000,7 @@ mark.search-highlight {
|
||||
}
|
||||
|
||||
.filter-pill:hover { border-color: var(--accent); color: var(--text); }
|
||||
.filter-pill.active { background: var(--accent); color: var(--bg-primary); border-color: var(--accent); }
|
||||
.filter-pill.active { background: var(--accent); color: var(--text-on-accent); border-color: var(--accent); }
|
||||
|
||||
/* ── Difficulty badges ── */
|
||||
.difficulty-badge {
|
||||
@@ -2211,7 +2264,7 @@ mark.search-highlight {
|
||||
.wizard-progress-step.wiz-active .wizard-progress-circle {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
}
|
||||
|
||||
@@ -2438,7 +2491,7 @@ mark.search-highlight {
|
||||
/* ── Try-It Mini Chat ── */
|
||||
.tryit-messages { max-height: 200px; overflow-y: auto; margin: 12px 0; }
|
||||
.tryit-msg { padding: 6px 10px; border-radius: 6px; margin: 4px 0; font-size: 12px; line-height: 1.5; word-break: break-word; }
|
||||
.tryit-msg-user { background: var(--accent); color: var(--bg-primary); margin-left: 40px; }
|
||||
.tryit-msg-user { background: var(--accent); color: var(--text-on-accent); margin-left: 40px; }
|
||||
.tryit-msg-agent { background: var(--surface2); margin-right: 40px; }
|
||||
|
||||
/* ── Suggested Message Chips ── */
|
||||
@@ -2456,7 +2509,7 @@ mark.search-highlight {
|
||||
.channel-steps { display: flex; align-items: center; gap: 0; margin-bottom: 20px; }
|
||||
.channel-step-item { display: flex; align-items: center; gap: 6px; flex: 1; }
|
||||
.channel-step-num { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; border: 2px solid var(--border); color: var(--text-dim); flex-shrink: 0; transition: all 0.2s; }
|
||||
.channel-step-num.active { border-color: var(--accent); background: var(--accent); color: var(--bg-primary); }
|
||||
.channel-step-num.active { border-color: var(--accent); background: var(--accent); color: var(--text-on-accent); }
|
||||
.channel-step-num.done { border-color: var(--success); background: var(--success); color: #000; }
|
||||
.channel-step-label { font-size: 11px; color: var(--text-dim); }
|
||||
.channel-step-label.active { color: var(--accent); font-weight: 600; }
|
||||
@@ -2473,7 +2526,7 @@ mark.search-highlight {
|
||||
.wizard-category-pills { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.wizard-category-pill { padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 600; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--text-dim); transition: all 0.15s; font-family: var(--font-mono); }
|
||||
.wizard-category-pill:hover { border-color: var(--accent); color: var(--text); }
|
||||
.wizard-category-pill.active { background: var(--accent); color: var(--bg-primary); border-color: var(--accent); }
|
||||
.wizard-category-pill.active { background: var(--accent); color: var(--text-on-accent); border-color: var(--accent); }
|
||||
|
||||
/* ── Capability Preview Panel ── */
|
||||
.capability-preview { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px; margin-top: 12px; }
|
||||
@@ -2579,7 +2632,7 @@ mark.search-highlight {
|
||||
.personality-pill.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: 0 0 12px var(--accent-subtle);
|
||||
}
|
||||
|
||||
@@ -2646,7 +2699,7 @@ mark.search-highlight {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.nav-section-chevron {
|
||||
font-size: 8px;
|
||||
font-size: 16px;
|
||||
transition: transform var(--transition-fast);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -2752,7 +2805,7 @@ mark.search-highlight {
|
||||
.hand-step-item.active .hand-step-num {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.hand-step-item.done .hand-step-num {
|
||||
border-color: var(--success);
|
||||
@@ -3200,3 +3253,207 @@ mark.search-highlight {
|
||||
.comms-event-row:hover { background: var(--bg-hover); }
|
||||
.comms-event-time { min-width: 50px; text-align: right; }
|
||||
.comms-event-detail { margin-left: auto; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Trader Dashboard
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.trader-dashboard {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 96vw;
|
||||
max-width: 1200px;
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.trader-dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface);
|
||||
z-index: 10;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
.trader-dashboard-body {
|
||||
padding: 16px 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* KPI Cards */
|
||||
.trader-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.trader-kpi-row { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.trader-kpi-row { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
.trader-kpi-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.trader-kpi-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.trader-kpi-value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.kpi-positive { color: var(--success) !important; }
|
||||
.kpi-negative { color: var(--error) !important; }
|
||||
|
||||
/* Chart Rows */
|
||||
.trader-chart-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.trader-chart-row { flex-direction: column; }
|
||||
}
|
||||
.trader-chart-panel {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
.trader-chart-title {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.trader-chart-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
}
|
||||
.trader-chart-wrap canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
.trader-chart-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Heatmap Table */
|
||||
.trader-heatmap-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.trader-heatmap-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.trader-heatmap-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.trader-heatmap-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.heatmap-positive { color: var(--success); font-weight: 600; }
|
||||
.heatmap-negative { color: var(--error); font-weight: 600; }
|
||||
|
||||
/* Signal Badges */
|
||||
.signal-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.signal-strong_buy, .signal-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.signal-sell, .signal-strong_sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
.signal-hold { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
|
||||
|
||||
/* Confidence Bar */
|
||||
.confidence-bar-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 100px;
|
||||
}
|
||||
.confidence-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.conf-high { background: var(--success); }
|
||||
.conf-mid { background: var(--warning); }
|
||||
.conf-low { background: var(--error); }
|
||||
.confidence-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
min-width: 32px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Trades Table */
|
||||
.trader-trades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.trader-trades-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.trader-trades-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.trade-side-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.trade-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.trade-sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* OpenFang Layout — Grid + Sidebar + Responsive */
|
||||
|
||||
/* Firefox compat: hide x-cloak elements until Alpine.js initializes.
|
||||
Without this, the sidebar flashes hidden in Firefox while Alpine
|
||||
processes the nested x-data scopes for nav sections. */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
@@ -55,6 +60,11 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
[data-theme="light"] .sidebar-logo img,
|
||||
[data-theme="light"] .message-avatar img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
@@ -115,11 +125,11 @@
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
font-size: 9px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-muted);
|
||||
padding: 12px 12px 4px;
|
||||
padding: 12px 12px 6px 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -149,7 +159,7 @@
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-sm), 0 2px 8px rgba(255, 92, 0, 0.2);
|
||||
}
|
||||
@@ -238,6 +248,14 @@
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
position: fixed !important;
|
||||
top: 12px;
|
||||
left: 16px;
|
||||
z-index: 98;
|
||||
padding: 6px 10px !important;
|
||||
}
|
||||
|
||||
/* Wide desktop — larger card grids */
|
||||
@media (min-width: 1400px) {
|
||||
.card-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||
@@ -272,6 +290,8 @@
|
||||
left: -300px;
|
||||
}
|
||||
.mobile-menu-btn { display: flex !important; }
|
||||
/* Offset header content so it does not overlap the fixed mobile menu button. */
|
||||
.page-header > :first-child { margin-left: 52px; }
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
@@ -280,7 +300,14 @@
|
||||
|
||||
/* Mobile small screen */
|
||||
@media (max-width: 480px) {
|
||||
.page-header { flex-direction: column; gap: 8px; align-items: flex-start; padding: 12px 16px; }
|
||||
.page-header {
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-header h2 {
|
||||
line-height: 44px;
|
||||
}
|
||||
.page-body { padding: 12px; }
|
||||
.stats-row { flex-wrap: wrap; }
|
||||
.stat-card { min-width: 80px; flex: 1 1 40%; }
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
/* Chat-specific */
|
||||
--agent-bg: #F5F4F2;
|
||||
--user-bg: #FFF3E6;
|
||||
--text-on-accent: #FFFFFF;
|
||||
|
||||
/* Layout */
|
||||
--sidebar-width: 240px;
|
||||
@@ -91,16 +92,17 @@
|
||||
--bg: #080706;
|
||||
--bg-primary: #0F0E0E;
|
||||
--bg-elevated: #161413;
|
||||
--surface: #1F1D1C;
|
||||
--surface2: #2A2725;
|
||||
--surface: #242221;
|
||||
--surface2: #2F2D2C;
|
||||
--surface3: #1A1817;
|
||||
--border: #2D2A28;
|
||||
--border-light: #3D3A38;
|
||||
--border-subtle: #232120;
|
||||
--text: #F0EFEE;
|
||||
--text-secondary: #C4C0BC;
|
||||
--text-dim: #8A8380;
|
||||
--text-muted: #5C5754;
|
||||
--border: #363230;
|
||||
--border-light: #4A4644;
|
||||
--border-subtle: #2D2A28;
|
||||
--text: #FFFFFF;
|
||||
--text-secondary: #D1D1D1;
|
||||
--text-dim: #9FA0A0;
|
||||
--text-muted: #6B6663;
|
||||
--text-on-accent: #FFFFFF;
|
||||
--accent: #FF5C00;
|
||||
--accent-light: #FF7A2E;
|
||||
--accent-dim: #E05200;
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
{
|
||||
"app.name": "OpenFang",
|
||||
"app.version": "v",
|
||||
|
||||
"nav.chat": "Chat",
|
||||
"nav.monitor": "Monitor",
|
||||
"nav.overview": "Overview",
|
||||
"nav.analytics": "Analytics",
|
||||
"nav.logs": "Logs",
|
||||
"nav.agents": "Agents",
|
||||
"nav.sessions": "Sessions",
|
||||
"nav.approvals": "Approvals",
|
||||
"nav.comms": "Comms",
|
||||
"nav.automation": "Automation",
|
||||
"nav.workflows": "Workflows",
|
||||
"nav.scheduler": "Scheduler",
|
||||
"nav.extensions": "Extensions",
|
||||
"nav.channels": "Channels",
|
||||
"nav.skills": "Skills",
|
||||
"nav.hands": "Hands",
|
||||
"nav.system": "System",
|
||||
"nav.runtime": "Runtime",
|
||||
"nav.settings": "Settings",
|
||||
|
||||
"auth.sign_in": "Sign In",
|
||||
"auth.enter_credentials": "Enter your dashboard credentials.",
|
||||
"auth.username": "Username",
|
||||
"auth.password": "Password",
|
||||
"auth.api_key_required": "API Key Required",
|
||||
"auth.api_key_desc": "This instance requires an API key. Enter the key from your config.toml.",
|
||||
"auth.api_key_hint": "Add api_key = \"your-key\" at the top of ~/.openfang/config.toml (not under any [section]).",
|
||||
"auth.enter_api_key": "Enter API key...",
|
||||
"auth.unlock_dashboard": "Unlock Dashboard",
|
||||
"auth.login_failed": "Login failed",
|
||||
|
||||
"status.agents_running": "agent(s) running",
|
||||
"status.connecting": "Connecting...",
|
||||
"status.reconnecting": "Reconnecting...",
|
||||
"status.disconnected": "disconnected",
|
||||
"status.ws": "WS",
|
||||
"status.http": "HTTP",
|
||||
"status.ready": "Ready",
|
||||
"status.loading": "Loading...",
|
||||
"status.loading_workflows": "Loading workflows...",
|
||||
"status.loading_channels": "Loading channels...",
|
||||
"status.loading_skills": "Loading skills...",
|
||||
"status.loading_jobs": "Loading scheduled jobs...",
|
||||
"status.loading_triggers": "Loading triggers...",
|
||||
"status.loading_history": "Loading run history...",
|
||||
"status.loading_hands": "Loading hands...",
|
||||
"status.loading_active_hands": "Loading active hands...",
|
||||
"status.loading_mcp": "Loading MCP servers...",
|
||||
"status.loading_files": "Loading files...",
|
||||
"status.loading_skills_details": "Loading skills details...",
|
||||
"status.no_channels_match": "No channels match your search",
|
||||
|
||||
"actions.logout": "Logout",
|
||||
"actions.new_agent": "New Agent",
|
||||
"actions.browse_skills": "Browse Skills",
|
||||
"actions.add_channel": "Add Channel",
|
||||
"actions.create_workflow": "Create Workflow",
|
||||
"actions.settings": "Settings",
|
||||
"actions.create_agent": "Create Agent",
|
||||
"actions.configure_provider": "Configure Provider",
|
||||
"actions.cancel": "Cancel",
|
||||
"actions.confirm": "Confirm",
|
||||
"actions.save": "Save",
|
||||
"actions.delete": "Delete",
|
||||
"actions.edit": "Edit",
|
||||
"actions.clone": "Clone",
|
||||
"actions.stop": "Stop",
|
||||
"actions.run": "Run",
|
||||
"actions.enable": "Enable",
|
||||
"actions.disable": "Disable",
|
||||
"actions.view_all": "View All",
|
||||
"actions.retry": "Retry",
|
||||
"actions.refresh": "Refresh",
|
||||
"actions.approve": "Approve",
|
||||
"actions.reject": "Reject",
|
||||
"actions.update": "Update",
|
||||
"actions.test_connection": "Test Connection",
|
||||
"actions.remove": "Remove",
|
||||
"actions.save_test": "Save & Test",
|
||||
"actions.export_toml": "Export TOML",
|
||||
"actions.save_workflow": "Save Workflow",
|
||||
"actions.auto_layout": "Auto Layout",
|
||||
"actions.clear": "Clear",
|
||||
"actions.zoom_out": "Zoom out",
|
||||
"actions.zoom_in": "Zoom in",
|
||||
"actions.fit": "Fit",
|
||||
"actions.duplicate": "Duplicate",
|
||||
"actions.copy_clipboard": "Copy to Clipboard",
|
||||
"actions.copied": "Copied!",
|
||||
"actions.copy": "Copy",
|
||||
"actions.hide_code": "Hide Code",
|
||||
"actions.view_code": "View Code",
|
||||
"actions.install": "Install",
|
||||
"actions.installing": "Installing...",
|
||||
"actions.installed": "Installed",
|
||||
"actions.load_more": "Load More",
|
||||
"actions.back_to_browse": "Back to browse",
|
||||
"actions.activate": "Activate",
|
||||
"actions.create_schedule": "Create Schedule",
|
||||
"actions.submit": "Submit",
|
||||
"actions.close": "Close",
|
||||
"actions.next": "Next",
|
||||
"actions.back": "Back",
|
||||
"actions.spawn_agent": "Spawn Agent",
|
||||
"actions.spawning": "Spawning...",
|
||||
"actions.create_job": "Create Job",
|
||||
"actions.spawn_wizard": "Wizard",
|
||||
"actions.raw_toml": "Raw TOML",
|
||||
"actions.setup_wizard": "Setup Wizard",
|
||||
"actions.configure_manually": "Configure Manually",
|
||||
"actions.dismiss": "Dismiss",
|
||||
"actions.create_workflow_btn": "Create Workflow",
|
||||
"actions.execute": "Execute",
|
||||
"actions.executing": "Executing...",
|
||||
"actions.generated_toml": "Generated TOML",
|
||||
"actions.running": "Running...",
|
||||
|
||||
"footer.shortcuts": "Ctrl+K agents | Ctrl+N new",
|
||||
|
||||
"theme.light": "Light",
|
||||
"theme.system": "System",
|
||||
"theme.dark": "Dark",
|
||||
|
||||
"errors.connection_error": "Connection Error",
|
||||
"errors.daemon_unreachable": "Cannot reach daemon — is openfang running?",
|
||||
"errors.not_authorized": "Not authorized — check your API key",
|
||||
"errors.permission_denied": "Permission denied",
|
||||
"errors.resource_not_found": "Resource not found",
|
||||
"errors.rate_limited": "Rate limited — slow down and try again",
|
||||
"errors.request_too_large": "Request too large",
|
||||
"errors.server_error": "Server error — check daemon logs",
|
||||
"errors.daemon_unavailable": "Daemon unavailable — is it running?",
|
||||
"errors.unexpected": "Unexpected error",
|
||||
"errors.reconnected": "Reconnected",
|
||||
"errors.connection_lost": "Connection lost, reconnecting...",
|
||||
"errors.switched_http": "Connection lost — switched to HTTP mode",
|
||||
"errors.connection_lost": "Connection lost, reconnecting...",
|
||||
"errors.switched_http": "Connection lost — switched to HTTP mode",
|
||||
"errors.reconnected": "Reconnected",
|
||||
|
||||
"toasts.approval_waiting": "An agent is waiting for approval. Open Approvals to review.",
|
||||
"toasts.agent_created": "Agent Created",
|
||||
"toasts.agent_stopped": "Agent Stopped",
|
||||
"toasts.tool_used": "Tool Used",
|
||||
"toasts.tool_completed": "Tool Completed",
|
||||
"toasts.message_in": "Message In",
|
||||
"toasts.response_sent": "Response Sent",
|
||||
"toasts.session_reset": "Session Reset",
|
||||
"toasts.compacted": "Compacted",
|
||||
"toasts.model_changed": "Model Changed",
|
||||
"toasts.login_attempt": "Login Attempt",
|
||||
"toasts.login_ok": "Login OK",
|
||||
"toasts.login_failed": "Login Failed",
|
||||
"toasts.denied": "Denied",
|
||||
"toasts.rate_limited": "Rate Limited",
|
||||
"toasts.workflow_run": "Workflow Run",
|
||||
"toasts.trigger_fired": "Trigger Fired",
|
||||
"toasts.skill_installed": "Skill Installed",
|
||||
"toasts.mcp_connected": "MCP Connected",
|
||||
"toasts.session_deleted": "Session deleted",
|
||||
|
||||
"overview.welcome": "Welcome to OpenFang",
|
||||
"overview.getting_started": "Getting Started",
|
||||
"overview.setup_wizard": "Setup Wizard",
|
||||
"overview.steps_completed": "of 5 steps completed",
|
||||
"overview.agents_running": "Agents Running",
|
||||
"overview.tokens_used": "Tokens Used",
|
||||
"overview.total_cost": "Total Cost",
|
||||
"overview.uptime": "Uptime",
|
||||
"overview.channels": "Channels",
|
||||
"overview.skills": "Skills",
|
||||
"overview.mcp_servers": "MCP Servers",
|
||||
"overview.tool_calls": "Tool Calls",
|
||||
"overview.providers": "Providers",
|
||||
"overview.recent_activity": "Recent Activity",
|
||||
"overview.no_recent_activity": "No Recent Activity",
|
||||
"overview.chat_with_agent": "Chat with an Agent",
|
||||
"overview.system_health": "System Health",
|
||||
"overview.healthy": "Healthy",
|
||||
"overview.unreachable": "Unreachable",
|
||||
"overview.security_systems": "Security Systems",
|
||||
"overview.llm_providers": "LLM Providers",
|
||||
"overview.defense_active": "9 defense-in-depth systems active",
|
||||
"overview.quick_actions": "Quick Actions",
|
||||
|
||||
"setup.configure_provider": "Configure an LLM provider",
|
||||
"setup.create_first_agent": "Create your first agent",
|
||||
"setup.send_first_message": "Send your first message",
|
||||
"setup.connect_channel": "Connect a messaging channel",
|
||||
"setup.browse_install_skill": "Browse or install a skill",
|
||||
|
||||
"tooltips.cooling_down": "cooling down (rate limited)",
|
||||
"tooltips.circuit_open": "circuit breaker open",
|
||||
"tooltips.ready": "ready",
|
||||
"tooltips.not_configured": "not configured",
|
||||
|
||||
"chat.placeholder": "Message OpenFang... (/ for commands)",
|
||||
"chat.ready": "Ready",
|
||||
"chat.generating": "Generating...",
|
||||
"chat.queued": "queued",
|
||||
"chat.sessions": "Sessions",
|
||||
"chat.new_session": "+ New",
|
||||
"chat.no_sessions": "No sessions",
|
||||
"chat.search_messages": "Search messages...",
|
||||
"chat.select_agent": "Select an agent to start chatting",
|
||||
"chat.recording": "Recording... release to send",
|
||||
"chat.drop_files": "Drop files here",
|
||||
"chat.attach_file": "Attach file",
|
||||
"chat.stop_generating": "Stop generating",
|
||||
"chat.switch_model": "Switch model",
|
||||
"chat.search_models": "Search models...",
|
||||
"chat.no_models_found": "No models found",
|
||||
"chat.available_models": "Available models — pick one or keep typing",
|
||||
"chat.switching": "Switching...",
|
||||
"chat.model_switched": "Switched to",
|
||||
"chat.model_switch_failed": "Model switch failed",
|
||||
"chat.using_http_mode": "Using HTTP mode (no streaming)",
|
||||
"chat.session_name_prompt": "Session name (optional):",
|
||||
"chat.session_created": "Session created",
|
||||
"chat.session_create_failed": "Failed to create session",
|
||||
"chat.stop_agent_title": "Stop Agent",
|
||||
"chat.stop_agent_confirm": "Stop agent",
|
||||
"chat.agent_stopped": "Agent stopped",
|
||||
"chat.stop_agent_failed": "Failed to stop agent",
|
||||
"chat.welcome_message": "**Welcome to OpenFang Chat!**\n\n- Type `/` to see available commands\n- `/help` shows all commands\n- `/think on` enables extended reasoning\n- `/context` shows context window usage\n- `/verbose off` hides tool details\n- `Ctrl+Shift+F` toggles focus mode\n- Drag & drop files to attach them\n- `Ctrl+/` opens the command palette",
|
||||
|
||||
"chat.slash.help": "Show available commands",
|
||||
"chat.slash.agents": "Switch to Agents page",
|
||||
"chat.slash.new": "New session (clear history)",
|
||||
"chat.slash.compact": "Compact session context",
|
||||
"chat.slash.model": "Show or switch model (/model [name])",
|
||||
"chat.slash.stop": "Cancel current agent run",
|
||||
"chat.slash.usage": "Show token usage",
|
||||
"chat.slash.think": "Toggle reasoning (/think [on|off|stream])",
|
||||
"chat.slash.context": "Show context window usage",
|
||||
"chat.slash.verbose": "Toggle tool details (/verbose [off|on|full])",
|
||||
"chat.slash.queue": "Check if agent is processing",
|
||||
"chat.slash.status": "Show system status",
|
||||
"chat.slash.clear": "Clear chat",
|
||||
"chat.slash.exit": "Disconnect from agent",
|
||||
"chat.slash.budget": "Show budget limits and costs",
|
||||
"chat.slash.peers": "Show OFP network status",
|
||||
"chat.slash.a2a": "List A2A agents",
|
||||
|
||||
"commands.help": "Show available commands",
|
||||
"commands.agents": "Switch to Agents page",
|
||||
"commands.new": "Reset session",
|
||||
"commands.switch": "Switch agent",
|
||||
"commands.clear": "Clear conversation",
|
||||
"commands.model": "Switch model",
|
||||
"commands.think": "Toggle reasoning mode",
|
||||
"commands.focus": "Toggle focus mode",
|
||||
"commands.theme": "Cycle theme",
|
||||
|
||||
"tips.commands": "Type / for commands",
|
||||
"tips.think": "/think on for reasoning",
|
||||
"tips.focus": "Ctrl+Shift+F for focus mode",
|
||||
|
||||
"agents.info": "Info",
|
||||
"agents.files": "Files",
|
||||
"agents.config": "Config",
|
||||
"agents.chat": "Chat",
|
||||
"agents.clone": "Clone",
|
||||
"agents.clear_history": "Clear History",
|
||||
"agents.change": "Change",
|
||||
"agents.none_fallback": "None — add a fallback chain",
|
||||
"agents.add": "+ Add",
|
||||
"agents.loading_files": "Loading files...",
|
||||
"agents.no_workspace_files": "No workspace files found",
|
||||
"agents.save_config": "Save Config",
|
||||
"agents.tool_filters": "Tool Filters",
|
||||
"agents.allowlist": "Allowlist",
|
||||
"agents.blocklist": "Blocklist",
|
||||
"agents.agent_name": "Agent Name",
|
||||
"agents.emoji": "Emoji",
|
||||
"agents.color": "Color",
|
||||
"agents.archetype": "Archetype",
|
||||
"agents.provider": "Provider",
|
||||
"agents.model": "Model",
|
||||
"agents.system_prompt": "System Prompt",
|
||||
"agents.soul_persona": "Soul / Persona",
|
||||
"agents.tool_profile": "Tool Profile",
|
||||
"agents.minimal_profile": "Minimal — Read-only file access",
|
||||
"agents.coding_profile": "Coding — Files + shell + web fetch",
|
||||
"agents.fullstack_profile": "Full-Stack — Files + shell + web fetch + search",
|
||||
"agents.research_profile": "Research — Web + search + analysis",
|
||||
"agents.admin_profile": "Admin — Full system access (dangerous)",
|
||||
"agents.agent_created": "Agent Created",
|
||||
"agents.agent_stopped": "Agent Stopped",
|
||||
"agents.agent_deleted": "Agent Deleted",
|
||||
|
||||
"presets.professional": "Professional",
|
||||
"presets.professional_desc": "Precise, business-oriented assistant focused on efficiency and clarity. Prioritizes actionable insights and structured communication.",
|
||||
"presets.professional_soul": "Communicate in a clear, professional tone. Be direct and structured. Use formal language and data-driven reasoning. Prioritize accuracy over personality.",
|
||||
"presets.friendly": "Friendly",
|
||||
"presets.friendly_desc": "Warm and approachable assistant that builds rapport and uses conversational language. Great for brainstorming and exploration.",
|
||||
"presets.friendly_soul": "Be warm, approachable, and conversational. Use casual language and show genuine interest in the user. Add personality to your responses while staying helpful.",
|
||||
"presets.technical": "Technical",
|
||||
"presets.technical_desc": "Expert developer companion optimized for code, architecture, and technical problem-solving. Precise terminology, deep dives, benchmarks.",
|
||||
"presets.technical_soul": "Focus on technical accuracy and depth. Use precise terminology. Show your work and reasoning. Prefer code examples and structured explanations.",
|
||||
"presets.creative": "Creative",
|
||||
"presets.creative_desc": "Imaginative collaborator for content creation, design thinking, and unconventional solutions. Embraces ambiguity and explores possibilities.",
|
||||
"presets.creative_soul": "Be imaginative and expressive. Use vivid language, analogies, and unexpected connections. Encourage creative thinking and explore multiple perspectives.",
|
||||
"presets.concise": "Concise",
|
||||
"presets.concise_desc": "Minimal and direct assistant that respects your time. Cuts through noise to deliver focused, actionable responses.",
|
||||
"presets.concise_soul": "Be extremely brief and to the point. No filler, no pleasantries. Answer in the fewest words possible while remaining accurate and complete.",
|
||||
"presets.mentor": "Mentor",
|
||||
"presets.mentor_desc": "Patient educator that explains concepts thoroughly, provides context, and guides learning. Socratic method when appropriate.",
|
||||
"presets.mentor_soul": "Be patient and encouraging like a great teacher. Break down complex topics step by step. Ask guiding questions. Celebrate progress and build confidence.",
|
||||
|
||||
"agents.profile.minimal": "Minimal",
|
||||
"agents.profile.minimal_desc": "Read-only file access",
|
||||
"agents.profile.coding": "Coding",
|
||||
"agents.profile.coding_desc": "Files + shell + web fetch",
|
||||
"agents.profile.research": "Research",
|
||||
"agents.profile.research_desc": "Web search + file read/write",
|
||||
"agents.profile.messaging": "Messaging",
|
||||
"agents.profile.messaging_desc": "Agents + memory access",
|
||||
"agents.profile.automation": "Automation",
|
||||
"agents.profile.automation_desc": "All tools except custom",
|
||||
"agents.profile.balanced": "Balanced",
|
||||
"agents.profile.balanced_desc": "General-purpose tool set",
|
||||
"agents.profile.precise": "Precise",
|
||||
"agents.profile.precise_desc": "Focused tool set for accuracy",
|
||||
"agents.profile.creative": "Creative",
|
||||
"agents.profile.creative_desc": "Full tools with creative emphasis",
|
||||
"agents.profile.full": "Full",
|
||||
"agents.profile.full_desc": "All 35+ tools",
|
||||
|
||||
"wizard.general_assistant": "General Assistant",
|
||||
"wizard.general_assistant_desc": "You are a versatile AI assistant that helps users with a wide range of tasks. You are knowledgeable, helpful, and able to adapt to the user's needs.",
|
||||
"wizard.code_helper": "Code Helper",
|
||||
"wizard.code_helper_desc": "You are an expert programming assistant specialized in software development. You help write, debug, and refactor code across multiple languages.",
|
||||
"wizard.researcher": "Research Assistant",
|
||||
"wizard.researcher_desc": "You are a research assistant that helps users find, analyze, and synthesize information from various sources.",
|
||||
"wizard.writer": "Writer",
|
||||
"wizard.writer_desc": "You are a skilled writer that helps with content creation, editing, and creative writing projects.",
|
||||
"wizard.data_analyst": "Data Analyst",
|
||||
"wizard.data_analyst_desc": "You are a data analyst that helps explore, analyze, and visualize data to extract insights.",
|
||||
"wizard.devops": "DevOps Engineer",
|
||||
"wizard.devops_desc": "You are a DevOps engineer that helps with infrastructure, deployment, CI/CD, and system administration.",
|
||||
"wizard.support": "Customer Support",
|
||||
"wizard.support_desc": "You are a customer support representative that helps resolve inquiries with patience and professionalism.",
|
||||
"wizard.tutor": "Tutor",
|
||||
"wizard.tutor_desc": "You are an educational tutor that explains concepts clearly and adapts teaching to the student's level.",
|
||||
"wizard.api_designer": "API Designer",
|
||||
"wizard.api_designer_desc": "You are an API designer that helps create well-structured, intuitive APIs following best practices.",
|
||||
"wizard.meeting_notes": "Meeting Notes",
|
||||
"wizard.meeting_notes_desc": "You are a meeting notes specialist that summarizes discussions, extracts action items, and tracks decisions.",
|
||||
|
||||
"wizard.step_welcome": "Welcome",
|
||||
"wizard.step_provider": "Provider",
|
||||
"wizard.step_agent": "Agent",
|
||||
"wizard.step_try_it": "Try It",
|
||||
"wizard.step_channel": "Channel",
|
||||
"wizard.step_done": "Done",
|
||||
|
||||
"wizard.cat_general": "General",
|
||||
"wizard.cat_development": "Development",
|
||||
"wizard.cat_research": "Research",
|
||||
"wizard.cat_writing": "Writing",
|
||||
"wizard.cat_business": "Business",
|
||||
|
||||
"wizard.channel_telegram": "Telegram",
|
||||
"wizard.channel_telegram_desc": "Connect your agent to a Telegram bot for messaging.",
|
||||
"wizard.channel_telegram_token": "Bot Token",
|
||||
"wizard.channel_telegram_help": "Create a bot via @BotFather on Telegram to get your token.",
|
||||
"wizard.channel_discord": "Discord",
|
||||
"wizard.channel_discord_desc": "Connect your agent to a Discord server via bot token.",
|
||||
"wizard.channel_discord_token": "Bot Token",
|
||||
"wizard.channel_discord_help": "Create a Discord application at discord.com/developers and add a bot.",
|
||||
"wizard.channel_slack": "Slack",
|
||||
"wizard.channel_slack_desc": "Connect your agent to a Slack workspace.",
|
||||
"wizard.channel_slack_token": "Bot Token",
|
||||
"wizard.channel_slack_help": "Create a Slack app at api.slack.com/apps and install it to your workspace.",
|
||||
|
||||
"wizard.profile_minimal": "Minimal",
|
||||
"wizard.profile_minimal_desc": "Read-only file access",
|
||||
"wizard.profile_coding": "Coding",
|
||||
"wizard.profile_coding_desc": "Files + shell + web fetch",
|
||||
"wizard.profile_research": "Research",
|
||||
"wizard.profile_research_desc": "Web search + file read/write",
|
||||
"wizard.profile_balanced": "Balanced",
|
||||
"wizard.profile_balanced_desc": "General-purpose tool set",
|
||||
"wizard.profile_precise": "Precise",
|
||||
"wizard.profile_precise_desc": "Focused tool set for accuracy",
|
||||
"wizard.profile_creative": "Creative",
|
||||
"wizard.profile_creative_desc": "Full tools with creative emphasis",
|
||||
"wizard.profile_full": "Full",
|
||||
"wizard.profile_full_desc": "All 35+ tools",
|
||||
|
||||
"wizard.enter_api_key": "Please enter an API key",
|
||||
"wizard.api_key_saved": "API key saved for",
|
||||
"wizard.failed_save_key": "Failed to save key:",
|
||||
"wizard.connected": "connected",
|
||||
"wizard.connection_failed": "Connection failed",
|
||||
"wizard.test_failed": "Test failed:",
|
||||
"wizard.enter_agent_name": "Please enter a name for your agent",
|
||||
"wizard.agent_created": "Agent created",
|
||||
"wizard.failed_create_agent": "Failed to create agent:",
|
||||
"wizard.enter_token": "Please enter the",
|
||||
"wizard.channel_configured": "configured and activated.",
|
||||
"wizard.failed_configure": "Failed:",
|
||||
|
||||
"wizard.suggestions.general.1": "What can you help me with?",
|
||||
"wizard.suggestions.general.2": "Tell me a fun fact",
|
||||
"wizard.suggestions.general.3": "Summarize the latest AI news",
|
||||
"wizard.suggestions.development.1": "Write a Python hello world",
|
||||
"wizard.suggestions.development.2": "Explain async/await",
|
||||
"wizard.suggestions.development.3": "Review this code snippet",
|
||||
"wizard.suggestions.research.1": "Explain quantum computing simply",
|
||||
"wizard.suggestions.research.2": "Compare React vs Vue",
|
||||
"wizard.suggestions.research.3": "What are the latest trends in AI?",
|
||||
"wizard.suggestions.writing.1": "Help me write a professional email",
|
||||
"wizard.suggestions.writing.2": "Improve this paragraph",
|
||||
"wizard.suggestions.writing.3": "Write a blog intro about AI",
|
||||
"wizard.suggestions.business.1": "Draft a meeting agenda",
|
||||
"wizard.suggestions.business.2": "How do I handle a complaint?",
|
||||
"wizard.suggestions.business.3": "Create a project status update",
|
||||
|
||||
"approvals.title": "Execution Approvals",
|
||||
"approvals.pending": "pending",
|
||||
"approvals.all": "All",
|
||||
"approvals.pending_tab": "Pending",
|
||||
"approvals.approved": "Approved",
|
||||
"approvals.rejected": "Rejected",
|
||||
"approvals.expired": "Expired",
|
||||
"approvals.no_approvals": "No approvals",
|
||||
"approvals.approve": "Approve",
|
||||
"approvals.reject": "Reject",
|
||||
|
||||
"workflows.title": "Workflows",
|
||||
"workflows.visual_builder": "Visual Builder",
|
||||
"workflows.what_are": "What are Workflows?",
|
||||
"workflows.no_workflows": "No workflows yet",
|
||||
"workflows.sequential": "Sequential",
|
||||
"workflows.fan_out": "Fan Out",
|
||||
"workflows.conditional": "Conditional",
|
||||
"workflows.loop": "Loop",
|
||||
"workflows.add_step": "+ Add Step",
|
||||
"workflows.execute": "Execute",
|
||||
"workflows.result": "Result",
|
||||
"workflows.node_palette": "Node Palette",
|
||||
"workflows.drag_nodes": "Drag nodes onto the canvas",
|
||||
"workflows.steps_connections": "steps, connections",
|
||||
"workflows.agent": "Agent",
|
||||
"workflows.prompt_template": "Prompt Template",
|
||||
"workflows.expression": "Expression",
|
||||
"workflows.top_port_true": "Top port = true, bottom port = false",
|
||||
"workflows.max_iterations": "Max Iterations",
|
||||
"workflows.until_stop": "Until (stop condition)",
|
||||
"workflows.fan_out_count": "Fan-out Count",
|
||||
"workflows.wait_all": "Wait for all",
|
||||
"workflows.first_finish": "First to finish",
|
||||
"workflows.majority_vote": "Majority vote",
|
||||
"workflows.connection_selected": "Connection selected",
|
||||
"workflows.delete_connection": "Delete Connection",
|
||||
|
||||
"scheduler.title": "Scheduler",
|
||||
"scheduler.scheduled_jobs": "Scheduled Jobs",
|
||||
"scheduler.event_triggers": "Event Triggers",
|
||||
"scheduler.run_history": "Run History",
|
||||
"scheduler.new_job": "+ New Job",
|
||||
"scheduler.job_name": "Job Name",
|
||||
"scheduler.cron_expression": "Cron Expression",
|
||||
"scheduler.quick_presets": "Quick Presets",
|
||||
"scheduler.target_agent": "Target Agent",
|
||||
"scheduler.any_agent": "Any available agent",
|
||||
"scheduler.message_send": "Message to Send",
|
||||
"scheduler.enabled": "Enabled (will start running immediately)",
|
||||
"scheduler.disabled": "Disabled (create paused)",
|
||||
"scheduler.active": "Active",
|
||||
"scheduler.paused": "Paused",
|
||||
"scheduler.cron_job": "Cron Job",
|
||||
"scheduler.trigger": "Trigger",
|
||||
"scheduler.no_jobs": "No scheduled jobs",
|
||||
"scheduler.no_triggers": "No event triggers",
|
||||
"scheduler.no_history": "No run history yet",
|
||||
|
||||
"channels.title": "Channels",
|
||||
"channels.configured": "configured",
|
||||
"channels.search": "Search channels...",
|
||||
"channels.setup": "Set up",
|
||||
"channels.edit": "Edit",
|
||||
"channels.configure": "Configure",
|
||||
"channels.verify": "Verify",
|
||||
"channels.ready": "Ready",
|
||||
"channels.is_ready": "is ready!",
|
||||
"channels.get_credentials": "How to get credentials",
|
||||
"channels.show_advanced": "Show advanced",
|
||||
"channels.hide_advanced": "Hide advanced",
|
||||
"channels.connecting": "Connecting to WhatsApp Web gateway...",
|
||||
"channels.linked_success": "WhatsApp linked successfully!",
|
||||
"channels.business_api": "Business API",
|
||||
|
||||
"skills.title": "Skills & Ecosystem",
|
||||
"skills.installed": "Installed",
|
||||
"skills.clawhub": "ClawHub",
|
||||
"skills.mcp_servers": "MCP Servers",
|
||||
"skills.quick_start": "Quick Start",
|
||||
"skills.no_installed": "No skills installed",
|
||||
"skills.browse_clawhub": "Browse ClawHub",
|
||||
"skills.search_clawhub": "Search ClawHub skills...",
|
||||
"skills.trending": "Trending",
|
||||
"skills.most_downloaded": "Most Downloaded",
|
||||
"skills.most_starred": "Most Starred",
|
||||
"skills.recently_updated": "Recently Updated",
|
||||
"skills.categories": "CATEGORIES",
|
||||
"skills.already_installed": "Already Installed",
|
||||
"skills.no_skills_found": "No skills found",
|
||||
"skills.security_warnings": "Security Warnings",
|
||||
"skills.security_scan": "Skills are security-scanned before installation",
|
||||
"skills.create": "Create Skill",
|
||||
"skills.created": "Created",
|
||||
|
||||
"skills.cat_coding": "Coding & IDEs",
|
||||
"skills.cat_git": "Git & GitHub",
|
||||
"skills.cat_frontend": "Web & Frontend",
|
||||
"skills.cat_devops": "DevOps & Cloud",
|
||||
"skills.cat_database": "Database",
|
||||
"skills.cat_security": "Security",
|
||||
"skills.cat_ai": "AI & ML",
|
||||
"skills.cat_data": "Data & Analytics",
|
||||
"skills.cat_mobile": "Mobile",
|
||||
"skills.cat_desktop": "Desktop Apps",
|
||||
"skills.cat_api": "API & Integrations",
|
||||
"skills.cat_testing": "Testing",
|
||||
"skills.cat_docs": "Documentation",
|
||||
"skills.cat_productivity": "Productivity",
|
||||
"skills.cat_other": "Other",
|
||||
|
||||
"skills.cat_browser": "Browser & Automation",
|
||||
"skills.cat_search": "Search & Research",
|
||||
"skills.cat_communication": "Communication",
|
||||
"skills.cat_media": "Media & Streaming",
|
||||
"skills.cat_notes": "Notes & PKM",
|
||||
"skills.cat_cli": "CLI Utilities",
|
||||
"skills.cat_marketing": "Marketing & Sales",
|
||||
"skills.cat_finance": "Finance",
|
||||
"skills.cat_smarthome": "Smart Home & IoT",
|
||||
|
||||
"skills.uninstall_skill": "Uninstall Skill",
|
||||
"skills.uninstall_confirm": "Uninstall skill",
|
||||
|
||||
"skills.source_clawhub": "ClawHub",
|
||||
"skills.source_openclaw": "OpenClaw",
|
||||
"skills.source_builtin": "Built-in",
|
||||
"skills.source_local": "Local",
|
||||
|
||||
"hands.title": "Hands — Curated Autonomous Capability Packages",
|
||||
"hands.available": "Available",
|
||||
"hands.active": "Active",
|
||||
"hands.ready": "Ready",
|
||||
"hands.setup_needed": "Setup needed",
|
||||
"hands.requirements": "REQUIREMENTS",
|
||||
"hands.details": "Details",
|
||||
"hands.no_hands": "No hands available",
|
||||
|
||||
"sessions.title": "Sessions",
|
||||
"sessions.memory": "Memory",
|
||||
"sessions.delete_session": "Delete Session",
|
||||
"sessions.delete_confirm": "This will permanently remove the session and its messages.",
|
||||
"sessions.delete_key": "Delete Key",
|
||||
"sessions.delete_key_confirm": "Delete key",
|
||||
|
||||
"logs.title": "Logs",
|
||||
"logs.live": "Live",
|
||||
"logs.audit_trail": "Audit Trail",
|
||||
|
||||
"settings.title": "Settings",
|
||||
"settings.providers": "Providers",
|
||||
"settings.models": "Models",
|
||||
"settings.config": "Config",
|
||||
"settings.tools": "Tools",
|
||||
"settings.migration": "Migration",
|
||||
"settings.security": "Security",
|
||||
"settings.network": "Network",
|
||||
"settings.migration": "Migration",
|
||||
"settings.language": "Language",
|
||||
|
||||
"settings.sec_path_traversal": "Path Traversal Prevention",
|
||||
"settings.sec_path_traversal_desc": "Blocks attempts to access files outside the workspace directory using .. or absolute paths.",
|
||||
"settings.sec_ssrf": "SSRF Protection",
|
||||
"settings.sec_ssrf_desc": "Prevents agents from making requests to internal IP ranges (localhost, cloud metadata, private networks).",
|
||||
"settings.sec_capability": "Capability-Based Access Control",
|
||||
"settings.sec_capability_desc": "Agents can only access explicitly granted capabilities. No implicit access to tools or data.",
|
||||
"settings.sec_taint": "Taint Tracking",
|
||||
"settings.sec_taint_desc": "Tracks untrusted data (user input, file content) through agent reasoning to prevent prompt injection.",
|
||||
"settings.sec_sandbox": "WASM Sandbox",
|
||||
"settings.sec_sandbox_desc": "Executes untrusted code in isolated WebAssembly sandboxes with memory and syscall restrictions.",
|
||||
"settings.sec_audit": "Merkle Audit",
|
||||
"settings.sec_audit_desc": "Maintains a verifiable audit log of all agent actions using Merkle tree cryptography.",
|
||||
"settings.sec_workspace": "Workspace Isolation",
|
||||
"settings.sec_workspace_desc": "Each agent has an isolated workspace directory. No cross-agent file access unless explicitly granted.",
|
||||
"settings.sec_rate_limit": "Rate Limiting",
|
||||
"settings.sec_rate_limit_desc": "Enforces per-agent and global rate limits to prevent resource exhaustion and cost overruns.",
|
||||
"settings.sec_approval": "Execution Approvals",
|
||||
"settings.sec_approval_desc": "Requires human approval for high-risk actions (shell commands, file writes, external requests).",
|
||||
|
||||
"settings.sec_enabled": "Enabled",
|
||||
"settings.sec_disabled": "Disabled",
|
||||
"settings.sec_inherited": "Inherited",
|
||||
"settings.sec_global": "Global"
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* OpenFang i18n (Internationalization) Module
|
||||
*
|
||||
* Provides runtime language switching for the OpenFang dashboard UI.
|
||||
* Supports English (default) and Russian.
|
||||
*
|
||||
* Usage:
|
||||
* - HTML: <span data-i18n="nav.overview">Overview</span>
|
||||
* - JS: window.t('nav.overview')
|
||||
* - Auto-applies translations on load based on stored/preferred language
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Language store
|
||||
let currentLang = 'en';
|
||||
let translations = {};
|
||||
let isInitialized = false;
|
||||
|
||||
/**
|
||||
* Load translations from a JSON file
|
||||
* @param {string} lang - Language code (en, ru)
|
||||
* @returns {Promise<Object>} Translation object
|
||||
*/
|
||||
async function loadTranslations(lang) {
|
||||
try {
|
||||
// Use cached translations if available
|
||||
if (window.__i18nCache && window.__i18nCache[lang]) {
|
||||
return window.__i18nCache[lang];
|
||||
}
|
||||
|
||||
const response = await fetch(`/i18n/${lang}.json`);
|
||||
if (!response.ok) {
|
||||
console.warn(`[i18n] Failed to load ${lang}.json, falling back to en`);
|
||||
if (lang !== 'en') {
|
||||
return loadTranslations('en');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Cache for future use
|
||||
if (!window.__i18nCache) window.__i18nCache = {};
|
||||
window.__i18nCache[lang] = data;
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`[i18n] Error loading translations for ${lang}:`, error);
|
||||
if (lang !== 'en') {
|
||||
return loadTranslations('en');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a translated string by key
|
||||
* @param {string} key - Translation key (e.g., 'nav.overview')
|
||||
* @param {Object} params - Optional interpolation parameters
|
||||
* @returns {string} Translated string or key if not found
|
||||
*/
|
||||
function t(key, params) {
|
||||
if (!isInitialized) {
|
||||
console.warn('[i18n] Not initialized, returning key');
|
||||
return key;
|
||||
}
|
||||
|
||||
let text = translations[key] || key;
|
||||
|
||||
// Handle interpolation (e.g., 'Hello, {{name}}')
|
||||
if (params && typeof params === 'object') {
|
||||
Object.keys(params).forEach(param => {
|
||||
text = text.replace(new RegExp(`{{${param}}}`, 'g'), params[param]);
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply translations to all elements with data-i18n attribute
|
||||
* Also updates the <html> lang attribute
|
||||
*/
|
||||
function applyTranslations() {
|
||||
// Update document language
|
||||
document.documentElement.lang = currentLang;
|
||||
|
||||
// Find and translate all elements with data-i18n attribute
|
||||
const elements = document.querySelectorAll('[data-i18n]');
|
||||
elements.forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const translation = t(key);
|
||||
|
||||
// Check if element is a form input/textarea
|
||||
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||||
// For form elements, only update if it's a placeholder or aria-label
|
||||
if (el.hasAttribute('placeholder')) {
|
||||
el.placeholder = translation;
|
||||
}
|
||||
if (el.hasAttribute('aria-label')) {
|
||||
el.setAttribute('aria-label', translation);
|
||||
}
|
||||
if (el.hasAttribute('title')) {
|
||||
el.setAttribute('title', translation);
|
||||
}
|
||||
} else {
|
||||
// For regular elements, update text content
|
||||
el.textContent = translation;
|
||||
}
|
||||
});
|
||||
|
||||
// Update elements with data-i18n-* attributes for attributes
|
||||
const attrElements = document.querySelectorAll('[data-i18n-placeholder], [data-i18n-title], [data-i18n-aria-label]');
|
||||
attrElements.forEach(el => {
|
||||
if (el.hasAttribute('data-i18n-placeholder')) {
|
||||
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
|
||||
}
|
||||
if (el.hasAttribute('data-i18n-title')) {
|
||||
el.title = t(el.getAttribute('data-i18n-title'));
|
||||
}
|
||||
if (el.hasAttribute('data-i18n-aria-label')) {
|
||||
el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria-label')));
|
||||
}
|
||||
});
|
||||
|
||||
// Update meta tags
|
||||
const metaDesc = document.querySelector('meta[name="description"]');
|
||||
if (metaDesc) {
|
||||
const desc = t('app.description', { name: 'OpenFang' });
|
||||
if (desc !== 'app.description') {
|
||||
metaDesc.content = desc;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[i18n] Applied translations for language: ${currentLang}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current language and apply translations
|
||||
* @param {string} lang - Language code (en, ru)
|
||||
* @param {boolean} persist - Whether to save to localStorage
|
||||
*/
|
||||
async function setLanguage(lang, persist = true) {
|
||||
if (!['en', 'ru'].includes(lang)) {
|
||||
console.warn(`[i18n] Unknown language: ${lang}, defaulting to en`);
|
||||
lang = 'en';
|
||||
}
|
||||
|
||||
currentLang = lang;
|
||||
translations = await loadTranslations(lang);
|
||||
isInitialized = true;
|
||||
|
||||
// Save preference
|
||||
if (persist) {
|
||||
localStorage.setItem('openfang_language', lang);
|
||||
}
|
||||
|
||||
// Apply to DOM
|
||||
applyTranslations();
|
||||
|
||||
// Dispatch event for Alpine.js components to react
|
||||
window.dispatchEvent(new CustomEvent('i18n:language-changed', {
|
||||
detail: { language: lang }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current language
|
||||
* @returns {string} Current language code
|
||||
*/
|
||||
function getLanguage() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize i18n system
|
||||
* Loads language preference and applies translations
|
||||
*/
|
||||
async function init() {
|
||||
// Determine language priority:
|
||||
// 1. localStorage (user preference)
|
||||
// 2. Browser language
|
||||
// 3. Default to English
|
||||
|
||||
let lang = localStorage.getItem('openfang_language');
|
||||
|
||||
if (!lang) {
|
||||
// Try to detect browser language
|
||||
const browserLang = navigator.language || navigator.userLanguage || '';
|
||||
if (browserLang.startsWith('ru')) {
|
||||
lang = 'ru';
|
||||
} else {
|
||||
lang = 'en';
|
||||
}
|
||||
}
|
||||
|
||||
await setLanguage(lang, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available languages
|
||||
* @returns {Array<{code: string, name: string}>}
|
||||
*/
|
||||
function getAvailableLanguages() {
|
||||
return [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'ru', name: 'Русский' }
|
||||
];
|
||||
}
|
||||
|
||||
// Expose to global scope
|
||||
window.i18n = {
|
||||
t,
|
||||
setLanguage,
|
||||
getLanguage,
|
||||
getAvailableLanguages,
|
||||
init,
|
||||
isInitialized: () => isInitialized
|
||||
};
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,607 @@
|
||||
{
|
||||
"app.name": "OpenFang",
|
||||
"app.version": "v",
|
||||
|
||||
"nav.chat": "Чат",
|
||||
"nav.monitor": "Мониторинг",
|
||||
"nav.overview": "Обзор",
|
||||
"nav.analytics": "Аналитика",
|
||||
"nav.logs": "Логи",
|
||||
"nav.agents": "Агенты",
|
||||
"nav.sessions": "Сессии",
|
||||
"nav.approvals": "Одобрения",
|
||||
"nav.comms": "Коммуникации",
|
||||
"nav.automation": "Автоматизация",
|
||||
"nav.workflows": "Рабочие процессы",
|
||||
"nav.scheduler": "Планировщик",
|
||||
"nav.extensions": "Расширения",
|
||||
"nav.channels": "Каналы",
|
||||
"nav.skills": "Навыки",
|
||||
"nav.hands": "Руки",
|
||||
"nav.system": "Система",
|
||||
"nav.runtime": "Среда выполнения",
|
||||
"nav.settings": "Настройки",
|
||||
|
||||
"auth.sign_in": "Войти",
|
||||
"auth.enter_credentials": "Введите учётные данные панели управления.",
|
||||
"auth.username": "Имя пользователя",
|
||||
"auth.password": "Пароль",
|
||||
"auth.api_key_required": "Требуется API-ключ",
|
||||
"auth.api_key_desc": "Этот экземпляр требует API-ключ. Введите ключ из вашего config.toml.",
|
||||
"auth.api_key_hint": "Добавьте api_key = \"your-key\" в начало ~/.openfang/config.toml (не внутри секции).",
|
||||
"auth.enter_api_key": "Введите API-ключ...",
|
||||
"auth.unlock_dashboard": "Разблокировать панель",
|
||||
"auth.login_failed": "Ошибка входа",
|
||||
|
||||
"status.agents_running": "агент(ов) запущено",
|
||||
"status.connecting": "Подключение...",
|
||||
"status.reconnecting": "Переподключение...",
|
||||
"status.disconnected": "отключено",
|
||||
"status.ws": "ВС",
|
||||
"status.http": "HTTP",
|
||||
"status.ready": "Готово",
|
||||
"status.loading": "Загрузка...",
|
||||
"status.loading_workflows": "Загрузка рабочих процессов...",
|
||||
"status.loading_channels": "Загрузка каналов...",
|
||||
"status.loading_skills": "Загрузка навыков...",
|
||||
"status.loading_jobs": "Загрузка заданий...",
|
||||
"status.loading_triggers": "Загрузка триггеров...",
|
||||
"status.loading_history": "Загрузка истории...",
|
||||
"status.loading_hands": "Загрузка модулей...",
|
||||
"status.loading_active_hands": "Загрузка активных модулей...",
|
||||
"status.loading_mcp": "Загрузка MCP-серверов...",
|
||||
"status.loading_files": "Загрузка файлов...",
|
||||
"status.loading_skills_details": "Загрузка деталей навыков...",
|
||||
"status.no_channels_match": "Нет каналов по запросу",
|
||||
|
||||
"actions.logout": "Выйти",
|
||||
"actions.new_agent": "Новый агент",
|
||||
"actions.browse_skills": "Навыки",
|
||||
"actions.add_channel": "Добавить канал",
|
||||
"actions.create_workflow": "Создать процесс",
|
||||
"actions.settings": "Настройки",
|
||||
"actions.create_agent": "Создать агента",
|
||||
"actions.configure_provider": "Настроить провайдера",
|
||||
"actions.cancel": "Отмена",
|
||||
"actions.confirm": "Подтвердить",
|
||||
"actions.save": "Сохранить",
|
||||
"actions.delete": "Удалить",
|
||||
"actions.edit": "Редактировать",
|
||||
"actions.clone": "Клонировать",
|
||||
"actions.stop": "Остановить",
|
||||
"actions.run": "Запустить",
|
||||
"actions.enable": "Включить",
|
||||
"actions.disable": "Отключить",
|
||||
"actions.view_all": "Показать все",
|
||||
"actions.retry": "Повторить",
|
||||
"actions.refresh": "Обновить",
|
||||
"actions.approve": "Одобрить",
|
||||
"actions.reject": "Отклонить",
|
||||
"actions.update": "Обновить",
|
||||
"actions.test_connection": "Проверить подключение",
|
||||
"actions.remove": "Удалить",
|
||||
"actions.save_test": "Сохранить и проверить",
|
||||
"actions.export_toml": "Экспорт TOML",
|
||||
"actions.save_workflow": "Сохранить процесс",
|
||||
"actions.auto_layout": "Автораскладка",
|
||||
"actions.clear": "Очистить",
|
||||
"actions.zoom_out": "Уменьшить",
|
||||
"actions.zoom_in": "Увеличить",
|
||||
"actions.fit": "По размеру",
|
||||
"actions.duplicate": "Дублировать",
|
||||
"actions.copy_clipboard": "Копировать в буфер",
|
||||
"actions.copied": "Скопировано!",
|
||||
"actions.copy": "Копировать",
|
||||
"actions.hide_code": "Скрыть код",
|
||||
"actions.view_code": "Показать код",
|
||||
"actions.install": "Установить",
|
||||
"actions.installing": "Установка...",
|
||||
"actions.installed": "Установлено",
|
||||
"actions.load_more": "Загрузить ещё",
|
||||
"actions.back_to_browse": "Назад",
|
||||
"actions.activate": "Активировать",
|
||||
"actions.create_schedule": "Создать расписание",
|
||||
"actions.submit": "Отправить",
|
||||
"actions.close": "Закрыть",
|
||||
"actions.next": "Далее",
|
||||
"actions.back": "Назад",
|
||||
"actions.spawn_agent": "Создать агента",
|
||||
"actions.spawning": "Создание...",
|
||||
"actions.spawn_wizard": "Мастер",
|
||||
"actions.raw_toml": "TOML",
|
||||
"actions.setup_wizard": "Мастер настройки",
|
||||
"actions.configure_manually": "Настроить вручную",
|
||||
"actions.dismiss": "Закрыть",
|
||||
"actions.create_workflow_btn": "Создать процесс",
|
||||
"actions.execute": "Выполнить",
|
||||
"actions.executing": "Выполнение...",
|
||||
"actions.generated_toml": "Сгенерированный TOML",
|
||||
"actions.running": "Выполняется...",
|
||||
|
||||
"footer.shortcuts": "Ctrl+K агенты | Ctrl+N новый",
|
||||
|
||||
"theme.light": "Светлая",
|
||||
"theme.system": "Системная",
|
||||
"theme.dark": "Тёмная",
|
||||
|
||||
"errors.connection_error": "Ошибка подключения",
|
||||
"errors.daemon_unreachable": "Не удаётся связаться с демоном — запущен ли openfang?",
|
||||
"errors.not_authorized": "Не авторизован — проверьте API-ключ",
|
||||
"errors.permission_denied": "Доступ запрещён",
|
||||
"errors.resource_not_found": "Ресурс не найден",
|
||||
"errors.rate_limited": "Превышен лимит — подождите и попробуйте снова",
|
||||
"errors.request_too_large": "Запрос слишком большой",
|
||||
"errors.server_error": "Ошибка сервера — проверьте логи демона",
|
||||
"errors.daemon_unavailable": "Демон недоступен — запущен ли он?",
|
||||
"errors.unexpected": "Неожиданная ошибка",
|
||||
"errors.reconnected": "Переподключено",
|
||||
"errors.connection_lost": "Соединение потеряно, переподключение...",
|
||||
"errors.switched_http": "Соединение потеряно — переход на режим HTTP",
|
||||
"errors.connection_lost": "Соединение потеряно, переподключение...",
|
||||
"errors.switched_http": "Соединение потеряно — переход на режим HTTP",
|
||||
"errors.reconnected": "Переподключено",
|
||||
|
||||
"toasts.approval_waiting": "Агент ожидает одобрения. Откройте раздел Одобрения.",
|
||||
"toasts.agent_created": "Агент создан",
|
||||
"toasts.agent_stopped": "Агент остановлен",
|
||||
"toasts.tool_used": "Инструмент использован",
|
||||
"toasts.tool_completed": "Инструмент завершён",
|
||||
"toasts.message_in": "Входящее сообщение",
|
||||
"toasts.response_sent": "Ответ отправлен",
|
||||
"toasts.session_reset": "Сессия сброшена",
|
||||
"toasts.compacted": "Сжато",
|
||||
"toasts.model_changed": "Модель изменена",
|
||||
"toasts.login_attempt": "Попытка входа",
|
||||
"toasts.login_ok": "Вход успешен",
|
||||
"toasts.login_failed": "Ошибка входа",
|
||||
"toasts.denied": "Отклонено",
|
||||
"toasts.rate_limited": "Лимит запросов",
|
||||
"toasts.workflow_run": "Запуск процесса",
|
||||
"toasts.trigger_fired": "Триггер сработал",
|
||||
"toasts.skill_installed": "Навык установлен",
|
||||
"toasts.mcp_connected": "MCP подключён",
|
||||
"toasts.session_deleted": "Сессия удалена",
|
||||
|
||||
"overview.welcome": "Добро пожаловать в OpenFang",
|
||||
"overview.getting_started": "Начало работы",
|
||||
"overview.setup_wizard": "Мастер настройки",
|
||||
"overview.steps_completed": "из 5 шагов выполнено",
|
||||
"overview.agents_running": "Агентов запущено",
|
||||
"overview.tokens_used": "Использовано токенов",
|
||||
"overview.total_cost": "Общая стоимость",
|
||||
"overview.uptime": "Время работы",
|
||||
"overview.channels": "Каналы",
|
||||
"overview.skills": "Навыки",
|
||||
"overview.mcp_servers": "MCP-серверы",
|
||||
"overview.tool_calls": "Вызовов инструментов",
|
||||
"overview.providers": "Провайдеры",
|
||||
"overview.recent_activity": "Недавняя активность",
|
||||
"overview.no_recent_activity": "Нет недавней активности",
|
||||
"overview.chat_with_agent": "Написать агенту",
|
||||
"overview.system_health": "Состояние системы",
|
||||
"overview.healthy": "Исправно",
|
||||
"overview.unreachable": "Недоступно",
|
||||
"overview.security_systems": "Системы безопасности",
|
||||
"overview.llm_providers": "LLM-провайдеры",
|
||||
"overview.defense_active": "9 уровней защиты активно",
|
||||
"overview.quick_actions": "Быстрые действия",
|
||||
|
||||
"setup.configure_provider": "Настройте LLM-провайдера",
|
||||
"setup.create_first_agent": "Создайте первого агента",
|
||||
"setup.send_first_message": "Отправьте первое сообщение",
|
||||
"setup.connect_channel": "Подключите канал связи",
|
||||
"setup.browse_install_skill": "Найдите или установите навык",
|
||||
|
||||
"tooltips.cooling_down": "остывает (лимит запросов)",
|
||||
"tooltips.circuit_open": "автомат сработал",
|
||||
"tooltips.ready": "готово",
|
||||
"tooltips.not_configured": "не настроено",
|
||||
|
||||
"chat.placeholder": "Напишите OpenFang... (/ для команд)",
|
||||
"chat.ready": "Готово",
|
||||
"chat.generating": "Генерация...",
|
||||
"chat.queued": "в очереди",
|
||||
"chat.sessions": "Сессии",
|
||||
"chat.new_session": "+ Новая",
|
||||
"chat.no_sessions": "Нет сессий",
|
||||
"chat.search_messages": "Поиск сообщений...",
|
||||
"chat.select_agent": "Выберите агента для начала общения",
|
||||
"chat.recording": "Запись... отпустите для отправки",
|
||||
"chat.drop_files": "Перетащите файлы сюда",
|
||||
"chat.attach_file": "Прикрепить файл",
|
||||
"chat.stop_generating": "Остановить генерацию",
|
||||
"chat.switch_model": "Сменить модель",
|
||||
"chat.search_models": "Поиск моделей...",
|
||||
"chat.no_models_found": "Модели не найдены",
|
||||
"chat.available_models": "Доступные модели — выберите или продолжите ввод",
|
||||
"chat.switching": "Переключение...",
|
||||
"chat.model_switched": "Модель изменена на",
|
||||
"chat.model_switch_failed": "Не удалось сменить модель",
|
||||
"chat.using_http_mode": "Используется HTTP режим (без потоковой передачи)",
|
||||
"chat.session_name_prompt": "Название сессии (необязательно):",
|
||||
"chat.session_created": "Сессия создана",
|
||||
"chat.session_create_failed": "Не удалось создать сессию",
|
||||
"chat.stop_agent_title": "Остановить агента",
|
||||
"chat.stop_agent_confirm": "Остановить агента",
|
||||
"chat.agent_stopped": "Агент остановлен",
|
||||
"chat.stop_agent_failed": "Не удалось остановить агента",
|
||||
"chat.welcome_message": "**Добро пожаловать в OpenFang Чат!**\n\n- Введите `/` для просмотра команд\n- `/help` покажет все команды\n- `/think on` включает расширенные размышления\n- `/context` покажет использование контекста\n- `/verbose off` скроет детали инструментов\n- `Ctrl+Shift+F` переключает режим фокуса\n- Перетащите файлы для прикрепления\n- `Ctrl+/` открывает палитру команд",
|
||||
|
||||
"chat.slash.help": "Показать доступные команды",
|
||||
"chat.slash.agents": "Перейти на страницу агентов",
|
||||
"chat.slash.new": "Новая сессия (очистить историю)",
|
||||
"chat.slash.compact": "Сжать контекст сессии",
|
||||
"chat.slash.model": "Показать или сменить модель (/model [имя])",
|
||||
"chat.slash.stop": "Отменить текущий запуск агента",
|
||||
"chat.slash.usage": "Показать использование токенов",
|
||||
"chat.slash.think": "Переключить размышления (/think [on|off|stream])",
|
||||
"chat.slash.context": "Показать использование контекста",
|
||||
"chat.slash.verbose": "Переключить детали инструментов (/verbose [off|on|full])",
|
||||
"chat.slash.queue": "Проверить очередь обработки",
|
||||
"chat.slash.status": "Показать статус системы",
|
||||
"chat.slash.clear": "Очистить чат",
|
||||
"chat.slash.exit": "Отключиться от агента",
|
||||
"chat.slash.budget": "Показать лимиты и расходы",
|
||||
"chat.slash.peers": "Показать статус сети OFP",
|
||||
"chat.slash.a2a": "Список A2A агентов",
|
||||
|
||||
"commands.help": "Показать доступные команды",
|
||||
"commands.agents": "Перейти на страницу агентов",
|
||||
"commands.new": "Новая сессия",
|
||||
"commands.switch": "Сменить агента",
|
||||
"commands.clear": "Очистить диалог",
|
||||
"commands.model": "Сменить модель",
|
||||
"commands.think": "Переключить режим размышлений",
|
||||
"commands.focus": "Переключить режим фокуса",
|
||||
"commands.theme": "Сменить тему",
|
||||
|
||||
"tips.commands": "Введите / для команд",
|
||||
"tips.think": "/think on для размышлений",
|
||||
"tips.focus": "Ctrl+Shift+F для режима фокуса",
|
||||
|
||||
"agents.info": "Информация",
|
||||
"agents.files": "Файлы",
|
||||
"agents.config": "Настройки",
|
||||
"agents.chat": "Чат",
|
||||
"agents.clone": "Клонировать",
|
||||
"agents.clear_history": "Очистить историю",
|
||||
"agents.change": "Изменить",
|
||||
"agents.none_fallback": "Нет — добавить цепочку резервов",
|
||||
"agents.add": "+ Добавить",
|
||||
"agents.loading_files": "Загрузка файлов...",
|
||||
"agents.no_workspace_files": "Файлы рабочей области не найдены",
|
||||
"agents.save_config": "Сохранить настройки",
|
||||
"agents.tool_filters": "Фильтры инструментов",
|
||||
"agents.allowlist": "Белый список",
|
||||
"agents.blocklist": "Чёрный список",
|
||||
"agents.agent_name": "Имя агента",
|
||||
"agents.emoji": "Эмодзи",
|
||||
"agents.color": "Цвет",
|
||||
"agents.archetype": "Архетип",
|
||||
"agents.provider": "Провайдер",
|
||||
"agents.model": "Модель",
|
||||
"agents.system_prompt": "Системный промпт",
|
||||
"agents.soul_persona": "Душa / Персона",
|
||||
"agents.tool_profile": "Профиль инструментов",
|
||||
"agents.minimal_profile": "Минимальный — только чтение файлов",
|
||||
"agents.coding_profile": "Кодинг — файлы + оболочка + веб-запросы",
|
||||
"agents.fullstack_profile": "Full-Stack — файлы + оболочка + веб + поиск",
|
||||
"agents.research_profile": "Исследование — веб + поиск + анализ",
|
||||
"agents.admin_profile": "Админ — полный доступ к системе (опасно)",
|
||||
"agents.agent_created": "Агент создан",
|
||||
"agents.agent_stopped": "Агент остановлен",
|
||||
"agents.agent_deleted": "Агент удалён",
|
||||
|
||||
"presets.professional": "Деловой",
|
||||
"presets.professional_desc": "Точный, бизнес-ориентированный ассистент, сосредоточенный на эффективности и ясности. Приоритет — практические выводы и структурированная коммуникация.",
|
||||
"presets.professional_soul": "Общайтесь чётко и профессионально. Будьте прямым и структурированным. Используйте формальный язык и выводы на основе данных. ставьте точность выше личности.",
|
||||
"presets.friendly": "Дружелюбный",
|
||||
"presets.friendly_desc": "Тёплый и открытый ассистент, который выстраивает rapport и использует разговорный язык. Отлично подходит для мозгового штурма и исследования.",
|
||||
"presets.friendly_soul": "Будьте тёплым, доступным и разговорчивым. Используйте неформальный язык и проявляйте искренний интерес к пользователю. Добавляйте личность к вашим ответам, оставаясь полезным.",
|
||||
"presets.technical": "Технический",
|
||||
"presets.technical_desc": "Эксперт-помощник по разработке, оптимизированный для кода, архитектуры и технических задач. Точная терминология, глубокие погружения, бенчмарки.",
|
||||
"presets.technical_soul": "Сосредоточьтесь на технической точности и глубине. Используйте точную терминологию. Покажите вашу работу и рассуждения. Предпочитайте примеры кода и структурированные объяснения.",
|
||||
"presets.creative": "Креативный",
|
||||
"presets.creative_desc": "Творческий партнёр для создания контента, дизайн-мышления и нестандартных решений. Приветствует неоднозначность и исследует возможности.",
|
||||
"presets.creative_soul": "Будьте изобретательным и выразительным. Используйте яркий язык, аналогии и неожиданные связи. Поощряйте творческое мышление и исследуйте различные перспективы.",
|
||||
"presets.concise": "Краткий",
|
||||
"presets.concise_desc": "Минималистичный и прямой ассистент, который ценит ваше время. Убирает лишнее и даёт сфокусированные, практичные ответы.",
|
||||
"presets.concise_soul": "Будьте предельно кратким и точным. Без воды и формальностей. Отвечайте наименьшим количеством слов, оставаясь точным и полным.",
|
||||
"presets.mentor": "Наставник",
|
||||
"presets.mentor_desc": "Терпеливый педагог, который подробно объясняет концепции, даёт контекст и направляет обучение. Метод Сократа при необходимости.",
|
||||
"presets.mentor_soul": "Будьте терпеливым и ободряющим как хороший учитель. Разбивайте сложные темы по шагам. Задавайте направляющие вопросы. Празднуйте прогресс и укрепляйте уверенность.",
|
||||
|
||||
"agents.profile.minimal": "Минимальный",
|
||||
"agents.profile.minimal_desc": "Только чтение файлов",
|
||||
"agents.profile.coding": "Кодинг",
|
||||
"agents.profile.coding_desc": "Файлы + оболочка + веб-запросы",
|
||||
"agents.profile.research": "Исследование",
|
||||
"agents.profile.research_desc": "Веб-поиск + чтение/запись файлов",
|
||||
"agents.profile.messaging": "Коммуникации",
|
||||
"agents.profile.messaging_desc": "Агенты + доступ к памяти",
|
||||
"agents.profile.automation": "Автоматизация",
|
||||
"agents.profile.automation_desc": "Все инструменты кроме пользовательских",
|
||||
"agents.profile.balanced": "Сбалансированный",
|
||||
"agents.profile.balanced_desc": "Набор инструментов общего назначения",
|
||||
"agents.profile.precise": "Точный",
|
||||
"agents.profile.precise_desc": "Фокусированный набор инструментов для точности",
|
||||
"agents.profile.creative": "Креативный",
|
||||
"agents.profile.creative_desc": "Полный набор инструментов с творческим уклоном",
|
||||
"agents.profile.full": "Полный",
|
||||
"agents.profile.full_desc": "Все 35+ инструментов",
|
||||
|
||||
"wizard.general_assistant": "Универсальный ассистент",
|
||||
"wizard.general_assistant_desc": "Вы универсальный AI-ассистент, который помогает пользователям с широким кругом задач. Вы знающий, полезный и способны адаптироваться к потребностям пользователя.",
|
||||
"wizard.code_helper": "Помощник по коду",
|
||||
"wizard.code_helper_desc": "Вы опытный программный ассистент, специализирующийся на разработке ПО. Вы помогаете писать, отлаживать и рефакторить код на разных языках.",
|
||||
"wizard.researcher": "Исследовательский ассистент",
|
||||
"wizard.researcher_desc": "Вы исследовательский ассистент, который помогает находить, анализировать и синтезировать информацию из различных источников.",
|
||||
"wizard.writer": "Писатель",
|
||||
"wizard.writer_desc": "Вы квалифицированный писатель, который помогает с созданием контента, редактированием и творческими проектами.",
|
||||
"wizard.data_analyst": "Аналитик данных",
|
||||
"wizard.data_analyst_desc": "Вы аналитик данных, который помогает исследовать, анализировать и визуализировать данные для извлечения инсайтов.",
|
||||
"wizard.devops": "DevOps-инженер",
|
||||
"wizard.devops_desc": "Вы DevOps-инженер, который помогает с инфраструктурой, деплоем, CI/CD и системным администрированием.",
|
||||
"wizard.support": "Поддержка клиентов",
|
||||
"wizard.support_desc": "Вы представитель поддержки клиентов, который помогает решать вопросы с терпением и профессионализмом.",
|
||||
"wizard.tutor": "Репетитор",
|
||||
"wizard.tutor_desc": "Вы образовательный репетитор, который ясно объясняет концепции и адаптирует обучение к уровню ученика.",
|
||||
"wizard.api_designer": "API-дизайнер",
|
||||
"wizard.api_designer_desc": "Вы дизайнер API, который помогает создавать хорошо структурированные, интуитивные API по лучшим практикам.",
|
||||
"wizard.meeting_notes": "Заметки к встрече",
|
||||
"wizard.meeting_notes_desc": "Вы специалист по заметкам встреч, который суммирует обсуждения, извлекает задачи и отслеживает решения.",
|
||||
|
||||
"wizard.step_welcome": "Приветствие",
|
||||
"wizard.step_provider": "Провайдер",
|
||||
"wizard.step_agent": "Агент",
|
||||
"wizard.step_try_it": "Попробовать",
|
||||
"wizard.step_channel": "Канал",
|
||||
"wizard.step_done": "Готово",
|
||||
|
||||
"wizard.cat_general": "Общее",
|
||||
"wizard.cat_development": "Разработка",
|
||||
"wizard.cat_research": "Исследования",
|
||||
"wizard.cat_writing": "Написание",
|
||||
"wizard.cat_business": "Бизнес",
|
||||
|
||||
"wizard.channel_telegram": "Telegram",
|
||||
"wizard.channel_telegram_desc": "Подключите агента к Telegram-боту для обмена сообщениями.",
|
||||
"wizard.channel_telegram_token": "Токен бота",
|
||||
"wizard.channel_telegram_help": "Создайте бота через @BotFather в Telegram, чтобы получить токен.",
|
||||
"wizard.channel_discord": "Discord",
|
||||
"wizard.channel_discord_desc": "Подключите агента к Discord-серверу через токен бота.",
|
||||
"wizard.channel_discord_token": "Токен бота",
|
||||
"wizard.channel_discord_help": "Создайте приложение Discord на discord.com/developers и добавьте бота.",
|
||||
"wizard.channel_slack": "Slack",
|
||||
"wizard.channel_slack_desc": "Подключите агента к рабочему пространству Slack.",
|
||||
"wizard.channel_slack_token": "Токен бота",
|
||||
"wizard.channel_slack_help": "Создайте приложение Slack на api.slack.com/apps и установите его в рабочее пространство.",
|
||||
|
||||
"wizard.profile_minimal": "Минимальный",
|
||||
"wizard.profile_minimal_desc": "Только чтение файлов",
|
||||
"wizard.profile_coding": "Кодинг",
|
||||
"wizard.profile_coding_desc": "Файлы + оболочка + веб-запросы",
|
||||
"wizard.profile_research": "Исследование",
|
||||
"wizard.profile_research_desc": "Веб-поиск + чтение/запись файлов",
|
||||
"wizard.profile_balanced": "Сбалансированный",
|
||||
"wizard.profile_balanced_desc": "Набор инструментов общего назначения",
|
||||
"wizard.profile_precise": "Точный",
|
||||
"wizard.profile_precise_desc": "Фокусированный набор инструментов для точности",
|
||||
"wizard.profile_creative": "Креативный",
|
||||
"wizard.profile_creative_desc": "Полный набор инструментов с творческим уклоном",
|
||||
"wizard.profile_full": "Полный",
|
||||
"wizard.profile_full_desc": "Все 35+ инструментов",
|
||||
|
||||
"wizard.enter_api_key": "Пожалуйста, введите API-ключ",
|
||||
"wizard.api_key_saved": "API-ключ сохранён для",
|
||||
"wizard.failed_save_key": "Не удалось сохранить ключ:",
|
||||
"wizard.connected": "подключён",
|
||||
"wizard.connection_failed": "Ошибка подключения",
|
||||
"wizard.test_failed": "Тест не прошёл:",
|
||||
"wizard.enter_agent_name": "Пожалуйста, введите имя агента",
|
||||
"wizard.agent_created": "Агент создан",
|
||||
"wizard.failed_create_agent": "Не удалось создать агента:",
|
||||
"wizard.enter_token": "Пожалуйста, введите",
|
||||
"wizard.channel_configured": "настроен и активирован.",
|
||||
"wizard.failed_configure": "Ошибка:",
|
||||
|
||||
"wizard.suggestions.general.1": "Чем вы можете помочь?",
|
||||
"wizard.suggestions.general.2": "Расскажите интересный факт",
|
||||
"wizard.suggestions.general.3": "Резюмируйте последние новости AI",
|
||||
"wizard.suggestions.development.1": "Напишите Python hello world",
|
||||
"wizard.suggestions.development.2": "Объясните async/await",
|
||||
"wizard.suggestions.development.3": "Проверьте этот фрагмент кода",
|
||||
"wizard.suggestions.research.1": "Объясните квантовые вычисления просто",
|
||||
"wizard.suggestions.research.2": "Сравните React и Vue",
|
||||
"wizard.suggestions.research.3": "Какие последние тренды в AI?",
|
||||
"wizard.suggestions.writing.1": "Помогите написать профессиональное письмо",
|
||||
"wizard.suggestions.writing.2": "Улучшите этот абзац",
|
||||
"wizard.suggestions.writing.3": "Напишите введение в блог об AI",
|
||||
"wizard.suggestions.business.1": "Составьте повестку встречи",
|
||||
"wizard.suggestions.business.2": "Как обработать жалобу?",
|
||||
"wizard.suggestions.business.3": "Создайте статус-отчёт проекта",
|
||||
|
||||
"approvals.title": "Одобрения выполнения",
|
||||
"approvals.pending": "ожидает",
|
||||
"approvals.all": "Все",
|
||||
"approvals.pending_tab": "Ожидающие",
|
||||
"approvals.approved": "Одобрено",
|
||||
"approvals.rejected": "Отклонено",
|
||||
"approvals.expired": "Истекло",
|
||||
"approvals.no_approvals": "Нет одобрений",
|
||||
"approvals.approve": "Одобрить",
|
||||
"approvals.reject": "Отклонить",
|
||||
|
||||
"workflows.title": "Рабочие процессы",
|
||||
"workflows.visual_builder": "Визуальный конструктор",
|
||||
"workflows.what_are": "Что такое рабочие процессы?",
|
||||
"workflows.no_workflows": "Нет рабочих процессов",
|
||||
"workflows.sequential": "Последовательный",
|
||||
"workflows.fan_out": "Распределение",
|
||||
"workflows.conditional": "Условный",
|
||||
"workflows.loop": "Цикл",
|
||||
"workflows.add_step": "+ Добавить шаг",
|
||||
"workflows.execute": "Выполнить",
|
||||
"workflows.result": "Результат",
|
||||
"workflows.node_palette": "Палитра узлов",
|
||||
"workflows.drag_nodes": "Перетащите узлы на холст",
|
||||
"workflows.steps_connections": "шагов, связей",
|
||||
"workflows.agent": "Агент",
|
||||
"workflows.prompt_template": "Шаблон промпта",
|
||||
"workflows.expression": "Выражение",
|
||||
"workflows.top_port_true": "Верхний порт = истина, нижний = ложь",
|
||||
"workflows.max_iterations": "Макс. итераций",
|
||||
"workflows.until_stop": "До (условие остановки)",
|
||||
"workflows.fan_out_count": "Количество ветвей",
|
||||
"workflows.wait_all": "Ждать все",
|
||||
"workflows.first_finish": "Первый завершился",
|
||||
"workflows.majority_vote": "Большинство",
|
||||
"workflows.connection_selected": "Связь выбрана",
|
||||
"workflows.delete_connection": "Удалить связь",
|
||||
|
||||
"scheduler.title": "Планировщик",
|
||||
"scheduler.scheduled_jobs": "Запланированные задания",
|
||||
"scheduler.event_triggers": "Триггеры событий",
|
||||
"scheduler.run_history": "История запусков",
|
||||
"scheduler.new_job": "+ Новое задание",
|
||||
"scheduler.job_name": "Название задания",
|
||||
"scheduler.cron_expression": "Cron-выражение",
|
||||
"scheduler.quick_presets": "Быстрые шаблоны",
|
||||
"scheduler.target_agent": "Целевой агент",
|
||||
"scheduler.any_agent": "Любой доступный агент",
|
||||
"scheduler.message_send": "Сообщение для отправки",
|
||||
"scheduler.enabled": "Включено (запустится сразу)",
|
||||
"scheduler.disabled": "Отключено (создать приостановленным)",
|
||||
"scheduler.active": "Активно",
|
||||
"scheduler.paused": "Приостановлено",
|
||||
"scheduler.cron_job": "Cron-задание",
|
||||
"scheduler.trigger": "Триггер",
|
||||
"scheduler.no_jobs": "Нет запланированных заданий",
|
||||
"scheduler.no_triggers": "Нет триггеров событий",
|
||||
"scheduler.no_history": "Нет истории запусков",
|
||||
|
||||
"channels.title": "Каналы",
|
||||
"channels.configured": "настроено",
|
||||
"channels.search": "Поиск каналов...",
|
||||
"channels.setup": "Настроить",
|
||||
"channels.edit": "Изменить",
|
||||
"channels.configure": "Настройка",
|
||||
"channels.verify": "Проверить",
|
||||
"channels.ready": "Готово",
|
||||
"channels.is_ready": "готово!",
|
||||
"channels.get_credentials": "Как получить учётные данные",
|
||||
"channels.show_advanced": "Показать расширенные",
|
||||
"channels.hide_advanced": "Скрыть расширенные",
|
||||
"channels.connecting": "Подключение к шлюзу WhatsApp Web...",
|
||||
"channels.linked_success": "WhatsApp успешно связан!",
|
||||
"channels.business_api": "Business API",
|
||||
|
||||
"skills.title": "Навыки и экосистема",
|
||||
"skills.installed": "Установленные",
|
||||
"skills.clawhub": "ClawHub",
|
||||
"skills.mcp_servers": "MCP-серверы",
|
||||
"skills.quick_start": "Быстрый старт",
|
||||
"skills.no_installed": "Нет установленных навыков",
|
||||
"skills.browse_clawhub": "Обзор ClawHub",
|
||||
"skills.search_clawhub": "Поиск навыков ClawHub...",
|
||||
"skills.trending": "Популярные",
|
||||
"skills.most_downloaded": "Самые скачиваемые",
|
||||
"skills.most_starred": "Самые оценённые",
|
||||
"skills.recently_updated": "Недавно обновлённые",
|
||||
"skills.categories": "КАТЕГОРИИ",
|
||||
"skills.already_installed": "Уже установлено",
|
||||
"skills.no_skills_found": "Навыки не найдены",
|
||||
"skills.security_warnings": "Предупреждения безопасности",
|
||||
"skills.security_scan": "Навыки проверяются на безопасность перед установкой",
|
||||
"skills.create": "Создать навык",
|
||||
"skills.created": "Создан",
|
||||
|
||||
"skills.cat_coding": "Кодинг и IDE",
|
||||
"skills.cat_git": "Git и GitHub",
|
||||
"skills.cat_frontend": "Веб и фронтенд",
|
||||
"skills.cat_devops": "DevOps и облака",
|
||||
"skills.cat_database": "Базы данных",
|
||||
"skills.cat_security": "Безопасность",
|
||||
"skills.cat_ai": "AI и ML",
|
||||
"skills.cat_data": "Данные и аналитика",
|
||||
"skills.cat_mobile": "Мобильная разработка",
|
||||
"skills.cat_desktop": "Десктопные приложения",
|
||||
"skills.cat_api": "API и интеграции",
|
||||
"skills.cat_testing": "Тестирование",
|
||||
"skills.cat_docs": "Документация",
|
||||
"skills.cat_productivity": "Продуктивность",
|
||||
"skills.cat_other": "Другое",
|
||||
|
||||
"skills.cat_browser": "Браузер и автоматизация",
|
||||
"skills.cat_search": "Поиск и исследования",
|
||||
"skills.cat_communication": "Коммуникации",
|
||||
"skills.cat_media": "Медиа и стриминг",
|
||||
"skills.cat_notes": "Заметки и PKM",
|
||||
"skills.cat_cli": "CLI утилиты",
|
||||
"skills.cat_marketing": "Маркетинг и продажи",
|
||||
"skills.cat_finance": "Финансы",
|
||||
"skills.cat_smarthome": "Умный дом и IoT",
|
||||
|
||||
"skills.uninstall_skill": "Удалить навык",
|
||||
"skills.uninstall_confirm": "Удалить навык",
|
||||
|
||||
"skills.source_clawhub": "ClawHub",
|
||||
"skills.source_openclaw": "OpenClaw",
|
||||
"skills.source_builtin": "Встроенный",
|
||||
"skills.source_local": "Локальный",
|
||||
|
||||
"hands.title": "Руки — Наборы автономных возможностей",
|
||||
"hands.available": "Доступные",
|
||||
"hands.active": "Активные",
|
||||
"hands.ready": "Готово",
|
||||
"hands.setup_needed": "Требуется настройка",
|
||||
"hands.requirements": "ТРЕБОВАНИЯ",
|
||||
"hands.details": "Подробности",
|
||||
"hands.no_hands": "Нет доступных модулей",
|
||||
|
||||
"sessions.title": "Сессии",
|
||||
"sessions.memory": "Память",
|
||||
"sessions.delete_session": "Удалить сессию",
|
||||
"sessions.delete_confirm": "Это навсегда удалит сессию и все её сообщения.",
|
||||
"sessions.delete_key": "Удалить ключ",
|
||||
"sessions.delete_key_confirm": "Удалить ключ",
|
||||
|
||||
"logs.title": "Логи",
|
||||
"logs.live": "Онлайн",
|
||||
"logs.audit_trail": "Аудит",
|
||||
|
||||
"settings.title": "Настройки",
|
||||
"settings.providers": "Провайдеры",
|
||||
"settings.models": "Модели",
|
||||
"settings.config": "Конфигурация",
|
||||
"settings.tools": "Инструменты",
|
||||
"settings.migration": "Миграция",
|
||||
"settings.security": "Безопасность",
|
||||
"settings.network": "Сеть",
|
||||
"settings.migration": "Миграция",
|
||||
"settings.language": "Язык",
|
||||
|
||||
"settings.sec_path_traversal": "Защита от обхода пути",
|
||||
"settings.sec_path_traversal_desc": "Блокирует попытки доступа к файлам за пределами рабочей директории через .. или абсолютные пути.",
|
||||
"settings.sec_ssrf": "Защита от SSRF",
|
||||
"settings.sec_ssrf_desc": "Предотвращает запросы агентов к внутренним IP-диапазонам (localhost, облачный метаданные, частные сети).",
|
||||
"settings.sec_capability": "Управление доступом по возможностям",
|
||||
"settings.sec_capability_desc": "Агенты могут получать доступ только к явно предоставленным возможностям. Нет неявного доступа к инструментам или данным.",
|
||||
"settings.sec_taint": "Отслеживание заражения",
|
||||
"settings.sec_taint_desc": "Отслеживает ненадёжные данные (ввод пользователя, содержимое файлов) через рассуждения агента для предотвращения инъекции промпта.",
|
||||
"settings.sec_sandbox": "WASM-песочница",
|
||||
"settings.sec_sandbox_desc": "Выполняет ненадёжный код в изолированных WebAssembly-песочницах с ограничениями памяти и системных вызовов.",
|
||||
"settings.sec_audit": "Меркл-проверка",
|
||||
"settings.sec_audit_desc": "Ведёт верифицируемый журнал аудита всех действий агентов с использованием криптографии деревьев Меркла.",
|
||||
"settings.sec_workspace": "Изоляция рабочих областей",
|
||||
"settings.sec_workspace_desc": "Каждый агент имеет изолированную рабочую директорию. Нет межагентного доступа к файлам без явного разрешения.",
|
||||
"settings.sec_rate_limit": "Ограничение частоты",
|
||||
"settings.sec_rate_limit_desc": "Устанавливает лимиты на запросы для каждого агента и глобально для предотвращения истощения ресурсов и перерасхода.",
|
||||
"settings.sec_approval": "Одобрения выполнения",
|
||||
"settings.sec_approval_desc": "Требует одобрения человека для рискованных действий (команды оболочки, запись файлов, внешние запросы).",
|
||||
|
||||
"settings.sec_enabled": "Включено",
|
||||
"settings.sec_disabled": "Отключено",
|
||||
"settings.sec_inherited": "Унаследовано",
|
||||
"settings.sec_global": "Глобально"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@
|
||||
<title>OpenFang Dashboard</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/png" href="/logo.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -161,6 +161,17 @@ var OpenFangAPI = (function() {
|
||||
return fetch(BASE + path, opts).then(function(r) {
|
||||
if (_connectionState !== 'connected') setConnectionState('connected');
|
||||
if (!r.ok) {
|
||||
// On 401, auto-show auth prompt so the user can re-enter their key
|
||||
if (r.status === 401 && typeof Alpine !== 'undefined') {
|
||||
try {
|
||||
var store = Alpine.store('app');
|
||||
if (store && !store.showAuthPrompt) {
|
||||
_authToken = '';
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
store.showAuthPrompt = true;
|
||||
}
|
||||
} catch(e2) { /* ignore Alpine errors */ }
|
||||
}
|
||||
return r.text().then(function(text) {
|
||||
var msg = '';
|
||||
try {
|
||||
@@ -213,9 +224,12 @@ var OpenFangAPI = (function() {
|
||||
try {
|
||||
var url = WS_BASE + '/api/agents/' + agentId + '/ws';
|
||||
if (_authToken) url += '?token=' + encodeURIComponent(_authToken);
|
||||
_ws = new WebSocket(url);
|
||||
var socket = new WebSocket(url);
|
||||
_ws = socket;
|
||||
|
||||
_ws.onopen = function() {
|
||||
socket.onopen = function() {
|
||||
// Guard: ignore if this socket was superseded by a newer connection
|
||||
if (_ws !== socket) return;
|
||||
_wsConnected = true;
|
||||
_reconnectAttempts = 0;
|
||||
setConnectionState('connected');
|
||||
@@ -226,14 +240,20 @@ var OpenFangAPI = (function() {
|
||||
if (_wsCallbacks.onOpen) _wsCallbacks.onOpen();
|
||||
};
|
||||
|
||||
_ws.onmessage = function(e) {
|
||||
socket.onmessage = function(e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
|
||||
} catch(err) { /* ignore parse errors */ }
|
||||
} catch(parseErr) {
|
||||
return; // Ignore malformed JSON frames
|
||||
}
|
||||
// Dispatch outside try/catch so handler errors are not swallowed
|
||||
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
|
||||
};
|
||||
|
||||
_ws.onclose = function(e) {
|
||||
socket.onclose = function(e) {
|
||||
// Guard: only update state if this is still the active socket.
|
||||
// A superseded socket closing must not null-out the new connection.
|
||||
if (_ws !== socket) return;
|
||||
_wsConnected = false;
|
||||
_ws = null;
|
||||
if (_wsAgentId && _reconnectAttempts < MAX_RECONNECT && e.code !== 1000) {
|
||||
@@ -254,7 +274,9 @@ var OpenFangAPI = (function() {
|
||||
if (_wsCallbacks.onClose) _wsCallbacks.onClose();
|
||||
};
|
||||
|
||||
_ws.onerror = function() {
|
||||
socket.onerror = function() {
|
||||
// Guard: ignore errors from superseded sockets
|
||||
if (_ws !== socket) return;
|
||||
_wsConnected = false;
|
||||
if (_wsCallbacks.onError) _wsCallbacks.onError();
|
||||
};
|
||||
@@ -286,15 +308,15 @@ var OpenFangAPI = (function() {
|
||||
function getToken() { return _authToken; }
|
||||
|
||||
function upload(agentId, file) {
|
||||
var hdrs = {
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
'X-Filename': file.name
|
||||
};
|
||||
var hdrs = {};
|
||||
if (_authToken) hdrs['Authorization'] = 'Bearer ' + _authToken;
|
||||
var form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('filename', file.name);
|
||||
return fetch(BASE + '/api/agents/' + agentId + '/upload', {
|
||||
method: 'POST',
|
||||
headers: hdrs,
|
||||
body: file
|
||||
body: form
|
||||
}).then(function(r) {
|
||||
if (!r.ok) throw new Error('Upload failed');
|
||||
return r.json();
|
||||
|
||||
@@ -18,15 +18,49 @@ if (typeof marked !== 'undefined') {
|
||||
function escapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
return div.innerHTML.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
if (typeof marked !== 'undefined') {
|
||||
var html = marked.parse(text);
|
||||
// Protect LaTeX blocks from marked.js mangling (underscores, backslashes, etc.)
|
||||
var latexBlocks = [];
|
||||
var protected_ = text;
|
||||
// Protect display math $$...$$ first (greedy across lines)
|
||||
protected_ = protected_.replace(/\$\$([\s\S]+?)\$\$/g, function(match) {
|
||||
var idx = latexBlocks.length;
|
||||
latexBlocks.push(match);
|
||||
return '\x00LATEX' + idx + '\x00';
|
||||
});
|
||||
// Protect inline math $...$ (single line, not empty, not starting/ending with space)
|
||||
protected_ = protected_.replace(/\$([^\s$](?:[^$]*[^\s$])?)\$/g, function(match) {
|
||||
var idx = latexBlocks.length;
|
||||
latexBlocks.push(match);
|
||||
return '\x00LATEX' + idx + '\x00';
|
||||
});
|
||||
// Protect \[...\] display math
|
||||
protected_ = protected_.replace(/\\\[([\s\S]+?)\\\]/g, function(match) {
|
||||
var idx = latexBlocks.length;
|
||||
latexBlocks.push(match);
|
||||
return '\x00LATEX' + idx + '\x00';
|
||||
});
|
||||
// Protect \(...\) inline math
|
||||
protected_ = protected_.replace(/\\\(([\s\S]+?)\\\)/g, function(match) {
|
||||
var idx = latexBlocks.length;
|
||||
latexBlocks.push(match);
|
||||
return '\x00LATEX' + idx + '\x00';
|
||||
});
|
||||
|
||||
var html = marked.parse(protected_);
|
||||
// Restore LaTeX blocks
|
||||
for (var i = 0; i < latexBlocks.length; i++) {
|
||||
html = html.replace('\x00LATEX' + i + '\x00', latexBlocks[i]);
|
||||
}
|
||||
// Add copy buttons to code blocks
|
||||
html = html.replace(/<pre><code/g, '<pre><button class="copy-btn" onclick="copyCode(this)">Copy</button><code');
|
||||
// Open external links in new tab
|
||||
html = html.replace(/<a\s+href="(https?:\/\/[^"]*)"(?![^>]*target=)([^>]*)>/gi, '<a href="$1" target="_blank" rel="noopener"$2>');
|
||||
return html;
|
||||
}
|
||||
return escapeHtml(text);
|
||||
@@ -100,10 +134,14 @@ document.addEventListener('alpine:init', function() {
|
||||
lastError: '',
|
||||
version: '0.1.0',
|
||||
agentCount: 0,
|
||||
pendingApprovalCount: 0,
|
||||
lastPendingApprovalSignature: '',
|
||||
pendingAgent: null,
|
||||
focusMode: localStorage.getItem('openfang-focus') === 'true',
|
||||
showOnboarding: false,
|
||||
showAuthPrompt: false,
|
||||
authMode: 'apikey',
|
||||
sessionUser: null,
|
||||
|
||||
toggleFocusMode() {
|
||||
this.focusMode = !this.focusMode;
|
||||
@@ -118,6 +156,23 @@ document.addEventListener('alpine:init', function() {
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
async refreshApprovals() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/approvals');
|
||||
var approvals = Array.isArray(data) ? data : (data.approvals || []);
|
||||
var pending = approvals.filter(function(a) { return a.status === 'pending'; });
|
||||
var signature = pending
|
||||
.map(function(a) { return a.id; })
|
||||
.sort()
|
||||
.join(',');
|
||||
if (pending.length > 0 && signature !== this.lastPendingApprovalSignature && typeof OpenFangToast !== 'undefined') {
|
||||
OpenFangToast.warn('An agent is waiting for approval. Open Approvals to review.');
|
||||
}
|
||||
this.pendingApprovalCount = pending.length;
|
||||
this.lastPendingApprovalSignature = signature;
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
async checkStatus() {
|
||||
try {
|
||||
var s = await OpenFangAPI.get('/api/status');
|
||||
@@ -155,16 +210,33 @@ document.addEventListener('alpine:init', function() {
|
||||
|
||||
async checkAuth() {
|
||||
try {
|
||||
// Use a protected endpoint (not in the public allowlist) to detect
|
||||
// whether the server requires an API key.
|
||||
// First check if session-based auth is configured
|
||||
var authInfo = await OpenFangAPI.get('/api/auth/check');
|
||||
if (authInfo.mode === 'none') {
|
||||
// No session auth — fall back to API key detection
|
||||
this.authMode = 'apikey';
|
||||
this.sessionUser = null;
|
||||
} else if (authInfo.mode === 'session') {
|
||||
this.authMode = 'session';
|
||||
if (authInfo.authenticated) {
|
||||
this.sessionUser = authInfo.username;
|
||||
this.showAuthPrompt = false;
|
||||
return;
|
||||
}
|
||||
// Session auth enabled but not authenticated — show login prompt
|
||||
this.showAuthPrompt = true;
|
||||
return;
|
||||
}
|
||||
} catch(e) { /* ignore — fall through to API key check */ }
|
||||
|
||||
// API key mode detection
|
||||
try {
|
||||
await OpenFangAPI.get('/api/tools');
|
||||
this.showAuthPrompt = false;
|
||||
} catch(e) {
|
||||
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
|
||||
// Only show prompt if we don't already have a saved key
|
||||
var saved = localStorage.getItem('openfang-api-key');
|
||||
if (saved) {
|
||||
// Saved key might be stale — clear it and show prompt
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
}
|
||||
@@ -181,6 +253,29 @@ document.addEventListener('alpine:init', function() {
|
||||
this.refreshAgents();
|
||||
},
|
||||
|
||||
async sessionLogin(username, password) {
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/auth/login', { username: username, password: password });
|
||||
if (result.status === 'ok') {
|
||||
this.sessionUser = result.username;
|
||||
this.showAuthPrompt = false;
|
||||
this.refreshAgents();
|
||||
} else {
|
||||
OpenFangToast.error(result.error || 'Login failed');
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Login failed');
|
||||
}
|
||||
},
|
||||
|
||||
async sessionLogout() {
|
||||
try {
|
||||
await OpenFangAPI.post('/api/auth/logout');
|
||||
} catch(e) { /* ignore */ }
|
||||
this.sessionUser = null;
|
||||
this.showAuthPrompt = true;
|
||||
},
|
||||
|
||||
clearApiKey() {
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
@@ -274,9 +369,13 @@ function app() {
|
||||
|
||||
// Initial data load
|
||||
this.pollStatus();
|
||||
Alpine.store('app').refreshApprovals();
|
||||
Alpine.store('app').checkOnboarding();
|
||||
Alpine.store('app').checkAuth();
|
||||
setInterval(function() { self.pollStatus(); }, 5000);
|
||||
setInterval(function() {
|
||||
self.pollStatus();
|
||||
Alpine.store('app').refreshApprovals();
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
navigate(p) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// On-demand KaTeX loader and renderer for chat messages.
|
||||
|
||||
var KATEX_VERSION = '0.16.21';
|
||||
var KATEX_CSS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.css';
|
||||
var KATEX_JS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.js';
|
||||
var KATEX_AUTORENDER_URL =
|
||||
'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/contrib/auto-render.min.js';
|
||||
var katexLoadPromise = null;
|
||||
|
||||
function hasLatexDelimiters(text) {
|
||||
if (!text) return false;
|
||||
return /\$\$|\\\[|\\\(|\$(?=\S)[^$\n]+\$/.test(text);
|
||||
}
|
||||
|
||||
function loadScript(url) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.onload = function () {
|
||||
resolve();
|
||||
};
|
||||
script.onerror = function () {
|
||||
reject(new Error('Failed to load script: ' + url));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureKatexLoaded() {
|
||||
if (typeof renderMathInElement === 'function') return Promise.resolve(true);
|
||||
if (katexLoadPromise) return katexLoadPromise;
|
||||
|
||||
katexLoadPromise = new Promise(function (resolve) {
|
||||
var cssId = 'openfang-katex-css';
|
||||
if (!document.getElementById(cssId)) {
|
||||
var link = document.createElement('link');
|
||||
link.id = cssId;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = KATEX_CSS_URL;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
loadScript(KATEX_JS_URL)
|
||||
.then(function () {
|
||||
return loadScript(KATEX_AUTORENDER_URL);
|
||||
})
|
||||
.then(function () {
|
||||
resolve(typeof renderMathInElement === 'function');
|
||||
})
|
||||
.catch(function () {
|
||||
katexLoadPromise = null;
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
return katexLoadPromise;
|
||||
}
|
||||
|
||||
// Render LaTeX math in the chat message container using KaTeX auto-render.
|
||||
// Call this after new messages are inserted into the DOM.
|
||||
function renderLatex(el) {
|
||||
var target = el || document.getElementById('messages');
|
||||
if (!target) return;
|
||||
if (!hasLatexDelimiters(target.textContent || '')) return;
|
||||
|
||||
ensureKatexLoaded().then(function (ok) {
|
||||
if (!ok || typeof renderMathInElement !== 'function') return;
|
||||
try {
|
||||
renderMathInElement(target, {
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
],
|
||||
throwOnError: false,
|
||||
trust: false,
|
||||
});
|
||||
} catch (e) {
|
||||
/* KaTeX render error — ignore gracefully */
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
// OpenFang Agents Page — Multi-step spawn wizard, detail view with tabs, file editor, personality presets
|
||||
'use strict';
|
||||
|
||||
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n""").
|
||||
* Backslashes are escaped, and runs of 3+ consecutive double-quotes are
|
||||
* broken up so the TOML parser never sees an unintended closing delimiter.
|
||||
*/
|
||||
function tomlMultilineEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
|
||||
}
|
||||
|
||||
/** Escape a string for use inside a TOML basic (single-line) string ("...").
|
||||
* Backslashes, double-quotes, and common control chars are escaped.
|
||||
*/
|
||||
function tomlBasicEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
function agentsPage() {
|
||||
return {
|
||||
tab: 'agents',
|
||||
@@ -25,6 +40,8 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// -- Multi-step wizard state --
|
||||
spawnProviders: [], // populated from /api/providers on wizard open
|
||||
spawnProvidersLoading: false,
|
||||
spawnStep: 1,
|
||||
spawnIdentity: { emoji: '', color: '#FF5C00', archetype: '' },
|
||||
selectedPreset: '',
|
||||
@@ -36,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',
|
||||
@@ -62,6 +88,8 @@ function agentsPage() {
|
||||
// -- Model switch --
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
editingProvider: false,
|
||||
newProviderValue: '',
|
||||
modelSaving: false,
|
||||
// -- Fallback chain --
|
||||
editingFallback: false,
|
||||
@@ -75,112 +103,36 @@ function agentsPage() {
|
||||
selectedCategory: 'All',
|
||||
searchQuery: '',
|
||||
|
||||
builtinTemplates: [
|
||||
{
|
||||
name: 'General Assistant',
|
||||
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
|
||||
category: 'General',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.'
|
||||
},
|
||||
{
|
||||
name: 'Code Helper',
|
||||
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.'
|
||||
},
|
||||
{
|
||||
name: 'Researcher',
|
||||
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
|
||||
category: 'Research',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'research',
|
||||
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.'
|
||||
},
|
||||
{
|
||||
name: 'Writer',
|
||||
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
|
||||
category: 'Writing',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.'
|
||||
},
|
||||
{
|
||||
name: 'Data Analyst',
|
||||
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.'
|
||||
},
|
||||
{
|
||||
name: 'DevOps Engineer',
|
||||
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'automation',
|
||||
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.'
|
||||
},
|
||||
{
|
||||
name: 'Customer Support',
|
||||
description: 'A professional, empathetic agent for handling customer inquiries and resolving issues.',
|
||||
category: 'Business',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'messaging',
|
||||
system_prompt: 'You are a professional customer support representative. Be empathetic, patient, and solution-oriented. Acknowledge concerns before offering solutions. Escalate complex issues appropriately.'
|
||||
},
|
||||
{
|
||||
name: 'Tutor',
|
||||
description: 'A patient educational agent that explains concepts step-by-step and adapts to the learner\'s level.',
|
||||
category: 'General',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a patient and encouraging tutor. Explain concepts step by step, starting from fundamentals. Use analogies and examples. Check understanding before moving on. Adapt to the learner\'s pace.'
|
||||
},
|
||||
{
|
||||
name: 'API Designer',
|
||||
description: 'An agent specialized in RESTful API design, OpenAPI specs, and integration architecture.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an API design expert. Help users design clean, consistent RESTful APIs following best practices. Cover endpoint naming, request/response schemas, error handling, and versioning.'
|
||||
},
|
||||
{
|
||||
name: 'Meeting Notes',
|
||||
description: 'Summarizes meeting transcripts into structured notes with action items and key decisions.',
|
||||
category: 'Business',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'minimal',
|
||||
system_prompt: 'You are a meeting summarizer. When given a meeting transcript or notes, produce a structured summary with: key decisions, action items (with owners), discussion highlights, and follow-up questions.'
|
||||
}
|
||||
],
|
||||
builtinTemplates: [],
|
||||
|
||||
// Load templates from API
|
||||
async init() {
|
||||
await this.loadTemplates();
|
||||
// Load personality presets with i18n
|
||||
this.loadPersonalityPresets();
|
||||
},
|
||||
|
||||
// ── Profile Descriptions ──
|
||||
profileDescriptions: {
|
||||
minimal: { label: 'Minimal', desc: 'Read-only file access' },
|
||||
coding: { label: 'Coding', desc: 'Files + shell + web fetch' },
|
||||
research: { label: 'Research', desc: 'Web search + file read/write' },
|
||||
messaging: { label: 'Messaging', desc: 'Agents + memory access' },
|
||||
automation: { label: 'Automation', desc: 'All tools except custom' },
|
||||
balanced: { label: 'Balanced', desc: 'General-purpose tool set' },
|
||||
precise: { label: 'Precise', desc: 'Focused tool set for accuracy' },
|
||||
creative: { label: 'Creative', desc: 'Full tools with creative emphasis' },
|
||||
full: { label: 'Full', desc: 'All 35+ tools' }
|
||||
// ── Profile Descriptions (loaded dynamically with i18n) ──
|
||||
_profileDescriptionsLoaded: false,
|
||||
profileDescriptions: {},
|
||||
loadProfileDescriptions: function() {
|
||||
if (this._profileDescriptionsLoaded) return;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
this.profileDescriptions = {
|
||||
minimal: { label: t('agents.profile.minimal'), desc: t('agents.profile.minimal_desc') },
|
||||
coding: { label: t('agents.profile.coding'), desc: t('agents.profile.coding_desc') },
|
||||
research: { label: t('agents.profile.research'), desc: t('agents.profile.research_desc') },
|
||||
messaging: { label: t('agents.profile.messaging'), desc: t('agents.profile.messaging_desc') },
|
||||
automation: { label: t('agents.profile.automation'), desc: t('agents.profile.automation_desc') },
|
||||
balanced: { label: t('agents.profile.balanced'), desc: t('agents.profile.balanced_desc') },
|
||||
precise: { label: t('agents.profile.precise'), desc: t('agents.profile.precise_desc') },
|
||||
creative: { label: t('agents.profile.creative'), desc: t('agents.profile.creative_desc') },
|
||||
full: { label: t('agents.profile.full'), desc: t('agents.profile.full_desc') }
|
||||
};
|
||||
this._profileDescriptionsLoaded = true;
|
||||
},
|
||||
profileInfo: function(name) {
|
||||
this.loadProfileDescriptions();
|
||||
return this.profileDescriptions[name] || { label: name, desc: '' };
|
||||
},
|
||||
|
||||
@@ -263,6 +215,9 @@ function agentsPage() {
|
||||
this.loadError = '';
|
||||
try {
|
||||
await Alpine.store('app').refreshAgents();
|
||||
await this.loadTemplates();
|
||||
this.loadPersonalityPresets();
|
||||
this.loadProfileDescriptions();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load agents. Is the daemon running?';
|
||||
}
|
||||
@@ -300,10 +255,73 @@ function agentsPage() {
|
||||
OpenFangAPI.get('/api/templates'),
|
||||
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
|
||||
]);
|
||||
this.tplTemplates = results[0].templates || [];
|
||||
// Combine static and dynamic templates
|
||||
this.builtinTemplates = [
|
||||
{
|
||||
name: 'General Assistant',
|
||||
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
|
||||
category: 'General',
|
||||
provider: 'default',
|
||||
model: 'default',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.',
|
||||
manifest_toml: 'name = "General Assistant"\ndescription = "A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Code Helper',
|
||||
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
|
||||
category: 'Development',
|
||||
provider: 'default',
|
||||
model: 'default',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.',
|
||||
manifest_toml: 'name = "Code Helper"\ndescription = "A programming-focused agent that writes, reviews, and debugs code across multiple languages."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Researcher',
|
||||
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
|
||||
category: 'Research',
|
||||
provider: 'default',
|
||||
model: 'default',
|
||||
profile: 'research',
|
||||
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.',
|
||||
manifest_toml: 'name = "Researcher"\ndescription = "An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries."\nmodule = "builtin:chat"\nprofile = "research"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Writer',
|
||||
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
|
||||
category: 'Writing',
|
||||
provider: 'default',
|
||||
model: 'default',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.',
|
||||
manifest_toml: 'name = "Writer"\ndescription = "A creative writing agent that helps with drafting, editing, and improving written content of all kinds."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Data Analyst',
|
||||
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
|
||||
category: 'Development',
|
||||
provider: 'default',
|
||||
model: 'default',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.',
|
||||
manifest_toml: 'name = "Data Analyst"\ndescription = "A data-focused agent that helps analyze datasets, create queries, and interpret statistical results."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'DevOps Engineer',
|
||||
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
|
||||
category: 'Development',
|
||||
provider: 'default',
|
||||
model: 'default',
|
||||
profile: 'automation',
|
||||
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.',
|
||||
manifest_toml: 'name = "DevOps Engineer"\ndescription = "A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting."\nmodule = "builtin:chat"\nprofile = "automation"\n\n[model]\nprovider = "default"\nmodel = "default"\nsystem_prompt = """\nYou are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.\n"""'
|
||||
},
|
||||
...results[0].templates || []
|
||||
];
|
||||
this.tplProviders = results[1].providers || [];
|
||||
} catch(e) {
|
||||
this.tplTemplates = [];
|
||||
this.builtinTemplates = [];
|
||||
this.tplLoadError = e.message || 'Could not load templates.';
|
||||
}
|
||||
this.tplLoading = false;
|
||||
@@ -319,29 +337,38 @@ function agentsPage() {
|
||||
OpenFangAPI.wsDisconnect();
|
||||
},
|
||||
|
||||
buildConfigForm(agent) {
|
||||
var identity = (agent && agent.identity) || {};
|
||||
return {
|
||||
name: (agent && agent.name) || '',
|
||||
system_prompt: (agent && agent.system_prompt) || '',
|
||||
emoji: identity.emoji || '',
|
||||
color: identity.color || '#FF5C00',
|
||||
archetype: identity.archetype || '',
|
||||
vibe: identity.vibe || ''
|
||||
};
|
||||
},
|
||||
|
||||
async showDetail(agent) {
|
||||
this.detailAgent = agent;
|
||||
this.detailAgent._fallbacks = [];
|
||||
this.detailTab = 'info';
|
||||
this.agentFiles = [];
|
||||
this.editingFile = null;
|
||||
this.fileContent = '';
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
this.configForm = {
|
||||
name: agent.name || '',
|
||||
system_prompt: agent.system_prompt || '',
|
||||
emoji: (agent.identity && agent.identity.emoji) || '',
|
||||
color: (agent.identity && agent.identity.color) || '#FF5C00',
|
||||
archetype: (agent.identity && agent.identity.archetype) || '',
|
||||
vibe: (agent.identity && agent.identity.vibe) || ''
|
||||
};
|
||||
this.showDetailModal = true;
|
||||
// Fetch full agent detail to get fallback_models
|
||||
// Load the full detail payload before opening the modal so editable
|
||||
// fields such as system_prompt and identity metadata are hydrated.
|
||||
var detail = agent;
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
|
||||
this.detailAgent._fallbacks = full.fallback_models || [];
|
||||
} catch(e) { /* ignore */ }
|
||||
detail = Object.assign({}, agent, full, {
|
||||
identity: Object.assign({}, (agent && agent.identity) || {}, (full && full.identity) || {})
|
||||
});
|
||||
} catch(e) { /* fall back to list payload */ }
|
||||
this.detailAgent = detail;
|
||||
this.detailAgent._fallbacks = detail.fallback_models || [];
|
||||
this.configForm = this.buildConfigForm(detail);
|
||||
this.showDetailModal = true;
|
||||
},
|
||||
|
||||
killAgent(agent) {
|
||||
@@ -358,6 +385,29 @@ function agentsPage() {
|
||||
});
|
||||
},
|
||||
|
||||
// Issue #1163: uninstall an agent (kill + remove ~/.openfang/agents/<name>/).
|
||||
uninstallAgent(agent) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm(
|
||||
'Uninstall Agent',
|
||||
'Uninstall agent "' + agent.name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
var res = await OpenFangAPI.del('/api/agents/' + agent.id + '/uninstall');
|
||||
var msg = 'Agent "' + agent.name + '" uninstalled';
|
||||
if (res && res.dir_removed === false) {
|
||||
msg += ' (no on-disk files found)';
|
||||
}
|
||||
OpenFangToast.success(msg);
|
||||
self.showDetailModal = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
killAllAgents() {
|
||||
var list = this.filteredAgents;
|
||||
if (!list.length) return;
|
||||
@@ -378,7 +428,7 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// ── Multi-step wizard navigation ──
|
||||
openSpawnWizard() {
|
||||
async openSpawnWizard() {
|
||||
this.showSpawnModal = true;
|
||||
this.spawnStep = 1;
|
||||
this.spawnMode = 'wizard';
|
||||
@@ -386,8 +436,26 @@ function agentsPage() {
|
||||
this.selectedPreset = '';
|
||||
this.soulContent = '';
|
||||
this.spawnForm.name = '';
|
||||
this.spawnForm.provider = 'default';
|
||||
this.spawnForm.model = 'default';
|
||||
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
|
||||
this.spawnForm.profile = 'full';
|
||||
// Fetch status defaults and dynamic provider list concurrently
|
||||
this.spawnProvidersLoading = true;
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/status').catch(function() { return {}; }),
|
||||
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
|
||||
]);
|
||||
var status = results[0];
|
||||
var provData = results[1];
|
||||
if (status.default_provider) this.spawnForm.provider = status.default_provider;
|
||||
if (status.default_model) this.spawnForm.model = status.default_model;
|
||||
this.spawnProviders = provData.providers || [];
|
||||
} catch(e) {
|
||||
this.spawnProviders = [];
|
||||
}
|
||||
this.spawnProvidersLoading = false;
|
||||
},
|
||||
|
||||
nextStep() {
|
||||
@@ -411,7 +479,7 @@ function agentsPage() {
|
||||
var f = this.spawnForm;
|
||||
var si = this.spawnIdentity;
|
||||
var lines = [
|
||||
'name = "' + f.name + '"',
|
||||
'name = "' + tomlBasicEscape(f.name) + '"',
|
||||
'module = "builtin:chat"'
|
||||
];
|
||||
if (f.profile && f.profile !== 'custom') {
|
||||
@@ -420,7 +488,7 @@ function agentsPage() {
|
||||
lines.push('', '[model]');
|
||||
lines.push('provider = "' + f.provider + '"');
|
||||
lines.push('model = "' + f.model + '"');
|
||||
lines.push('system_prompt = "' + f.systemPrompt.replace(/"/g, '\\"') + '"');
|
||||
lines.push('system_prompt = """\n' + tomlMultilineEscape(f.systemPrompt) + '\n"""');
|
||||
if (f.profile === 'custom') {
|
||||
lines.push('', '[capabilities]');
|
||||
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
|
||||
@@ -563,15 +631,20 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// -- Template methods --
|
||||
async spawnFromTemplate(name) {
|
||||
async spawnFromTemplate(template) {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(name));
|
||||
if (data.manifest_toml) {
|
||||
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: data.manifest_toml });
|
||||
var manifestToml = template.manifest_toml;
|
||||
if (!manifestToml) {
|
||||
// If template doesn't have manifest_toml, fetch it from the API
|
||||
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(template.name));
|
||||
manifestToml = data.manifest_toml;
|
||||
}
|
||||
if (manifestToml) {
|
||||
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: manifestToml });
|
||||
if (res.agent_id) {
|
||||
OpenFangToast.success('Agent "' + (res.name || name) + '" spawned from template');
|
||||
OpenFangToast.success('Agent "' + (res.name || template.name) + '" spawned from template');
|
||||
await Alpine.store('app').refreshAgents();
|
||||
this.chatWithAgent({ id: res.agent_id, name: res.name || name, model_provider: '?', model_name: '?' });
|
||||
this.chatWithAgent({ id: res.agent_id, name: res.name || template.name, model_provider: '?', model_name: '?' });
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
@@ -597,8 +670,9 @@ function agentsPage() {
|
||||
if (!this.detailAgent || !this.newModelValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
OpenFangToast.success('Model changed (memory reset)');
|
||||
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
var providerInfo = (resp && resp.provider) ? ' (provider: ' + resp.provider + ')' : '';
|
||||
OpenFangToast.success('Model changed' + providerInfo + ' (memory reset)');
|
||||
this.editingModel = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
// Refresh detailAgent
|
||||
@@ -612,6 +686,26 @@ function agentsPage() {
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Provider switch ──
|
||||
async changeProvider() {
|
||||
if (!this.detailAgent || !this.newProviderValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
var combined = this.newProviderValue.trim() + '/' + this.detailAgent.model_name;
|
||||
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: combined });
|
||||
OpenFangToast.success('Provider changed to ' + (resp && resp.provider ? resp.provider : this.newProviderValue.trim()));
|
||||
this.editingProvider = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
var agents = Alpine.store('app').agents;
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to change provider: ' + e.message);
|
||||
}
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Fallback model chain ──
|
||||
async addFallback() {
|
||||
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
|
||||
@@ -697,12 +791,12 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
async spawnBuiltin(t) {
|
||||
var toml = 'name = "' + t.name + '"\n';
|
||||
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
|
||||
var toml = 'name = "' + tomlBasicEscape(t.name) + '"\n';
|
||||
toml += 'description = "' + tomlBasicEscape(t.description) + '"\n';
|
||||
toml += 'module = "builtin:chat"\n';
|
||||
toml += 'profile = "' + t.profile + '"\n\n';
|
||||
toml += '[model]\nprovider = "' + t.provider + '"\nmodel = "' + t.model + '"\n';
|
||||
toml += 'system_prompt = """\n' + t.system_prompt + '\n"""\n';
|
||||
toml += 'system_prompt = """\n' + tomlMultilineEscape(t.system_prompt) + '\n"""\n';
|
||||
|
||||
try {
|
||||
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
|
||||
|
||||
@@ -7,6 +7,22 @@ function approvalsPage() {
|
||||
filterStatus: 'all',
|
||||
loading: true,
|
||||
loadError: '',
|
||||
refreshTimer: null,
|
||||
|
||||
init() {
|
||||
var self = this;
|
||||
this.loadData();
|
||||
this.refreshTimer = setInterval(function() {
|
||||
self.loadData();
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
get filtered() {
|
||||
var f = this.filterStatus;
|
||||
|
||||
@@ -37,38 +37,36 @@ function chatPage() {
|
||||
// Model switcher dropdown
|
||||
showModelSwitcher: false,
|
||||
modelSwitcherFilter: '',
|
||||
modelSwitcherProviderFilter: '',
|
||||
modelSwitcherIdx: 0,
|
||||
modelSwitching: false,
|
||||
_modelCache: null,
|
||||
_modelCacheTime: 0,
|
||||
slashCommands: [
|
||||
{ cmd: '/help', desc: 'Show available commands' },
|
||||
{ cmd: '/agents', desc: 'Switch to Agents page' },
|
||||
{ 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() {
|
||||
@@ -99,14 +97,25 @@ function chatPage() {
|
||||
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
|
||||
},
|
||||
|
||||
get switcherProviders() {
|
||||
var seen = {};
|
||||
(this._modelCache || []).forEach(function(m) { seen[m.provider] = true; });
|
||||
return Object.keys(seen).sort();
|
||||
},
|
||||
|
||||
get filteredSwitcherModels() {
|
||||
var models = this._modelCache || [];
|
||||
if (!this.modelSwitcherFilter) return models;
|
||||
var f = this.modelSwitcherFilter.toLowerCase();
|
||||
var provFilter = this.modelSwitcherProviderFilter;
|
||||
var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : '';
|
||||
if (!provFilter && !textFilter) return models;
|
||||
return models.filter(function(m) {
|
||||
return m.id.toLowerCase().indexOf(f) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(f) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(f) !== -1;
|
||||
if (provFilter && m.provider !== provFilter) return false;
|
||||
if (textFilter) {
|
||||
return m.id.toLowerCase().indexOf(textFilter) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(textFilter) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(textFilter) !== -1;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -125,12 +134,38 @@ function chatPage() {
|
||||
init() {
|
||||
var self = this;
|
||||
|
||||
// Initialize slash commands with i18n
|
||||
this.initSlashCommands();
|
||||
|
||||
// Start tip cycle
|
||||
this.startTipCycle();
|
||||
|
||||
// Fetch dynamic commands from server
|
||||
this.fetchCommands();
|
||||
|
||||
// Observe DOM for new messages and render LaTeX
|
||||
this._latexObserver = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
mutation.addedNodes.forEach(function(node) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
var bubbles = node.querySelector ? node.querySelectorAll('.message-bubble') : [];
|
||||
if (node.classList && node.classList.contains('message-bubble')) {
|
||||
bubbles = [node];
|
||||
}
|
||||
bubbles.forEach(function(bubble) {
|
||||
if (bubble.textContent && hasLatexDelimiters(bubble.textContent)) {
|
||||
renderLatex(bubble);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
this._latexObserver.observe(document.getElementById('messages') || document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Ctrl+/ keyboard shortcut
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||
@@ -163,6 +198,10 @@ function chatPage() {
|
||||
if (store.pendingAgent) {
|
||||
self.selectAgent(store.pendingAgent);
|
||||
store.pendingAgent = null;
|
||||
} else {
|
||||
// Restore previously active agent after page refresh (#1179).
|
||||
// The agent list may not be loaded yet, so resolve once it appears.
|
||||
self._restoreActiveAgent();
|
||||
}
|
||||
|
||||
// Watch for future pending agent selections (e.g., user clicks agent while on chat)
|
||||
@@ -173,6 +212,13 @@ function chatPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// Re-attempt restore once the agent list arrives from the server
|
||||
this.$watch('$store.app.agents', function(agents) {
|
||||
if (!self.currentAgent && agents && agents.length) {
|
||||
self._restoreActiveAgent();
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for slash commands + model autocomplete
|
||||
this.$watch('inputText', function(val) {
|
||||
var modelMatch = val.match(/^\/model\s+(.*)$/i);
|
||||
@@ -220,6 +266,7 @@ function chatPage() {
|
||||
var now = Date.now();
|
||||
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
|
||||
this.modelSwitcherFilter = '';
|
||||
this.modelSwitcherProviderFilter = '';
|
||||
this.modelSwitcherIdx = 0;
|
||||
this.showModelSwitcher = true;
|
||||
this.$nextTick(function() {
|
||||
@@ -234,6 +281,7 @@ function chatPage() {
|
||||
self._modelCacheTime = Date.now();
|
||||
self.modelPickerList = models;
|
||||
self.modelSwitcherFilter = '';
|
||||
self.modelSwitcherProviderFilter = '';
|
||||
self.modelSwitcherIdx = 0;
|
||||
self.showModelSwitcher = true;
|
||||
self.$nextTick(function() {
|
||||
@@ -250,34 +298,78 @@ function chatPage() {
|
||||
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
this.modelSwitching = true;
|
||||
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function() {
|
||||
self.currentAgent.model_name = model.id;
|
||||
self.currentAgent.model_provider = model.provider;
|
||||
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
|
||||
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(t('chat.model_switched') + ' ' + (model.display_name || model.id));
|
||||
self.showModelSwitcher = false;
|
||||
self.modelSwitching = false;
|
||||
}).catch(function(e) {
|
||||
OpenFangToast.error('Switch failed: ' + e.message);
|
||||
OpenFangToast.error(t('chat.model_switch_failed') + ': ' + e.message);
|
||||
self.modelSwitching = false;
|
||||
});
|
||||
},
|
||||
|
||||
// Fetch dynamic slash commands from server
|
||||
// Initialize slash commands with i18n translations
|
||||
initSlashCommands: function() {
|
||||
if (this._slashCommandsLoaded) return;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
this.slashCommands = [
|
||||
{ cmd: '/help', desc: t('chat.slash.help') },
|
||||
{ cmd: '/agents', desc: t('chat.slash.agents') },
|
||||
{ cmd: '/new', desc: t('chat.slash.new') },
|
||||
{ cmd: '/compact', desc: t('chat.slash.compact') },
|
||||
{ cmd: '/model', desc: t('chat.slash.model') },
|
||||
{ cmd: '/stop', desc: t('chat.slash.stop') },
|
||||
{ cmd: '/usage', desc: t('chat.slash.usage') },
|
||||
{ cmd: '/think', desc: t('chat.slash.think') },
|
||||
{ cmd: '/context', desc: t('chat.slash.context') },
|
||||
{ cmd: '/verbose', desc: t('chat.slash.verbose') },
|
||||
{ cmd: '/queue', desc: t('chat.slash.queue') },
|
||||
{ cmd: '/status', desc: t('chat.slash.status') },
|
||||
{ cmd: '/clear', desc: t('chat.slash.clear') },
|
||||
{ cmd: '/exit', desc: t('chat.slash.exit') },
|
||||
{ cmd: '/budget', desc: t('chat.slash.budget') },
|
||||
{ cmd: '/peers', desc: t('chat.slash.peers') },
|
||||
{ cmd: '/a2a', desc: t('chat.slash.a2a') }
|
||||
];
|
||||
this._slashCommandsLoaded = true;
|
||||
},
|
||||
|
||||
// Fetch slash commands from the unified registry (/api/commands?surface=web).
|
||||
// Replaces the hardcoded initSlashCommands() list once loaded — ensures
|
||||
// the help panel and autocomplete stay in sync with the backend registry.
|
||||
fetchCommands: function() {
|
||||
var self = this;
|
||||
OpenFangAPI.get('/api/commands').then(function(data) {
|
||||
if (data.commands && data.commands.length) {
|
||||
// Build a set of known cmds to avoid duplicates
|
||||
var existing = {};
|
||||
self.slashCommands.forEach(function(c) { existing[c.cmd] = true; });
|
||||
data.commands.forEach(function(c) {
|
||||
if (!existing[c.cmd]) {
|
||||
self.slashCommands.push({ cmd: c.cmd, desc: c.desc || '', source: c.source || 'server' });
|
||||
existing[c.cmd] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}).catch(function() { /* silent — use hardcoded list */ });
|
||||
OpenFangAPI.get('/api/commands?surface=web').then(function(data) {
|
||||
var cmds = (data && data.commands) || [];
|
||||
if (!cmds.length) return;
|
||||
self.slashCommands = cmds.map(function(c) {
|
||||
// Prefer unified-registry shape { name, aliases, description, category, requires_agent }.
|
||||
// Fall back to legacy { cmd, desc } shape so older shims keep working.
|
||||
if (c.name) {
|
||||
return {
|
||||
cmd: '/' + c.name,
|
||||
desc: c.description || '',
|
||||
category: c.category || 'general',
|
||||
aliases: c.aliases || [],
|
||||
requires_agent: !!c.requires_agent,
|
||||
source: 'registry'
|
||||
};
|
||||
}
|
||||
return {
|
||||
cmd: c.cmd,
|
||||
desc: c.desc || '',
|
||||
category: c.category || 'general',
|
||||
aliases: c.aliases || [],
|
||||
requires_agent: !!c.requires_agent,
|
||||
source: c.source || 'server'
|
||||
};
|
||||
});
|
||||
self._slashCommandsLoaded = true;
|
||||
}).catch(function() { /* silent — keep hardcoded fallback list */ });
|
||||
},
|
||||
|
||||
get filteredSlashCommands() {
|
||||
@@ -288,6 +380,44 @@ function chatPage() {
|
||||
});
|
||||
},
|
||||
|
||||
// Render `/help` output grouped by category, mirroring the
|
||||
// backend's render_help(Surfaces::WEB). Falls back to a flat list if
|
||||
// categories are not populated (pre-fetch hardcoded list).
|
||||
renderHelpText: function() {
|
||||
var order = ['general', 'session', 'model', 'control', 'memory', 'info', 'automation', 'monitoring'];
|
||||
var labels = {
|
||||
general: 'General', session: 'Session', model: 'Model', control: 'Control',
|
||||
memory: 'Memory', info: 'Info', automation: 'Automation', monitoring: 'Monitoring'
|
||||
};
|
||||
var anyCategorised = this.slashCommands.some(function(c) { return c.category; });
|
||||
if (!anyCategorised) {
|
||||
return this.slashCommands.map(function(c) {
|
||||
return '`' + c.cmd + '` \u2014 ' + c.desc;
|
||||
}).join('\n');
|
||||
}
|
||||
var groups = {};
|
||||
this.slashCommands.forEach(function(c) {
|
||||
var cat = c.category || 'general';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(c);
|
||||
});
|
||||
var lines = ['**Available commands:**'];
|
||||
order.forEach(function(cat) {
|
||||
var list = groups[cat];
|
||||
if (!list || !list.length) return;
|
||||
lines.push('');
|
||||
lines.push('**' + (labels[cat] || cat) + '**');
|
||||
list.forEach(function(c) {
|
||||
var aliasText = '';
|
||||
if (c.aliases && c.aliases.length) {
|
||||
aliasText = ' (aliases: ' + c.aliases.map(function(a) { return '/' + a; }).join(', ') + ')';
|
||||
}
|
||||
lines.push('- `' + c.cmd + '`' + aliasText + ' \u2014 ' + c.desc);
|
||||
});
|
||||
});
|
||||
return lines.join('\n');
|
||||
},
|
||||
|
||||
// Clear any stuck typing indicator after 120s
|
||||
_resetTypingTimeout: function() {
|
||||
var self = this;
|
||||
@@ -313,7 +443,7 @@ function chatPage() {
|
||||
cmdArgs = cmdArgs || '';
|
||||
switch (cmd) {
|
||||
case '/help':
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: self.slashCommands.map(function(c) { return '`' + c.cmd + '` — ' + c.desc; }).join('\n'), meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: self.renderHelpText(), meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
break;
|
||||
case '/agents':
|
||||
@@ -377,7 +507,7 @@ function chatPage() {
|
||||
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
|
||||
OpenFangAPI.wsSend({ type: 'command', command: 'context', args: '' });
|
||||
} else {
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected. Connect to an agent first.', meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
@@ -385,7 +515,7 @@ function chatPage() {
|
||||
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
|
||||
OpenFangAPI.wsSend({ type: 'command', command: 'verbose', args: cmdArgs });
|
||||
} else {
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected. Connect to an agent first.', meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
@@ -393,7 +523,7 @@ function chatPage() {
|
||||
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
|
||||
OpenFangAPI.wsSend({ type: 'command', command: 'queue', args: '' });
|
||||
} else {
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected.', meta: '', tools: [] });
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + ').', meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
@@ -406,9 +536,13 @@ function chatPage() {
|
||||
case '/model':
|
||||
if (self.currentAgent) {
|
||||
if (cmdArgs) {
|
||||
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function() {
|
||||
self.currentAgent.model_name = cmdArgs;
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`', meta: '', tools: [] });
|
||||
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) {
|
||||
// Use server-resolved model/provider (fixes #387/#466)
|
||||
var resolvedModel = (resp && resp.model) || cmdArgs;
|
||||
var resolvedProvider = (resp && resp.provider) || '';
|
||||
self.currentAgent.model_name = resolvedModel;
|
||||
if (resolvedProvider) { self.currentAgent.model_provider = resolvedProvider; }
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + resolvedModel + '`' + (resolvedProvider ? ' (provider: `' + resolvedProvider + '`)' : ''), meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); });
|
||||
} else {
|
||||
@@ -428,6 +562,7 @@ function chatPage() {
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
window.dispatchEvent(new Event('close-chat'));
|
||||
break;
|
||||
case '/budget':
|
||||
@@ -463,25 +598,35 @@ function chatPage() {
|
||||
}
|
||||
},
|
||||
|
||||
// Restore the previously-active agent (set in selectAgent) after a page
|
||||
// refresh, so the WebSocket re-attaches to the same session and any
|
||||
// in-flight tool output streams back into the chat (#1179).
|
||||
_restoreActiveAgent: function() {
|
||||
var storedId = null;
|
||||
try { storedId = localStorage.getItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
if (!storedId) return;
|
||||
var agents = (Alpine.store('app') && Alpine.store('app').agents) || [];
|
||||
var match = null;
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i] && agents[i].id === storedId) { match = agents[i]; break; }
|
||||
}
|
||||
if (match) {
|
||||
this.selectAgent(match);
|
||||
}
|
||||
},
|
||||
|
||||
selectAgent(agent) {
|
||||
this.currentAgent = agent;
|
||||
this.messages = [];
|
||||
try { localStorage.setItem('of-active-agent', agent.id); } catch(e) { /* ignore */ }
|
||||
this.connectWs(agent.id);
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
// Show welcome tips on first use
|
||||
if (!localStorage.getItem('of-chat-tips-seen')) {
|
||||
var localMsgId = 0;
|
||||
this.messages.push({
|
||||
id: ++localMsgId,
|
||||
id: ++msgId,
|
||||
role: 'system',
|
||||
text: '**Welcome to OpenFang Chat!**\n\n' +
|
||||
'- Type `/` to see available commands\n' +
|
||||
'- `/help` shows all commands\n' +
|
||||
'- `/think on` enables extended reasoning\n' +
|
||||
'- `/context` shows context window usage\n' +
|
||||
'- `/verbose off` hides tool details\n' +
|
||||
'- `Ctrl+Shift+F` toggles focus mode\n' +
|
||||
'- Drag & drop files to attach them\n' +
|
||||
'- `Ctrl+/` opens the command palette',
|
||||
text: t('chat.welcome_message'),
|
||||
meta: '',
|
||||
tools: []
|
||||
});
|
||||
@@ -500,7 +645,14 @@ function chatPage() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/agents/' + agentId + '/session');
|
||||
if (data.messages && data.messages.length) {
|
||||
self.messages = data.messages.map(function(m) {
|
||||
// Defense-in-depth (#935): never render system-role messages in the
|
||||
// conversation history view, even if the backend somehow returns
|
||||
// one. The server already filters these out by default, but we
|
||||
// guard here too so a regression cannot leak the system prompt.
|
||||
var visible = data.messages.filter(function(m) {
|
||||
return m && m.role !== 'System' && m.role !== 'system';
|
||||
});
|
||||
self.messages = visible.map(function(m) {
|
||||
var role = m.role === 'User' ? 'user' : (m.role === 'System' ? 'system' : 'agent');
|
||||
var text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
|
||||
// Sanitize any raw function-call text from history
|
||||
@@ -511,13 +663,16 @@ function chatPage() {
|
||||
id: (t.name || 'tool') + '-hist-' + idx,
|
||||
name: t.name || 'unknown',
|
||||
running: false,
|
||||
expanded: false,
|
||||
expanded: true,
|
||||
input: t.input || '',
|
||||
result: t.result || '',
|
||||
is_error: !!t.is_error
|
||||
};
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools };
|
||||
var images = (m.images || []).map(function(img) {
|
||||
return { file_id: img.file_id, filename: img.filename || 'image' };
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools, images: images };
|
||||
});
|
||||
self.$nextTick(function() { self.scrollToBottom(); });
|
||||
}
|
||||
@@ -535,7 +690,8 @@ function chatPage() {
|
||||
// Multi-session: create a new session
|
||||
async createSession() {
|
||||
if (!this.currentAgent) return;
|
||||
var label = prompt('Session name (optional):');
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
var label = prompt(t('chat.session_name_prompt'));
|
||||
if (label === null) return; // cancelled
|
||||
try {
|
||||
await OpenFangAPI.post('/api/agents/' + this.currentAgent.id + '/sessions', {
|
||||
@@ -545,9 +701,9 @@ function chatPage() {
|
||||
await this.loadSession(this.currentAgent.id);
|
||||
this.messages = [];
|
||||
this.scrollToBottom();
|
||||
if (typeof OpenFangToast !== 'undefined') OpenFangToast.success('New session created');
|
||||
if (typeof OpenFangToast !== 'undefined') OpenFangToast.success(t('chat.session_created'));
|
||||
} catch(e) {
|
||||
if (typeof OpenFangToast !== 'undefined') OpenFangToast.error('Failed to create session');
|
||||
if (typeof OpenFangToast !== 'undefined') OpenFangToast.error(t('chat.session_create_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -592,6 +748,15 @@ function chatPage() {
|
||||
switch (data.type) {
|
||||
case 'connected': break;
|
||||
|
||||
// Incoming message from server (e.g., cron trigger) — display as user message
|
||||
case 'message':
|
||||
if (data.content) {
|
||||
var meta = data.source === 'cron' ? '[Scheduled: ' + (data.job_name || data.job_id || '') + ']' : '';
|
||||
this.messages.push({ id: ++msgId, role: 'user', text: data.content, meta: meta, tools: [], images: [], ts: Date.now() });
|
||||
this.scrollToBottom();
|
||||
}
|
||||
break;
|
||||
|
||||
// Legacy thinking event (backward compat)
|
||||
case 'thinking':
|
||||
if (!this.messages.length || !this.messages[this.messages.length - 1].thinking) {
|
||||
@@ -600,8 +765,12 @@ function chatPage() {
|
||||
this.scrollToBottom();
|
||||
this._resetTypingTimeout();
|
||||
} else if (data.level) {
|
||||
var lastThink = this.messages[this.messages.length - 1];
|
||||
if (lastThink && lastThink.thinking) lastThink.text = 'Thinking (' + data.level + ')...';
|
||||
var thinkIdx = this.messages.length - 1;
|
||||
var lastThink = thinkIdx >= 0 ? this.messages[thinkIdx] : null;
|
||||
if (lastThink && lastThink.thinking) {
|
||||
lastThink.text = 'Thinking (' + data.level + ')...';
|
||||
this.messages.splice(thinkIdx, 1, lastThink);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -614,9 +783,11 @@ function chatPage() {
|
||||
}
|
||||
this._resetTypingTimeout();
|
||||
} else if (data.state === 'tool') {
|
||||
var typingMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var toolTypIdx = this.messages.length - 1;
|
||||
var typingMsg = toolTypIdx >= 0 ? this.messages[toolTypIdx] : null;
|
||||
if (typingMsg && (typingMsg.thinking || typingMsg.streaming)) {
|
||||
typingMsg.text = 'Using ' + (data.tool || 'tool') + '...';
|
||||
this.messages.splice(toolTypIdx, 1, typingMsg);
|
||||
}
|
||||
this._resetTypingTimeout();
|
||||
} else if (data.state === 'stop') {
|
||||
@@ -626,26 +797,45 @@ function chatPage() {
|
||||
|
||||
case 'phase':
|
||||
// Show tool/phase progress so the user sees the agent is working
|
||||
var phaseMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var phaseIdx = this.messages.length - 1;
|
||||
var phaseMsg = phaseIdx >= 0 ? this.messages[phaseIdx] : null;
|
||||
if (phaseMsg && (phaseMsg.thinking || phaseMsg.streaming)) {
|
||||
var detail = data.detail || data.phase || 'Working...';
|
||||
// Context warning: show prominently
|
||||
// Skip phases that have no user-meaningful display text — "streaming"
|
||||
// and "done" are lifecycle signals, not status to show in the chat bubble.
|
||||
if (data.phase === 'streaming' || data.phase === 'done') {
|
||||
break;
|
||||
}
|
||||
// Context warning: show prominently as a separate system message
|
||||
if (data.phase === 'context_warning') {
|
||||
this.messages.push({ id: ++msgId, role: 'system', text: detail, meta: '', tools: [] });
|
||||
var cwDetail = data.detail || 'Context limit reached.';
|
||||
this.messages.push({ id: ++msgId, role: 'system', text: cwDetail, meta: '', tools: [] });
|
||||
} else if (data.phase === 'thinking' && this.thinkingMode === 'stream') {
|
||||
// Stream reasoning tokens to a collapsible panel
|
||||
if (!phaseMsg._reasoning) phaseMsg._reasoning = '';
|
||||
phaseMsg._reasoning += (detail || '') + '\n';
|
||||
phaseMsg._reasoning += (data.detail || '') + '\n';
|
||||
phaseMsg.text = '<details><summary>Reasoning...</summary>\n\n' + phaseMsg._reasoning + '</details>';
|
||||
} else {
|
||||
phaseMsg.text = detail;
|
||||
this.messages.splice(phaseIdx, 1, phaseMsg);
|
||||
} else if (phaseMsg.thinking) {
|
||||
// Only update text on messages still in thinking state (not yet
|
||||
// receiving streamed content) to avoid overwriting accumulated text.
|
||||
var phaseDetail;
|
||||
if (data.phase === 'tool_use') {
|
||||
phaseDetail = 'Using ' + (data.detail || 'tool') + '...';
|
||||
} else if (data.phase === 'thinking') {
|
||||
phaseDetail = 'Thinking...';
|
||||
} else {
|
||||
phaseDetail = data.detail || 'Working...';
|
||||
}
|
||||
phaseMsg.text = phaseDetail;
|
||||
this.messages.splice(phaseIdx, 1, phaseMsg);
|
||||
}
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
|
||||
case 'text_delta':
|
||||
var last = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var lastIdx = this.messages.length - 1;
|
||||
var last = lastIdx >= 0 ? this.messages[lastIdx] : null;
|
||||
if (last && last.streaming) {
|
||||
if (last.thinking) { last.text = ''; last.thinking = false; }
|
||||
// If we already detected a text-based tool call, skip further text
|
||||
@@ -666,7 +856,7 @@ function chatPage() {
|
||||
id: toolMatch[1] + '-txt-' + Date.now(),
|
||||
name: toolMatch[1],
|
||||
running: true,
|
||||
expanded: false,
|
||||
expanded: true,
|
||||
input: inputMatch ? inputMatch[1].replace(/<\/function>?\s*$/, '').trim() : '',
|
||||
result: '',
|
||||
is_error: false
|
||||
@@ -674,6 +864,10 @@ function chatPage() {
|
||||
}
|
||||
}
|
||||
this.tokenCount = Math.round(last.text.length / 4);
|
||||
// Force Alpine reactivity: splice-in-place so x-for re-renders
|
||||
// this item. Direct property mutation on array elements may not
|
||||
// trigger DOM updates from async WebSocket callbacks.
|
||||
this.messages.splice(lastIdx, 1, last);
|
||||
} else {
|
||||
this.messages.push({ id: ++msgId, role: 'agent', text: data.content, meta: '', streaming: true, tools: [] });
|
||||
}
|
||||
@@ -681,17 +875,20 @@ function chatPage() {
|
||||
break;
|
||||
|
||||
case 'tool_start':
|
||||
var lastMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var tsIdx = this.messages.length - 1;
|
||||
var lastMsg = tsIdx >= 0 ? this.messages[tsIdx] : null;
|
||||
if (lastMsg && lastMsg.streaming) {
|
||||
if (!lastMsg.tools) lastMsg.tools = [];
|
||||
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: false, input: '', result: '', is_error: false });
|
||||
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: true, input: '', result: '', is_error: false });
|
||||
this.messages.splice(tsIdx, 1, lastMsg);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
|
||||
case 'tool_end':
|
||||
// Tool call parsed by LLM — update tool card with input params
|
||||
var lastMsg2 = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var teIdx = this.messages.length - 1;
|
||||
var lastMsg2 = teIdx >= 0 ? this.messages[teIdx] : null;
|
||||
if (lastMsg2 && lastMsg2.tools) {
|
||||
for (var ti = lastMsg2.tools.length - 1; ti >= 0; ti--) {
|
||||
if (lastMsg2.tools[ti].name === data.tool && lastMsg2.tools[ti].running) {
|
||||
@@ -699,12 +896,14 @@ function chatPage() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.messages.splice(teIdx, 1, lastMsg2);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
// Tool execution completed — update tool card with result
|
||||
var lastMsg3 = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var trIdx = this.messages.length - 1;
|
||||
var lastMsg3 = trIdx >= 0 ? this.messages[trIdx] : null;
|
||||
if (lastMsg3 && lastMsg3.tools) {
|
||||
for (var ri = lastMsg3.tools.length - 1; ri >= 0; ri--) {
|
||||
if (lastMsg3.tools[ri].name === data.tool && lastMsg3.tools[ri].running) {
|
||||
@@ -733,6 +932,7 @@ function chatPage() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.messages.splice(trIdx, 1, lastMsg3);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
@@ -949,8 +1149,9 @@ function chatPage() {
|
||||
}
|
||||
|
||||
// HTTP fallback
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
if (!OpenFangAPI.isWsConnected()) {
|
||||
OpenFangToast.info('Using HTTP mode (no streaming)');
|
||||
OpenFangToast.info(t('chat.using_http_mode'));
|
||||
}
|
||||
this.messages.push({ id: ++msgId, role: 'agent', text: '', meta: '', thinking: true, tools: [], ts: Date.now() });
|
||||
this.scrollToBottom();
|
||||
@@ -993,26 +1194,65 @@ function chatPage() {
|
||||
killAgent() {
|
||||
if (!this.currentAgent) return;
|
||||
var self = this;
|
||||
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
|
||||
var name = this.currentAgent.name;
|
||||
OpenFangToast.confirm('Stop Agent', 'Stop agent "' + name + '"? The agent will be shut down.', async function() {
|
||||
OpenFangToast.confirm(t('chat.stop_agent_title'), t('chat.stop_agent_confirm') + ' "' + name + '"?', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/agents/' + self.currentAgent.id);
|
||||
OpenFangAPI.wsDisconnect();
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
OpenFangToast.success('Agent "' + name + '" stopped');
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
OpenFangToast.success(t('chat.agent_stopped') + ' "' + name + '"');
|
||||
Alpine.store('app').refreshAgents();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to stop agent: ' + e.message);
|
||||
OpenFangToast.error(t('chat.stop_agent_failed') + ': ' + e.message);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Permanently uninstall the agent: kill + remove ~/.openfang/agents/<name>/
|
||||
// Issue #1163.
|
||||
uninstallAgent: function() {
|
||||
if (!this.currentAgent) return;
|
||||
var self = this;
|
||||
var name = this.currentAgent.name;
|
||||
var agentId = this.currentAgent.id;
|
||||
OpenFangToast.confirm(
|
||||
'Uninstall Agent',
|
||||
'Uninstall agent "' + name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
var res = await OpenFangAPI.del('/api/agents/' + agentId + '/uninstall');
|
||||
OpenFangAPI.wsDisconnect();
|
||||
self._wsAgent = null;
|
||||
self.currentAgent = null;
|
||||
self.messages = [];
|
||||
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
|
||||
var msg = 'Agent "' + name + '" uninstalled';
|
||||
if (res && res.dir_removed === false) {
|
||||
msg += ' (no on-disk files found)';
|
||||
}
|
||||
OpenFangToast.success(msg);
|
||||
Alpine.store('app').refreshAgents();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
_latexTimer: null,
|
||||
scrollToBottom() {
|
||||
var self = this;
|
||||
var el = document.getElementById('messages');
|
||||
if (el) self.$nextTick(function() { el.scrollTop = el.scrollHeight; });
|
||||
if (el) self.$nextTick(function() {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
// Debounce LaTeX rendering to avoid running on every streaming token
|
||||
if (self._latexTimer) clearTimeout(self._latexTimer);
|
||||
self._latexTimer = setTimeout(function() { renderLatex(el); }, 150);
|
||||
});
|
||||
},
|
||||
|
||||
addFiles(files) {
|
||||
@@ -1082,6 +1322,9 @@ function chatPage() {
|
||||
|
||||
formatToolJson: function(text) {
|
||||
if (!text) return '';
|
||||
if (typeof text === 'object') {
|
||||
return JSON.stringify(text, null, 2);
|
||||
}
|
||||
try { return JSON.stringify(JSON.parse(text), null, 2); }
|
||||
catch(e) { return text; }
|
||||
},
|
||||
|
||||
@@ -39,7 +39,7 @@ function commsPage() {
|
||||
startSSE() {
|
||||
if (this.sseSource) this.sseSource.close();
|
||||
var self = this;
|
||||
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
|
||||
var url = '/api/comms/events/stream';
|
||||
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
|
||||
this.sseSource = new EventSource(url);
|
||||
this.sseSource.onmessage = function(ev) {
|
||||
|
||||
@@ -18,6 +18,15 @@ function handsPage() {
|
||||
browserViewerOpen: false,
|
||||
_browserPollTimer: null,
|
||||
|
||||
// ── Trader Dashboard State ────────────────────────────────────────────
|
||||
dashboardOpen: false,
|
||||
dashboardLoading: false,
|
||||
dashboardData: null,
|
||||
_dashboardInst: null,
|
||||
_chartEquity: null,
|
||||
_chartPnl: null,
|
||||
_chartRadar: null,
|
||||
|
||||
// ── Setup Wizard State ──────────────────────────────────────────────
|
||||
setupWizard: null,
|
||||
setupStep: 1,
|
||||
@@ -27,6 +36,7 @@ function handsPage() {
|
||||
_clipboardTimer: null,
|
||||
detectedPlatform: 'linux',
|
||||
installPlatforms: {},
|
||||
apiKeyInputs: {},
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
@@ -101,13 +111,19 @@ function handsPage() {
|
||||
} else {
|
||||
this._detectClientPlatform();
|
||||
}
|
||||
// Initialize per-requirement platform selections
|
||||
// Initialize per-requirement platform selections and API key inputs
|
||||
this.installPlatforms = {};
|
||||
this.apiKeyInputs = {};
|
||||
if (data.requirements) {
|
||||
for (var j = 0; j < data.requirements.length; j++) {
|
||||
this.installPlatforms[data.requirements[j].key] = this.detectedPlatform;
|
||||
if (data.requirements[j].type === 'ApiKey') {
|
||||
this.apiKeyInputs[data.requirements[j].key] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
@@ -274,7 +290,10 @@ function handsPage() {
|
||||
if (!this.setupWizard || !this.setupWizard.requirements) return 0;
|
||||
var count = 0;
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
if (this.setupWizard.requirements[i].satisfied) count++;
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.satisfied) { count++; continue; }
|
||||
// Count API key reqs as met if user entered a value
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') count++;
|
||||
}
|
||||
return count;
|
||||
},
|
||||
@@ -285,7 +304,34 @@ function handsPage() {
|
||||
},
|
||||
|
||||
get setupAllReqsMet() {
|
||||
return this.setupReqsTotal > 0 && this.setupReqsMet === this.setupReqsTotal;
|
||||
if (!this.setupWizard || !this.setupWizard.requirements) return false;
|
||||
if (this.setupReqsTotal === 0) return false;
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.satisfied) continue;
|
||||
// API key reqs are satisfied if the user entered a value in the input
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') continue;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
getSettingKeyForReq(req) {
|
||||
// Find the matching setting key for an API key requirement.
|
||||
// Convention: setting key is the lowercase version of the requirement key.
|
||||
if (!this.setupWizard || !this.setupWizard.settings) return null;
|
||||
var lowerKey = req.key.toLowerCase();
|
||||
for (var i = 0; i < this.setupWizard.settings.length; i++) {
|
||||
if (this.setupWizard.settings[i].key === lowerKey) return lowerKey;
|
||||
}
|
||||
// Fallback: try matching by check_value lowercased
|
||||
if (req.check_value) {
|
||||
var lowerCheck = req.check_value.toLowerCase();
|
||||
for (var j = 0; j < this.setupWizard.settings.length; j++) {
|
||||
if (this.setupWizard.settings[j].key === lowerCheck) return lowerCheck;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
get setupHasReqs() {
|
||||
@@ -297,6 +343,10 @@ function handsPage() {
|
||||
},
|
||||
|
||||
setupNextStep() {
|
||||
// When leaving step 1, sync API key inputs into settings values
|
||||
if (this.setupStep === 1) {
|
||||
this._syncApiKeysToSettings();
|
||||
}
|
||||
if (this.setupStep === 1 && this.setupHasSettings) {
|
||||
this.setupStep = 2;
|
||||
} else if (this.setupStep === 1) {
|
||||
@@ -306,6 +356,19 @@ function handsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
_syncApiKeysToSettings() {
|
||||
if (!this.setupWizard || !this.setupWizard.requirements) return;
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
|
||||
var settingKey = this.getSettingKeyForReq(req);
|
||||
if (settingKey) {
|
||||
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setupPrevStep() {
|
||||
if (this.setupStep === 3 && this.setupHasSettings) {
|
||||
this.setupStep = 2;
|
||||
@@ -323,19 +386,38 @@ function handsPage() {
|
||||
this.setupChecking = false;
|
||||
this.clipboardMsg = null;
|
||||
this.installPlatforms = {};
|
||||
this.apiKeyInputs = {};
|
||||
},
|
||||
|
||||
async launchHand() {
|
||||
if (!this.setupWizard) return;
|
||||
var handId = this.setupWizard.id;
|
||||
// Sync API key inputs from step 1 into settings values
|
||||
if (this.setupWizard.requirements) {
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
|
||||
var settingKey = this.getSettingKeyForReq(req);
|
||||
if (settingKey) {
|
||||
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var config = {};
|
||||
for (var key in this.settingsValues) {
|
||||
config[key] = this.settingsValues[key];
|
||||
}
|
||||
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';
|
||||
@@ -499,6 +581,382 @@ function handsPage() {
|
||||
this.stopBrowserPolling();
|
||||
this.browserViewerOpen = false;
|
||||
this.browserViewer = null;
|
||||
},
|
||||
|
||||
// ── Trader Dashboard ──────────────────────────────────────────────────
|
||||
|
||||
isTraderHand(inst) {
|
||||
return inst.hand_id === 'trader';
|
||||
},
|
||||
|
||||
async openDashboard(inst) {
|
||||
this._dashboardInst = inst;
|
||||
this.dashboardOpen = true;
|
||||
this.dashboardLoading = true;
|
||||
this.dashboardData = null;
|
||||
await this._fetchDashboardData(inst);
|
||||
this.dashboardLoading = false;
|
||||
// Render charts after DOM update
|
||||
var self = this;
|
||||
setTimeout(function() { self._renderCharts(); }, 60);
|
||||
},
|
||||
|
||||
async refreshDashboard() {
|
||||
if (!this._dashboardInst) return;
|
||||
this.dashboardLoading = true;
|
||||
await this._fetchDashboardData(this._dashboardInst);
|
||||
this.dashboardLoading = false;
|
||||
var self = this;
|
||||
setTimeout(function() { self._renderCharts(); }, 60);
|
||||
},
|
||||
|
||||
closeDashboard() {
|
||||
this.dashboardOpen = false;
|
||||
this._destroyCharts();
|
||||
this.dashboardData = null;
|
||||
this._dashboardInst = null;
|
||||
},
|
||||
|
||||
async _fetchDashboardData(inst) {
|
||||
var data = {
|
||||
agent_name: inst.agent_name || inst.hand_id,
|
||||
portfolio_value: null,
|
||||
total_pnl: null,
|
||||
win_rate: null,
|
||||
sharpe_ratio: null,
|
||||
max_drawdown: null,
|
||||
trades_count: null,
|
||||
equity_curve: [],
|
||||
daily_pnl: [],
|
||||
watchlist_heatmap: [],
|
||||
signal_radar: null,
|
||||
recent_trades: []
|
||||
};
|
||||
|
||||
// Fetch basic stats from the hand stats endpoint
|
||||
try {
|
||||
var stats = await OpenFangAPI.get('/api/hands/instances/' + inst.instance_id + '/stats');
|
||||
var m = stats.metrics || {};
|
||||
if (m['Portfolio Value']) data.portfolio_value = this._metricVal(m['Portfolio Value']);
|
||||
if (m['Total P&L']) data.total_pnl = this._metricVal(m['Total P&L']);
|
||||
if (m['Win Rate']) data.win_rate = this._metricVal(m['Win Rate']);
|
||||
if (m['Sharpe Ratio']) data.sharpe_ratio = this._metricVal(m['Sharpe Ratio']);
|
||||
if (m['Max Drawdown']) data.max_drawdown = this._metricVal(m['Max Drawdown']);
|
||||
if (m['Trades Executed']) data.trades_count = this._metricVal(m['Trades Executed']);
|
||||
} catch(e) {
|
||||
// Stats endpoint might fail — continue with KV data
|
||||
}
|
||||
|
||||
// Fetch rich chart data from agent memory KV
|
||||
var agentId = inst.agent_id || 'shared';
|
||||
var kvKeys = [
|
||||
'trader_hand_equity_curve',
|
||||
'trader_hand_daily_pnl',
|
||||
'trader_hand_watchlist_heatmap',
|
||||
'trader_hand_signal_radar',
|
||||
'trader_hand_recent_trades',
|
||||
'trader_hand_portfolio_value',
|
||||
'trader_hand_total_pnl',
|
||||
'trader_hand_win_rate',
|
||||
'trader_hand_sharpe_ratio',
|
||||
'trader_hand_max_drawdown',
|
||||
'trader_hand_trades_count'
|
||||
];
|
||||
|
||||
for (var i = 0; i < kvKeys.length; i++) {
|
||||
try {
|
||||
var resp = await OpenFangAPI.get('/api/memory/agents/' + agentId + '/kv/' + kvKeys[i]);
|
||||
if (resp && resp.value !== null && resp.value !== undefined) {
|
||||
var val = resp.value;
|
||||
this._applyKvToData(data, kvKeys[i], val);
|
||||
}
|
||||
} catch(e) {
|
||||
// Key might not exist yet — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
this.dashboardData = data;
|
||||
},
|
||||
|
||||
_metricVal(metric) {
|
||||
if (!metric) return null;
|
||||
var v = metric.value;
|
||||
if (v === null || v === undefined) return null;
|
||||
// Values come as JSON values — could be string, number, etc.
|
||||
if (typeof v === 'string') return v;
|
||||
return String(v);
|
||||
},
|
||||
|
||||
_applyKvToData(data, key, val) {
|
||||
// Values from KV can be strings (JSON-encoded) or already parsed
|
||||
var parsed = val;
|
||||
if (typeof val === 'string') {
|
||||
try { parsed = JSON.parse(val); } catch(e) { parsed = val; }
|
||||
}
|
||||
|
||||
switch(key) {
|
||||
case 'trader_hand_portfolio_value':
|
||||
if (!data.portfolio_value) data.portfolio_value = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_total_pnl':
|
||||
if (!data.total_pnl) data.total_pnl = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_win_rate':
|
||||
if (!data.win_rate) data.win_rate = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_sharpe_ratio':
|
||||
if (!data.sharpe_ratio) data.sharpe_ratio = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_max_drawdown':
|
||||
if (!data.max_drawdown) data.max_drawdown = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_trades_count':
|
||||
if (!data.trades_count) data.trades_count = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_equity_curve':
|
||||
if (Array.isArray(parsed)) data.equity_curve = parsed;
|
||||
break;
|
||||
case 'trader_hand_daily_pnl':
|
||||
if (Array.isArray(parsed)) data.daily_pnl = parsed;
|
||||
break;
|
||||
case 'trader_hand_watchlist_heatmap':
|
||||
if (Array.isArray(parsed)) data.watchlist_heatmap = parsed;
|
||||
break;
|
||||
case 'trader_hand_signal_radar':
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data.signal_radar = parsed;
|
||||
break;
|
||||
case 'trader_hand_recent_trades':
|
||||
if (Array.isArray(parsed)) data.recent_trades = parsed;
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_destroyCharts() {
|
||||
if (this._chartEquity) { this._chartEquity.destroy(); this._chartEquity = null; }
|
||||
if (this._chartPnl) { this._chartPnl.destroy(); this._chartPnl = null; }
|
||||
if (this._chartRadar) { this._chartRadar.destroy(); this._chartRadar = null; }
|
||||
},
|
||||
|
||||
_renderCharts() {
|
||||
if (typeof Chart === 'undefined') return;
|
||||
this._destroyCharts();
|
||||
if (!this.dashboardData) return;
|
||||
|
||||
var d = this.dashboardData;
|
||||
|
||||
// Detect theme
|
||||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
var gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
||||
var textColor = isDark ? '#8A8380' : '#6B6560';
|
||||
var accentColor = '#FF5C00';
|
||||
var successColor = isDark ? '#4ADE80' : '#22C55E';
|
||||
var errorColor = '#EF4444';
|
||||
|
||||
// ── Equity Curve ──
|
||||
if (d.equity_curve && d.equity_curve.length > 0) {
|
||||
var eqCanvas = document.getElementById('traderEquityChart');
|
||||
if (eqCanvas) {
|
||||
var labels = [];
|
||||
var values = [];
|
||||
for (var i = 0; i < d.equity_curve.length; i++) {
|
||||
labels.push(d.equity_curve[i].date || '');
|
||||
values.push(parseFloat(d.equity_curve[i].value) || 0);
|
||||
}
|
||||
// Determine gradient
|
||||
var eqCtx = eqCanvas.getContext('2d');
|
||||
var gradient = eqCtx.createLinearGradient(0, 0, 0, eqCanvas.parentElement.clientHeight || 180);
|
||||
gradient.addColorStop(0, isDark ? 'rgba(255, 92, 0, 0.25)' : 'rgba(255, 92, 0, 0.15)');
|
||||
gradient.addColorStop(1, 'rgba(255, 92, 0, 0)');
|
||||
|
||||
this._chartEquity = new Chart(eqCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
borderColor: accentColor,
|
||||
backgroundColor: gradient,
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: d.equity_curve.length > 20 ? 0 : 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: accentColor
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
||||
titleColor: textColor,
|
||||
bodyColor: isDark ? '#e0e0e0' : '#333',
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
return '$' + ctx.parsed.y.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: gridColor },
|
||||
ticks: { color: textColor, maxTicksLimit: 8, font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 10 },
|
||||
callback: function(v) { return '$' + v.toLocaleString(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Daily P&L Bar Chart ──
|
||||
if (d.daily_pnl && d.daily_pnl.length > 0) {
|
||||
var pnlCanvas = document.getElementById('traderPnlChart');
|
||||
if (pnlCanvas) {
|
||||
var pnlLabels = [];
|
||||
var pnlValues = [];
|
||||
var pnlColors = [];
|
||||
for (var j = 0; j < d.daily_pnl.length; j++) {
|
||||
pnlLabels.push(d.daily_pnl[j].date || '');
|
||||
var pnlVal = parseFloat(d.daily_pnl[j].pnl) || 0;
|
||||
pnlValues.push(pnlVal);
|
||||
pnlColors.push(pnlVal >= 0 ? successColor : errorColor);
|
||||
}
|
||||
|
||||
this._chartPnl = new Chart(pnlCanvas.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: pnlLabels,
|
||||
datasets: [{
|
||||
data: pnlValues,
|
||||
backgroundColor: pnlColors,
|
||||
borderRadius: 3,
|
||||
borderSkipped: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
||||
titleColor: textColor,
|
||||
bodyColor: isDark ? '#e0e0e0' : '#333',
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
var v = ctx.parsed.y;
|
||||
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: textColor, maxTicksLimit: 7, font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 10 },
|
||||
callback: function(v) {
|
||||
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signal Radar Chart ──
|
||||
if (d.signal_radar) {
|
||||
var radarCanvas = document.getElementById('traderRadarChart');
|
||||
if (radarCanvas) {
|
||||
var radarLabels = [];
|
||||
var radarValues = [];
|
||||
var keys = ['technical', 'fundamental', 'sentiment', 'macro'];
|
||||
var displayLabels = ['Technical', 'Fundamental', 'Sentiment', 'Macro'];
|
||||
for (var k = 0; k < keys.length; k++) {
|
||||
radarLabels.push(displayLabels[k]);
|
||||
radarValues.push(parseFloat(d.signal_radar[keys[k]]) || 0);
|
||||
}
|
||||
|
||||
this._chartRadar = new Chart(radarCanvas.getContext('2d'), {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: radarLabels,
|
||||
datasets: [{
|
||||
data: radarValues,
|
||||
borderColor: accentColor,
|
||||
backgroundColor: isDark ? 'rgba(255, 92, 0, 0.2)' : 'rgba(255, 92, 0, 0.12)',
|
||||
borderWidth: 2,
|
||||
pointBackgroundColor: accentColor,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
||||
titleColor: textColor,
|
||||
bodyColor: isDark ? '#e0e0e0' : '#333',
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function(ctx) { return ctx.parsed.r + '/100'; }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
beginAtZero: true,
|
||||
grid: { color: gridColor },
|
||||
angleLines: { color: gridColor },
|
||||
pointLabels: {
|
||||
color: textColor,
|
||||
font: { size: 11, weight: '600' }
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
backdropColor: 'transparent',
|
||||
stepSize: 25,
|
||||
font: { size: 9 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,13 +26,33 @@ function schedulerPage() {
|
||||
cron: '',
|
||||
agent_id: '',
|
||||
message: '',
|
||||
enabled: true
|
||||
enabled: true,
|
||||
delivery_targets: []
|
||||
},
|
||||
creating: false,
|
||||
|
||||
// -- Run Now state --
|
||||
runningJobId: '',
|
||||
|
||||
// -- Delivery targets picker (create modal) --
|
||||
showTargetPicker: false,
|
||||
pickerType: 'channel',
|
||||
draftTarget: null,
|
||||
|
||||
// -- Expanded job / delivery log state --
|
||||
expandedJobId: '',
|
||||
deliveryLog: { targets: [], entries: [] },
|
||||
deliveryLogLoading: false,
|
||||
deliveryLogError: '',
|
||||
|
||||
// -- Edit targets state (per-existing-job) --
|
||||
editingTargetsJobId: '',
|
||||
editingTargets: [],
|
||||
savingTargets: false,
|
||||
|
||||
// -- Available channel types (populated from /api/channels) --
|
||||
channelTypes: [],
|
||||
|
||||
// Cron presets
|
||||
cronPresets: [
|
||||
{ label: 'Every minute', cron: '* * * * *' },
|
||||
@@ -55,12 +75,32 @@ function schedulerPage() {
|
||||
this.loadError = '';
|
||||
try {
|
||||
await this.loadJobs();
|
||||
// Channels are optional — failure is non-fatal for the scheduler page.
|
||||
this.loadChannelTypes();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load scheduler data.';
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadChannelTypes() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/channels');
|
||||
// /api/channels returns an array of channel descriptors; pull names.
|
||||
var list = Array.isArray(data) ? data : (data && data.channels) || [];
|
||||
var names = [];
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var ch = list[i];
|
||||
var name = ch && (ch.name || ch.display_name || ch.channel_type);
|
||||
if (name && names.indexOf(name) === -1) names.push(name);
|
||||
}
|
||||
this.channelTypes = names;
|
||||
} catch(e) {
|
||||
// Fall through silently — the form uses a plain input as fallback.
|
||||
this.channelTypes = [];
|
||||
}
|
||||
},
|
||||
|
||||
async loadJobs() {
|
||||
var data = await OpenFangAPI.get('/api/cron/jobs');
|
||||
var raw = data.jobs || [];
|
||||
@@ -82,6 +122,7 @@ function schedulerPage() {
|
||||
last_run: j.last_run,
|
||||
next_run: j.next_run,
|
||||
delivery: j.delivery ? j.delivery.kind || '' : '',
|
||||
delivery_targets: Array.isArray(j.delivery_targets) ? j.delivery_targets : [],
|
||||
created_at: j.created_at
|
||||
};
|
||||
});
|
||||
@@ -162,9 +203,12 @@ function schedulerPage() {
|
||||
delivery: { kind: 'last_channel' },
|
||||
enabled: this.newJob.enabled
|
||||
};
|
||||
if (this.newJob.delivery_targets && this.newJob.delivery_targets.length) {
|
||||
body.delivery_targets = this.newJob.delivery_targets.map(this.sanitizeTarget);
|
||||
}
|
||||
await OpenFangAPI.post('/api/cron/jobs', body);
|
||||
this.showCreateForm = false;
|
||||
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
|
||||
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true, delivery_targets: [] };
|
||||
OpenFangToast.success('Schedule "' + jobName + '" created');
|
||||
await this.loadJobs();
|
||||
} catch(e) {
|
||||
@@ -201,19 +245,225 @@ function schedulerPage() {
|
||||
async runNow(job) {
|
||||
this.runningJobId = job.id;
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
|
||||
if (result.status === 'completed') {
|
||||
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
|
||||
job.last_run = new Date().toISOString();
|
||||
var result = await OpenFangAPI.post('/api/cron/jobs/' + job.id + '/run', {});
|
||||
if (result.status === 'triggered' || result.status === 'completed') {
|
||||
OpenFangToast.success('Job "' + (job.name || 'job') + '" triggered');
|
||||
// Don't update job.last_run here — the job runs asynchronously in the
|
||||
// background. The real last_run is set by the server on completion and
|
||||
// will appear on the next data refresh.
|
||||
} else {
|
||||
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
|
||||
OpenFangToast.error('Run failed: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Run Now is not yet available for cron jobs');
|
||||
OpenFangToast.error('Run failed: ' + (e.message || e));
|
||||
}
|
||||
this.runningJobId = '';
|
||||
},
|
||||
|
||||
// ── Delivery target editing (create modal) ──
|
||||
|
||||
openTargetPicker() {
|
||||
this.pickerType = 'channel';
|
||||
this.draftTarget = this.blankTarget('channel');
|
||||
this.showTargetPicker = true;
|
||||
},
|
||||
|
||||
cancelTargetPicker() {
|
||||
this.showTargetPicker = false;
|
||||
this.draftTarget = null;
|
||||
},
|
||||
|
||||
onPickerTypeChange() {
|
||||
this.draftTarget = this.blankTarget(this.pickerType);
|
||||
},
|
||||
|
||||
blankTarget(type) {
|
||||
if (type === 'channel') {
|
||||
return { type: 'channel', channel_type: '', recipient: '' };
|
||||
}
|
||||
if (type === 'webhook') {
|
||||
return { type: 'webhook', url: '', auth_header: '' };
|
||||
}
|
||||
if (type === 'local_file') {
|
||||
return { type: 'local_file', path: '', append: false };
|
||||
}
|
||||
if (type === 'email') {
|
||||
return { type: 'email', to: '', subject_template: '' };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
addDraftTarget() {
|
||||
var err = this.validateTarget(this.draftTarget);
|
||||
if (err) {
|
||||
OpenFangToast.warn(err);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(this.newJob.delivery_targets)) this.newJob.delivery_targets = [];
|
||||
this.newJob.delivery_targets.push(this.sanitizeTarget(this.draftTarget));
|
||||
this.showTargetPicker = false;
|
||||
this.draftTarget = null;
|
||||
},
|
||||
|
||||
removeTarget(idx) {
|
||||
if (!Array.isArray(this.newJob.delivery_targets)) return;
|
||||
this.newJob.delivery_targets.splice(idx, 1);
|
||||
},
|
||||
|
||||
validateTarget(t) {
|
||||
if (!t || !t.type) return 'Pick a target type';
|
||||
if (t.type === 'channel') {
|
||||
if (!t.channel_type || !t.channel_type.trim()) return 'Channel type is required';
|
||||
if (!t.recipient || !t.recipient.trim()) return 'Recipient is required';
|
||||
} else if (t.type === 'webhook') {
|
||||
if (!t.url || !t.url.trim()) return 'Webhook URL is required';
|
||||
if (t.url.indexOf('http://') !== 0 && t.url.indexOf('https://') !== 0) {
|
||||
return 'Webhook URL must start with http:// or https://';
|
||||
}
|
||||
} else if (t.type === 'local_file') {
|
||||
if (!t.path || !t.path.trim()) return 'File path is required';
|
||||
} else if (t.type === 'email') {
|
||||
if (!t.to || !t.to.trim()) return 'Recipient email is required';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Strip empty-string optional fields so serde accepts the payload cleanly.
|
||||
sanitizeTarget(t) {
|
||||
if (!t) return null;
|
||||
var out = { type: t.type };
|
||||
if (t.type === 'channel') {
|
||||
out.channel_type = (t.channel_type || '').trim();
|
||||
out.recipient = (t.recipient || '').trim();
|
||||
} else if (t.type === 'webhook') {
|
||||
out.url = (t.url || '').trim();
|
||||
if (t.auth_header && t.auth_header.trim()) out.auth_header = t.auth_header.trim();
|
||||
} else if (t.type === 'local_file') {
|
||||
out.path = (t.path || '').trim();
|
||||
out.append = !!t.append;
|
||||
} else if (t.type === 'email') {
|
||||
out.to = (t.to || '').trim();
|
||||
if (t.subject_template && t.subject_template.trim()) {
|
||||
out.subject_template = t.subject_template.trim();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
// ── Chip rendering helpers ──
|
||||
|
||||
targetChipLabel(t) {
|
||||
if (!t || !t.type) return '?';
|
||||
if (t.type === 'channel') return 'CHANNEL: ' + (t.channel_type || '?');
|
||||
if (t.type === 'webhook') return 'WEBHOOK';
|
||||
if (t.type === 'local_file') return 'FILE: ' + this.truncate(t.path || '', 28);
|
||||
if (t.type === 'email') return 'EMAIL: ' + this.truncate(t.to || '', 24);
|
||||
return t.type.toUpperCase();
|
||||
},
|
||||
|
||||
targetChipClass(t) {
|
||||
if (!t || !t.type) return 'badge-dim';
|
||||
if (t.type === 'channel') return 'badge-info';
|
||||
if (t.type === 'webhook') return 'badge-created';
|
||||
if (t.type === 'local_file') return 'badge-muted';
|
||||
if (t.type === 'email') return 'badge-warn';
|
||||
return 'badge-dim';
|
||||
},
|
||||
|
||||
targetSummary(t) {
|
||||
if (!t) return '';
|
||||
if (t.type === 'channel') return (t.channel_type || '?') + ' -> ' + (t.recipient || '?');
|
||||
if (t.type === 'webhook') return t.url || '(no url)';
|
||||
if (t.type === 'local_file') return (t.append ? 'append ' : 'overwrite ') + (t.path || '');
|
||||
if (t.type === 'email') {
|
||||
var base = t.to || '';
|
||||
if (t.subject_template) base += ' · subject: ' + t.subject_template;
|
||||
return base;
|
||||
}
|
||||
return JSON.stringify(t);
|
||||
},
|
||||
|
||||
// ── Expand row / delivery log ──
|
||||
|
||||
async toggleExpand(job) {
|
||||
if (this.expandedJobId === job.id) {
|
||||
this.expandedJobId = '';
|
||||
return;
|
||||
}
|
||||
this.expandedJobId = job.id;
|
||||
this.deliveryLog = { targets: [], entries: [] };
|
||||
this.deliveryLogError = '';
|
||||
this.deliveryLogLoading = true;
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/schedules/' + job.id + '/delivery-log');
|
||||
this.deliveryLog = {
|
||||
targets: Array.isArray(data.targets) ? data.targets : [],
|
||||
entries: Array.isArray(data.entries) ? data.entries : []
|
||||
};
|
||||
} catch(e) {
|
||||
this.deliveryLogError = e.message || 'Could not load delivery log.';
|
||||
}
|
||||
this.deliveryLogLoading = false;
|
||||
},
|
||||
|
||||
// ── Edit targets on existing job ──
|
||||
|
||||
startEditTargets(job) {
|
||||
this.editingTargetsJobId = job.id;
|
||||
// Clone so cancel doesn't mutate the loaded list.
|
||||
this.editingTargets = (job.delivery_targets || []).map(function(t) {
|
||||
return JSON.parse(JSON.stringify(t));
|
||||
});
|
||||
this.pickerType = 'channel';
|
||||
this.draftTarget = null;
|
||||
this.showTargetPicker = false;
|
||||
},
|
||||
|
||||
cancelEditTargets() {
|
||||
this.editingTargetsJobId = '';
|
||||
this.editingTargets = [];
|
||||
this.draftTarget = null;
|
||||
this.showTargetPicker = false;
|
||||
},
|
||||
|
||||
addEditTarget() {
|
||||
this.pickerType = 'channel';
|
||||
this.draftTarget = this.blankTarget('channel');
|
||||
this.showTargetPicker = true;
|
||||
},
|
||||
|
||||
addDraftTargetToEdit() {
|
||||
var err = this.validateTarget(this.draftTarget);
|
||||
if (err) {
|
||||
OpenFangToast.warn(err);
|
||||
return;
|
||||
}
|
||||
this.editingTargets.push(this.sanitizeTarget(this.draftTarget));
|
||||
this.showTargetPicker = false;
|
||||
this.draftTarget = null;
|
||||
},
|
||||
|
||||
removeEditTarget(idx) {
|
||||
this.editingTargets.splice(idx, 1);
|
||||
},
|
||||
|
||||
async saveEditTargets() {
|
||||
if (!this.editingTargetsJobId) return;
|
||||
this.savingTargets = true;
|
||||
try {
|
||||
var clean = this.editingTargets.map(this.sanitizeTarget);
|
||||
await OpenFangAPI.put('/api/schedules/' + this.editingTargetsJobId, {
|
||||
delivery_targets: clean
|
||||
});
|
||||
OpenFangToast.success('Delivery targets updated');
|
||||
this.cancelEditTargets();
|
||||
await this.loadJobs();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update targets: ' + (e.message || e));
|
||||
}
|
||||
this.savingTargets = false;
|
||||
},
|
||||
|
||||
// ── Trigger helpers ──
|
||||
|
||||
triggerType(pattern) {
|
||||
@@ -374,6 +624,12 @@ function schedulerPage() {
|
||||
} catch(e) { return 'never'; }
|
||||
},
|
||||
|
||||
truncate(s, n) {
|
||||
if (!s) return '';
|
||||
if (s.length <= n) return s;
|
||||
return s.substring(0, n - 1) + '…';
|
||||
},
|
||||
|
||||
jobCount() {
|
||||
var enabled = 0;
|
||||
for (var i = 0; i < this.jobs.length; i++) {
|
||||
|
||||
@@ -64,15 +64,20 @@ function sessionsPage() {
|
||||
|
||||
deleteSession(sessionId) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Delete Session', 'This will permanently remove the session and its messages.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/sessions/' + sessionId);
|
||||
self.sessions = self.sessions.filter(function(s) { return s.session_id !== sessionId; });
|
||||
OpenFangToast.success('Session deleted');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete session: ' + e.message);
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
OpenFangToast.confirm(
|
||||
t('sessions.delete_session') || 'Delete Session',
|
||||
t('sessions.delete_confirm') || 'This will permanently remove the session and its messages.',
|
||||
async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/sessions/' + sessionId);
|
||||
self.sessions = self.sessions.filter(function(s) { return s.session_id !== sessionId; });
|
||||
OpenFangToast.success('Session deleted');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete session: ' + e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
},
|
||||
|
||||
// -- Memory methods --
|
||||
@@ -108,15 +113,20 @@ function sessionsPage() {
|
||||
|
||||
deleteKey(key) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Delete Key', 'Delete key "' + key + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/memory/agents/' + self.memAgentId + '/kv/' + encodeURIComponent(key));
|
||||
OpenFangToast.success('Key "' + key + '" deleted');
|
||||
await self.loadKv();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete key: ' + e.message);
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
OpenFangToast.confirm(
|
||||
t('sessions.delete_key') || 'Delete Key',
|
||||
(t('sessions.delete_key_confirm') || 'Delete key') + ' "' + key + '"? This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/memory/agents/' + self.memAgentId + '/kv/' + encodeURIComponent(key));
|
||||
OpenFangToast.success('Key "' + key + '" deleted');
|
||||
await self.loadKv();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete key: ' + e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
},
|
||||
|
||||
startEdit(kv) {
|
||||
|
||||
@@ -25,7 +25,15 @@ function settingsPage() {
|
||||
providerUrlSaving: {},
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
providerSearch: '',
|
||||
providerStatusFilter: '',
|
||||
providerCategoryFilter: '',
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
customProviderName: '',
|
||||
customProviderUrl: '',
|
||||
customProviderKey: '',
|
||||
customProviderStatus: '',
|
||||
addingCustomProvider: false,
|
||||
loading: true,
|
||||
loadError: '',
|
||||
|
||||
@@ -290,11 +298,14 @@ function settingsPage() {
|
||||
|
||||
async saveConfigField(section, field, value) {
|
||||
var key = section + '.' + field;
|
||||
// Root-level fields (api_key, api_listen, log_level) use just the field name
|
||||
var sectionMeta = this.configSchema && this.configSchema[section];
|
||||
var path = (sectionMeta && sectionMeta.root_level) ? field : key;
|
||||
this.configSaving[key] = true;
|
||||
try {
|
||||
await OpenFangAPI.post('/api/config/set', { path: key, value: value });
|
||||
await OpenFangAPI.post('/api/config/set', { path: path, value: value });
|
||||
this.configDirty[key] = false;
|
||||
OpenFangToast.success('Saved ' + key);
|
||||
OpenFangToast.success('Saved ' + field);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save: ' + e.message);
|
||||
}
|
||||
@@ -330,6 +341,94 @@ function settingsPage() {
|
||||
return Object.keys(seen).sort();
|
||||
},
|
||||
|
||||
/// Coarse category for a provider used to group the Providers tab.
|
||||
/// Returns: 'frontier' | 'oss' | 'local' | 'aggregator' | 'regional' | 'other'.
|
||||
providerCategory(p) {
|
||||
if (!p) return 'other';
|
||||
if (p.is_local || p.key_required === false) return 'local';
|
||||
var id = (p.id || '').toLowerCase();
|
||||
var FRONTIER = ['anthropic','openai','gemini','google','xai','bedrock','azure','vertex'];
|
||||
var OSS = ['groq','together','fireworks','cerebras','sambanova','deepseek','mistral','perplexity','cohere','ai21','huggingface','replicate','nvidia','venice','novita','chutes'];
|
||||
var AGG = ['openrouter','litellm','github-copilot','claude-code'];
|
||||
var REGIONAL = ['qwen','minimax','zhipu','zai','moonshot','qianfan','volcengine','kimi'];
|
||||
if (FRONTIER.indexOf(id) !== -1) return 'frontier';
|
||||
if (REGIONAL.indexOf(id) !== -1) return 'regional';
|
||||
if (AGG.indexOf(id) !== -1) return 'aggregator';
|
||||
if (OSS.indexOf(id) !== -1) return 'oss';
|
||||
return 'other';
|
||||
},
|
||||
|
||||
providerCategoryLabel(cat) {
|
||||
switch (cat) {
|
||||
case 'frontier': return 'Frontier (Anthropic, OpenAI, Google, xAI, Bedrock)';
|
||||
case 'oss': return 'Open-Weight Hosts (Groq, Together, Fireworks, DeepSeek, etc.)';
|
||||
case 'aggregator': return 'Aggregators & Gateways (OpenRouter, GitHub Copilot)';
|
||||
case 'regional': return 'Regional / China (Qwen, Zhipu, Moonshot, MiniMax)';
|
||||
case 'local': return 'Local / Self-Hosted (Ollama, vLLM, LM Studio, Lemonade)';
|
||||
default: return 'Other Providers';
|
||||
}
|
||||
},
|
||||
|
||||
/// Stable category order for grouped rendering.
|
||||
get providerCategoriesOrdered() {
|
||||
return ['frontier', 'oss', 'aggregator', 'regional', 'local', 'other'];
|
||||
},
|
||||
|
||||
/// Returns filter-matched providers grouped by category, preserving order.
|
||||
/// Each entry: { category, label, items: [...] }. Empty groups are omitted.
|
||||
get providersGrouped() {
|
||||
var self = this;
|
||||
var filtered = this.filteredProviders;
|
||||
var by = {};
|
||||
filtered.forEach(function(p) {
|
||||
var c = self.providerCategory(p);
|
||||
if (!by[c]) by[c] = [];
|
||||
by[c].push(p);
|
||||
});
|
||||
// Sort each group: configured first, then alphabetical
|
||||
Object.keys(by).forEach(function(c) {
|
||||
by[c].sort(function(a, b) {
|
||||
var ac = a.auth_status === 'configured' ? 0 : 1;
|
||||
var bc = b.auth_status === 'configured' ? 0 : 1;
|
||||
if (ac !== bc) return ac - bc;
|
||||
return (a.display_name || a.id).localeCompare(b.display_name || b.id);
|
||||
});
|
||||
});
|
||||
var out = [];
|
||||
this.providerCategoriesOrdered.forEach(function(c) {
|
||||
if (by[c] && by[c].length) {
|
||||
out.push({ category: c, label: self.providerCategoryLabel(c), items: by[c] });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
},
|
||||
|
||||
get filteredProviders() {
|
||||
var self = this;
|
||||
return this.providers.filter(function(p) {
|
||||
if (self.providerStatusFilter === 'configured' && p.auth_status !== 'configured') return false;
|
||||
if (self.providerStatusFilter === 'unconfigured' && p.auth_status === 'configured') return false;
|
||||
if (self.providerCategoryFilter && self.providerCategory(p) !== self.providerCategoryFilter) return false;
|
||||
if (self.providerSearch) {
|
||||
var q = self.providerSearch.toLowerCase();
|
||||
if ((p.display_name || '').toLowerCase().indexOf(q) === -1 &&
|
||||
(p.id || '').toLowerCase().indexOf(q) === -1 &&
|
||||
(p.api_key_env || '').toLowerCase().indexOf(q) === -1) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
get configuredProviderCount() {
|
||||
return this.providers.filter(function(p) { return p.auth_status === 'configured'; }).length;
|
||||
},
|
||||
|
||||
clearProviderFilters() {
|
||||
this.providerSearch = '';
|
||||
this.providerStatusFilter = '';
|
||||
this.providerCategoryFilter = '';
|
||||
},
|
||||
|
||||
get uniqueTiers() {
|
||||
var seen = {};
|
||||
this.models.forEach(function(m) { if (m.tier) seen[m.tier] = true; });
|
||||
@@ -344,7 +443,10 @@ function settingsPage() {
|
||||
|
||||
providerAuthText(p) {
|
||||
if (p.auth_status === 'configured') return 'Configured';
|
||||
if (p.auth_status === 'not_set' || p.auth_status === 'missing') return 'Not Set';
|
||||
if (p.auth_status === 'not_set' || p.auth_status === 'missing') {
|
||||
if (p.id === 'claude-code') return 'Not Installed';
|
||||
return 'Not Set';
|
||||
}
|
||||
return 'No Key Needed';
|
||||
},
|
||||
|
||||
@@ -390,8 +492,12 @@ function settingsPage() {
|
||||
var key = this.providerKeyInputs[provider.id];
|
||||
if (!key || !key.trim()) { OpenFangToast.error('Please enter an API key'); return; }
|
||||
try {
|
||||
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
|
||||
OpenFangToast.success('API key saved for ' + provider.display_name);
|
||||
var resp = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
|
||||
if (resp && resp.switched_default) {
|
||||
OpenFangToast.warning(resp.message || 'Default provider was switched to ' + provider.display_name);
|
||||
} else {
|
||||
OpenFangToast.success('API key saved for ' + provider.display_name);
|
||||
}
|
||||
this.providerKeyInputs[provider.id] = '';
|
||||
await this.loadProviders();
|
||||
await this.loadModels();
|
||||
@@ -499,6 +605,34 @@ function settingsPage() {
|
||||
this.providerUrlSaving[provider.id] = false;
|
||||
},
|
||||
|
||||
async addCustomProvider() {
|
||||
var name = this.customProviderName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
||||
if (!name) { OpenFangToast.error('Please enter a provider name'); return; }
|
||||
var url = this.customProviderUrl.trim();
|
||||
if (!url) { OpenFangToast.error('Please enter a base URL'); return; }
|
||||
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
||||
OpenFangToast.error('URL must start with http:// or https://'); return;
|
||||
}
|
||||
this.addingCustomProvider = true;
|
||||
this.customProviderStatus = '';
|
||||
try {
|
||||
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(name) + '/url', { base_url: url });
|
||||
if (this.customProviderKey.trim()) {
|
||||
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(name) + '/key', { key: this.customProviderKey.trim() });
|
||||
}
|
||||
this.customProviderName = '';
|
||||
this.customProviderUrl = '';
|
||||
this.customProviderKey = '';
|
||||
this.customProviderStatus = '';
|
||||
OpenFangToast.success('Provider "' + name + '" added' + (result.reachable ? ' (reachable)' : ' (not reachable yet)'));
|
||||
await this.loadProviders();
|
||||
} catch(e) {
|
||||
this.customProviderStatus = 'Error: ' + (e.message || 'Failed');
|
||||
OpenFangToast.error('Failed to add provider: ' + e.message);
|
||||
}
|
||||
this.addingCustomProvider = false;
|
||||
},
|
||||
|
||||
// -- Security methods --
|
||||
async loadSecurity() {
|
||||
this.secLoading = true;
|
||||
|
||||
@@ -30,31 +30,44 @@ function skillsPage() {
|
||||
skillCodeFilename: '',
|
||||
skillCodeLoading: false,
|
||||
|
||||
// Skill config modal (local skill configuration from SKILL.md frontmatter)
|
||||
configSkill: null, // skill object whose config is being edited
|
||||
configDeclared: {}, // { var_name: { description, env, default, required } }
|
||||
configResolved: {}, // { var_name: { value, source, is_secret } }
|
||||
configDraft: {}, // { var_name: user-edited string value }
|
||||
configRevealed: {}, // { var_name: bool } — toggle password reveal per row
|
||||
configLoading: false,
|
||||
configSaving: false,
|
||||
configError: '',
|
||||
|
||||
// MCP servers
|
||||
mcpServers: [],
|
||||
mcpLoading: false,
|
||||
|
||||
// Category definitions from the OpenClaw ecosystem
|
||||
categories: [
|
||||
{ id: 'coding', name: 'Coding & IDEs' },
|
||||
{ id: 'git', name: 'Git & GitHub' },
|
||||
{ id: 'web', name: 'Web & Frontend' },
|
||||
{ id: 'devops', name: 'DevOps & Cloud' },
|
||||
{ id: 'browser', name: 'Browser & Automation' },
|
||||
{ id: 'search', name: 'Search & Research' },
|
||||
{ id: 'ai', name: 'AI & LLMs' },
|
||||
{ id: 'data', name: 'Data & Analytics' },
|
||||
{ id: 'productivity', name: 'Productivity' },
|
||||
{ id: 'communication', name: 'Communication' },
|
||||
{ id: 'media', name: 'Media & Streaming' },
|
||||
{ id: 'notes', name: 'Notes & PKM' },
|
||||
{ id: 'security', name: 'Security' },
|
||||
{ id: 'cli', name: 'CLI Utilities' },
|
||||
{ id: 'marketing', name: 'Marketing & Sales' },
|
||||
{ id: 'finance', name: 'Finance' },
|
||||
{ id: 'smart-home', name: 'Smart Home & IoT' },
|
||||
{ id: 'docs', name: 'PDF & Documents' },
|
||||
],
|
||||
// Category definitions from the OpenClaw ecosystem (loaded from i18n)
|
||||
get categories() {
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
return [
|
||||
{ id: 'coding', name: t('skills.cat_coding') || 'Coding & IDEs' },
|
||||
{ id: 'git', name: t('skills.cat_git') || 'Git & GitHub' },
|
||||
{ id: 'web', name: t('skills.cat_frontend') || 'Web & Frontend' },
|
||||
{ id: 'devops', name: t('skills.cat_devops') || 'DevOps & Cloud' },
|
||||
{ id: 'browser', name: t('skills.cat_browser') || 'Browser & Automation' },
|
||||
{ id: 'search', name: t('skills.cat_search') || 'Search & Research' },
|
||||
{ id: 'ai', name: t('skills.cat_ai') || 'AI & ML' },
|
||||
{ id: 'data', name: t('skills.cat_data') || 'Data & Analytics' },
|
||||
{ id: 'productivity', name: t('skills.cat_productivity') || 'Productivity' },
|
||||
{ id: 'communication', name: t('skills.cat_communication') || 'Communication' },
|
||||
{ id: 'media', name: t('skills.cat_media') || 'Media & Streaming' },
|
||||
{ id: 'notes', name: t('skills.cat_notes') || 'Notes & PKM' },
|
||||
{ id: 'security', name: t('skills.cat_security') || 'Security' },
|
||||
{ id: 'cli', name: t('skills.cat_cli') || 'CLI Utilities' },
|
||||
{ id: 'marketing', name: t('skills.cat_marketing') || 'Marketing & Sales' },
|
||||
{ id: 'finance', name: t('skills.cat_finance') || 'Finance' },
|
||||
{ id: 'smart-home', name: t('skills.cat_smarthome') || 'Smart Home & IoT' },
|
||||
{ id: 'docs', name: t('skills.cat_docs') || 'Documentation' },
|
||||
];
|
||||
},
|
||||
|
||||
runtimeBadge: function(rt) {
|
||||
var r = (rt || '').toLowerCase();
|
||||
@@ -98,7 +111,8 @@ function skillsPage() {
|
||||
tags: s.tags || [],
|
||||
enabled: s.enabled !== false,
|
||||
source: s.source || { type: 'local' },
|
||||
has_prompt_context: !!s.has_prompt_context
|
||||
has_prompt_context: !!s.has_prompt_context,
|
||||
config_declared_count: s.config_declared_count || 0
|
||||
};
|
||||
});
|
||||
} catch(e) {
|
||||
@@ -108,6 +122,161 @@ function skillsPage() {
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
// ── Skill config editing ────────────────────────────────────────────
|
||||
async openSkillConfig(skill) {
|
||||
this.configSkill = skill;
|
||||
this.configDeclared = {};
|
||||
this.configResolved = {};
|
||||
this.configDraft = {};
|
||||
this.configRevealed = {};
|
||||
this.configError = '';
|
||||
this.configLoading = true;
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/skills/' + encodeURIComponent(skill.name) + '/config');
|
||||
this.configDeclared = data.declared || {};
|
||||
this.configResolved = data.resolved || {};
|
||||
// Pre-populate draft values only for vars the user has already
|
||||
// overridden — never copy redacted responses back into inputs or
|
||||
// they'd be re-saved as "****redacted****" strings.
|
||||
var names = Object.keys(this.configDeclared);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var n = names[i];
|
||||
var res = this.configResolved[n] || {};
|
||||
if (res.source === 'user' && !res.is_secret) {
|
||||
this.configDraft[n] = res.value == null ? '' : String(res.value);
|
||||
} else {
|
||||
this.configDraft[n] = '';
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
this.configError = e.message || 'Failed to load skill config.';
|
||||
}
|
||||
this.configLoading = false;
|
||||
},
|
||||
|
||||
closeSkillConfig() {
|
||||
this.configSkill = null;
|
||||
this.configDeclared = {};
|
||||
this.configResolved = {};
|
||||
this.configDraft = {};
|
||||
this.configRevealed = {};
|
||||
this.configError = '';
|
||||
},
|
||||
|
||||
configRowInvalid(name) {
|
||||
// A required var is invalid iff the user hasn't entered anything AND
|
||||
// no env/default resolves it. Source from the server tells us where
|
||||
// the current value came from; if it's "unresolved" and the draft is
|
||||
// blank, the save would write an empty string over the required var.
|
||||
var decl = this.configDeclared[name] || {};
|
||||
if (!decl.required) return false;
|
||||
var draft = (this.configDraft[name] || '').trim();
|
||||
if (draft) return false;
|
||||
var res = this.configResolved[name] || {};
|
||||
if (res.source === 'env' || res.source === 'default') return false;
|
||||
// If the user has an existing secret override we don't want to force
|
||||
// them to re-type it — treat that as "currently resolved".
|
||||
if (res.source === 'user') return false;
|
||||
return true;
|
||||
},
|
||||
|
||||
hasInvalidConfig() {
|
||||
var names = Object.keys(this.configDeclared);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
if (this.configRowInvalid(names[i])) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
toggleReveal(name) {
|
||||
this.configRevealed[name] = !this.configRevealed[name];
|
||||
},
|
||||
|
||||
sourceBadgeClass(source) {
|
||||
switch (source) {
|
||||
case 'user': return 'badge-success';
|
||||
case 'env': return 'badge-info';
|
||||
case 'default': return 'badge-dim';
|
||||
default: return 'badge-danger';
|
||||
}
|
||||
},
|
||||
|
||||
sourceBadgeLabel(res) {
|
||||
if (!res) return 'unresolved';
|
||||
switch (res.source) {
|
||||
case 'user': return 'user override';
|
||||
case 'env': return 'env' + ((this.configDeclared[res.__name] && this.configDeclared[res.__name].env) ? ':' + this.configDeclared[res.__name].env : '');
|
||||
case 'default': return 'default';
|
||||
default: return 'unresolved';
|
||||
}
|
||||
},
|
||||
|
||||
async saveSkillConfig() {
|
||||
if (!this.configSkill) return;
|
||||
if (this.hasInvalidConfig()) {
|
||||
OpenFangToast.error('Fill in all required variables before saving.');
|
||||
return;
|
||||
}
|
||||
this.configSaving = true;
|
||||
this.configError = '';
|
||||
// Only PUT values the user actually typed. Empty strings are dropped
|
||||
// so we don't silently clobber an env/default with "".
|
||||
var payload = {};
|
||||
var names = Object.keys(this.configDeclared);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var n = names[i];
|
||||
var v = (this.configDraft[n] || '').trim();
|
||||
if (v.length > 0) payload[n] = v;
|
||||
}
|
||||
try {
|
||||
await OpenFangAPI.put('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config', { values: payload });
|
||||
OpenFangToast.success('Saved, reloading agents\u2026');
|
||||
// Refresh the modal contents so the new source/value shows up.
|
||||
var refreshed = this.configSkill;
|
||||
await this.loadSkills();
|
||||
this.closeSkillConfig();
|
||||
// Find the possibly-refreshed skill object and reopen.
|
||||
var self = this;
|
||||
var updated = this.skills.find(function(s) { return s.name === refreshed.name; });
|
||||
if (updated) await self.openSkillConfig(updated);
|
||||
} catch(e) {
|
||||
this.configError = e.message || 'Save failed.';
|
||||
OpenFangToast.error('Save failed: ' + (e.message || 'unknown error'));
|
||||
}
|
||||
this.configSaving = false;
|
||||
},
|
||||
|
||||
async resetSkillConfigVar(name) {
|
||||
if (!this.configSkill) return;
|
||||
var decl = this.configDeclared[name] || {};
|
||||
var res = this.configResolved[name] || {};
|
||||
// If the server is already reporting a non-user source there's nothing
|
||||
// to remove; just clear the draft so the input disappears.
|
||||
if (res.source !== 'user') {
|
||||
this.configDraft[name] = '';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await OpenFangAPI.del('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config/' + encodeURIComponent(name));
|
||||
OpenFangToast.success('Reset ' + name);
|
||||
// Refresh modal state from server.
|
||||
var data = await OpenFangAPI.get('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config');
|
||||
this.configResolved = data.resolved || {};
|
||||
this.configDraft[name] = '';
|
||||
} catch(e) {
|
||||
var msg = e.message || 'Reset failed';
|
||||
if (msg.indexOf('required') !== -1 || msg.indexOf('409') !== -1) {
|
||||
OpenFangToast.error('Cannot reset: ' + decl.description + ' is required with no fallback.');
|
||||
} else {
|
||||
OpenFangToast.error('Reset failed: ' + msg);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
get configDeclaredNames() {
|
||||
return Object.keys(this.configDeclared).sort();
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
await this.loadSkills();
|
||||
},
|
||||
@@ -264,15 +433,20 @@ function skillsPage() {
|
||||
// Uninstall
|
||||
uninstallSkill: function(name) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Uninstall Skill', 'Uninstall skill "' + name + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
|
||||
OpenFangToast.success('Skill "' + name + '" uninstalled');
|
||||
await self.loadSkills();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
|
||||
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
|
||||
OpenFangToast.confirm(
|
||||
t('skills.uninstall_skill') || 'Uninstall Skill',
|
||||
t('skills.uninstall_confirm') + ' "' + name + '"? This cannot be undone.',
|
||||
async function() {
|
||||
try {
|
||||
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
|
||||
OpenFangToast.success('Skill "' + name + '" uninstalled');
|
||||
await self.loadSkills();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
},
|
||||
|
||||
// Create prompt-only skill
|
||||
|
||||
@@ -191,6 +191,33 @@ function analyticsPage() {
|
||||
return segments;
|
||||
},
|
||||
|
||||
donutSegmentsSvg() {
|
||||
var segments = this.donutSegments();
|
||||
if (!segments.length) return '';
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
var out = [];
|
||||
for (var i = 0; i < segments.length; i++) {
|
||||
var seg = segments[i];
|
||||
var title = seg.provider + ': ' + seg.percent + '% (' + this.formatCost(seg.cost) + ')';
|
||||
out.push(
|
||||
'<circle cx="80" cy="80" r="60" fill="none" stroke="' + escapeXml(seg.color) + '" stroke-width="24" stroke-dasharray="' + escapeXml(seg.dasharray) + '" stroke-dashoffset="' + escapeXml(seg.dashoffset) + '" transform="rotate(-90 80 80)" class="donut-segment">' +
|
||||
'<title>' + escapeXml(title) + '</title>' +
|
||||
'</circle>'
|
||||
);
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
},
|
||||
|
||||
// ── Bar chart (last 7 days) ──
|
||||
|
||||
barChartData() {
|
||||
@@ -218,6 +245,42 @@ function analyticsPage() {
|
||||
return result;
|
||||
},
|
||||
|
||||
barChartSvg() {
|
||||
var bars = this.barChartData();
|
||||
if (!bars.length) return '';
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
var out = [];
|
||||
for (var i = 0; i < bars.length; i++) {
|
||||
var bar = bars[i];
|
||||
var x = i * 50 + 18;
|
||||
var labelX = i * 50 + 30;
|
||||
var y = 150 - bar.barHeight;
|
||||
var costLabelY = y - 4;
|
||||
var title = bar.date + ': ' + this.formatCost(bar.cost) + ' (' + bar.calls + ' calls)';
|
||||
|
||||
out.push(
|
||||
'<g>' +
|
||||
'<rect x="' + x + '" y="' + y + '" width="24" height="' + bar.barHeight + '" rx="3" fill="var(--accent)" class="cost-bar" style="opacity:0.85">' +
|
||||
'<title>' + escapeXml(title) + '</title>' +
|
||||
'</rect>' +
|
||||
'<text x="' + labelX + '" y="166" text-anchor="middle" fill="var(--text-muted)" style="font-size:9px;font-family:var(--font-mono)">' + escapeXml(bar.dayName) + '</text>' +
|
||||
'<text x="' + labelX + '" y="' + costLabelY + '" text-anchor="middle" fill="var(--text-dim)" style="font-size:8px;font-family:var(--font-mono)">' + escapeXml(this.formatCost(bar.cost)) + '</text>' +
|
||||
'</g>'
|
||||
);
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
},
|
||||
|
||||
// ── Cost by model table (sorted by cost descending) ──
|
||||
|
||||
costByModelSorted() {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
// OpenFang Setup Wizard — First-run guided setup (Provider + Agent + Channel)
|
||||
'use strict';
|
||||
|
||||
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n"""). */
|
||||
function wizardTomlMultilineEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
|
||||
}
|
||||
|
||||
/** Escape a string for use inside a TOML basic (single-line) string ("..."). */
|
||||
function wizardTomlBasicEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
function wizardPage() {
|
||||
return {
|
||||
step: 1,
|
||||
@@ -148,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: '' }; },
|
||||
|
||||
@@ -164,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];
|
||||
@@ -194,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,
|
||||
@@ -244,6 +282,15 @@ function wizardPage() {
|
||||
this.error = '';
|
||||
try {
|
||||
await this.loadProviders();
|
||||
// Pre-select first unconfigured provider, or first one
|
||||
var unconfigured = this.providers.filter(function(p) {
|
||||
return p.auth_status !== 'configured' && p.api_key_env;
|
||||
});
|
||||
if (unconfigured.length > 0) {
|
||||
this.selectedProvider = unconfigured[0].id;
|
||||
} else if (this.providers.length > 0) {
|
||||
this.selectedProvider = this.providers[0].id;
|
||||
}
|
||||
} catch(e) {
|
||||
this.error = e.message || 'Could not load setup data.';
|
||||
}
|
||||
@@ -278,16 +325,26 @@ 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] || '';
|
||||
},
|
||||
|
||||
get canGoNext() {
|
||||
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider;
|
||||
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider || this.claudeCodeDetected;
|
||||
if (this.step === 3) return this.agentName.trim().length > 0;
|
||||
return true;
|
||||
},
|
||||
|
||||
claudeCodeDetected: false,
|
||||
|
||||
get hasConfiguredProvider() {
|
||||
var self = this;
|
||||
return this.providers.some(function(p) {
|
||||
@@ -301,15 +358,6 @@ function wizardPage() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/providers');
|
||||
this.providers = data.providers || [];
|
||||
// Pre-select first unconfigured provider, or first one
|
||||
var unconfigured = this.providers.filter(function(p) {
|
||||
return p.auth_status !== 'configured' && p.api_key_env;
|
||||
});
|
||||
if (unconfigured.length > 0) {
|
||||
this.selectedProvider = unconfigured[0].id;
|
||||
} else if (this.providers.length > 0) {
|
||||
this.selectedProvider = this.providers[0].id;
|
||||
}
|
||||
} catch(e) { this.providers = []; }
|
||||
},
|
||||
|
||||
@@ -320,7 +368,7 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get popularProviders() {
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
|
||||
return this.providers.filter(function(p) {
|
||||
return popular.indexOf(p.id) >= 0;
|
||||
}).sort(function(a, b) {
|
||||
@@ -329,7 +377,7 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get otherProviders() {
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
|
||||
return this.providers.filter(function(p) {
|
||||
return popular.indexOf(p.id) < 0;
|
||||
});
|
||||
@@ -355,7 +403,8 @@ function wizardPage() {
|
||||
fireworks: { url: 'https://fireworks.ai/account/api-keys', text: 'Get your key from Fireworks AI' },
|
||||
perplexity: { url: 'https://www.perplexity.ai/settings/api', text: 'Get your key from Perplexity Settings' },
|
||||
cohere: { url: 'https://dashboard.cohere.com/api-keys', text: 'Get your key from the Cohere Dashboard' },
|
||||
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' }
|
||||
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' },
|
||||
'claude-code': { url: 'https://docs.anthropic.com/en/docs/claude-code', text: 'Install: npm install -g @anthropic-ai/claude-code && claude auth (no API key needed)' }
|
||||
};
|
||||
return help[id] || null;
|
||||
},
|
||||
@@ -369,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;
|
||||
@@ -378,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;
|
||||
},
|
||||
@@ -397,13 +446,35 @@ 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;
|
||||
},
|
||||
|
||||
async detectClaudeCode() {
|
||||
this.testingProvider = true;
|
||||
this.testResult = null;
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/providers/claude-code/test', {});
|
||||
this.testResult = result;
|
||||
if (result.status === 'ok') {
|
||||
this.claudeCodeDetected = true;
|
||||
this.keySaved = true;
|
||||
this.setupSummary.provider = 'Claude Code';
|
||||
OpenFangToast.success('Claude Code detected (' + (result.latency_ms || '?') + 'ms)');
|
||||
} else {
|
||||
this.testResult = { status: 'error', error: 'Claude Code CLI not detected' };
|
||||
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
|
||||
}
|
||||
} catch(e) {
|
||||
this.testResult = { status: 'error', error: e.message };
|
||||
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
|
||||
}
|
||||
this.testingProvider = false;
|
||||
},
|
||||
@@ -423,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;
|
||||
}
|
||||
|
||||
@@ -437,12 +508,12 @@ function wizardPage() {
|
||||
}
|
||||
|
||||
var toml = '[agent]\n';
|
||||
toml += 'name = "' + name.replace(/"/g, '\\"') + '"\n';
|
||||
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
|
||||
toml += 'name = "' + wizardTomlBasicEscape(name) + '"\n';
|
||||
toml += 'description = "' + wizardTomlBasicEscape(tpl.description) + '"\n';
|
||||
toml += 'profile = "' + tpl.profile + '"\n\n';
|
||||
toml += '[model]\nprovider = "' + provider + '"\n';
|
||||
toml += 'model = "' + model + '"\n';
|
||||
toml += 'system_prompt = """\n' + tpl.system_prompt + '\n"""\n';
|
||||
toml += 'system_prompt = """\n' + wizardTomlMultilineEscape(tpl.system_prompt) + '\n"""\n';
|
||||
|
||||
this.creatingAgent = true;
|
||||
try {
|
||||
@@ -450,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;
|
||||
},
|
||||
@@ -468,13 +539,14 @@ function wizardPage() {
|
||||
gemini: 'gemini-2.5-flash',
|
||||
groq: 'llama-3.3-70b-versatile',
|
||||
deepseek: 'deepseek-chat',
|
||||
openrouter: 'openrouter/auto',
|
||||
openrouter: 'openrouter/google/gemini-2.5-flash',
|
||||
mistral: 'mistral-large-latest',
|
||||
together: 'meta-llama/Llama-3-70b-chat-hf',
|
||||
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
||||
perplexity: 'llama-3.1-sonar-large-128k-online',
|
||||
cohere: 'command-r-plus',
|
||||
xai: 'grok-2'
|
||||
xai: 'grok-2',
|
||||
'claude-code': 'claude-code/sonnet'
|
||||
};
|
||||
return defaults[providerId] || '';
|
||||
},
|
||||
@@ -502,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;
|
||||
@@ -513,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;
|
||||
},
|
||||
|
||||
@@ -38,6 +38,11 @@ function workflowBuilder() {
|
||||
],
|
||||
|
||||
_renderScheduled: false,
|
||||
_lastClickNodeId: null,
|
||||
_lastClickTime: 0,
|
||||
_didDrag: false,
|
||||
_didConnect: false,
|
||||
_didPan: false,
|
||||
|
||||
async init() {
|
||||
var self = this;
|
||||
@@ -334,8 +339,23 @@ function workflowBuilder() {
|
||||
|
||||
onNodeMouseDown: function(node, e) {
|
||||
e.stopPropagation();
|
||||
// Detect double-click manually — the native dblclick event never fires
|
||||
// because scheduleRender() destroys and recreates all SVG elements between
|
||||
// the first and second click, so the browser loses the DOM target for dblclick.
|
||||
var now = Date.now();
|
||||
if (this._lastClickNodeId === node.id && (now - this._lastClickTime) < 350) {
|
||||
// Double-click detected — open editor instead of starting drag
|
||||
this._lastClickNodeId = null;
|
||||
this._lastClickTime = 0;
|
||||
this.editNode(node);
|
||||
return;
|
||||
}
|
||||
this._lastClickNodeId = node.id;
|
||||
this._lastClickTime = now;
|
||||
|
||||
this.selectedNode = node;
|
||||
this.selectedConnection = null;
|
||||
this._didDrag = false;
|
||||
this.dragging = node.id;
|
||||
var rect = this._getCanvasRect();
|
||||
this.dragOffset = {
|
||||
@@ -350,6 +370,7 @@ function workflowBuilder() {
|
||||
this.selectedConnection = null;
|
||||
this.showNodeEditor = false;
|
||||
// Start canvas pan
|
||||
this._didPan = false;
|
||||
this.canvasDragging = true;
|
||||
this.canvasDragStart = { x: e.clientX - this.canvasOffset.x * this.zoom, y: e.clientY - this.canvasOffset.y * this.zoom };
|
||||
},
|
||||
@@ -357,6 +378,7 @@ function workflowBuilder() {
|
||||
onCanvasMouseMove: function(e) {
|
||||
var rect = this._getCanvasRect();
|
||||
if (this.dragging) {
|
||||
this._didDrag = true;
|
||||
var node = this.getNode(this.dragging);
|
||||
if (node) {
|
||||
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
|
||||
@@ -364,12 +386,14 @@ function workflowBuilder() {
|
||||
}
|
||||
this.scheduleRender();
|
||||
} else if (this.connecting) {
|
||||
this._didConnect = true;
|
||||
this.connectPreview = {
|
||||
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
|
||||
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
|
||||
};
|
||||
this.scheduleRender();
|
||||
} else if (this.canvasDragging) {
|
||||
this._didPan = true;
|
||||
this.canvasOffset = {
|
||||
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
|
||||
y: (e.clientY - this.canvasDragStart.y) / this.zoom
|
||||
@@ -378,11 +402,19 @@ function workflowBuilder() {
|
||||
},
|
||||
|
||||
onCanvasMouseUp: function() {
|
||||
// Only re-render if something actually moved. Rendering on every mouseup
|
||||
// destroys SVG elements between clicks, which prevents dblclick detection.
|
||||
var needsRender = this._didDrag || this._didConnect || this._didPan;
|
||||
this.dragging = null;
|
||||
this.connecting = null;
|
||||
this.connectPreview = null;
|
||||
this.canvasDragging = false;
|
||||
this.scheduleRender();
|
||||
this._didDrag = false;
|
||||
this._didConnect = false;
|
||||
this._didPan = false;
|
||||
if (needsRender) {
|
||||
this.scheduleRender();
|
||||
}
|
||||
},
|
||||
|
||||
onCanvasWheel: function(e) {
|
||||
@@ -427,6 +459,12 @@ function workflowBuilder() {
|
||||
editNode: function(node) {
|
||||
this.selectedNode = node;
|
||||
this.showNodeEditor = true;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// Called from editor panel inputs to reflect changes on the canvas SVG
|
||||
applyNodeEdit: function() {
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── TOML Generation ──────────────────────────────────
|
||||
|
||||
@@ -13,6 +13,8 @@ function workflowsPage() {
|
||||
loading: true,
|
||||
loadError: '',
|
||||
newWf: { name: '', description: '', steps: [{ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' }] },
|
||||
editModal: null,
|
||||
editWf: { name: '', description: '', steps: [] },
|
||||
|
||||
// -- Workflows methods --
|
||||
async loadWorkflows() {
|
||||
@@ -74,6 +76,57 @@ function workflowsPage() {
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to load run history: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWorkflow(wf) {
|
||||
if (!confirm('Delete workflow "' + wf.name + '"? This cannot be undone.')) return;
|
||||
try {
|
||||
await OpenFangAPI.delete('/api/workflows/' + wf.id);
|
||||
OpenFangToast.success('Workflow "' + wf.name + '" deleted');
|
||||
await this.loadWorkflows();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete workflow: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async showEditModal(wf) {
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/workflows/' + wf.id);
|
||||
this.editWf = {
|
||||
name: full.name || '',
|
||||
description: full.description || '',
|
||||
steps: (full.steps || []).map(function(s) {
|
||||
return {
|
||||
name: s.name || '',
|
||||
agent_name: (s.agent && s.agent.name) || '',
|
||||
mode: s.mode || 'sequential',
|
||||
prompt: s.prompt_template || '{{input}}'
|
||||
};
|
||||
})
|
||||
};
|
||||
if (this.editWf.steps.length === 0) {
|
||||
this.editWf.steps.push({ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' });
|
||||
}
|
||||
this.editModal = wf;
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to load workflow: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async saveWorkflow() {
|
||||
if (!this.editModal) return;
|
||||
var steps = this.editWf.steps.map(function(s) {
|
||||
return { name: s.name || 'step', agent_name: s.agent_name, mode: s.mode, prompt: s.prompt || '{{input}}' };
|
||||
});
|
||||
try {
|
||||
var wfName = this.editWf.name;
|
||||
await OpenFangAPI.put('/api/workflows/' + this.editModal.id, { name: wfName, description: this.editWf.description, steps: steps });
|
||||
this.editModal = null;
|
||||
OpenFangToast.success('Workflow "' + wfName + '" updated');
|
||||
await this.loadWorkflows();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update workflow: ' + e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "OpenFang Agent OS",
|
||||
"short_name": "OpenFang",
|
||||
"description": "Open-source Agent Operating System",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#6366f1",
|
||||
"icons": [
|
||||
{"src": "/logo.png", "sizes": "128x128", "type": "image/png"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -98,6 +98,7 @@ async fn test_full_daemon_lifecycle() {
|
||||
model: "test".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
@@ -114,6 +115,8 @@ async fn test_full_daemon_lifecycle() {
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -223,6 +226,7 @@ async fn test_server_immediate_responsiveness() {
|
||||
model: "test".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
@@ -238,6 +242,8 @@ async fn test_server_immediate_responsiveness() {
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -42,6 +42,7 @@ async fn start_test_server() -> TestServer {
|
||||
model: "test-model".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
@@ -58,6 +59,8 @@ async fn start_test_server() -> TestServer {
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Integration tests for the `/api/skills/{id}/config` surface.
|
||||
//!
|
||||
//! These boot a real kernel, start a real axum server on a random port, plant
|
||||
//! a synthetic skill on disk whose SKILL.md declares a `config:` section, and
|
||||
//! exercise GET / PUT / DELETE end to end. Bundled skills currently declare
|
||||
//! no runtime config, so the synthetic skill fixture is what lets us prove
|
||||
//! the wire contract.
|
||||
//!
|
||||
//! Run: cargo test -p openfang-api --test skill_config_api_test -- --nocapture
|
||||
|
||||
use axum::Router;
|
||||
use openfang_api::middleware;
|
||||
use openfang_api::routes::{self, AppState};
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_types::config::{DefaultModelConfig, KernelConfig};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test server harness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
home_dir: std::path::PathBuf,
|
||||
#[allow(dead_code)]
|
||||
state: Arc<AppState>,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
self.state.kernel.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a skill fixture under `<home>/skills/<name>/SKILL.md` that declares
|
||||
/// a `config:` section. This matches the on-disk format that OpenClaw skills
|
||||
/// use, so the loader's real `parse_skillmd_str` path is exercised.
|
||||
fn plant_skill_with_config(home: &std::path::Path, skill_name: &str) {
|
||||
let skill_dir = home.join("skills").join(skill_name);
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
// Leading four spaces inside YAML lists matter — keep them.
|
||||
let skillmd = format!(
|
||||
"---
|
||||
name: {skill_name}
|
||||
description: Synthetic skill for config endpoint tests
|
||||
config:
|
||||
github_token:
|
||||
description: GitHub personal access token
|
||||
env: OPENFANG_TEST_SKILLCFG_GH_TOKEN
|
||||
required: true
|
||||
default_branch:
|
||||
description: Default branch name
|
||||
default: main
|
||||
required: false
|
||||
---
|
||||
# Test Skill
|
||||
|
||||
Placeholder body so the parser accepts this as a valid prompt-only skill.
|
||||
"
|
||||
);
|
||||
std::fs::write(skill_dir.join("SKILL.md"), skillmd).unwrap();
|
||||
}
|
||||
|
||||
async fn start_test_server() -> TestServer {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let home = tmp.path().to_path_buf();
|
||||
|
||||
let config = KernelConfig {
|
||||
home_dir: home.clone(),
|
||||
data_dir: home.join("data"),
|
||||
default_model: DefaultModelConfig {
|
||||
provider: "ollama".to_string(),
|
||||
model: "test-model".to_string(),
|
||||
api_key_env: "OLLAMA_API_KEY".to_string(),
|
||||
base_url: None,
|
||||
subprocess_timeout_secs: None,
|
||||
},
|
||||
..KernelConfig::default()
|
||||
};
|
||||
|
||||
// Plant synthetic skill BEFORE booting so the initial skill load picks it up.
|
||||
plant_skill_with_config(&home, "test-config-skill");
|
||||
|
||||
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boot");
|
||||
let kernel = Arc::new(kernel);
|
||||
kernel.set_self_handle();
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
kernel,
|
||||
started_at: Instant::now(),
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/skills", axum::routing::get(routes::list_skills))
|
||||
.route(
|
||||
"/api/skills/{id}/config",
|
||||
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/{id}/config/{var_name}",
|
||||
axum::routing::delete(routes::delete_skill_config_var),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(middleware::request_logging))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state.clone());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test port");
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
TestServer {
|
||||
base_url: format!("http://{}", addr),
|
||||
home_dir: home,
|
||||
state,
|
||||
_tmp: tmp,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_config_returns_declared_and_resolved() {
|
||||
// Make sure no host leak from prior tests interferes.
|
||||
// SAFETY: single-threaded test, env var is unique to this test suite.
|
||||
unsafe { std::env::remove_var("OPENFANG_TEST_SKILLCFG_GH_TOKEN") };
|
||||
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
|
||||
assert_eq!(body["skill"], "test-config-skill");
|
||||
// Both vars declared
|
||||
assert!(body["declared"]["github_token"].is_object());
|
||||
assert!(body["declared"]["default_branch"].is_object());
|
||||
assert_eq!(body["declared"]["github_token"]["required"], true);
|
||||
assert_eq!(body["declared"]["default_branch"]["required"], false);
|
||||
|
||||
// github_token has no user override, no env, no default -> unresolved.
|
||||
assert_eq!(
|
||||
body["resolved"]["github_token"]["source"], "unresolved",
|
||||
"github_token should be unresolved without env"
|
||||
);
|
||||
assert!(body["resolved"]["github_token"]["is_secret"]
|
||||
.as_bool()
|
||||
.unwrap());
|
||||
|
||||
// default_branch falls back to default "main".
|
||||
assert_eq!(body["resolved"]["default_branch"]["source"], "default");
|
||||
assert_eq!(body["resolved"]["default_branch"]["value"], "main");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_config_redacts_secret_values_after_put() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Write a real-looking token via PUT.
|
||||
let payload = serde_json::json!({
|
||||
"values": {
|
||||
"github_token": "ghp_realsecretvalue_DO_NOT_LEAK",
|
||||
"default_branch": "develop"
|
||||
}
|
||||
});
|
||||
let resp = client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200, "PUT should succeed");
|
||||
|
||||
// GET back and confirm the secret is redacted and non-secret is visible.
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
|
||||
let returned_token = body["resolved"]["github_token"]["value"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(
|
||||
!returned_token.contains("realsecretvalue"),
|
||||
"secret leaked on the wire: {returned_token}"
|
||||
);
|
||||
assert!(
|
||||
returned_token.contains("redacted"),
|
||||
"expected redaction marker, got: {returned_token}"
|
||||
);
|
||||
assert_eq!(body["resolved"]["github_token"]["source"], "user");
|
||||
|
||||
// Non-secret var kept as-is.
|
||||
assert_eq!(body["resolved"]["default_branch"]["value"], "develop");
|
||||
assert_eq!(body["resolved"]["default_branch"]["source"], "user");
|
||||
|
||||
// config.toml persisted the change, including the full secret value
|
||||
// (redaction is only on the wire — disk is the source of truth).
|
||||
let cfg = std::fs::read_to_string(server.home_dir.join("config.toml")).unwrap();
|
||||
assert!(
|
||||
cfg.contains("[skills.test-config-skill]"),
|
||||
"skills section missing: {cfg}"
|
||||
);
|
||||
assert!(cfg.contains("realsecretvalue"));
|
||||
assert!(cfg.contains("develop"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_rejects_unknown_variable() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"values": { "nonexistent_var": "value" }
|
||||
});
|
||||
let resp = client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 400);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(body["error"].as_str().unwrap().contains("nonexistent_var"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_override_reverts_to_default() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Set override first.
|
||||
client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"values": { "default_branch": "develop" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Remove it.
|
||||
let resp = client
|
||||
.delete(format!(
|
||||
"{}/api/skills/test-config-skill/config/default_branch",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Now source should be "default" again with value "main".
|
||||
let body: serde_json::Value = client
|
||||
.get(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(body["resolved"]["default_branch"]["source"], "default");
|
||||
assert_eq!(body["resolved"]["default_branch"]["value"], "main");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_refuses_to_strand_required_var() {
|
||||
// github_token is required, has no default, and no env — so removing an
|
||||
// override would leave it unresolvable. The endpoint must refuse.
|
||||
// SAFETY: single-threaded test.
|
||||
unsafe { std::env::remove_var("OPENFANG_TEST_SKILLCFG_GH_TOKEN") };
|
||||
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Set an override.
|
||||
client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"values": { "github_token": "ghp_value" }
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Try to delete — should 409.
|
||||
let resp = client
|
||||
.delete(format!(
|
||||
"{}/api/skills/test-config-skill/config/github_token",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 409);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_unknown_skill_returns_404() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{}/api/skills/this-skill-does-not-exist/config",
|
||||
server.base_url
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_reloads_registry_so_agents_see_change() {
|
||||
let server = start_test_server().await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
client
|
||||
.put(format!(
|
||||
"{}/api/skills/test-config-skill/config",
|
||||
server.base_url
|
||||
))
|
||||
.json(&serde_json::json!({
|
||||
"values": {
|
||||
"github_token": "ghp_new",
|
||||
"default_branch": "release"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The kernel's live override map must now hold the new values.
|
||||
let guard = server.state.kernel.skill_config_overrides.read().unwrap();
|
||||
let overrides = guard.as_ref().expect("override map set after PUT");
|
||||
let skill_cfg = overrides.get("test-config-skill").expect("skill present");
|
||||
assert_eq!(skill_cfg.get("github_token").unwrap(), "ghp_new");
|
||||
assert_eq!(skill_cfg.get("default_branch").unwrap(), "release");
|
||||
}
|
||||
@@ -14,6 +14,7 @@ chrono = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -24,13 +25,20 @@ zeroize = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
base64 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
html-escape = { workspace = true }
|
||||
regex-lite = "0.1"
|
||||
roxmltree = "0.21"
|
||||
|
||||
lettre = { workspace = true }
|
||||
imap = { workspace = true }
|
||||
native-tls = { workspace = true }
|
||||
mailparse = { workspace = true }
|
||||
rumqttc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -215,7 +215,7 @@ impl BlueskyAdapter {
|
||||
let chunks = split_message(text, MAX_MESSAGE_LEN);
|
||||
|
||||
for chunk in chunks {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
|
||||
let mut record = serde_json::json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
@@ -435,7 +435,11 @@ impl ChannelAdapter for BlueskyAdapter {
|
||||
service_url
|
||||
);
|
||||
if let Some(ref seen) = last_seen_at {
|
||||
url.push_str(&format!("&seenAt={}", seen));
|
||||
let encoded: String = url::form_urlencoded::Serializer::new(String::new())
|
||||
.append_pair("seenAt", seen)
|
||||
.finish();
|
||||
url.push('&');
|
||||
url.push_str(&encoded);
|
||||
}
|
||||
|
||||
let resp = match client.get(&url).bearer_auth(&token).send().await {
|
||||
@@ -492,7 +496,7 @@ impl ChannelAdapter for BlueskyAdapter {
|
||||
if last_seen_at.is_some() {
|
||||
let mark_url = format!("{}/xrpc/app.bsky.notification.updateSeen", service_url);
|
||||
let mark_body = serde_json::json!({
|
||||
"seenAt": Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
|
||||
"seenAt": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
});
|
||||
let _ = client
|
||||
.post(&mark_url)
|
||||
|
||||
+1841
-150
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,600 @@
|
||||
//! DingTalk Stream channel adapter.
|
||||
//!
|
||||
//! Uses DingTalk Stream Mode (WebSocket long-connection) instead of the
|
||||
//! legacy webhook approach. The webhook adapter in `dingtalk.rs` is preserved
|
||||
//! for backwards compatibility.
|
||||
//!
|
||||
//! Protocol:
|
||||
//! 1. POST /v1.0/oauth2/accessToken → get access token
|
||||
//! 2. POST /v1.0/gateway/connections/open → get WebSocket URL
|
||||
//! 3. Connect via WebSocket, handle ping/pong and EVENT messages
|
||||
//! 4. Outbound: POST /v1.0/robot/oToMessages/batchSend
|
||||
|
||||
use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use futures::{SinkExt, Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const API_BASE: &str = "https://api.dingtalk.com";
|
||||
const MAX_MESSAGE_LEN: usize = 20000;
|
||||
|
||||
// ─── Adapter ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct DingTalkStreamAdapter {
|
||||
app_key: String,
|
||||
app_secret: String,
|
||||
robot_code: String,
|
||||
client: reqwest::Client,
|
||||
token_cache: Arc<Mutex<TokenCache>>,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
impl DingTalkStreamAdapter {
|
||||
pub fn new(app_key: String, app_secret: String, robot_code: String) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
app_key,
|
||||
app_secret,
|
||||
robot_code,
|
||||
client: reqwest::Client::new(),
|
||||
token_cache: Arc::new(Mutex::new(TokenCache::default())),
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_token(&self) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_secs();
|
||||
{
|
||||
let c = self.token_cache.lock().unwrap();
|
||||
if !c.token.is_empty() && c.expire_at > now + 300 {
|
||||
return Ok(c.token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let resp: serde_json::Value = self
|
||||
.client
|
||||
.post(format!("{API_BASE}/v1.0/oauth2/accessToken"))
|
||||
.json(&serde_json::json!({
|
||||
"appKey": self.app_key,
|
||||
"appSecret": self.app_secret,
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let token = resp["accessToken"]
|
||||
.as_str()
|
||||
.ok_or("missing accessToken")?
|
||||
.to_string();
|
||||
let expire_in = resp["expireIn"].as_u64().unwrap_or(7200);
|
||||
|
||||
{
|
||||
let mut c = self.token_cache.lock().unwrap();
|
||||
c.token = token.clone();
|
||||
c.expire_at = now + expire_in;
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
async fn send_to_ids(
|
||||
&self,
|
||||
user_ids: &[&str],
|
||||
content: ChannelContent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let token = self
|
||||
.get_token()
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error> { e })?;
|
||||
|
||||
let (msg_key, _msg_param) = match &content {
|
||||
ChannelContent::Text(t) => (
|
||||
"sampleText",
|
||||
serde_json::json!({ "content": t }).to_string(),
|
||||
),
|
||||
_ => (
|
||||
"sampleText",
|
||||
serde_json::json!({ "content": "(unsupported content type)" }).to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let text = match &content {
|
||||
ChannelContent::Text(t) => t.as_str(),
|
||||
_ => "(unsupported)",
|
||||
};
|
||||
let chunks = split_message(text, MAX_MESSAGE_LEN);
|
||||
|
||||
for chunk in &chunks {
|
||||
let param = serde_json::json!({ "content": chunk }).to_string();
|
||||
let body = serde_json::json!({
|
||||
"robotCode": self.robot_code,
|
||||
"userIds": user_ids,
|
||||
"msgKey": msg_key,
|
||||
"msgParam": param,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{API_BASE}/v1.0/robot/oToMessages/batchSend"))
|
||||
.header("x-acs-dingtalk-access-token", &token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let err_body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("DingTalk batchSend error {status}: {err_body}").into());
|
||||
}
|
||||
|
||||
if chunks.len() > 1 {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for DingTalkStreamAdapter {
|
||||
fn name(&self) -> &str {
|
||||
"dingtalk_stream"
|
||||
}
|
||||
|
||||
fn channel_type(&self) -> ChannelType {
|
||||
ChannelType::Custom("dingtalk_stream".to_string())
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let app_key = self.app_key.clone();
|
||||
let app_secret = self.app_secret.clone();
|
||||
let client = self.client.clone();
|
||||
let token_cache = Arc::clone(&self.token_cache);
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
|
||||
info!("DingTalk Stream adapter starting WebSocket connection");
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut attempt: u32 = 0;
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("DingTalk Stream: shutdown requested");
|
||||
break;
|
||||
}
|
||||
|
||||
// 1. Get access token
|
||||
let token =
|
||||
match get_access_token(&client, &app_key, &app_secret, &token_cache).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
warn!("DingTalk Stream: token fetch failed: {e}");
|
||||
attempt += 1;
|
||||
tokio::time::sleep(backoff(attempt)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Get WebSocket endpoint
|
||||
let ws_url = match get_ws_endpoint(&client, &app_key, &app_secret, &token).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
warn!("DingTalk Stream: endpoint fetch failed: {e}");
|
||||
attempt += 1;
|
||||
tokio::time::sleep(backoff(attempt)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"DingTalk Stream: connecting to {}...",
|
||||
&ws_url[..ws_url.len().min(60)]
|
||||
);
|
||||
|
||||
// 3. Connect
|
||||
let ws_stream = match connect_async(&ws_url).await {
|
||||
Ok((ws, _)) => ws,
|
||||
Err(e) => {
|
||||
warn!("DingTalk Stream: WS connect failed: {e}");
|
||||
attempt += 1;
|
||||
tokio::time::sleep(backoff(attempt)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
info!("DingTalk Stream: connected");
|
||||
attempt = 0;
|
||||
let (mut sink, mut source) = ws_stream.split();
|
||||
|
||||
// 4. Message loop
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("DingTalk Stream: graceful shutdown");
|
||||
return;
|
||||
}
|
||||
}
|
||||
msg = source.next() => {
|
||||
match msg {
|
||||
None => { warn!("DingTalk Stream: connection closed"); break; }
|
||||
Some(Err(e)) => { warn!("DingTalk Stream: WS error: {e}"); break; }
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
handle_frame(&text, &mut sink, &tx).await;
|
||||
}
|
||||
Some(Ok(Message::Ping(d))) => { let _ = sink.send(Message::Pong(d)).await; }
|
||||
Some(Ok(Message::Close(_))) => { info!("DingTalk Stream: close frame"); break; }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect
|
||||
attempt += 1;
|
||||
let delay = backoff(attempt);
|
||||
info!("DingTalk Stream: reconnecting in {delay:?}");
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let uid = &user.platform_id;
|
||||
if uid.is_empty() {
|
||||
return Err("DingTalk Stream: no platform_id to reply to".into());
|
||||
}
|
||||
self.send_to_ids(&[uid.as_str()], content).await
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Token helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Default)]
|
||||
struct TokenCache {
|
||||
token: String,
|
||||
expire_at: u64,
|
||||
}
|
||||
|
||||
async fn get_access_token(
|
||||
http: &reqwest::Client,
|
||||
app_key: &str,
|
||||
app_secret: &str,
|
||||
cache: &Arc<Mutex<TokenCache>>,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_secs();
|
||||
{
|
||||
let c = cache.lock().unwrap();
|
||||
if !c.token.is_empty() && c.expire_at > now + 300 {
|
||||
return Ok(c.token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let resp: serde_json::Value = http
|
||||
.post(format!("{API_BASE}/v1.0/oauth2/accessToken"))
|
||||
.json(&serde_json::json!({ "appKey": app_key, "appSecret": app_secret }))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let token = resp["accessToken"]
|
||||
.as_str()
|
||||
.ok_or("missing accessToken")?
|
||||
.to_string();
|
||||
let expire_in = resp["expireIn"].as_u64().unwrap_or(7200);
|
||||
{
|
||||
let mut c = cache.lock().unwrap();
|
||||
c.token = token.clone();
|
||||
c.expire_at = now + expire_in;
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
// ─── Gateway / WebSocket helpers ─────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OpenConnectionRequest<'a> {
|
||||
#[serde(rename = "clientId")]
|
||||
client_id: &'a str,
|
||||
#[serde(rename = "clientSecret")]
|
||||
client_secret: &'a str,
|
||||
subscriptions: Vec<SubItem>,
|
||||
ua: &'a str,
|
||||
#[serde(rename = "localIp")]
|
||||
local_ip: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SubItem {
|
||||
#[serde(rename = "type")]
|
||||
sub_type: String,
|
||||
topic: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenConnectionResponse {
|
||||
endpoint: String,
|
||||
ticket: String,
|
||||
}
|
||||
|
||||
async fn get_ws_endpoint(
|
||||
http: &reqwest::Client,
|
||||
app_key: &str,
|
||||
app_secret: &str,
|
||||
token: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let body = OpenConnectionRequest {
|
||||
client_id: app_key,
|
||||
client_secret: app_secret,
|
||||
subscriptions: vec![SubItem {
|
||||
sub_type: "CALLBACK".to_string(),
|
||||
topic: "/v1.0/im/bot/messages/get".to_string(),
|
||||
}],
|
||||
ua: "openfang/0.3",
|
||||
local_ip: "",
|
||||
};
|
||||
let resp: OpenConnectionResponse = http
|
||||
.post(format!("{API_BASE}/v1.0/gateway/connections/open"))
|
||||
.header("x-acs-dingtalk-access-token", token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let sep = if resp.endpoint.contains('?') {
|
||||
"&"
|
||||
} else {
|
||||
"?"
|
||||
};
|
||||
Ok(format!("{}{}ticket={}", resp.endpoint, sep, resp.ticket))
|
||||
}
|
||||
|
||||
// ─── Frame handling ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProtoFrame {
|
||||
#[serde(rename = "type")]
|
||||
msg_type: String,
|
||||
headers: ProtoHeaders,
|
||||
#[serde(default)]
|
||||
data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProtoHeaders {
|
||||
#[serde(rename = "messageId", default)]
|
||||
message_id: String,
|
||||
#[serde(default)]
|
||||
topic: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AckReply {
|
||||
code: u32,
|
||||
headers: AckHeaders,
|
||||
message: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AckHeaders {
|
||||
#[serde(rename = "contentType")]
|
||||
content_type: String,
|
||||
#[serde(rename = "messageId")]
|
||||
message_id: String,
|
||||
topic: String,
|
||||
}
|
||||
|
||||
fn make_ack(message_id: &str, topic: &str) -> String {
|
||||
serde_json::to_string(&AckReply {
|
||||
code: 200,
|
||||
headers: AckHeaders {
|
||||
content_type: "application/json".to_string(),
|
||||
message_id: message_id.to_string(),
|
||||
topic: topic.to_string(),
|
||||
},
|
||||
message: "OK".to_string(),
|
||||
data: String::new(),
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CallbackPayload {
|
||||
#[serde(rename = "msgtype", default)]
|
||||
msg_type: String,
|
||||
#[serde(default)]
|
||||
text: Option<TextContent>,
|
||||
#[serde(rename = "senderStaffId", default)]
|
||||
sender_staff_id: String,
|
||||
#[serde(rename = "senderId", default)]
|
||||
sender_id: String,
|
||||
#[serde(rename = "senderNick", default)]
|
||||
sender_nick: String,
|
||||
#[serde(rename = "conversationId", default)]
|
||||
conversation_id: String,
|
||||
#[serde(rename = "conversationType", default)]
|
||||
conversation_type: String,
|
||||
#[serde(rename = "messageId", default)]
|
||||
message_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TextContent {
|
||||
content: String,
|
||||
}
|
||||
|
||||
async fn handle_frame<S>(text: &str, sink: &mut S, tx: &mpsc::Sender<ChannelMessage>)
|
||||
where
|
||||
S: SinkExt<Message> + Unpin,
|
||||
<S as futures::Sink<Message>>::Error: std::fmt::Display,
|
||||
{
|
||||
let frame: ProtoFrame = match serde_json::from_str(text) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!("DingTalk Stream: bad frame: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mid = &frame.headers.message_id;
|
||||
let topic = &frame.headers.topic;
|
||||
|
||||
match frame.msg_type.as_str() {
|
||||
"SYSTEM" if topic == "ping" => {
|
||||
let _ = sink.send(Message::Text(make_ack(mid, "pong"))).await;
|
||||
}
|
||||
"CALLBACK" | "EVENT" => {
|
||||
let data_str = frame.data.to_string();
|
||||
// Try direct parse, then try unwrapping double-encoded string
|
||||
let cb: Option<CallbackPayload> = serde_json::from_str(&data_str).ok().or_else(|| {
|
||||
serde_json::from_str::<String>(&data_str)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
});
|
||||
|
||||
if let Some(cb) = cb {
|
||||
if cb.msg_type == "text" {
|
||||
if let Some(ref tc) = cb.text {
|
||||
let trimmed = tc.content.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
let content = if trimmed.starts_with('/') {
|
||||
let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
|
||||
let cmd = parts[0].trim_start_matches('/');
|
||||
let args: Vec<String> = parts
|
||||
.get(1)
|
||||
.map(|a| a.split_whitespace().map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
ChannelContent::Command {
|
||||
name: cmd.to_string(),
|
||||
args,
|
||||
}
|
||||
} else {
|
||||
ChannelContent::Text(trimmed)
|
||||
};
|
||||
|
||||
let mut meta = HashMap::new();
|
||||
meta.insert(
|
||||
"conversation_id".to_string(),
|
||||
serde_json::Value::String(cb.conversation_id),
|
||||
);
|
||||
|
||||
let uid = if cb.sender_staff_id.is_empty() {
|
||||
cb.sender_id
|
||||
} else {
|
||||
cb.sender_staff_id
|
||||
};
|
||||
|
||||
let msg = ChannelMessage {
|
||||
channel: ChannelType::Custom("dingtalk_stream".to_string()),
|
||||
platform_message_id: cb.message_id,
|
||||
sender: ChannelUser {
|
||||
platform_id: uid,
|
||||
display_name: cb.sender_nick,
|
||||
openfang_user: None,
|
||||
},
|
||||
content,
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: cb.conversation_type == "2",
|
||||
thread_id: None,
|
||||
metadata: meta,
|
||||
};
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
error!("DingTalk Stream: channel receiver dropped");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = sink.send(Message::Text(make_ack(mid, topic))).await;
|
||||
}
|
||||
_ => {
|
||||
let _ = sink.send(Message::Text(make_ack(mid, topic))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backoff(attempt: u32) -> Duration {
|
||||
let ms = (1000u64 * 2u64.saturating_pow(attempt.min(6))).min(60_000);
|
||||
Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adapter_creation() {
|
||||
let a = DingTalkStreamAdapter::new("k".into(), "s".into(), "r".into());
|
||||
assert_eq!(a.name(), "dingtalk_stream");
|
||||
assert_eq!(
|
||||
a.channel_type(),
|
||||
ChannelType::Custom("dingtalk_stream".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_doubles() {
|
||||
assert_eq!(backoff(0), Duration::from_millis(1000));
|
||||
assert_eq!(backoff(1), Duration::from_millis(2000));
|
||||
assert_eq!(backoff(2), Duration::from_millis(4000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_capped() {
|
||||
assert_eq!(backoff(10), Duration::from_millis(60_000));
|
||||
assert_eq!(backoff(20), Duration::from_millis(60_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_ack_valid_json() {
|
||||
let ack = make_ack("msg1", "topic1");
|
||||
let v: serde_json::Value = serde_json::from_str(&ack).unwrap();
|
||||
assert_eq!(v["code"], 200);
|
||||
assert_eq!(v["headers"]["messageId"], "msg1");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,21 @@ use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// SASL PLAIN authenticator for IMAP servers that reject LOGIN
|
||||
/// (e.g., Lark/Larksuite which only advertise AUTH=PLAIN).
|
||||
struct PlainAuthenticator {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl imap::Authenticator for PlainAuthenticator {
|
||||
type Response = String;
|
||||
fn process(&self, _data: &[u8]) -> Self::Response {
|
||||
// SASL PLAIN: \0<username>\0<password>
|
||||
format!("\x00{}\x00{}", self.username, self.password)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reply context for email threading (In-Reply-To / Subject continuity).
|
||||
#[derive(Debug, Clone)]
|
||||
struct ReplyCtx {
|
||||
@@ -124,8 +139,7 @@ impl EmailAdapter {
|
||||
async fn build_smtp_transport(
|
||||
&self,
|
||||
) -> Result<AsyncSmtpTransport<Tokio1Executor>, Box<dyn std::error::Error>> {
|
||||
let creds =
|
||||
Credentials::new(self.username.clone(), self.password.as_str().to_string());
|
||||
let creds = Credentials::new(self.username.clone(), self.password.as_str().to_string());
|
||||
|
||||
let transport = if self.smtp_port == 465 {
|
||||
// Implicit TLS (port 465)
|
||||
@@ -200,12 +214,25 @@ fn fetch_unseen_emails(
|
||||
.build()
|
||||
.map_err(|e| format!("TLS connector error: {e}"))?;
|
||||
|
||||
let client = imap::connect((host, port), host, &tls)
|
||||
.map_err(|e| format!("IMAP connect failed: {e}"))?;
|
||||
let client =
|
||||
imap::connect((host, port), host, &tls).map_err(|e| format!("IMAP connect failed: {e}"))?;
|
||||
|
||||
let mut session = client
|
||||
.login(username, password)
|
||||
.map_err(|(e, _)| format!("IMAP login failed: {e}"))?;
|
||||
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
|
||||
// that reject LOGIN and only support AUTH=PLAIN (SASL).
|
||||
let mut session = match client.login(username, password) {
|
||||
Ok(s) => s,
|
||||
Err((login_err, client)) => {
|
||||
let authenticator = PlainAuthenticator {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
client
|
||||
.authenticate("PLAIN", &authenticator)
|
||||
.map_err(|(e, _)| {
|
||||
format!("IMAP login failed: {login_err}; AUTH=PLAIN also failed: {e}")
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
@@ -362,8 +389,7 @@ impl ChannelAdapter for EmailAdapter {
|
||||
}
|
||||
|
||||
// Extract target agent from subject brackets (stored in metadata for router)
|
||||
let _target_agent =
|
||||
EmailAdapter::extract_agent_from_subject(&subject);
|
||||
let _target_agent = EmailAdapter::extract_agent_from_subject(&subject);
|
||||
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
|
||||
|
||||
// Build the message body: prepend subject context
|
||||
|
||||
+1457
-302
File diff suppressed because it is too large
Load Diff
@@ -17,21 +17,177 @@ pub fn format_for_channel(text: &str, format: OutputFormat) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a message for WeCom, using a stronger plain-text conversion to avoid
|
||||
/// leaking Markdown syntax into enterprise chat replies.
|
||||
pub fn format_for_wecom(text: &str, format: OutputFormat) -> String {
|
||||
match format {
|
||||
OutputFormat::PlainText => markdown_to_wecom_plain(text),
|
||||
_ => format_for_channel(text, format),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Markdown to Telegram HTML subset.
|
||||
///
|
||||
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`.
|
||||
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`, `<blockquote>`.
|
||||
fn markdown_to_telegram_html(text: &str) -> String {
|
||||
// Escape HTML special characters first so agent names and other text
|
||||
// don't get interpreted as HTML tags by Telegram's parser.
|
||||
let mut result = text
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">");
|
||||
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let mut blocks = Vec::new();
|
||||
let lines: Vec<&str> = normalized.lines().collect();
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fenced code block
|
||||
if let Some(fence) = fence_delimiter(trimmed) {
|
||||
i += 1;
|
||||
let mut code_lines = Vec::new();
|
||||
while i < lines.len() {
|
||||
let candidate = lines[i].trim();
|
||||
if candidate.starts_with(fence) {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
code_lines.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
let code = escape_html(&code_lines.join("\n"));
|
||||
blocks.push(format!("<pre><code>{}</code></pre>", code));
|
||||
continue;
|
||||
}
|
||||
|
||||
// ATX heading (#, ##, ...)
|
||||
if let Some(content) = heading_text(trimmed) {
|
||||
blocks.push(format!("<b>{}</b>", render_inline_markdown(content.trim())));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if trimmed.starts_with('>') {
|
||||
let mut quote_lines = Vec::new();
|
||||
while i < lines.len() {
|
||||
let current = lines[i].trim();
|
||||
if current.is_empty() || !current.starts_with('>') {
|
||||
break;
|
||||
}
|
||||
let content = current.strip_prefix('>').unwrap_or(current).trim_start();
|
||||
quote_lines.push(render_inline_markdown(content));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push(format!(
|
||||
"<blockquote>{}</blockquote>",
|
||||
quote_lines.join("\n")
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if let Some(item) = unordered_list_item(trimmed) {
|
||||
let mut items = vec![format!("• {}", render_inline_markdown(item.trim()))];
|
||||
i += 1;
|
||||
while i < lines.len() {
|
||||
let current = lines[i].trim();
|
||||
if let Some(next_item) = unordered_list_item(current) {
|
||||
items.push(format!("• {}", render_inline_markdown(next_item.trim())));
|
||||
i += 1;
|
||||
} else if current.is_empty() {
|
||||
i += 1;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
blocks.push(items.join("\n"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if let Some(item) = ordered_list_item(trimmed) {
|
||||
let mut items = vec![format!("1. {}", render_inline_markdown(item.trim()))];
|
||||
let mut counter = 2;
|
||||
i += 1;
|
||||
while i < lines.len() {
|
||||
let current = lines[i].trim();
|
||||
if let Some(next_item) = ordered_list_item(current) {
|
||||
items.push(format!(
|
||||
"{}. {}",
|
||||
counter,
|
||||
render_inline_markdown(next_item.trim())
|
||||
));
|
||||
counter += 1;
|
||||
i += 1;
|
||||
} else if current.is_empty() {
|
||||
i += 1;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
blocks.push(items.join("\n"));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph
|
||||
let mut paragraph_lines = vec![trimmed];
|
||||
i += 1;
|
||||
while i < lines.len() {
|
||||
let current = lines[i].trim();
|
||||
if current.is_empty()
|
||||
|| fence_delimiter(current).is_some()
|
||||
|| heading_text(current).is_some()
|
||||
|| current.starts_with('>')
|
||||
|| unordered_list_item(current).is_some()
|
||||
|| ordered_list_item(current).is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
paragraph_lines.push(current);
|
||||
i += 1;
|
||||
}
|
||||
let joined = paragraph_lines.join("\n");
|
||||
blocks.push(render_inline_markdown(&joined));
|
||||
}
|
||||
|
||||
blocks.join("\n\n")
|
||||
}
|
||||
|
||||
fn render_inline_markdown(text: &str) -> String {
|
||||
let mut result = escape_html(text);
|
||||
|
||||
// Links: [text](url) → <a href="url">text</a>
|
||||
while let Some(bracket_start) = result.find('[') {
|
||||
if let Some(bracket_end_rel) = result[bracket_start..].find("](") {
|
||||
let bracket_end = bracket_start + bracket_end_rel;
|
||||
if let Some(paren_end_rel) = result[bracket_end + 2..].find(')') {
|
||||
let paren_end = bracket_end + 2 + paren_end_rel;
|
||||
let link_text = result[bracket_start + 1..bracket_end].to_string();
|
||||
let url = result[bracket_end + 2..paren_end].to_string();
|
||||
result = format!(
|
||||
"{}<a href=\"{}\">{}</a>{}",
|
||||
&result[..bracket_start],
|
||||
url,
|
||||
link_text,
|
||||
&result[paren_end + 1..]
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Bold: **text** → <b>text</b>
|
||||
while let Some(start) = result.find("**") {
|
||||
if let Some(end) = result[start + 2..].find("**") {
|
||||
let end = start + 2 + end;
|
||||
if let Some(end_rel) = result[start + 2..].find("**") {
|
||||
let end = start + 2 + end_rel;
|
||||
let inner = result[start + 2..end].to_string();
|
||||
result = format!("{}<b>{}</b>{}", &result[..start], inner, &result[end + 2..]);
|
||||
} else {
|
||||
@@ -39,8 +195,23 @@ fn markdown_to_telegram_html(text: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Italic: *text* → <i>text</i> (but not inside bold tags)
|
||||
// Simple heuristic: match single * not preceded/followed by *
|
||||
// Inline code: `text` → <code>text</code>
|
||||
while let Some(start) = result.find('`') {
|
||||
if let Some(end_rel) = result[start + 1..].find('`') {
|
||||
let end = start + 1 + end_rel;
|
||||
let inner = result[start + 1..end].to_string();
|
||||
result = format!(
|
||||
"{}<code>{}</code>{}",
|
||||
&result[..start],
|
||||
inner,
|
||||
&result[end + 1..]
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Italic: *text* → <i>text</i> (single star only)
|
||||
let mut out = String::with_capacity(result.len());
|
||||
let chars: Vec<char> = result.chars().collect();
|
||||
let mut i = 0;
|
||||
@@ -61,48 +232,57 @@ fn markdown_to_telegram_html(text: &str) -> String {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
result = out;
|
||||
|
||||
// Inline code: `text` → <code>text</code>
|
||||
while let Some(start) = result.find('`') {
|
||||
if let Some(end) = result[start + 1..].find('`') {
|
||||
let end = start + 1 + end;
|
||||
let inner = result[start + 1..end].to_string();
|
||||
result = format!(
|
||||
"{}<code>{}</code>{}",
|
||||
&result[..start],
|
||||
inner,
|
||||
&result[end + 1..]
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
out
|
||||
}
|
||||
|
||||
fn escape_html(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn fence_delimiter(line: &str) -> Option<&'static str> {
|
||||
if line.starts_with("```") {
|
||||
Some("```")
|
||||
} else if line.starts_with("~~~") {
|
||||
Some("~~~")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn heading_text(line: &str) -> Option<&str> {
|
||||
let hashes = line.chars().take_while(|c| *c == '#').count();
|
||||
if (1..=6).contains(&hashes) && line.chars().nth(hashes) == Some(' ') {
|
||||
Some(&line[hashes + 1..])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn unordered_list_item(line: &str) -> Option<&str> {
|
||||
for prefix in ["- ", "* ", "+ "] {
|
||||
if let Some(rest) = line.strip_prefix(prefix) {
|
||||
return Some(rest);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Links: [text](url) → <a href="url">text</a>
|
||||
while let Some(bracket_start) = result.find('[') {
|
||||
if let Some(bracket_end) = result[bracket_start..].find("](") {
|
||||
let bracket_end = bracket_start + bracket_end;
|
||||
if let Some(paren_end) = result[bracket_end + 2..].find(')') {
|
||||
let paren_end = bracket_end + 2 + paren_end;
|
||||
let link_text = &result[bracket_start + 1..bracket_end];
|
||||
let url = &result[bracket_end + 2..paren_end];
|
||||
result = format!(
|
||||
"{}<a href=\"{}\">{}</a>{}",
|
||||
&result[..bracket_start],
|
||||
url,
|
||||
link_text,
|
||||
&result[paren_end + 1..]
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
fn ordered_list_item(line: &str) -> Option<&str> {
|
||||
let digit_count = line.chars().take_while(|c| c.is_ascii_digit()).count();
|
||||
if digit_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let rest = &line[digit_count..];
|
||||
if let Some(item) = rest.strip_prefix(". ") {
|
||||
Some(item)
|
||||
} else if let Some(item) = rest.strip_prefix(") ") {
|
||||
Some(item)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Convert Markdown to Slack mrkdwn format.
|
||||
@@ -146,6 +326,192 @@ fn markdown_to_slack_mrkdwn(text: &str) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
fn strip_atx_heading(line: &str) -> String {
|
||||
let trimmed = line.trim_start();
|
||||
let heading_level = trimmed.chars().take_while(|c| *c == '#').count();
|
||||
if !(1..=6).contains(&heading_level) {
|
||||
return line.to_string();
|
||||
}
|
||||
|
||||
if trimmed.chars().nth(heading_level) != Some(' ') {
|
||||
return line.to_string();
|
||||
}
|
||||
|
||||
trimmed[heading_level..]
|
||||
.trim()
|
||||
.trim_end_matches('#')
|
||||
.trim_end()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn strip_blockquote_prefix(line: &str) -> String {
|
||||
let mut trimmed = line.trim_start();
|
||||
while let Some(rest) = trimmed.strip_prefix('>') {
|
||||
trimmed = rest.trim_start();
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn strip_task_list_prefix(line: &str) -> String {
|
||||
let trimmed = line.trim_start();
|
||||
for prefix in [
|
||||
"- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] ", "+ [ ] ", "+ [x] ", "+ [X] ",
|
||||
] {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix) {
|
||||
return rest.to_string();
|
||||
}
|
||||
}
|
||||
line.to_string()
|
||||
}
|
||||
|
||||
fn is_fenced_code_marker(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
let mut chars = trimmed.chars();
|
||||
let Some(marker) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
if marker != '`' && marker != '~' {
|
||||
return false;
|
||||
}
|
||||
chars.all(|c| c == marker || c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn is_setext_heading_underline(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
trimmed.chars().all(|c| c == '=' || c == '-') && trimmed.contains(['=', '-'])
|
||||
}
|
||||
|
||||
fn is_table_divider(line: &str) -> bool {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.is_empty() && trimmed.chars().all(|c| matches!(c, '|' | ':' | '-' | ' '))
|
||||
}
|
||||
|
||||
fn strip_inline_markdown(mut text: String) -> String {
|
||||
while let Some(start) = text.find("![") {
|
||||
if let Some(mid) = text[start..].find("](") {
|
||||
let mid = start + mid;
|
||||
if let Some(end) = text[mid + 2..].find(')') {
|
||||
let end = mid + 2 + end;
|
||||
let alt = &text[start + 2..mid];
|
||||
let url = &text[mid + 2..end];
|
||||
let replacement = if alt.is_empty() {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("{alt} ({url})")
|
||||
};
|
||||
text = format!("{}{}{}", &text[..start], replacement, &text[end + 1..]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
while let Some(start) = text.find('[') {
|
||||
if let Some(mid) = text[start..].find("](") {
|
||||
let mid = start + mid;
|
||||
if let Some(end) = text[mid + 2..].find(')') {
|
||||
let end = mid + 2 + end;
|
||||
let label = &text[start + 1..mid];
|
||||
let url = &text[mid + 2..end];
|
||||
text = format!("{}{} ({}){}", &text[..start], label, url, &text[end + 1..]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
while let Some(start) = text.find('<') {
|
||||
if let Some(end) = text[start + 1..].find('>') {
|
||||
let end = start + 1 + end;
|
||||
let inner = &text[start + 1..end];
|
||||
if inner.starts_with("http://")
|
||||
|| inner.starts_with("https://")
|
||||
|| inner.starts_with("mailto:")
|
||||
{
|
||||
text = format!("{}{}{}", &text[..start], inner, &text[end + 1..]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
text = text.replace("**", "");
|
||||
text = text.replace("__", "");
|
||||
text = text.replace("~~", "");
|
||||
text = text.replace('`', "");
|
||||
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
for (i, &ch) in chars.iter().enumerate() {
|
||||
if ch == '*'
|
||||
&& (i == 0 || chars[i - 1] != '*')
|
||||
&& (i + 1 >= chars.len() || chars[i + 1] != '*')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Strip common Markdown blocks for WeCom plain-text replies.
|
||||
fn markdown_to_wecom_plain(text: &str) -> String {
|
||||
let mut result_lines = Vec::new();
|
||||
let mut in_fenced_code = false;
|
||||
|
||||
for raw_line in text.replace("\r\n", "\n").lines() {
|
||||
let trimmed = raw_line.trim();
|
||||
|
||||
if is_fenced_code_marker(trimmed) {
|
||||
in_fenced_code = !in_fenced_code;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_fenced_code {
|
||||
result_lines.push(raw_line.trim_end().to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_setext_heading_underline(trimmed) || is_table_divider(trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut line = strip_atx_heading(raw_line);
|
||||
line = strip_blockquote_prefix(&line);
|
||||
line = strip_task_list_prefix(&line);
|
||||
|
||||
let trimmed_line = line.trim();
|
||||
if trimmed_line.starts_with('|') && trimmed_line.ends_with('|') && trimmed_line.len() > 2 {
|
||||
line = trimmed_line
|
||||
.trim_matches('|')
|
||||
.split('|')
|
||||
.map(|cell| cell.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
line = strip_inline_markdown(line);
|
||||
result_lines.push(line.trim().to_string());
|
||||
}
|
||||
|
||||
let mut collapsed = Vec::new();
|
||||
for line in result_lines {
|
||||
if line.is_empty()
|
||||
&& collapsed
|
||||
.last()
|
||||
.is_some_and(|prev: &String| prev.is_empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
collapsed.push(line);
|
||||
}
|
||||
|
||||
collapsed.join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
/// Strip all Markdown formatting, producing plain text.
|
||||
fn markdown_to_plain(text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
@@ -231,6 +597,36 @@ mod tests {
|
||||
assert_eq!(result, "<a href=\"https://example.com\">click here</a>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_html_heading() {
|
||||
let result = markdown_to_telegram_html("## Result");
|
||||
assert_eq!(result, "<b>Result</b>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_html_unordered_list() {
|
||||
let result = markdown_to_telegram_html("- alpha\n- beta");
|
||||
assert_eq!(result, "• alpha\n• beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_html_ordered_list() {
|
||||
let result = markdown_to_telegram_html("1. alpha\n2. beta");
|
||||
assert_eq!(result, "1. alpha\n2. beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_html_fenced_code_block() {
|
||||
let result = markdown_to_telegram_html("```rust\nfn main() {}\n```");
|
||||
assert_eq!(result, "<pre><code>fn main() {}</code></pre>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_telegram_html_blockquote() {
|
||||
let result = markdown_to_telegram_html("> note\n> second line");
|
||||
assert_eq!(result, "<blockquote>note\nsecond line</blockquote>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slack_mrkdwn_bold() {
|
||||
let result = markdown_to_slack_mrkdwn("Hello **world**!");
|
||||
@@ -254,4 +650,26 @@ mod tests {
|
||||
let result = markdown_to_plain("[click](https://example.com)");
|
||||
assert_eq!(result, "click (https://example.com)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wecom_plain_text_strips_common_markdown_blocks() {
|
||||
let result = markdown_to_wecom_plain(
|
||||
"# Title\n\
|
||||
\n\
|
||||
> quoted text\n\
|
||||
\n\
|
||||
- [x] done item\n\
|
||||
- [ ] todo item\n\
|
||||
\n\
|
||||
```rust\n\
|
||||
let value = 1;\n\
|
||||
```\n\
|
||||
\n\
|
||||
[docs](https://example.com)\n",
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
"Title\n\nquoted text\n\ndone item\ntodo item\n\nlet value = 1;\n\ndocs (https://example.com)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,19 +326,17 @@ impl ChannelAdapter for IrcAdapter {
|
||||
}
|
||||
|
||||
// RPL_WELCOME (001) — registration complete, join channels
|
||||
"001" => {
|
||||
if !joined {
|
||||
info!("IRC registered as {nick_clone}");
|
||||
for ch in &channels_clone {
|
||||
let join_cmd = format!("JOIN {ch}\r\n");
|
||||
if let Err(e) = writer.write_all(join_cmd.as_bytes()).await {
|
||||
warn!("IRC JOIN send failed: {e}");
|
||||
break 'inner true;
|
||||
}
|
||||
info!("IRC joining {ch}");
|
||||
"001" if !joined => {
|
||||
info!("IRC registered as {nick_clone}");
|
||||
for ch in &channels_clone {
|
||||
let join_cmd = format!("JOIN {ch}\r\n");
|
||||
if let Err(e) = writer.write_all(join_cmd.as_bytes()).await {
|
||||
warn!("IRC JOIN send failed: {e}");
|
||||
break 'inner true;
|
||||
}
|
||||
joined = true;
|
||||
info!("IRC joining {ch}");
|
||||
}
|
||||
joined = true;
|
||||
}
|
||||
|
||||
// PRIVMSG — incoming message
|
||||
|
||||
@@ -43,10 +43,13 @@ pub mod twist;
|
||||
pub mod webex;
|
||||
// Wave 5 — Niche & differentiating channels
|
||||
pub mod dingtalk;
|
||||
pub mod dingtalk_stream;
|
||||
pub mod discourse;
|
||||
pub mod gitter;
|
||||
pub mod gotify;
|
||||
pub mod linkedin;
|
||||
pub mod mqtt;
|
||||
pub mod mumble;
|
||||
pub mod ntfy;
|
||||
pub mod webhook;
|
||||
pub mod wecom;
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// LINE push message API endpoint.
|
||||
@@ -62,8 +62,8 @@ impl LineAdapter {
|
||||
pub fn new(channel_secret: String, access_token: String, webhook_port: u16) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
channel_secret: Zeroizing::new(channel_secret),
|
||||
access_token: Zeroizing::new(access_token),
|
||||
channel_secret: Zeroizing::new(channel_secret.trim().to_string()),
|
||||
access_token: Zeroizing::new(access_token.trim().to_string()),
|
||||
webhook_port,
|
||||
client: reqwest::Client::new(),
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
@@ -96,12 +96,35 @@ impl LineAdapter {
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if result.len() != expected.len() {
|
||||
debug!(
|
||||
"LINE: signature length mismatch: computed={} received={}",
|
||||
result.len(),
|
||||
expected.len()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (a, b) in result.iter().zip(expected.iter()) {
|
||||
diff |= a ^ b;
|
||||
}
|
||||
if diff != 0 {
|
||||
let computed = base64::engine::general_purpose::STANDARD.encode(result);
|
||||
// Log first/last 4 chars of each signature for debugging without leaking full HMAC
|
||||
let comp_redacted = format!(
|
||||
"{}...{}",
|
||||
&computed[..4.min(computed.len())],
|
||||
&computed[computed.len().saturating_sub(4)..]
|
||||
);
|
||||
let recv_redacted = format!(
|
||||
"{}...{}",
|
||||
&signature[..4.min(signature.len())],
|
||||
&signature[signature.len().saturating_sub(4)..]
|
||||
);
|
||||
debug!(
|
||||
"LINE: signature mismatch: computed={comp_redacted} received={recv_redacted} body_len={}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
@@ -358,19 +381,18 @@ impl ChannelAdapter for LineAdapter {
|
||||
axum::routing::post({
|
||||
let secret = Arc::clone(&channel_secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
move |headers: axum::http::HeaderMap,
|
||||
body: axum::extract::Json<serde_json::Value>| {
|
||||
move |headers: axum::http::HeaderMap, body: axum::body::Bytes| {
|
||||
let secret = Arc::clone(&secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
async move {
|
||||
// Verify X-Line-Signature
|
||||
// Verify X-Line-Signature using the raw request
|
||||
// body bytes — NOT re-serialized JSON — because the
|
||||
// HMAC must be computed over the exact bytes LINE sent.
|
||||
let signature = headers
|
||||
.get("x-line-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let body_bytes = serde_json::to_vec(&body.0).unwrap_or_default();
|
||||
|
||||
// Create a temporary adapter-like verifier
|
||||
let adapter = LineAdapter {
|
||||
channel_secret: secret.as_ref().clone(),
|
||||
@@ -381,15 +403,23 @@ impl ChannelAdapter for LineAdapter {
|
||||
shutdown_rx: watch::channel(false).1,
|
||||
};
|
||||
|
||||
if !signature.is_empty()
|
||||
&& !adapter.verify_signature(&body_bytes, signature)
|
||||
if !signature.is_empty() && !adapter.verify_signature(&body, signature)
|
||||
{
|
||||
warn!("LINE: invalid webhook signature");
|
||||
return axum::http::StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
|
||||
// Parse the raw bytes into JSON after signature verification
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("LINE: failed to parse webhook body as JSON: {e}");
|
||||
return axum::http::StatusCode::BAD_REQUEST;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse events array
|
||||
if let Some(events) = body.0["events"].as_array() {
|
||||
if let Some(events) = parsed["events"].as_array() {
|
||||
for event in events {
|
||||
if let Some(msg) = parse_line_event(event) {
|
||||
let _ = tx.send(msg).await;
|
||||
@@ -626,6 +656,71 @@ mod tests {
|
||||
assert!(parse_line_event(&event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_with_raw_body() {
|
||||
// Verify that HMAC-SHA256 signature validation works with raw body bytes
|
||||
let secret = "test-channel-secret";
|
||||
let adapter = LineAdapter::new(secret.to_string(), "token".to_string(), 9000);
|
||||
|
||||
// Compute the expected signature manually
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let body = br#"{"events":[{"type":"message"}]}"#;
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let expected_sig =
|
||||
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
assert!(adapter.verify_signature(body, &expected_sig));
|
||||
|
||||
// Re-serialized JSON should NOT match (this was the bug)
|
||||
let parsed: serde_json::Value = serde_json::from_slice(body).unwrap();
|
||||
let reserialized = serde_json::to_vec(&parsed).unwrap();
|
||||
// The re-serialized form may differ in whitespace/key order
|
||||
// If it happens to be identical for this input, the test still validates
|
||||
// the core mechanism works with raw bytes
|
||||
if reserialized != body.to_vec() {
|
||||
assert!(!adapter.verify_signature(&reserialized, &expected_sig));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_secret_trimmed() {
|
||||
// Environment variables often have trailing newlines or spaces
|
||||
let adapter = LineAdapter::new(
|
||||
" my-secret\n".to_string(),
|
||||
" my-token\r\n".to_string(),
|
||||
9000,
|
||||
);
|
||||
assert_eq!(adapter.channel_secret.as_str(), "my-secret");
|
||||
assert_eq!(adapter.access_token.as_str(), "my-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_bad_base64() {
|
||||
let adapter = LineAdapter::new("secret".to_string(), "token".to_string(), 9000);
|
||||
assert!(!adapter.verify_signature(b"body", "not-valid-base64!!!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_wrong_secret() {
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let body = b"test body";
|
||||
let mut mac = HmacSha256::new_from_slice(b"wrong-secret").unwrap();
|
||||
mac.update(body);
|
||||
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
let adapter = LineAdapter::new("correct-secret".to_string(), "token".to_string(), 9000);
|
||||
assert!(!adapter.verify_signature(body, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_line_event_room_source() {
|
||||
let event = serde_json::json!({
|
||||
|
||||
@@ -298,17 +298,8 @@ fn strip_html_tags(html: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Decode HTML entities
|
||||
let decoded = result
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ");
|
||||
|
||||
// Decode HTML entities (handles named, decimal, and hex entities)
|
||||
let decoded = html_escape::decode_html_entities(&result);
|
||||
decoded.trim().to_string()
|
||||
}
|
||||
|
||||
@@ -465,10 +456,18 @@ impl ChannelAdapter for MastodonAdapter {
|
||||
let notifications: Vec<serde_json::Value> =
|
||||
poll_resp.json().await.unwrap_or_default();
|
||||
|
||||
for notif in ¬ifications {
|
||||
if let Some(nid) = notif["id"].as_str() {
|
||||
// Mastodon returns notifications newest-first. Record the first
|
||||
// (highest) ID before processing so we never re-fetch these on
|
||||
// the next poll. Updating inside the loop would leave us with
|
||||
// the oldest ID, causing every previously seen notification to
|
||||
// be re-delivered and re-processed.
|
||||
if let Some(newest) = notifications.first() {
|
||||
if let Some(nid) = newest["id"].as_str() {
|
||||
last_notification_id = Some(nid.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
for notif in ¬ifications {
|
||||
if let Some(msg) = parse_mastodon_notification(notif, &own_account_id) {
|
||||
if tx.send(msg).await.is_err() {
|
||||
return;
|
||||
@@ -527,6 +526,10 @@ impl ChannelAdapter for MastodonAdapter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn suppress_error_responses(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
Ok(())
|
||||
|
||||
@@ -12,20 +12,26 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
const SYNC_TIMEOUT_MS: u64 = 30000;
|
||||
const MAX_MESSAGE_LEN: usize = 4096;
|
||||
|
||||
/// Shared access + refresh token pair. Tokens are zeroized on drop and rotated
|
||||
/// in place when MSC2918 refresh succeeds.
|
||||
type TokenPair = Arc<RwLock<(Zeroizing<String>, Option<Zeroizing<String>>)>>;
|
||||
|
||||
/// Matrix channel adapter using the Client-Server API.
|
||||
pub struct MatrixAdapter {
|
||||
/// Matrix homeserver URL (e.g., `"https://matrix.org"`).
|
||||
homeserver_url: String,
|
||||
/// Bot's user ID (e.g., "@openfang:matrix.org").
|
||||
user_id: String,
|
||||
/// SECURITY: Access token is zeroized on drop.
|
||||
access_token: Zeroizing<String>,
|
||||
/// SECURITY: Access + refresh tokens are zeroized on drop. Stored behind
|
||||
/// an RwLock so the sync loop and send paths see rotated tokens after a
|
||||
/// MSC2918 /refresh call (matrix.org/MAS rotates both tokens every refresh).
|
||||
tokens: TokenPair,
|
||||
/// HTTP client.
|
||||
client: reqwest::Client,
|
||||
/// Allowed room IDs (empty = all joined rooms).
|
||||
@@ -35,29 +41,66 @@ pub struct MatrixAdapter {
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Sync token for resuming /sync.
|
||||
since_token: Arc<RwLock<Option<String>>>,
|
||||
/// Whether to auto-accept room invites.
|
||||
auto_accept_invites: bool,
|
||||
}
|
||||
|
||||
impl MatrixAdapter {
|
||||
/// Create a new Matrix adapter.
|
||||
/// Create a new Matrix adapter without a refresh token.
|
||||
pub fn new(
|
||||
homeserver_url: String,
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
allowed_rooms: Vec<String>,
|
||||
auto_accept_invites: bool,
|
||||
) -> Self {
|
||||
Self::with_refresh_token(
|
||||
homeserver_url,
|
||||
user_id,
|
||||
access_token,
|
||||
None,
|
||||
allowed_rooms,
|
||||
auto_accept_invites,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new Matrix adapter with an optional refresh token (MSC2918).
|
||||
///
|
||||
/// When `refresh_token` is `Some`, the adapter will automatically call
|
||||
/// `POST /_matrix/client/v3/refresh` on `401 M_UNKNOWN_TOKEN` responses
|
||||
/// and retry the failed request once. Both tokens rotate on each refresh
|
||||
/// under Matrix Authentication Service (MAS).
|
||||
pub fn with_refresh_token(
|
||||
homeserver_url: String,
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
allowed_rooms: Vec<String>,
|
||||
auto_accept_invites: bool,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let tokens: TokenPair = Arc::new(RwLock::new((
|
||||
Zeroizing::new(access_token),
|
||||
refresh_token.map(Zeroizing::new),
|
||||
)));
|
||||
Self {
|
||||
homeserver_url,
|
||||
user_id,
|
||||
access_token: Zeroizing::new(access_token),
|
||||
tokens,
|
||||
client: reqwest::Client::new(),
|
||||
allowed_rooms,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
since_token: Arc::new(RwLock::new(None)),
|
||||
auto_accept_invites,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current access token (cloned).
|
||||
async fn current_access_token(&self) -> String {
|
||||
self.tokens.read().await.0.as_str().to_string()
|
||||
}
|
||||
|
||||
/// Send a text message to a Matrix room.
|
||||
async fn api_send_message(
|
||||
&self,
|
||||
@@ -77,18 +120,47 @@ impl MatrixAdapter {
|
||||
"body": chunk,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let token = self.current_access_token().await;
|
||||
let resp = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Matrix API error {status}: {body}").into());
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
|
||||
// Try a single refresh+retry on M_UNKNOWN_TOKEN (MSC2918).
|
||||
if attempt == 1
|
||||
&& status == reqwest::StatusCode::UNAUTHORIZED
|
||||
&& is_unknown_token_body(&body_text)
|
||||
{
|
||||
match try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!("Matrix: access token refreshed via MSC2918, retrying send");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"Matrix API error {status}: {body_text} (refresh failed: {e})"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Err(format!("Matrix API error {status}: {body_text}").into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,29 +171,193 @@ impl MatrixAdapter {
|
||||
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver_url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.send()
|
||||
.await?;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let token = self.current_access_token().await;
|
||||
let resp = self.client.get(&url).bearer_auth(&token).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
if resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
|
||||
return Ok(user_id);
|
||||
}
|
||||
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
if attempt == 1
|
||||
&& status == reqwest::StatusCode::UNAUTHORIZED
|
||||
&& is_unknown_token_body(&body_text)
|
||||
&& try_refresh_tokens(&self.client, &self.homeserver_url, &self.tokens)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
info!("Matrix: access token refreshed via MSC2918, retrying /whoami");
|
||||
continue;
|
||||
}
|
||||
return Err("Matrix authentication failed".into());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let user_id = body["user_id"].as_str().unwrap_or("unknown").to_string();
|
||||
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(test)]
|
||||
fn is_allowed_room(&self, room_id: &str) -> bool {
|
||||
self.allowed_rooms.is_empty() || self.allowed_rooms.iter().any(|r| r == room_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect `M_UNKNOWN_TOKEN` errors in a Matrix response body.
|
||||
///
|
||||
/// Matrix returns 401 for multiple reasons; we only want to refresh on
|
||||
/// `M_UNKNOWN_TOKEN` (the access token expired or was revoked). See
|
||||
/// <https://spec.matrix.org/latest/client-server-api/#soft-logout>.
|
||||
fn is_unknown_token_body(body: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("errcode").and_then(|c| c.as_str()).map(String::from))
|
||||
.map(|c| c == "M_UNKNOWN_TOKEN")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether a Matrix 401 body indicates a hard logout (operator must re-login).
|
||||
///
|
||||
/// `soft_logout: true` (or absent — default per spec) means the device is still
|
||||
/// known to the server and a refresh-token grant is valid. `soft_logout: false`
|
||||
/// means the device was invalidated and the operator must perform a new
|
||||
/// `m.login.password` flow.
|
||||
fn is_hard_logout(body: &str) -> bool {
|
||||
serde_json::from_str::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("soft_logout").and_then(|s| s.as_bool()))
|
||||
.map(|soft| !soft)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Call `POST /_matrix/client/v3/refresh` (MSC2918) and rotate the stored tokens.
|
||||
///
|
||||
/// On success, replaces the access token and (if the server returned one) the
|
||||
/// refresh token. MAS (matrix.org since 2025-04-07) rotates the refresh token
|
||||
/// on every call, so callers must use the new value next time.
|
||||
async fn try_refresh_tokens(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
tokens: &TokenPair,
|
||||
) -> Result<(), String> {
|
||||
let refresh_token = {
|
||||
let guard = tokens.read().await;
|
||||
match guard.1.as_ref() {
|
||||
Some(rt) => rt.as_str().to_string(),
|
||||
None => return Err("no refresh token configured".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{homeserver}/_matrix/client/v3/refresh");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({ "refresh_token": refresh_token }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("refresh request failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("refresh returned {status}: {body}"));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("refresh response parse error: {e}"))?;
|
||||
|
||||
let new_access = body
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "refresh response missing access_token".to_string())?;
|
||||
let new_refresh = body
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let mut guard = tokens.write().await;
|
||||
guard.0 = Zeroizing::new(new_access.to_string());
|
||||
if let Some(rt) = new_refresh {
|
||||
guard.1 = Some(Zeroizing::new(rt));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
|
||||
async fn accept_invite(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
access_token: &str,
|
||||
room_id: &str,
|
||||
) {
|
||||
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/join");
|
||||
match client
|
||||
.post(&url)
|
||||
.bearer_auth(access_token)
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
info!("Matrix: auto-accepted invite to {room_id}");
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
warn!("Matrix: failed to accept invite to {room_id}: {status}");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Matrix: error accepting invite to {room_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the number of joined members in a room.
|
||||
async fn get_room_member_count(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
access_token: &str,
|
||||
room_id: &str,
|
||||
) -> Option<usize> {
|
||||
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/joined_members");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body["joined"].as_object().map(|m| m.len())
|
||||
}
|
||||
|
||||
/// Do an initial /sync with timeout=0 to get the since token without processing events.
|
||||
/// This prevents replaying old messages when the adapter first connects.
|
||||
async fn initial_sync(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
access_token: &str,
|
||||
) -> Option<String> {
|
||||
let url = format!(
|
||||
"{homeserver}/_matrix/client/v3/sync?timeout=0&filter={{\"room\":{{\"timeline\":{{\"limit\":0}}}}}}"
|
||||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body["next_batch"].as_str().map(String::from)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for MatrixAdapter {
|
||||
fn name(&self) -> &str {
|
||||
@@ -142,15 +378,34 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let homeserver = self.homeserver_url.clone();
|
||||
let access_token = self.access_token.clone();
|
||||
let user_id = self.user_id.clone();
|
||||
let tokens = Arc::clone(&self.tokens);
|
||||
// Use the validated user ID from /whoami instead of the config value.
|
||||
// Matrix server delegation or casing differences can cause self.user_id
|
||||
// to not match the sender field in timeline events, making the bot
|
||||
// process its own replies in an infinite loop (see #757).
|
||||
let user_id = validated_user;
|
||||
let allowed_rooms = self.allowed_rooms.clone();
|
||||
let client = self.client.clone();
|
||||
let since_token = Arc::clone(&self.since_token);
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
let auto_accept = self.auto_accept_invites;
|
||||
|
||||
// FIX #4: Do an initial sync to get the since token, skipping old messages.
|
||||
if since_token.read().await.is_none() {
|
||||
let token = self.current_access_token().await;
|
||||
if let Some(next) = initial_sync(&client, &homeserver, &token).await {
|
||||
info!("Matrix: initial sync complete, skipping old messages");
|
||||
*since_token.write().await = Some(next);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
// Track recently seen event IDs to prevent duplicate processing
|
||||
// on sync token races or reconnects.
|
||||
let mut seen_events: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::new();
|
||||
const MAX_SEEN: usize = 500;
|
||||
|
||||
loop {
|
||||
// Build /sync URL
|
||||
@@ -163,12 +418,13 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
url.push_str(&format!("&since={token}"));
|
||||
}
|
||||
|
||||
let current_token = tokens.read().await.0.as_str().to_string();
|
||||
let resp = tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Matrix adapter shutting down");
|
||||
break;
|
||||
}
|
||||
result = client.get(&url).bearer_auth(&*access_token).send() => {
|
||||
result = client.get(&url).bearer_auth(¤t_token).send() => {
|
||||
match result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -182,7 +438,38 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
warn!("Matrix sync returned {}", resp.status());
|
||||
let status = resp.status();
|
||||
// MSC2918: on 401 M_UNKNOWN_TOKEN with a refresh token configured,
|
||||
// try refreshing once and loop again immediately. Hard logout
|
||||
// (soft_logout:false) is unrecoverable here — the operator must
|
||||
// perform a fresh m.login.password.
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
if is_unknown_token_body(&body_text) {
|
||||
if is_hard_logout(&body_text) {
|
||||
warn!(
|
||||
"Matrix: hard logout (soft_logout=false), operator must re-login"
|
||||
);
|
||||
} else {
|
||||
match try_refresh_tokens(&client, &homeserver, &tokens).await {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
"Matrix: access token refreshed via MSC2918, resuming /sync"
|
||||
);
|
||||
backoff = Duration::from_secs(1);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Matrix: token refresh failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Matrix sync returned {status}: {body_text}");
|
||||
}
|
||||
} else {
|
||||
warn!("Matrix sync returned {status}");
|
||||
}
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(60));
|
||||
continue;
|
||||
@@ -203,6 +490,24 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
*since_token.write().await = Some(next.to_string());
|
||||
}
|
||||
|
||||
// FIX #1: Auto-accept room invites.
|
||||
if auto_accept {
|
||||
if let Some(invites) = body["rooms"]["invite"].as_object() {
|
||||
for (room_id, _invite_data) in invites {
|
||||
if !allowed_rooms.is_empty()
|
||||
&& !allowed_rooms.iter().any(|r| r == room_id)
|
||||
{
|
||||
debug!(
|
||||
"Matrix: ignoring invite to {room_id} (not in allowed_rooms)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let tok = tokens.read().await.0.as_str().to_string();
|
||||
accept_invite(&client, &homeserver, &tok, room_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process room events
|
||||
if let Some(rooms) = body["rooms"]["join"].as_object() {
|
||||
for (room_id, room_data) in rooms {
|
||||
@@ -223,6 +528,21 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
continue; // Skip own messages
|
||||
}
|
||||
|
||||
// Dedup: skip events we've already processed.
|
||||
let event_id_str =
|
||||
event["event_id"].as_str().unwrap_or("").to_string();
|
||||
if !event_id_str.is_empty() {
|
||||
if seen_events.contains(&event_id_str) {
|
||||
debug!("Matrix: skipping duplicate event {event_id_str}");
|
||||
continue;
|
||||
}
|
||||
seen_events.insert(event_id_str.clone());
|
||||
// Prevent unbounded growth
|
||||
if seen_events.len() > MAX_SEEN {
|
||||
seen_events.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let content = event["content"]["body"].as_str().unwrap_or("");
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
@@ -243,11 +563,69 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
ChannelContent::Text(content.to_string())
|
||||
};
|
||||
|
||||
let event_id = event["event_id"].as_str().unwrap_or("").to_string();
|
||||
// FIX #2: Detect @mentions in message text.
|
||||
let mut metadata = HashMap::new();
|
||||
if content.contains(&user_id) {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let tok_for_count = tokens.read().await.0.as_str().to_string();
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
&tok_for_count,
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
.map(|count| count > 2)
|
||||
.unwrap_or(true);
|
||||
|
||||
// For DMs, auto-set was_mentioned so dm_policy works.
|
||||
if !is_group {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
metadata.insert("is_dm".to_string(), serde_json::json!(true));
|
||||
}
|
||||
|
||||
// FIX #2: Detect @mentions in message text.
|
||||
let mut metadata = HashMap::new();
|
||||
if content.contains(&user_id) {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let tok_for_count = tokens.read().await.0.as_str().to_string();
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
&tok_for_count,
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
.map(|count| count > 2)
|
||||
.unwrap_or(true);
|
||||
|
||||
// For DMs, auto-set was_mentioned so dm_policy works.
|
||||
if !is_group {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
metadata.insert("is_dm".to_string(), serde_json::json!(true));
|
||||
}
|
||||
|
||||
let channel_msg = ChannelMessage {
|
||||
channel: ChannelType::Matrix,
|
||||
platform_message_id: event_id,
|
||||
platform_message_id: event_id_str,
|
||||
sender: ChannelUser {
|
||||
platform_id: room_id.clone(),
|
||||
display_name: sender.to_string(),
|
||||
@@ -256,9 +634,9 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
content: msg_content,
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: true,
|
||||
is_group,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
metadata,
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
@@ -302,10 +680,11 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
"timeout": 5000,
|
||||
});
|
||||
|
||||
let token = self.current_access_token().await;
|
||||
let _ = self
|
||||
.client
|
||||
.put(&url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await;
|
||||
@@ -330,10 +709,88 @@ mod tests {
|
||||
"@bot:matrix.org".to_string(),
|
||||
"access_token".to_string(),
|
||||
vec![],
|
||||
false,
|
||||
);
|
||||
assert_eq!(adapter.name(), "matrix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_unknown_token_body() {
|
||||
// Real matrix.org body for M_UNKNOWN_TOKEN under MAS.
|
||||
let body = r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Token is not active","soft_logout":true}"#;
|
||||
assert!(is_unknown_token_body(body));
|
||||
assert!(!is_hard_logout(body));
|
||||
|
||||
let hard = r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Invalidated","soft_logout":false}"#;
|
||||
assert!(is_unknown_token_body(hard));
|
||||
assert!(is_hard_logout(hard));
|
||||
|
||||
let other = r#"{"errcode":"M_FORBIDDEN","error":"You are not allowed"}"#;
|
||||
assert!(!is_unknown_token_body(other));
|
||||
assert!(!is_hard_logout(other));
|
||||
|
||||
// Empty / non-JSON must not trigger refresh.
|
||||
assert!(!is_unknown_token_body(""));
|
||||
assert!(!is_unknown_token_body("not json"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_tokens_rotates_pair() {
|
||||
// Spin up a tiny axum server that mimics MSC2918 /refresh: rotates both
|
||||
// access and refresh tokens and returns the new pair.
|
||||
use axum::{routing::post, Json, Router};
|
||||
|
||||
async fn refresh_handler(
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let incoming = body
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
assert_eq!(incoming, "old_refresh");
|
||||
Json(serde_json::json!({
|
||||
"access_token": "new_access",
|
||||
"refresh_token": "new_refresh",
|
||||
"expires_in_ms": 3_600_000u64,
|
||||
}))
|
||||
}
|
||||
|
||||
let app = Router::new().route("/_matrix/client/v3/refresh", post(refresh_handler));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let homeserver = format!("http://{addr}");
|
||||
let tokens: TokenPair = Arc::new(RwLock::new((
|
||||
Zeroizing::new("old_access".to_string()),
|
||||
Some(Zeroizing::new("old_refresh".to_string())),
|
||||
)));
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
try_refresh_tokens(&client, &homeserver, &tokens)
|
||||
.await
|
||||
.expect("refresh succeeds");
|
||||
|
||||
let guard = tokens.read().await;
|
||||
assert_eq!(guard.0.as_str(), "new_access");
|
||||
assert_eq!(guard.1.as_ref().map(|s| s.as_str()), Some("new_refresh"));
|
||||
drop(guard);
|
||||
|
||||
// Refresh with no refresh token configured must fail cleanly.
|
||||
let no_refresh: TokenPair = Arc::new(RwLock::new((
|
||||
Zeroizing::new("a".to_string()),
|
||||
None,
|
||||
)));
|
||||
let err = try_refresh_tokens(&client, &homeserver, &no_refresh)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.contains("no refresh token"));
|
||||
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matrix_allowed_rooms() {
|
||||
let adapter = MatrixAdapter::new(
|
||||
@@ -341,6 +798,7 @@ mod tests {
|
||||
"@bot:matrix.org".to_string(),
|
||||
"token".to_string(),
|
||||
vec!["!room1:matrix.org".to_string()],
|
||||
false,
|
||||
);
|
||||
assert!(adapter.is_allowed_room("!room1:matrix.org"));
|
||||
assert!(!adapter.is_allowed_room("!room2:matrix.org"));
|
||||
@@ -350,6 +808,7 @@ mod tests {
|
||||
"@bot:matrix.org".to_string(),
|
||||
"token".to_string(),
|
||||
vec![],
|
||||
false,
|
||||
);
|
||||
assert!(open.is_allowed_room("!any:matrix.org"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
//! MQTT channel adapter.
|
||||
//!
|
||||
//! Provides a generic MQTT pub/sub interface for IoT and messaging integration.
|
||||
//! Supports standard MQTT 3.1.1/5.0 brokers with optional TLS and authentication.
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! ```toml
|
||||
//! [channels.mqtt]
|
||||
//! broker_url = "tcp://broker.hivemq.com:1883"
|
||||
//! subscribe_topic = "openfang/inbox"
|
||||
//! publish_topic = "openfang/outbox"
|
||||
//! username_env = "MQTT_USERNAME"
|
||||
//! password_env = "MQTT_PASSWORD"
|
||||
//! use_tls = false
|
||||
//! qos = 1
|
||||
//! ```
|
||||
//!
|
||||
//! # Message Format
|
||||
//!
|
||||
//! Incoming messages are expected as UTF-8 text. The adapter supports:
|
||||
//! - Plain text messages
|
||||
//! - JSON payloads with `{"text": "message"}` format
|
||||
//! - Command messages starting with `/`
|
||||
|
||||
use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use futures::Stream;
|
||||
use rumqttc::{AsyncClient, Event, Incoming, MqttOptions, QoS};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Maximum MQTT message length.
|
||||
const MAX_MESSAGE_LEN: usize = 4096;
|
||||
|
||||
/// Type alias for the publish channel sender.
|
||||
type PublishSender = Arc<RwLock<Option<mpsc::Sender<(String, String)>>>>;
|
||||
|
||||
/// MQTT pub/sub channel adapter.
|
||||
///
|
||||
/// Connects to an MQTT broker, subscribes to a topic for incoming messages,
|
||||
/// and publishes responses to another topic.
|
||||
pub struct MqttAdapter {
|
||||
/// MQTT broker URL (e.g., `"tcp://broker.hivemq.com:1883"`).
|
||||
broker_url: String,
|
||||
/// Client identifier (auto-generated if empty).
|
||||
client_id: String,
|
||||
/// Topic to subscribe to for incoming messages.
|
||||
subscribe_topic: String,
|
||||
/// Topic to publish responses to.
|
||||
publish_topic: String,
|
||||
/// Optional username for authentication.
|
||||
username: Option<String>,
|
||||
/// Optional password for authentication.
|
||||
password: Option<String>,
|
||||
/// Use TLS/SSL connection.
|
||||
use_tls: bool,
|
||||
/// Keep-alive interval in seconds.
|
||||
keep_alive: u16,
|
||||
/// Clean session flag.
|
||||
clean_session: bool,
|
||||
/// QoS level for subscriptions.
|
||||
qos: QoS,
|
||||
/// Shutdown signal.
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Sender for publishing messages (used to communicate with the event loop task).
|
||||
publish_tx: PublishSender,
|
||||
}
|
||||
|
||||
impl MqttAdapter {
|
||||
/// Create a new MQTT adapter.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `broker_url` - MQTT broker URL (e.g., `"tcp://broker.hivemq.com:1883"`).
|
||||
/// * `client_id` - Client identifier (auto-generated if empty).
|
||||
/// * `subscribe_topic` - Topic to subscribe to for incoming messages.
|
||||
/// * `publish_topic` - Topic to publish responses to (defaults to subscribe_topic if empty).
|
||||
/// * `username` - Optional username for authentication.
|
||||
/// * `password` - Optional password for authentication.
|
||||
/// * `use_tls` - Use TLS/SSL connection.
|
||||
/// * `keep_alive` - Keep-alive interval in seconds.
|
||||
/// * `clean_session` - Clean session flag.
|
||||
/// * `qos` - QoS level (0, 1, or 2).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
broker_url: String,
|
||||
client_id: String,
|
||||
subscribe_topic: String,
|
||||
publish_topic: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
use_tls: bool,
|
||||
keep_alive: u16,
|
||||
clean_session: bool,
|
||||
qos: u8,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let publish_topic = if publish_topic.is_empty() {
|
||||
subscribe_topic.clone()
|
||||
} else {
|
||||
publish_topic
|
||||
};
|
||||
let qos = match qos {
|
||||
0 => QoS::AtMostOnce,
|
||||
2 => QoS::ExactlyOnce,
|
||||
_ => QoS::AtLeastOnce,
|
||||
};
|
||||
|
||||
Self {
|
||||
broker_url,
|
||||
client_id,
|
||||
subscribe_topic,
|
||||
publish_topic,
|
||||
username,
|
||||
password,
|
||||
use_tls,
|
||||
keep_alive,
|
||||
clean_session,
|
||||
qos,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
publish_tx: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse broker URL into host and port.
|
||||
fn parse_broker_url(&self) -> Result<(String, u16), Box<dyn std::error::Error>> {
|
||||
let url = self.broker_url.trim();
|
||||
|
||||
// Handle different URL schemes
|
||||
if let Some(rest) = url.strip_prefix("tcp://") {
|
||||
Self::parse_host_port(rest, 1883)
|
||||
} else if let Some(rest) = url.strip_prefix("ssl://") {
|
||||
Self::parse_host_port(rest, 8883)
|
||||
} else if self.use_tls && !url.contains("://") {
|
||||
// Plain host with TLS flag
|
||||
Self::parse_host_port(url, 8883)
|
||||
} else if url.contains("://") {
|
||||
Err(format!("Unsupported MQTT URL scheme: {url}").into())
|
||||
} else {
|
||||
// Plain host:port or just host (no TLS)
|
||||
Self::parse_host_port(url, 1883)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse host:port string.
|
||||
fn parse_host_port(
|
||||
s: &str,
|
||||
default_port: u16,
|
||||
) -> Result<(String, u16), Box<dyn std::error::Error>> {
|
||||
let s = s.trim();
|
||||
if let Some(colon_pos) = s.rfind(':') {
|
||||
let host = s[..colon_pos].to_string();
|
||||
let port = s[colon_pos + 1..].parse::<u16>()?;
|
||||
Ok((host, port))
|
||||
} else {
|
||||
Ok((s.to_string(), default_port))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build MQTT options.
|
||||
fn build_mqtt_options(&self) -> Result<MqttOptions, Box<dyn std::error::Error>> {
|
||||
let (host, port) = self.parse_broker_url()?;
|
||||
let client_id = if self.client_id.is_empty() {
|
||||
format!("openfang-{}", uuid::Uuid::new_v4())
|
||||
} else {
|
||||
self.client_id.clone()
|
||||
};
|
||||
|
||||
let mut options = MqttOptions::new(client_id, host, port);
|
||||
options.set_keep_alive(Duration::from_secs(self.keep_alive as u64));
|
||||
options.set_clean_session(self.clean_session);
|
||||
|
||||
if let (Some(user), Some(pass)) = (&self.username, &self.password) {
|
||||
options.set_credentials(user, pass);
|
||||
}
|
||||
|
||||
// Note: TLS support requires additional configuration with rustls
|
||||
// For now, we use native TLS through the use_tls flag
|
||||
if self.use_tls {
|
||||
// rumqttc handles TLS automatically when using ssl:// or with explicit config
|
||||
// This is a simplified approach; production use may need custom TLS config
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
/// Parse incoming MQTT payload.
|
||||
fn parse_payload(payload: &[u8]) -> Option<String> {
|
||||
if payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try UTF-8 first
|
||||
if let Ok(text) = std::str::from_utf8(payload) {
|
||||
// Check for JSON format {"text": "message"}
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(text) {
|
||||
if let Some(text_val) = json.get("text").and_then(|v| v.as_str()) {
|
||||
return Some(text_val.to_string());
|
||||
}
|
||||
}
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Publish a message to the configured topic.
|
||||
async fn publish_message(&self, text: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tx_guard = self.publish_tx.read().await;
|
||||
if let Some(tx) = tx_guard.as_ref() {
|
||||
let chunks = split_message(text, MAX_MESSAGE_LEN);
|
||||
for chunk in chunks {
|
||||
tx.send((self.publish_topic.clone(), chunk.to_string()))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send publish request: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err("MQTT client not connected".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for MqttAdapter {
|
||||
fn name(&self) -> &str {
|
||||
"mqtt"
|
||||
}
|
||||
|
||||
fn channel_type(&self) -> ChannelType {
|
||||
ChannelType::Mqtt
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
let options = self.build_mqtt_options()?;
|
||||
let (client, mut eventloop) = AsyncClient::new(options, 10);
|
||||
|
||||
info!(
|
||||
"MQTT adapter connecting to {} (subscribe: {}, publish: {})",
|
||||
self.broker_url, self.subscribe_topic, self.publish_topic
|
||||
);
|
||||
|
||||
// Subscribe to topic
|
||||
client.subscribe(&self.subscribe_topic, self.qos).await?;
|
||||
|
||||
// Channel for incoming messages
|
||||
let (msg_tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
|
||||
// Channel for outgoing publish requests
|
||||
let (publish_tx, mut publish_rx) = mpsc::channel::<(String, String)>(64);
|
||||
|
||||
// Store the publish sender
|
||||
{
|
||||
let mut tx_guard = self.publish_tx.write().await;
|
||||
*tx_guard = Some(publish_tx);
|
||||
}
|
||||
|
||||
let subscribe_topic = self.subscribe_topic.clone();
|
||||
let qos = self.qos;
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
|
||||
// Spawn the event loop task
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
let max_backoff = Duration::from_secs(60);
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("MQTT adapter shutting down");
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("MQTT adapter shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
publish_req = publish_rx.recv() => {
|
||||
if let Some((topic, payload)) = publish_req {
|
||||
if let Err(e) = client.publish(&topic, qos, false, payload).await {
|
||||
warn!("MQTT publish error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
event = eventloop.poll() => {
|
||||
match event {
|
||||
Ok(Event::Incoming(Incoming::Publish(publish))) => {
|
||||
backoff = Duration::from_secs(1); // Reset backoff on success
|
||||
|
||||
let topic = publish.topic.clone();
|
||||
if topic != subscribe_topic {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(text) = Self::parse_payload(&publish.payload) {
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = if text.starts_with('/') {
|
||||
let parts: Vec<&str> = text.splitn(2, ' ').collect();
|
||||
let cmd = parts[0].trim_start_matches('/');
|
||||
let args: Vec<String> = parts
|
||||
.get(1)
|
||||
.map(|a| {
|
||||
a.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ChannelContent::Command {
|
||||
name: cmd.to_string(),
|
||||
args,
|
||||
}
|
||||
} else {
|
||||
ChannelContent::Text(text)
|
||||
};
|
||||
|
||||
let msg = ChannelMessage {
|
||||
channel: ChannelType::Mqtt,
|
||||
platform_message_id: format!("{:?}", publish.pkid),
|
||||
sender: ChannelUser {
|
||||
platform_id: "mqtt-user".to_string(),
|
||||
display_name: "MQTT User".to_string(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content,
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: true,
|
||||
thread_id: None,
|
||||
metadata: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(
|
||||
"topic".to_string(),
|
||||
serde_json::Value::String(topic.clone()),
|
||||
);
|
||||
m.insert(
|
||||
"qos".to_string(),
|
||||
serde_json::Value::Number((publish.qos as i64).into()),
|
||||
);
|
||||
m
|
||||
},
|
||||
};
|
||||
|
||||
if msg_tx.send(msg).await.is_err() {
|
||||
info!("MQTT receiver dropped, stopping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Incoming(Incoming::ConnAck(_))) => {
|
||||
info!("MQTT connected to broker");
|
||||
backoff = Duration::from_secs(1);
|
||||
}
|
||||
Ok(Event::Incoming(Incoming::Disconnect)) => {
|
||||
warn!("MQTT disconnected from broker");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("MQTT connection error: {}, backing off for {:?}", e, backoff);
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(max_backoff);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("MQTT event loop stopped");
|
||||
});
|
||||
|
||||
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&self,
|
||||
_user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let text = match content {
|
||||
ChannelContent::Text(t) => t,
|
||||
ChannelContent::Command { name, args } => {
|
||||
if args.is_empty() {
|
||||
format!("/{name}")
|
||||
} else {
|
||||
format!("/{} {}", name, args.join(" "))
|
||||
}
|
||||
}
|
||||
_ => "(Unsupported content type)".to_string(),
|
||||
};
|
||||
self.publish_message(&text).await
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// MQTT has no typing indicator concept.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
|
||||
// Clear the publish channel
|
||||
let mut tx_guard = self.publish_tx.write().await;
|
||||
*tx_guard = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mqtt_adapter_creation() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.hivemq.com:1883".to_string(),
|
||||
"test-client".to_string(),
|
||||
"test/topic".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
assert_eq!(adapter.name(), "mqtt");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Mqtt);
|
||||
assert_eq!(adapter.subscribe_topic, "test/topic");
|
||||
assert_eq!(adapter.publish_topic, "test/topic"); // Falls back to subscribe_topic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mqtt_adapter_with_separate_publish_topic() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.hivemq.com:1883".to_string(),
|
||||
String::new(),
|
||||
"inbox".to_string(),
|
||||
"outbox".to_string(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
assert_eq!(adapter.subscribe_topic, "inbox");
|
||||
assert_eq!(adapter.publish_topic, "outbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_tcp() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com:1883".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 1883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_tcp_default_port() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 1883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_ssl() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"ssl://broker.example.com:8883".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 8883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_plain_host() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 1883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_payload_text() {
|
||||
let payload = b"Hello, MQTT!";
|
||||
let result = MqttAdapter::parse_payload(payload);
|
||||
assert_eq!(result, Some("Hello, MQTT!".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_payload_json() {
|
||||
let payload = br#"{"text": "Hello from JSON"}"#;
|
||||
let result = MqttAdapter::parse_payload(payload);
|
||||
assert_eq!(result, Some("Hello from JSON".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_payload_empty() {
|
||||
let payload = b"";
|
||||
let result = MqttAdapter::parse_payload(payload);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qos_conversion() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
0,
|
||||
);
|
||||
assert_eq!(adapter.qos, QoS::AtMostOnce);
|
||||
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
assert_eq!(adapter.qos, QoS::AtLeastOnce);
|
||||
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
2,
|
||||
);
|
||||
assert_eq!(adapter.qos, QoS::ExactlyOnce);
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,7 @@ impl ChannelAdapter for NextcloudAdapter {
|
||||
|
||||
// Use lookIntoFuture=1 and lastKnownMessageId for incremental polling
|
||||
let url = format!(
|
||||
"{}/ocs/v2.php/apps/spreed/api/v4/room/{}/chat?format=json&lookIntoFuture=1&limit=100&lastKnownMessageId={}",
|
||||
"{}/ocs/v2.php/apps/spreed/api/v1/chat/{}?format=json&lookIntoFuture=1&limit=100&lastKnownMessageId={}",
|
||||
server_url, room_token, last_id
|
||||
);
|
||||
|
||||
|
||||
@@ -165,7 +165,10 @@ impl ChannelAdapter for NostrAdapter {
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
let pubkey = self.derive_pubkey();
|
||||
info!("Nostr adapter starting (pubkey: {}...)", &pubkey[..16]);
|
||||
info!(
|
||||
"Nostr adapter starting (pubkey: {}...)",
|
||||
openfang_types::truncate_str(&pubkey, 16)
|
||||
);
|
||||
|
||||
if self.relays.is_empty() {
|
||||
return Err("Nostr: no relay URLs configured".into());
|
||||
@@ -339,7 +342,7 @@ impl ChannelAdapter for NostrAdapter {
|
||||
platform_id: sender_pubkey.clone(),
|
||||
display_name: format!(
|
||||
"{}...",
|
||||
&sender_pubkey[..8.min(sender_pubkey.len())]
|
||||
openfang_types::truncate_str(&sender_pubkey, 8)
|
||||
),
|
||||
openfang_user: None,
|
||||
},
|
||||
|
||||
@@ -94,6 +94,11 @@ impl RevoltAdapter {
|
||||
adapter
|
||||
}
|
||||
|
||||
/// Set allowed channel IDs (empty = all channels the bot is in).
|
||||
pub fn set_allowed_channels(&mut self, channels: Vec<String>) {
|
||||
self.allowed_channels = channels;
|
||||
}
|
||||
|
||||
/// Add the bot token header to a request builder.
|
||||
fn auth_header(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("x-bot-token", self.bot_token.as_str())
|
||||
|
||||
@@ -18,6 +18,10 @@ pub struct BindingContext {
|
||||
pub peer_id: String,
|
||||
/// Guild/server ID.
|
||||
pub guild_id: Option<String>,
|
||||
/// Channel/conversation ID (e.g. Discord channel, Slack conversation,
|
||||
/// Telegram chat, IRC channel name). Populated by bridges so bindings can
|
||||
/// route by room independent of which user posted.
|
||||
pub channel_id: Option<String>,
|
||||
/// User's roles.
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
@@ -34,6 +38,8 @@ pub struct AgentRouter {
|
||||
default_agent: Option<AgentId>,
|
||||
/// Per-channel-type default agent (e.g., Telegram -> agent_a, Discord -> agent_b).
|
||||
channel_defaults: DashMap<String, AgentId>,
|
||||
/// Per-channel-type default agent *name* (for re-resolution when UUID becomes stale).
|
||||
channel_default_names: DashMap<String, String>,
|
||||
/// Sorted bindings (most specific first). Uses Mutex for runtime updates via Arc.
|
||||
bindings: Mutex<Vec<(AgentBinding, String)>>,
|
||||
/// Broadcast configuration. Uses Mutex for runtime updates via Arc.
|
||||
@@ -50,6 +56,7 @@ impl AgentRouter {
|
||||
direct_routes: DashMap::new(),
|
||||
default_agent: None,
|
||||
channel_defaults: DashMap::new(),
|
||||
channel_default_names: DashMap::new(),
|
||||
bindings: Mutex::new(Vec::new()),
|
||||
broadcast: Mutex::new(BroadcastConfig::default()),
|
||||
agent_name_cache: DashMap::new(),
|
||||
@@ -66,6 +73,31 @@ impl AgentRouter {
|
||||
self.channel_defaults.insert(channel_key, agent_id);
|
||||
}
|
||||
|
||||
/// Set a per-channel-type default agent AND remember the agent name for
|
||||
/// re-resolution when the cached UUID becomes stale (e.g. after agent restart).
|
||||
pub fn set_channel_default_with_name(
|
||||
&self,
|
||||
channel_key: String,
|
||||
agent_id: AgentId,
|
||||
agent_name: String,
|
||||
) {
|
||||
self.channel_defaults.insert(channel_key.clone(), agent_id);
|
||||
self.channel_default_names.insert(channel_key, agent_name);
|
||||
}
|
||||
|
||||
/// Retrieve the stored agent name for a channel default (if any).
|
||||
pub fn channel_default_name(&self, channel_key: &str) -> Option<String> {
|
||||
self.channel_default_names
|
||||
.get(channel_key)
|
||||
.map(|r| r.clone())
|
||||
}
|
||||
|
||||
/// Update the cached agent ID for a channel default (after re-resolution).
|
||||
pub fn update_channel_default(&self, channel_key: &str, new_agent_id: AgentId) {
|
||||
self.channel_defaults
|
||||
.insert(channel_key.to_string(), new_agent_id);
|
||||
}
|
||||
|
||||
/// Set a user's default agent.
|
||||
pub fn set_user_default(&self, user_key: String, agent_id: AgentId) {
|
||||
self.user_defaults.insert(user_key, agent_id);
|
||||
@@ -115,6 +147,19 @@ impl AgentRouter {
|
||||
channel_type: &ChannelType,
|
||||
platform_user_id: &str,
|
||||
user_key: Option<&str>,
|
||||
) -> Option<AgentId> {
|
||||
self.resolve_with_channel_id(channel_type, platform_user_id, user_key, None)
|
||||
}
|
||||
|
||||
/// Resolve with an explicit channel/conversation ID, so bindings whose
|
||||
/// `match_rule.channel_id` is set can match. Used by bridges that know the
|
||||
/// room/conversation the message arrived in (Discord/Slack/Telegram/IRC).
|
||||
pub fn resolve_with_channel_id(
|
||||
&self,
|
||||
channel_type: &ChannelType,
|
||||
platform_user_id: &str,
|
||||
user_key: Option<&str>,
|
||||
channel_id: Option<&str>,
|
||||
) -> Option<AgentId> {
|
||||
let channel_key = format!("{channel_type:?}");
|
||||
|
||||
@@ -124,6 +169,7 @@ impl AgentRouter {
|
||||
account_id: None,
|
||||
peer_id: platform_user_id.to_string(),
|
||||
guild_id: None,
|
||||
channel_id: channel_id.map(|s| s.to_string()),
|
||||
roles: Vec::new(),
|
||||
};
|
||||
if let Some(agent_id) = self.resolve_binding(&ctx) {
|
||||
@@ -301,6 +347,11 @@ impl AgentRouter {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(ref cid) = rule.channel_id {
|
||||
if ctx.channel_id.as_ref() != Some(cid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if !rule.roles.is_empty() {
|
||||
// User must have at least one of the specified roles
|
||||
let has_role = rule.roles.iter().any(|r| ctx.roles.contains(r));
|
||||
@@ -327,6 +378,7 @@ fn channel_type_to_str(ct: &ChannelType) -> &str {
|
||||
ChannelType::WebChat => "webchat",
|
||||
ChannelType::CLI => "cli",
|
||||
ChannelType::Custom(s) => s.as_str(),
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,7 +662,128 @@ mod tests {
|
||||
guild_id: Some("guild".to_string()),
|
||||
roles: vec!["admin".to_string()],
|
||||
account_id: Some("bot".to_string()),
|
||||
channel_id: Some("ch_42".to_string()),
|
||||
};
|
||||
assert_eq!(full.specificity(), 17); // 8+4+2+2+1
|
||||
assert_eq!(full.specificity(), 25); // 8+8+4+2+2+1
|
||||
|
||||
// peer_id alone vs channel_id alone — both worth 8.
|
||||
let peer_only = BindingMatchRule {
|
||||
peer_id: Some("u".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let channel_id_only = BindingMatchRule {
|
||||
channel_id: Some("c".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(peer_only.specificity(), 8);
|
||||
assert_eq!(channel_id_only.specificity(), 8);
|
||||
|
||||
// Combined peer_id + channel_id (16) outranks either alone (8).
|
||||
let peer_and_channel = BindingMatchRule {
|
||||
peer_id: Some("u".to_string()),
|
||||
channel_id: Some("c".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(peer_and_channel.specificity(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_channel_id_match() {
|
||||
// A binding scoped to a specific Discord channel should match messages
|
||||
// from that channel and reject messages from other channels.
|
||||
let router = AgentRouter::new();
|
||||
let agent_id = AgentId::new();
|
||||
router.register_agent("ops-bot".to_string(), agent_id);
|
||||
router.load_bindings(&[AgentBinding {
|
||||
agent: "ops-bot".to_string(),
|
||||
match_rule: openfang_types::config::BindingMatchRule {
|
||||
channel: Some("discord".to_string()),
|
||||
channel_id: Some("1477803840265781391".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
}]);
|
||||
|
||||
// Same channel, any user — matches.
|
||||
let resolved = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"any-user",
|
||||
None,
|
||||
Some("1477803840265781391"),
|
||||
);
|
||||
assert_eq!(resolved, Some(agent_id));
|
||||
|
||||
// Different channel — no match.
|
||||
let resolved = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"any-user",
|
||||
None,
|
||||
Some("9999999999999999999"),
|
||||
);
|
||||
assert_eq!(resolved, None);
|
||||
|
||||
// Missing channel_id on the wire — no match (the binding is restrictive).
|
||||
let resolved = router.resolve_with_channel_id(&ChannelType::Discord, "any-user", None, None);
|
||||
assert_eq!(resolved, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_channel_id_plus_peer_outranks_channel_id_alone() {
|
||||
// user A in #medical → researcher; anyone else in #medical → general.
|
||||
let router = AgentRouter::new();
|
||||
let researcher = AgentId::new();
|
||||
let general = AgentId::new();
|
||||
router.register_agent("researcher".to_string(), researcher);
|
||||
router.register_agent("general".to_string(), general);
|
||||
router.load_bindings(&[
|
||||
AgentBinding {
|
||||
agent: "general".to_string(),
|
||||
match_rule: openfang_types::config::BindingMatchRule {
|
||||
channel_id: Some("ch-medical".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
AgentBinding {
|
||||
agent: "researcher".to_string(),
|
||||
match_rule: openfang_types::config::BindingMatchRule {
|
||||
channel_id: Some("ch-medical".to_string()),
|
||||
peer_id: Some("user-a".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// user-a in #medical → researcher (more specific wins)
|
||||
let r = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"user-a",
|
||||
None,
|
||||
Some("ch-medical"),
|
||||
);
|
||||
assert_eq!(r, Some(researcher));
|
||||
|
||||
// user-b in #medical → general (channel_id alone matches)
|
||||
let r = router.resolve_with_channel_id(
|
||||
&ChannelType::Discord,
|
||||
"user-b",
|
||||
None,
|
||||
Some("ch-medical"),
|
||||
);
|
||||
assert_eq!(r, Some(general));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binding_match_rule_unknown_field_rejected() {
|
||||
// Typos like `channnel_id` must fail loudly at deserialization rather
|
||||
// than silently producing a wide-open binding. This is the highest-
|
||||
// leverage line in the patch from issue #1127.
|
||||
let bad = r#"{ "channnel_id": "ch-1" }"#;
|
||||
let r: Result<openfang_types::config::BindingMatchRule, _> = serde_json::from_str(bad);
|
||||
assert!(r.is_err(), "unknown field must be rejected by serde");
|
||||
|
||||
// Sanity: known fields still parse.
|
||||
let good = r#"{ "channel_id": "ch-1", "channel": "discord" }"#;
|
||||
let r: openfang_types::config::BindingMatchRule = serde_json::from_str(good).unwrap();
|
||||
assert_eq!(r.channel_id.as_deref(), Some("ch-1"));
|
||||
assert_eq!(r.channel.as_deref(), Some("discord"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use futures::{SinkExt, Stream, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
@@ -20,6 +21,38 @@ const SLACK_API_BASE: &str = "https://slack.com/api";
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(60);
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
const SLACK_MSG_LIMIT: usize = 3000;
|
||||
/// TTL for envelope_id dedup entries. Well above the typical Slack
|
||||
/// connection-rotation overlap window (< 10s).
|
||||
const ENVELOPE_TTL: Duration = Duration::from_secs(60);
|
||||
/// Soft cap on the dedup cache size. When exceeded we GC expired entries.
|
||||
/// Recent envelope IDs are not reused by Slack, so 10k is more than enough.
|
||||
const ENVELOPE_CACHE_CAP: usize = 10_000;
|
||||
|
||||
/// Returns true if `envelope_id` was already seen within `ENVELOPE_TTL`.
|
||||
/// On first sight, records the timestamp and returns false. Performs
|
||||
/// opportunistic GC of expired entries when the cache grows large.
|
||||
///
|
||||
/// Slack Socket Mode delivers the same event to multiple active WebSocket
|
||||
/// connections during connection rotation. Apps must dedupe on `envelope_id`
|
||||
/// to avoid double-processing.
|
||||
fn is_duplicate_envelope(cache: &DashMap<String, Instant>, envelope_id: &str) -> bool {
|
||||
if envelope_id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Opportunistic GC: bound growth without per-call work.
|
||||
if cache.len() > ENVELOPE_CACHE_CAP {
|
||||
cache.retain(|_, ts| ts.elapsed() < ENVELOPE_TTL);
|
||||
}
|
||||
|
||||
if let Some(prev) = cache.get(envelope_id) {
|
||||
if prev.elapsed() < ENVELOPE_TTL {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
cache.insert(envelope_id.to_string(), Instant::now());
|
||||
false
|
||||
}
|
||||
|
||||
/// Slack Socket Mode adapter.
|
||||
pub struct SlackAdapter {
|
||||
@@ -32,10 +65,28 @@ pub struct SlackAdapter {
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Bot's own user ID (populated after auth.test).
|
||||
bot_user_id: Arc<RwLock<Option<String>>>,
|
||||
/// Threads where the bot was @-mentioned. Maps thread_ts -> last interaction time.
|
||||
active_threads: Arc<DashMap<String, Instant>>,
|
||||
/// How long to track a thread after last interaction.
|
||||
thread_ttl: Duration,
|
||||
/// Whether auto-thread-reply is enabled.
|
||||
auto_thread_reply: bool,
|
||||
/// Whether to unfurl (expand previews for) links in posted messages.
|
||||
unfurl_links: bool,
|
||||
/// Recently-seen envelope_ids. Slack Socket Mode redelivers the same event
|
||||
/// across rotated WebSocket connections; this prevents double-processing.
|
||||
seen_envelopes: Arc<DashMap<String, Instant>>,
|
||||
}
|
||||
|
||||
impl SlackAdapter {
|
||||
pub fn new(app_token: String, bot_token: String, allowed_channels: Vec<String>) -> Self {
|
||||
pub fn new(
|
||||
app_token: String,
|
||||
bot_token: String,
|
||||
allowed_channels: Vec<String>,
|
||||
auto_thread_reply: bool,
|
||||
thread_ttl_hours: u64,
|
||||
unfurl_links: bool,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
app_token: Zeroizing::new(app_token),
|
||||
@@ -45,6 +96,11 @@ impl SlackAdapter {
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
bot_user_id: Arc::new(RwLock::new(None)),
|
||||
active_threads: Arc::new(DashMap::new()),
|
||||
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
|
||||
auto_thread_reply,
|
||||
unfurl_links,
|
||||
seen_envelopes: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,14 +132,20 @@ impl SlackAdapter {
|
||||
&self,
|
||||
channel_id: &str,
|
||||
text: &str,
|
||||
thread_ts: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let chunks = split_message(text, SLACK_MSG_LIMIT);
|
||||
|
||||
for chunk in chunks {
|
||||
let body = serde_json::json!({
|
||||
let mut body = serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": chunk,
|
||||
"unfurl_links": self.unfurl_links,
|
||||
"unfurl_media": self.unfurl_links,
|
||||
});
|
||||
if let Some(ts) = thread_ts {
|
||||
body["thread_ts"] = serde_json::json!(ts);
|
||||
}
|
||||
|
||||
let resp: serde_json::Value = self
|
||||
.client
|
||||
@@ -133,6 +195,31 @@ impl ChannelAdapter for SlackAdapter {
|
||||
let allowed_channels = self.allowed_channels.clone();
|
||||
let client = self.client.clone();
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
let active_threads = self.active_threads.clone();
|
||||
let auto_thread_reply = self.auto_thread_reply;
|
||||
let seen_envelopes = self.seen_envelopes.clone();
|
||||
|
||||
// Spawn periodic cleanup of expired thread entries.
|
||||
{
|
||||
let active_threads = self.active_threads.clone();
|
||||
let thread_ttl = self.thread_ttl;
|
||||
let mut cleanup_shutdown = self.shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
active_threads.retain(|_, last| last.elapsed() < thread_ttl);
|
||||
}
|
||||
_ = cleanup_shutdown.changed() => {
|
||||
if *cleanup_shutdown.borrow() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = INITIAL_BACKOFF;
|
||||
@@ -238,10 +325,26 @@ impl ChannelAdapter for SlackAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup: Slack redelivers the same event on the new
|
||||
// connection during the rotation overlap. Ack on
|
||||
// both, but only forward to the agent once.
|
||||
if is_duplicate_envelope(&seen_envelopes, envelope_id) {
|
||||
debug!(
|
||||
"Slack: skipping duplicate envelope_id {envelope_id}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the event
|
||||
let event = &payload["payload"]["event"];
|
||||
if let Some(msg) =
|
||||
parse_slack_event(event, &bot_user_id, &allowed_channels).await
|
||||
if let Some(msg) = parse_slack_event(
|
||||
event,
|
||||
&bot_user_id,
|
||||
&allowed_channels,
|
||||
&active_threads,
|
||||
auto_thread_reply,
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
"Slack message from {}: {:?}",
|
||||
@@ -289,10 +392,30 @@ impl ChannelAdapter for SlackAdapter {
|
||||
let channel_id = &user.platform_id;
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(channel_id, &text).await?;
|
||||
self.api_send_message(channel_id, &text, None).await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(channel_id, "(Unsupported content type)")
|
||||
self.api_send_message(channel_id, "(Unsupported content type)", None)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_in_thread(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
thread_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let channel_id = &user.platform_id;
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(channel_id, &text, Some(thread_id))
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(channel_id, "(Unsupported content type)", Some(thread_id))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
@@ -335,9 +458,11 @@ async fn parse_slack_event(
|
||||
event: &serde_json::Value,
|
||||
bot_user_id: &Arc<RwLock<Option<String>>>,
|
||||
allowed_channels: &[String],
|
||||
active_threads: &Arc<DashMap<String, Instant>>,
|
||||
auto_thread_reply: bool,
|
||||
) -> Option<ChannelMessage> {
|
||||
let event_type = event["type"].as_str()?;
|
||||
if event_type != "message" {
|
||||
if event_type != "message" && event_type != "app_mention" {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -413,6 +538,53 @@ async fn parse_slack_event(
|
||||
ChannelContent::Text(text.to_string())
|
||||
};
|
||||
|
||||
// Extract thread_id: threaded replies have `thread_ts`, top-level messages
|
||||
// use their own `ts` so the reply will start a thread under the original.
|
||||
let thread_id = msg_data["thread_ts"]
|
||||
.as_str()
|
||||
.or_else(|| event["thread_ts"].as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| Some(ts.to_string()));
|
||||
|
||||
// Check if the bot was @-mentioned (for group_policy = "mention_only")
|
||||
let mut metadata = HashMap::new();
|
||||
// Stash the Slack user ID so the router can key bindings on user, not channel.
|
||||
// (`sender.platform_id` below is the channel ID, used for the send path.)
|
||||
metadata.insert("sender_user_id".to_string(), serde_json::json!(user_id));
|
||||
if event_type == "app_mention" {
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true));
|
||||
}
|
||||
|
||||
// Determine the real thread_ts from the event (None for top-level messages).
|
||||
let real_thread_ts = msg_data["thread_ts"]
|
||||
.as_str()
|
||||
.or_else(|| event["thread_ts"].as_str());
|
||||
|
||||
let mut explicitly_mentioned = false;
|
||||
if let Some(ref bid) = *bot_user_id.read().await {
|
||||
let mention_tag = format!("<@{bid}>");
|
||||
if text.contains(&mention_tag) {
|
||||
explicitly_mentioned = true;
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
|
||||
|
||||
// Track thread for auto-reply on subsequent messages.
|
||||
if let Some(tts) = real_thread_ts {
|
||||
active_threads.insert(tts.to_string(), Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-reply to follow-up messages in tracked threads.
|
||||
if !explicitly_mentioned && auto_thread_reply {
|
||||
if let Some(tts) = real_thread_ts {
|
||||
if let Some(mut entry) = active_threads.get_mut(tts) {
|
||||
// Refresh TTL and mark as mentioned so dispatch proceeds.
|
||||
*entry = Instant::now();
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Slack,
|
||||
platform_message_id: ts.to_string(),
|
||||
@@ -425,8 +597,8 @@ async fn parse_slack_event(
|
||||
target_agent: None,
|
||||
timestamp,
|
||||
is_group: true,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
thread_id,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -445,7 +617,9 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Slack);
|
||||
assert_eq!(msg.sender.platform_id, "C789");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello agent!"));
|
||||
@@ -463,7 +637,7 @@ mod tests {
|
||||
"bot_id": "B999"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -478,7 +652,7 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -494,12 +668,25 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed channels
|
||||
let msg =
|
||||
parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()]).await;
|
||||
let msg = parse_slack_event(
|
||||
&event,
|
||||
&bot_id,
|
||||
&["C111".to_string(), "C222".to_string()],
|
||||
&Arc::new(DashMap::new()),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed channels
|
||||
let msg = parse_slack_event(&event, &bot_id, &["C789".to_string()]).await;
|
||||
let msg = parse_slack_event(
|
||||
&event,
|
||||
&bot_id,
|
||||
&["C789".to_string()],
|
||||
&Arc::new(DashMap::new()),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -516,7 +703,7 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -531,7 +718,9 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -556,7 +745,9 @@ mod tests {
|
||||
"ts": "1700000001.000200"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Slack);
|
||||
assert_eq!(msg.sender.platform_id, "C789");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message text"));
|
||||
@@ -568,8 +759,91 @@ mod tests {
|
||||
"xapp-test".to_string(),
|
||||
"xoxb-test".to_string(),
|
||||
vec!["C123".to_string()],
|
||||
true,
|
||||
24,
|
||||
true,
|
||||
);
|
||||
assert_eq!(adapter.name(), "slack");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Slack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slack_adapter_unfurl_links_enabled() {
|
||||
let adapter = SlackAdapter::new(
|
||||
"xapp-test".to_string(),
|
||||
"xoxb-test".to_string(),
|
||||
vec![],
|
||||
true,
|
||||
24,
|
||||
true,
|
||||
);
|
||||
assert!(adapter.unfurl_links);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slack_adapter_unfurl_links_disabled() {
|
||||
let adapter = SlackAdapter::new(
|
||||
"xapp-test".to_string(),
|
||||
"xoxb-test".to_string(),
|
||||
vec![],
|
||||
true,
|
||||
24,
|
||||
false,
|
||||
);
|
||||
assert!(!adapter.unfurl_links);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_envelope_dedup_skips_second_delivery() {
|
||||
// Simulates Slack redelivering the same event across a connection
|
||||
// rotation: the envelope is acked on both connections but the agent
|
||||
// must only see it once.
|
||||
let cache: DashMap<String, Instant> = DashMap::new();
|
||||
let envelope_id = "8d2e1c5a-4f3b-49a1-b6e2-7c0a9f1234ab";
|
||||
|
||||
// First delivery on the old connection: not a duplicate, forward.
|
||||
assert!(
|
||||
!is_duplicate_envelope(&cache, envelope_id),
|
||||
"first sight of envelope must not be flagged as duplicate"
|
||||
);
|
||||
|
||||
// Second delivery on the new connection: duplicate, skip.
|
||||
assert!(
|
||||
is_duplicate_envelope(&cache, envelope_id),
|
||||
"second sight of same envelope must be flagged as duplicate"
|
||||
);
|
||||
|
||||
// Simulate the receive-loop pattern: count how many times the agent
|
||||
// would actually be invoked across two deliveries.
|
||||
let mut agent_invocations = 0;
|
||||
for _delivery in 0..2 {
|
||||
if !is_duplicate_envelope(&cache, envelope_id) {
|
||||
agent_invocations += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
agent_invocations, 0,
|
||||
"after initial double-delivery, no further invocations should occur within TTL"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_envelope_dedup_distinct_ids_pass_through() {
|
||||
let cache: DashMap<String, Instant> = DashMap::new();
|
||||
assert!(!is_duplicate_envelope(&cache, "envelope-a"));
|
||||
assert!(!is_duplicate_envelope(&cache, "envelope-b"));
|
||||
assert!(!is_duplicate_envelope(&cache, "envelope-c"));
|
||||
// Each unique envelope_id should be seen exactly once.
|
||||
assert_eq!(cache.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_envelope_dedup_empty_id_never_dedupes() {
|
||||
// Defensive: malformed payloads with no envelope_id should not poison
|
||||
// the cache or short-circuit forwarding.
|
||||
let cache: DashMap<String, Instant> = DashMap::new();
|
||||
assert!(!is_duplicate_envelope(&cache, ""));
|
||||
assert!(!is_duplicate_envelope(&cache, ""));
|
||||
assert_eq!(cache.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ pub enum ChannelType {
|
||||
Mattermost,
|
||||
WebChat,
|
||||
CLI,
|
||||
/// MQTT pub/sub messaging.
|
||||
Mqtt,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
@@ -48,6 +50,23 @@ pub enum ChannelContent {
|
||||
File {
|
||||
url: String,
|
||||
filename: String,
|
||||
/// Best-effort MIME type from the source platform (e.g. Discord's
|
||||
/// `attachments[].content_type`). `None` if the platform did not
|
||||
/// provide one; downstream consumers may sniff bytes or fall back
|
||||
/// to extension-based detection.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mime: Option<String>,
|
||||
/// Size in bytes, when known. Useful for capacity gating before
|
||||
/// the bridge attempts to materialize or transmit the file.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
size: Option<u64>,
|
||||
},
|
||||
/// Local file data (bytes read from disk). Used by the proactive `channel_send`
|
||||
/// tool when `file_path` is provided instead of `file_url`.
|
||||
FileData {
|
||||
data: Vec<u8>,
|
||||
filename: String,
|
||||
mime_type: String,
|
||||
},
|
||||
Voice {
|
||||
url: String,
|
||||
@@ -61,6 +80,12 @@ pub enum ChannelContent {
|
||||
name: String,
|
||||
args: Vec<String>,
|
||||
},
|
||||
/// A composite message carrying multiple content blocks (e.g. a Discord
|
||||
/// message with several attachments, or an image with a separate file
|
||||
/// sibling). Blocks are flat-mapped by the bridge into multiple LLM
|
||||
/// content blocks. Implementations should not produce nested `Multipart`
|
||||
/// values; consumers may `debug_assert!` against nesting.
|
||||
Multipart(Vec<ChannelContent>),
|
||||
}
|
||||
|
||||
/// A unified message from any channel.
|
||||
@@ -88,6 +113,60 @@ pub struct ChannelMessage {
|
||||
pub metadata: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
// Re-export the adapter allowlist from openfang-types so config validation
|
||||
// and routing share a single source of truth (no drift between the two).
|
||||
pub use openfang_types::config::CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL;
|
||||
|
||||
impl ChannelMessage {
|
||||
/// Return the platform-native channel/conversation ID for this message,
|
||||
/// suitable for matching against an `AgentBinding`'s `channel_id` field.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. For adapters in [`CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL`],
|
||||
/// `sender.platform_id` already *is* the channel ID (these adapters
|
||||
/// overload the field because it doubles as the send target).
|
||||
/// 2. Otherwise, fall back to `metadata["channel_id"]` if present (any
|
||||
/// adapter can opt in by populating that key).
|
||||
/// 3. Otherwise, `None`.
|
||||
///
|
||||
/// This is the central routing-time accessor — config validation and the
|
||||
/// router both consult it (directly or via the same allowlist) so the two
|
||||
/// cannot drift.
|
||||
pub fn channel_id(&self) -> Option<String> {
|
||||
// For builtin variants the string is already lowercase by construction.
|
||||
// For `Custom(s)`, adapters _should_ register lowercase names but we
|
||||
// case-fold here so a stray `Custom("Twitch")` cannot silently slip
|
||||
// past the allowlist (and out of step with the validation path, which
|
||||
// already lowercases user input). Allocates only on the Custom arm.
|
||||
let channel_str: std::borrow::Cow<'_, str> = match &self.channel {
|
||||
ChannelType::Telegram => "telegram".into(),
|
||||
ChannelType::Discord => "discord".into(),
|
||||
ChannelType::Slack => "slack".into(),
|
||||
ChannelType::WhatsApp => "whatsapp".into(),
|
||||
ChannelType::Signal => "signal".into(),
|
||||
ChannelType::Matrix => "matrix".into(),
|
||||
ChannelType::Email => "email".into(),
|
||||
ChannelType::Teams => "teams".into(),
|
||||
ChannelType::Mattermost => "mattermost".into(),
|
||||
ChannelType::WebChat => "webchat".into(),
|
||||
ChannelType::CLI => "cli".into(),
|
||||
ChannelType::Mqtt => "mqtt".into(),
|
||||
ChannelType::Custom(s) => s.to_lowercase().into(),
|
||||
};
|
||||
if CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL
|
||||
.iter()
|
||||
.any(|c| *c == channel_str.as_ref())
|
||||
{
|
||||
Some(self.sender.platform_id.clone())
|
||||
} else {
|
||||
self.metadata
|
||||
.get("channel_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent lifecycle phase for UX indicators.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -261,6 +340,33 @@ pub trait ChannelAdapter: Send + Sync {
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.send(user, content).await
|
||||
}
|
||||
|
||||
/// Determine whether to auto-create a thread for an incoming message.
|
||||
/// Returns Some(thread_name) to create a thread, or None to reply directly.
|
||||
/// Default implementation returns None (no auto-threading).
|
||||
async fn should_auto_thread(&self, _message: &ChannelMessage) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Create a new thread (typically triggered after should_auto_thread returns Some).
|
||||
/// Returns the new thread ID on success.
|
||||
async fn create_thread(
|
||||
&self,
|
||||
_user: &ChannelUser,
|
||||
_message_id: &str,
|
||||
_thread_name: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
Err("Thread creation not supported for this adapter".into())
|
||||
}
|
||||
|
||||
/// Whether this adapter should suppress sending internal agent errors back to the user.
|
||||
///
|
||||
/// Returns `true` for public broadcast channels (e.g. Mastodon) where posting
|
||||
/// an error message would create a public status update. Errors are always
|
||||
/// logged regardless of this setting.
|
||||
fn suppress_error_responses(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a message into chunks of at most `max_len` characters,
|
||||
@@ -347,6 +453,34 @@ mod tests {
|
||||
assert_eq!(back, ChannelType::Email);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_id_custom_arm_is_case_insensitive() {
|
||||
// A stray capitalized Custom variant must still resolve through the
|
||||
// allowlist. The validation path lowercases user input; the routing
|
||||
// path needs the same case-fold to stay in sync.
|
||||
let make = |name: &str| ChannelMessage {
|
||||
channel: ChannelType::Custom(name.to_string()),
|
||||
platform_message_id: "m".to_string(),
|
||||
sender: ChannelUser {
|
||||
platform_id: "C123".to_string(),
|
||||
display_name: "x".to_string(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content: ChannelContent::Text("hi".to_string()),
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: false,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
};
|
||||
assert_eq!(make("twitch").channel_id().as_deref(), Some("C123"));
|
||||
assert_eq!(make("Twitch").channel_id().as_deref(), Some("C123"));
|
||||
assert_eq!(make("TWITCH").channel_id().as_deref(), Some("C123"));
|
||||
// Lark spelling (Feishu Intl) must also match.
|
||||
assert_eq!(make("lark").channel_id().as_deref(), Some("C123"));
|
||||
assert_eq!(make("Lark").channel_id().as_deref(), Some("C123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_content_variants() {
|
||||
let text = ChannelContent::Text("hello".to_string());
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
//! WeCom (WeChat Work) channel adapter.
|
||||
//!
|
||||
//! Uses the WeCom Work API for sending messages and a webhook HTTP server for
|
||||
//! receiving inbound events. Authentication is performed via an access token
|
||||
//! obtained from `https://qyapi.weixin.qq.com/cgi-bin/gettoken`.
|
||||
//! The token is cached and refreshed automatically.
|
||||
|
||||
use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::Utc;
|
||||
use futures::Stream;
|
||||
use sha1::{Digest, Sha1};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// WeCom token endpoint.
|
||||
const WECOM_TOKEN_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/gettoken";
|
||||
|
||||
/// WeCom send message endpoint.
|
||||
const WECOM_SEND_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/message/send";
|
||||
|
||||
/// Maximum WeCom message text length (characters).
|
||||
const MAX_MESSAGE_LEN: usize = 2048;
|
||||
|
||||
/// Token refresh buffer — refresh 5 minutes before actual expiry.
|
||||
const TOKEN_REFRESH_BUFFER_SECS: u64 = 300;
|
||||
|
||||
fn decrypt_aes_cbc(key: &[u8], encrypted_base64: &str) -> Result<Vec<u8>, String> {
|
||||
use base64::Engine;
|
||||
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
|
||||
|
||||
// Decode base64
|
||||
let mut encrypted = base64::engine::general_purpose::STANDARD
|
||||
.decode(encrypted_base64)
|
||||
.map_err(|e| format!("base64 decode error: {}", e))?;
|
||||
|
||||
// IV is first 16 bytes of key
|
||||
type Aes256CbcDecrypt = cbc::Decryptor<aes::Aes256>;
|
||||
let iv = &key[..16];
|
||||
let cipher = Aes256CbcDecrypt::new(key.into(), iv.into());
|
||||
|
||||
let decrypted = cipher
|
||||
.decrypt_padded_mut::<aes::cipher::block_padding::NoPadding>(&mut encrypted)
|
||||
.map_err(|e| format!("decrypt error: {}", e))?;
|
||||
|
||||
let decrypted = decrypted.to_vec();
|
||||
let pad = decrypted
|
||||
.last()
|
||||
.copied()
|
||||
.ok_or_else(|| "decrypted payload is empty".to_string())? as usize;
|
||||
|
||||
if pad == 0 || pad > 32 || decrypted.len() < pad {
|
||||
return Err(format!("invalid WeCom PKCS7 padding length: {pad}"));
|
||||
}
|
||||
if !decrypted[decrypted.len() - pad..]
|
||||
.iter()
|
||||
.all(|byte| *byte as usize == pad)
|
||||
{
|
||||
return Err("invalid WeCom PKCS7 padding bytes".to_string());
|
||||
}
|
||||
|
||||
Ok(decrypted[..decrypted.len() - pad].to_vec())
|
||||
}
|
||||
|
||||
fn is_valid_wecom_signature(
|
||||
token: &str,
|
||||
timestamp: &str,
|
||||
nonce: &str,
|
||||
encrypted_payload: &str,
|
||||
msg_signature: &str,
|
||||
) -> bool {
|
||||
let mut parts = [token, timestamp, nonce, encrypted_payload];
|
||||
parts.sort_unstable();
|
||||
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(parts.concat().as_bytes());
|
||||
hex::encode(hasher.finalize()) == msg_signature
|
||||
}
|
||||
|
||||
fn decode_wecom_payload(encoding_aes_key: &str, encrypted_payload: &str) -> Result<String, String> {
|
||||
use base64::{
|
||||
alphabet,
|
||||
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
|
||||
Engine,
|
||||
};
|
||||
|
||||
let aes_key_engine = GeneralPurpose::new(
|
||||
&alphabet::STANDARD,
|
||||
GeneralPurposeConfig::new()
|
||||
.with_decode_padding_mode(DecodePaddingMode::RequireNone)
|
||||
.with_decode_allow_trailing_bits(true),
|
||||
);
|
||||
|
||||
let aes_key = aes_key_engine
|
||||
.decode(encoding_aes_key)
|
||||
.map_err(|e| format!("aes key decode error: {e}"))?;
|
||||
let decrypted = decrypt_aes_cbc(&aes_key, encrypted_payload)?;
|
||||
|
||||
if decrypted.len() < 20 {
|
||||
return Err("decrypted payload too short".to_string());
|
||||
}
|
||||
|
||||
let msg_len =
|
||||
u32::from_be_bytes([decrypted[16], decrypted[17], decrypted[18], decrypted[19]]) as usize;
|
||||
if decrypted.len() < 20 + msg_len {
|
||||
return Err("decrypted payload shorter than declared echostr".to_string());
|
||||
}
|
||||
|
||||
String::from_utf8(decrypted[20..20 + msg_len].to_vec())
|
||||
.map_err(|e| format!("echostr is not valid utf-8: {e}"))
|
||||
}
|
||||
|
||||
fn parse_wecom_xml_fields(xml: &str) -> Result<HashMap<String, String>, String> {
|
||||
let doc = roxmltree::Document::parse(xml).map_err(|e| format!("invalid xml: {e}"))?;
|
||||
let root = doc.root_element();
|
||||
if root.tag_name().name() != "xml" {
|
||||
return Err("root element is not <xml>".to_string());
|
||||
}
|
||||
|
||||
let mut fields = HashMap::new();
|
||||
for child in root.children().filter(|node| node.is_element()) {
|
||||
let value = child
|
||||
.children()
|
||||
.filter_map(|node| node.text())
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string();
|
||||
fields.insert(child.tag_name().name().to_string(), value);
|
||||
}
|
||||
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
fn decode_wecom_post_body(
|
||||
body: &str,
|
||||
params: &HashMap<String, String>,
|
||||
token: Option<&str>,
|
||||
encoding_aes_key: Option<&str>,
|
||||
) -> Result<HashMap<String, String>, String> {
|
||||
let parsed = parse_wecom_xml_fields(body)?;
|
||||
|
||||
let Some(encrypted_payload) = parsed.get("Encrypt") else {
|
||||
return Ok(parsed);
|
||||
};
|
||||
|
||||
let token = token.ok_or_else(|| "missing WeCom callback token".to_string())?;
|
||||
let timestamp = params
|
||||
.get("timestamp")
|
||||
.ok_or_else(|| "missing timestamp".to_string())?;
|
||||
let nonce = params
|
||||
.get("nonce")
|
||||
.ok_or_else(|| "missing nonce".to_string())?;
|
||||
let msg_signature = params
|
||||
.get("msg_signature")
|
||||
.ok_or_else(|| "missing msg_signature".to_string())?;
|
||||
|
||||
if !is_valid_wecom_signature(token, timestamp, nonce, encrypted_payload, msg_signature) {
|
||||
return Err("invalid WeCom callback signature".to_string());
|
||||
}
|
||||
|
||||
let aes_key = encoding_aes_key
|
||||
.filter(|key| !key.is_empty())
|
||||
.ok_or_else(|| "missing WeCom encoding_aes_key".to_string())?;
|
||||
let decrypted_xml = decode_wecom_payload(aes_key, encrypted_payload)?;
|
||||
parse_wecom_xml_fields(&decrypted_xml)
|
||||
}
|
||||
|
||||
fn wecom_success_response() -> axum::response::Response {
|
||||
(
|
||||
axum::http::StatusCode::OK,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; charset=utf-8",
|
||||
)],
|
||||
"success",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// WeCom adapter.
|
||||
pub struct WeComAdapter {
|
||||
/// WeCom corp ID.
|
||||
corp_id: String,
|
||||
/// WeCom application agent ID.
|
||||
agent_id: String,
|
||||
/// WeCom application secret, zeroized on drop.
|
||||
secret: Zeroizing<String>,
|
||||
/// Encoding AES key for callback verification (optional).
|
||||
encoding_aes_key: Option<String>,
|
||||
/// Token for callback verification (optional).
|
||||
token: Option<String>,
|
||||
/// Port on which the inbound webhook HTTP server listens.
|
||||
webhook_port: u16,
|
||||
/// HTTP client for API calls.
|
||||
client: reqwest::Client,
|
||||
/// Shutdown signal.
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Cached access token and its expiry instant.
|
||||
cached_token: Arc<RwLock<Option<(String, Instant)>>>,
|
||||
}
|
||||
|
||||
impl WeComAdapter {
|
||||
/// Create a new WeCom adapter.
|
||||
pub fn new(corp_id: String, agent_id: String, secret: String, webhook_port: u16) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
corp_id,
|
||||
agent_id,
|
||||
secret: Zeroizing::new(secret),
|
||||
encoding_aes_key: None,
|
||||
token: None,
|
||||
webhook_port,
|
||||
client: reqwest::Client::new(),
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
cached_token: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new WeCom adapter with callback verification.
|
||||
pub fn with_verification(
|
||||
corp_id: String,
|
||||
agent_id: String,
|
||||
secret: String,
|
||||
webhook_port: u16,
|
||||
encoding_aes_key: Option<String>,
|
||||
token: Option<String>,
|
||||
) -> Self {
|
||||
let mut adapter = Self::new(corp_id, agent_id, secret, webhook_port);
|
||||
adapter.encoding_aes_key = encoding_aes_key;
|
||||
adapter.token = token;
|
||||
adapter
|
||||
}
|
||||
|
||||
/// Obtain a valid access token, refreshing if expired or missing.
|
||||
async fn get_token(&self) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut cached = self.cached_token.write().await;
|
||||
|
||||
// Check if we have a valid cached token
|
||||
if let Some((token, expiry)) = cached.as_ref() {
|
||||
let now = Instant::now();
|
||||
let buffer = Duration::from_secs(TOKEN_REFRESH_BUFFER_SECS);
|
||||
if now + buffer < *expiry {
|
||||
return Ok(token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch new token
|
||||
let url = format!(
|
||||
"{}?corpid={}&corpsecret={}",
|
||||
WECOM_TOKEN_URL,
|
||||
self.corp_id,
|
||||
self.secret.as_str()
|
||||
);
|
||||
|
||||
let response = self.client.get(&url).send().await?;
|
||||
let json: serde_json::Value = response.json().await?;
|
||||
|
||||
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
|
||||
if errcode != 0 {
|
||||
return Err(format!(
|
||||
"WeCom API error: {} - {}",
|
||||
errcode,
|
||||
json.get("errmsg").and_then(|v| v.as_str()).unwrap_or("")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
let token = json["access_token"]
|
||||
.as_str()
|
||||
.ok_or("Missing access_token in response")?
|
||||
.to_string();
|
||||
|
||||
let expires_in = json["expires_in"].as_i64().unwrap_or(7200) as u64;
|
||||
|
||||
let expiry = Instant::now() + Duration::from_secs(expires_in);
|
||||
*cached = Some((token.clone(), expiry));
|
||||
|
||||
info!("WeCom access token refreshed, expires in {}s", expires_in);
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Send a text message to a user.
|
||||
async fn send_text(
|
||||
&self,
|
||||
user_id: &str,
|
||||
content: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let token = self.get_token().await?;
|
||||
|
||||
let url = format!("{}?access_token={}", WECOM_SEND_URL, token);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"touser": user_id,
|
||||
"msgtype": "text",
|
||||
"agentid": self.agent_id,
|
||||
"text": {
|
||||
"content": content
|
||||
}
|
||||
});
|
||||
|
||||
let response = self.client.post(&url).json(&payload).send().await?;
|
||||
|
||||
let json: serde_json::Value = response.json().await?;
|
||||
|
||||
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
|
||||
if errcode != 0 {
|
||||
return Err(format!(
|
||||
"WeCom send error: {} - {}",
|
||||
errcode,
|
||||
json.get("errmsg").and_then(|v| v.as_str()).unwrap_or("")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate credentials by getting the token.
|
||||
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let _token = self.get_token().await?;
|
||||
// Token obtained successfully means credentials are valid
|
||||
Ok(format!("corp_id={}", self.corp_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for WeComAdapter {
|
||||
fn name(&self) -> &str {
|
||||
"wecom"
|
||||
}
|
||||
|
||||
fn channel_type(&self) -> ChannelType {
|
||||
ChannelType::Custom("wecom".to_string())
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
// Validate credentials
|
||||
let _ = self.validate().await?;
|
||||
info!("WeCom adapter initialized");
|
||||
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let port = self.webhook_port;
|
||||
let token = self.token.clone();
|
||||
let encoding_aes_key = self.encoding_aes_key.clone();
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let token = Arc::new(token);
|
||||
let encoding_aes_key = Arc::new(encoding_aes_key);
|
||||
let tx = Arc::new(tx);
|
||||
|
||||
let app = axum::Router::new().route(
|
||||
"/wecom/webhook",
|
||||
axum::routing::get({
|
||||
let encoding_aes_key = Arc::clone(&encoding_aes_key);
|
||||
let token = Arc::clone(&token);
|
||||
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>| {
|
||||
let encoding_aes_key = Arc::clone(&encoding_aes_key);
|
||||
let token = Arc::clone(&token);
|
||||
async move {
|
||||
// Handle callback verification (URL validation GET request)
|
||||
// WeChat Work sends GET with msg_signature, timestamp, nonce, echostr
|
||||
if let (Some(echostr_encoded), Some(msg_sig), Some(timestamp), Some(nonce)) = (
|
||||
params.get("echostr"),
|
||||
params.get("msg_signature"),
|
||||
params.get("timestamp"),
|
||||
params.get("nonce"),
|
||||
) {
|
||||
let Some(token_str) = token.as_deref() else {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"missing WeCom callback token",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
if !is_valid_wecom_signature(
|
||||
token_str,
|
||||
timestamp,
|
||||
nonce,
|
||||
echostr_encoded,
|
||||
msg_sig,
|
||||
) {
|
||||
return (
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"invalid WeCom callback signature",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let body = match encoding_aes_key.as_deref() {
|
||||
Some(aes_key) if !aes_key.is_empty() => {
|
||||
match decode_wecom_payload(aes_key, echostr_encoded) {
|
||||
Ok(echostr_plain) => echostr_plain,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to decrypt WeCom echostr");
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"invalid WeCom echostr",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => echostr_encoded.clone(),
|
||||
};
|
||||
|
||||
return (
|
||||
axum::http::StatusCode::OK,
|
||||
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
||||
body,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"missing WeCom verification parameters",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}).post({
|
||||
let token = Arc::clone(&token);
|
||||
let encoding_aes_key = Arc::clone(&encoding_aes_key);
|
||||
let tx = Arc::clone(&tx);
|
||||
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>, body: String| {
|
||||
let token = Arc::clone(&token);
|
||||
let encoding_aes_key = Arc::clone(&encoding_aes_key);
|
||||
let tx = Arc::clone(&tx);
|
||||
async move {
|
||||
let fields = match decode_wecom_post_body(
|
||||
&body,
|
||||
¶ms,
|
||||
token.as_deref(),
|
||||
encoding_aes_key.as_deref(),
|
||||
) {
|
||||
Ok(fields) => fields,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "Failed to parse WeCom callback body");
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
||||
"invalid WeCom callback body",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let msg_type = fields.get("MsgType").map(String::as_str).unwrap_or("");
|
||||
let user_id = fields
|
||||
.get("FromUserName")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let event = fields.get("Event").map(String::as_str).unwrap_or("");
|
||||
|
||||
info!(
|
||||
msg_type = msg_type,
|
||||
event = event,
|
||||
from_user = %user_id,
|
||||
"Received WeCom callback"
|
||||
);
|
||||
|
||||
if msg_type == "event" {
|
||||
if (event == "subscribe" || event == "enter_agent")
|
||||
&& !user_id.is_empty()
|
||||
{
|
||||
let msg = ChannelMessage {
|
||||
channel: ChannelType::Custom("wecom".to_string()),
|
||||
platform_message_id: String::new(),
|
||||
sender: ChannelUser {
|
||||
platform_id: user_id.clone(),
|
||||
display_name: user_id.clone(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content: ChannelContent::Text(String::new()),
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: false,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
};
|
||||
let _ = tx.send(msg).await;
|
||||
}
|
||||
|
||||
return wecom_success_response();
|
||||
}
|
||||
|
||||
if msg_type == "text" {
|
||||
let content = fields.get("Content").cloned().unwrap_or_default();
|
||||
let msg_id = fields.get("MsgId").cloned().unwrap_or_default();
|
||||
|
||||
if !user_id.is_empty() && !content.is_empty() {
|
||||
let msg = ChannelMessage {
|
||||
channel: ChannelType::Custom("wecom".to_string()),
|
||||
platform_message_id: msg_id,
|
||||
sender: ChannelUser {
|
||||
platform_id: user_id.clone(),
|
||||
display_name: user_id.clone(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content: ChannelContent::Text(content),
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: false,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
};
|
||||
let _ = tx.send(msg).await;
|
||||
}
|
||||
}
|
||||
|
||||
wecom_success_response()
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
|
||||
info!("WeCom webhook server listening on http://0.0.0.0:{}", port);
|
||||
|
||||
let server = axum::serve(listener, app);
|
||||
|
||||
tokio::select! {
|
||||
result = server => {
|
||||
if let Err(e) = result {
|
||||
warn!("WeCom webhook server error: {}", e);
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("WeCom adapter shutting down");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let user_id = &user.platform_id;
|
||||
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
// Split long messages
|
||||
for chunk in split_message(&text, MAX_MESSAGE_LEN) {
|
||||
self.send_text(user_id, chunk).await?;
|
||||
}
|
||||
}
|
||||
ChannelContent::Command { name: _, args: _ } => {
|
||||
// WeCom doesn't support commands natively
|
||||
warn!("WeCom: commands not supported");
|
||||
}
|
||||
_ => {
|
||||
warn!("WeCom: unsupported content type");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_adapter_name() {
|
||||
let adapter = WeComAdapter::new(
|
||||
"corp_id".to_string(),
|
||||
"agent_id".to_string(),
|
||||
"secret".to_string(),
|
||||
8080,
|
||||
);
|
||||
assert_eq!(adapter.name(), "wecom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapter_channel_type() {
|
||||
let adapter = WeComAdapter::new(
|
||||
"corp_id".to_string(),
|
||||
"agent_id".to_string(),
|
||||
"secret".to_string(),
|
||||
8080,
|
||||
);
|
||||
assert_eq!(
|
||||
adapter.channel_type(),
|
||||
ChannelType::Custom("wecom".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_adapter_with_verification() {
|
||||
let adapter = WeComAdapter::with_verification(
|
||||
"corp_id".to_string(),
|
||||
"agent_id".to_string(),
|
||||
"secret".to_string(),
|
||||
8080,
|
||||
Some("encoding_aes_key".to_string()),
|
||||
Some("token".to_string()),
|
||||
);
|
||||
assert_eq!(adapter.name(), "wecom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_message_length() {
|
||||
// MAX_MESSAGE_LEN should be 2048 for WeCom
|
||||
assert_eq!(MAX_MESSAGE_LEN, 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_refresh_buffer() {
|
||||
// Token refresh buffer should be 5 minutes
|
||||
assert_eq!(TOKEN_REFRESH_BUFFER_SECS, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wecom_signature_validation() {
|
||||
assert!(is_valid_wecom_signature(
|
||||
"token",
|
||||
"1710000000",
|
||||
"nonce",
|
||||
"echostr",
|
||||
"bf56bf867459f80e3ceb854596f39f02a5ac5e13",
|
||||
));
|
||||
assert!(!is_valid_wecom_signature(
|
||||
"token",
|
||||
"1710000000",
|
||||
"nonce",
|
||||
"echostr",
|
||||
"bad-signature",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_wecom_payload() {
|
||||
let plain = decode_wecom_payload(
|
||||
"ShlNaJ0PrdXQAuCDVqMki7c2JLNnY6mebvQodTv9qoV",
|
||||
"/gKbXNFpvlyYNTCneTag1rGm1P4Q5fExE3OPzdYlEyUVDgi55PHVIbo+mHMXWatdW8H8RTQJCly0HBNrWry2Uw==",
|
||||
)
|
||||
.expect("echostr should decrypt");
|
||||
|
||||
assert_eq!(plain, "openfang-wecom-check");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wecom_xml_fields() {
|
||||
let fields = parse_wecom_xml_fields(
|
||||
r#"<xml>
|
||||
<ToUserName><![CDATA[wwcorp]]></ToUserName>
|
||||
<FromUserName><![CDATA[user123]]></FromUserName>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[hello]]></Content>
|
||||
<MsgId>123456</MsgId>
|
||||
</xml>"#,
|
||||
)
|
||||
.expect("xml should parse");
|
||||
|
||||
assert_eq!(
|
||||
fields.get("FromUserName").map(String::as_str),
|
||||
Some("user123")
|
||||
);
|
||||
assert_eq!(fields.get("MsgType").map(String::as_str), Some("text"));
|
||||
assert_eq!(fields.get("Content").map(String::as_str), Some("hello"));
|
||||
assert_eq!(fields.get("MsgId").map(String::as_str), Some("123456"));
|
||||
}
|
||||
}
|
||||
@@ -222,11 +222,9 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
if let Some(ref gw) = self.gateway_url {
|
||||
let text = match &content {
|
||||
ChannelContent::Text(t) => t.clone(),
|
||||
ChannelContent::Image { caption, .. } => {
|
||||
caption
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(Image — not supported in Web mode)".to_string())
|
||||
}
|
||||
ChannelContent::Image { caption, .. } => caption
|
||||
.clone()
|
||||
.unwrap_or_else(|| "(Image — not supported in Web mode)".to_string()),
|
||||
ChannelContent::File { filename, .. } => {
|
||||
format!("(File: {filename} — not supported in Web mode)")
|
||||
}
|
||||
@@ -260,14 +258,20 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
"https://graph.facebook.com/v21.0/{}/messages",
|
||||
self.phone_number_id
|
||||
);
|
||||
self.client
|
||||
let resp = self
|
||||
.client
|
||||
.post(&api_url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("WhatsApp API error {status}: {body}").into());
|
||||
}
|
||||
}
|
||||
ChannelContent::File { url, filename } => {
|
||||
ChannelContent::File { url, filename, .. } => {
|
||||
let body = serde_json::json!({
|
||||
"messaging_product": "whatsapp",
|
||||
"to": user.platform_id,
|
||||
@@ -281,12 +285,18 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
"https://graph.facebook.com/v21.0/{}/messages",
|
||||
self.phone_number_id
|
||||
);
|
||||
self.client
|
||||
let resp = self
|
||||
.client
|
||||
.post(&api_url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("WhatsApp API error {status}: {body}").into());
|
||||
}
|
||||
}
|
||||
ChannelContent::Location { lat, lon } => {
|
||||
let body = serde_json::json!({
|
||||
@@ -302,12 +312,18 @@ impl ChannelAdapter for WhatsAppAdapter {
|
||||
"https://graph.facebook.com/v21.0/{}/messages",
|
||||
self.phone_number_id
|
||||
);
|
||||
self.client
|
||||
let resp = self
|
||||
.client
|
||||
.post(&api_url)
|
||||
.bearer_auth(&*self.access_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("WhatsApp API error {status}: {body}").into());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(&user.platform_id, "(Unsupported content type)")
|
||||
|
||||
@@ -229,14 +229,23 @@ async fn test_bridge_dispatch_text_message() {
|
||||
let sent = adapter_ref.get_sent();
|
||||
assert_eq!(sent.len(), 1, "Expected 1 response, got {}", sent.len());
|
||||
assert_eq!(sent[0].0, "user1");
|
||||
assert_eq!(sent[0].1, "Echo: Hello agent!");
|
||||
// The bridge prepends sender identity: [From: Name] or [From: Name <email>]
|
||||
assert!(
|
||||
sent[0].1.contains("Hello agent!"),
|
||||
"Response should contain original text, got: {}",
|
||||
sent[0].1
|
||||
);
|
||||
|
||||
// Verify: handle received the message
|
||||
// Verify: handle received the message (with sender prefix)
|
||||
{
|
||||
let received = handle.received.lock().unwrap();
|
||||
assert_eq!(received.len(), 1);
|
||||
assert_eq!(received[0].0, agent_id);
|
||||
assert_eq!(received[0].1, "Hello agent!");
|
||||
assert!(
|
||||
received[0].1.contains("Hello agent!"),
|
||||
"Handle should receive text containing original message, got: {}",
|
||||
received[0].1
|
||||
);
|
||||
}
|
||||
|
||||
manager.stop().await;
|
||||
@@ -486,7 +495,10 @@ async fn test_bridge_manager_lifecycle() {
|
||||
assert_eq!(sent.len(), 5, "Expected 5 responses, got {}", sent.len());
|
||||
|
||||
for (i, (_, text)) in sent.iter().enumerate() {
|
||||
assert_eq!(*text, format!("Echo: message {i}"));
|
||||
assert!(
|
||||
text.contains(&format!("message {i}")),
|
||||
"Expected 'message {i}' in: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
// Stop — should complete without hanging
|
||||
@@ -535,11 +547,19 @@ async fn test_bridge_multiple_adapters() {
|
||||
|
||||
let tg_sent = tg_ref.get_sent();
|
||||
assert_eq!(tg_sent.len(), 1);
|
||||
assert_eq!(tg_sent[0].1, "Echo: from telegram");
|
||||
assert!(
|
||||
tg_sent[0].1.contains("from telegram"),
|
||||
"Expected 'from telegram' in: {}",
|
||||
tg_sent[0].1
|
||||
);
|
||||
|
||||
let dc_sent = dc_ref.get_sent();
|
||||
assert_eq!(dc_sent.len(), 1);
|
||||
assert_eq!(dc_sent[0].1, "Echo: from discord");
|
||||
assert!(
|
||||
dc_sent[0].1.contains("from discord"),
|
||||
"Expected 'from discord' in: {}",
|
||||
dc_sent[0].1
|
||||
);
|
||||
|
||||
manager.stop().await;
|
||||
}
|
||||
|
||||
@@ -31,3 +31,4 @@ openfang-runtime = { path = "../openfang-runtime" }
|
||||
uuid = { workspace = true }
|
||||
ratatui = { workspace = true }
|
||||
colored = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -7,34 +7,112 @@
|
||||
/// Returns all bundled agent templates as `(name, toml_content)` pairs.
|
||||
pub fn bundled_agents() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
("analyst", include_str!("../../../agents/analyst/agent.toml")),
|
||||
("architect", include_str!("../../../agents/architect/agent.toml")),
|
||||
("assistant", include_str!("../../../agents/assistant/agent.toml")),
|
||||
(
|
||||
"analyst",
|
||||
include_str!("../../../agents/analyst/agent.toml"),
|
||||
),
|
||||
(
|
||||
"architect",
|
||||
include_str!("../../../agents/architect/agent.toml"),
|
||||
),
|
||||
(
|
||||
"assistant",
|
||||
include_str!("../../../agents/assistant/agent.toml"),
|
||||
),
|
||||
("coder", include_str!("../../../agents/coder/agent.toml")),
|
||||
("code-reviewer", include_str!("../../../agents/code-reviewer/agent.toml")),
|
||||
("customer-support", include_str!("../../../agents/customer-support/agent.toml")),
|
||||
("data-scientist", include_str!("../../../agents/data-scientist/agent.toml")),
|
||||
("debugger", include_str!("../../../agents/debugger/agent.toml")),
|
||||
("devops-lead", include_str!("../../../agents/devops-lead/agent.toml")),
|
||||
("doc-writer", include_str!("../../../agents/doc-writer/agent.toml")),
|
||||
("email-assistant", include_str!("../../../agents/email-assistant/agent.toml")),
|
||||
("health-tracker", include_str!("../../../agents/health-tracker/agent.toml")),
|
||||
("hello-world", include_str!("../../../agents/hello-world/agent.toml")),
|
||||
("home-automation", include_str!("../../../agents/home-automation/agent.toml")),
|
||||
("legal-assistant", include_str!("../../../agents/legal-assistant/agent.toml")),
|
||||
("meeting-assistant", include_str!("../../../agents/meeting-assistant/agent.toml")),
|
||||
(
|
||||
"code-reviewer",
|
||||
include_str!("../../../agents/code-reviewer/agent.toml"),
|
||||
),
|
||||
(
|
||||
"customer-support",
|
||||
include_str!("../../../agents/customer-support/agent.toml"),
|
||||
),
|
||||
(
|
||||
"data-scientist",
|
||||
include_str!("../../../agents/data-scientist/agent.toml"),
|
||||
),
|
||||
(
|
||||
"debugger",
|
||||
include_str!("../../../agents/debugger/agent.toml"),
|
||||
),
|
||||
(
|
||||
"devops-lead",
|
||||
include_str!("../../../agents/devops-lead/agent.toml"),
|
||||
),
|
||||
(
|
||||
"doc-writer",
|
||||
include_str!("../../../agents/doc-writer/agent.toml"),
|
||||
),
|
||||
(
|
||||
"email-assistant",
|
||||
include_str!("../../../agents/email-assistant/agent.toml"),
|
||||
),
|
||||
(
|
||||
"health-tracker",
|
||||
include_str!("../../../agents/health-tracker/agent.toml"),
|
||||
),
|
||||
(
|
||||
"hello-world",
|
||||
include_str!("../../../agents/hello-world/agent.toml"),
|
||||
),
|
||||
(
|
||||
"home-automation",
|
||||
include_str!("../../../agents/home-automation/agent.toml"),
|
||||
),
|
||||
(
|
||||
"legal-assistant",
|
||||
include_str!("../../../agents/legal-assistant/agent.toml"),
|
||||
),
|
||||
(
|
||||
"meeting-assistant",
|
||||
include_str!("../../../agents/meeting-assistant/agent.toml"),
|
||||
),
|
||||
("ops", include_str!("../../../agents/ops/agent.toml")),
|
||||
("orchestrator", include_str!("../../../agents/orchestrator/agent.toml")),
|
||||
("personal-finance", include_str!("../../../agents/personal-finance/agent.toml")),
|
||||
("planner", include_str!("../../../agents/planner/agent.toml")),
|
||||
("recruiter", include_str!("../../../agents/recruiter/agent.toml")),
|
||||
("researcher", include_str!("../../../agents/researcher/agent.toml")),
|
||||
("sales-assistant", include_str!("../../../agents/sales-assistant/agent.toml")),
|
||||
("security-auditor", include_str!("../../../agents/security-auditor/agent.toml")),
|
||||
("social-media", include_str!("../../../agents/social-media/agent.toml")),
|
||||
("test-engineer", include_str!("../../../agents/test-engineer/agent.toml")),
|
||||
("translator", include_str!("../../../agents/translator/agent.toml")),
|
||||
("travel-planner", include_str!("../../../agents/travel-planner/agent.toml")),
|
||||
(
|
||||
"orchestrator",
|
||||
include_str!("../../../agents/orchestrator/agent.toml"),
|
||||
),
|
||||
(
|
||||
"personal-finance",
|
||||
include_str!("../../../agents/personal-finance/agent.toml"),
|
||||
),
|
||||
(
|
||||
"planner",
|
||||
include_str!("../../../agents/planner/agent.toml"),
|
||||
),
|
||||
(
|
||||
"recruiter",
|
||||
include_str!("../../../agents/recruiter/agent.toml"),
|
||||
),
|
||||
(
|
||||
"researcher",
|
||||
include_str!("../../../agents/researcher/agent.toml"),
|
||||
),
|
||||
(
|
||||
"sales-assistant",
|
||||
include_str!("../../../agents/sales-assistant/agent.toml"),
|
||||
),
|
||||
(
|
||||
"security-auditor",
|
||||
include_str!("../../../agents/security-auditor/agent.toml"),
|
||||
),
|
||||
(
|
||||
"social-media",
|
||||
include_str!("../../../agents/social-media/agent.toml"),
|
||||
),
|
||||
(
|
||||
"test-engineer",
|
||||
include_str!("../../../agents/test-engineer/agent.toml"),
|
||||
),
|
||||
(
|
||||
"translator",
|
||||
include_str!("../../../agents/translator/agent.toml"),
|
||||
),
|
||||
(
|
||||
"travel-planner",
|
||||
include_str!("../../../agents/travel-planner/agent.toml"),
|
||||
),
|
||||
("tutor", include_str!("../../../agents/tutor/agent.toml")),
|
||||
("writer", include_str!("../../../agents/writer/agent.toml")),
|
||||
]
|
||||
|
||||
+1017
-119
File diff suppressed because it is too large
Load Diff
@@ -216,8 +216,13 @@ fn read_message(reader: &mut impl BufRead) -> io::Result<Option<Value>> {
|
||||
/// Write a Content-Length framed JSON-RPC response to the writer.
|
||||
fn write_message(writer: &mut impl Write, msg: &Value) {
|
||||
let body = serde_json::to_string(msg).unwrap_or_default();
|
||||
let _ = write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body);
|
||||
let _ = writer.flush();
|
||||
if let Err(e) = write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body) {
|
||||
eprintln!("MCP write error: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = writer.flush() {
|
||||
eprintln!("MCP flush error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a JSON-RPC message and return an optional response.
|
||||
@@ -225,6 +230,10 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
let method = msg["method"].as_str().unwrap_or("");
|
||||
let id = msg.get("id").cloned();
|
||||
|
||||
// Per JSON-RPC 2.0 spec: requests MUST have an id field.
|
||||
// Use null if missing so we always send a response.
|
||||
let rid = id.unwrap_or(Value::Null);
|
||||
|
||||
match method {
|
||||
"initialize" => {
|
||||
let result = json!({
|
||||
@@ -234,10 +243,10 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "openfang",
|
||||
"version": "0.1.0"
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
});
|
||||
Some(jsonrpc_response(id?, result))
|
||||
Some(jsonrpc_response(rid, result))
|
||||
}
|
||||
|
||||
"notifications/initialized" => None, // Notification, no response
|
||||
@@ -269,7 +278,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(jsonrpc_response(id?, json!({ "tools": tools })))
|
||||
Some(jsonrpc_response(rid, json!({ "tools": tools })))
|
||||
}
|
||||
|
||||
"tools/call" => {
|
||||
@@ -281,14 +290,14 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
.to_string();
|
||||
|
||||
if message.is_empty() {
|
||||
return Some(jsonrpc_error(id?, -32602, "Missing 'message' argument"));
|
||||
return Some(jsonrpc_error(rid, -32602, "Missing 'message' argument"));
|
||||
}
|
||||
|
||||
let agent_id = match backend.resolve_tool_agent(tool_name) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Some(jsonrpc_error(
|
||||
id?,
|
||||
rid,
|
||||
-32602,
|
||||
&format!("Unknown tool: {tool_name}"),
|
||||
));
|
||||
@@ -297,7 +306,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
|
||||
match backend.send_message(&agent_id, &message) {
|
||||
Ok(response) => Some(jsonrpc_response(
|
||||
id?,
|
||||
rid,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
@@ -306,7 +315,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
}),
|
||||
)),
|
||||
Err(e) => Some(jsonrpc_response(
|
||||
id?,
|
||||
rid,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
@@ -319,8 +328,12 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
}
|
||||
|
||||
_ => {
|
||||
// Unknown method
|
||||
id.map(|id| jsonrpc_error(id, -32601, &format!("Method not found: {method}")))
|
||||
// Unknown method — always respond with error
|
||||
Some(jsonrpc_error(
|
||||
rid,
|
||||
-32601,
|
||||
&format!("Method not found: {method}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ impl StandaloneChat {
|
||||
name,
|
||||
result_preview,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
self.chat.tool_result(&name, &result_preview, is_error);
|
||||
}
|
||||
@@ -394,10 +395,7 @@ impl StandaloneChat {
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"].as_str().unwrap_or("").to_string(),
|
||||
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
|
||||
})
|
||||
.collect()
|
||||
@@ -459,16 +457,13 @@ impl StandaloneChat {
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let provider = body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Switched to {model_id}"));
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
@@ -506,16 +501,12 @@ impl StandaloneChat {
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
});
|
||||
self.chat.model_label = format!("{prov_label}/{model_id}");
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Switched to {model_id}"));
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Switch failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,11 @@ pub enum AppEvent {
|
||||
SkillUninstalled(String),
|
||||
/// MCP servers loaded.
|
||||
McpServersLoaded(Vec<McpServerInfo>),
|
||||
/// Skill config details loaded (installed skill `c` key).
|
||||
SkillConfigLoaded {
|
||||
skill: String,
|
||||
rows: Vec<crate::tui::screens::skills::SkillConfigVarDetail>,
|
||||
},
|
||||
/// Templates providers loaded (auth status).
|
||||
TemplateProvidersLoaded(Vec<ProviderAuth>),
|
||||
/// Security features loaded.
|
||||
@@ -303,7 +308,7 @@ pub fn spawn_inprocess_stream(
|
||||
// send_message_streaming() finds the reactor.
|
||||
let _guard = rt.enter();
|
||||
|
||||
match kernel.send_message_streaming(agent_id, &message, None) {
|
||||
match kernel.send_message_streaming(agent_id, &message, None, None, None, None) {
|
||||
Ok((mut rx, handle)) => {
|
||||
rt.block_on(async {
|
||||
while let Some(ev) = rx.recv().await {
|
||||
@@ -1417,18 +1422,36 @@ pub fn spawn_fetch_skills(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
|
||||
let client = daemon_client();
|
||||
if let Ok(resp) = client.get(format!("{base_url}/api/skills")).send() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let skills: Vec<SkillInfo> = body
|
||||
.as_array()
|
||||
// API returns {"skills": [...], "total": N} — extract the inner array.
|
||||
// Fall back to bare array for backward compat.
|
||||
let items = body
|
||||
.get("skills")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| body.as_array());
|
||||
let skills: Vec<SkillInfo> = items
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|s| SkillInfo {
|
||||
name: s["name"].as_str().unwrap_or("").to_string(),
|
||||
runtime: s["runtime"].as_str().unwrap_or("").to_string(),
|
||||
source: s["source"].as_str().unwrap_or("").to_string(),
|
||||
// "source" is an object {"type": "..."} — extract the type string
|
||||
source: s["source"]["type"]
|
||||
.as_str()
|
||||
.or_else(|| s["source"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
description: s["description"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
config_declared: s["config_declared_count"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
as usize,
|
||||
config_resolved: s["config_resolved_count"]
|
||||
.as_u64()
|
||||
.unwrap_or(0)
|
||||
as usize,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
@@ -1594,6 +1617,89 @@ pub fn spawn_fetch_mcp_servers(backend: BackendRef, tx: mpsc::Sender<AppEvent>)
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch declared + resolved config for a specific installed skill.
|
||||
///
|
||||
/// Pulls `GET /api/skills/{id}/config` and flattens the response into the
|
||||
/// `SkillConfigVarDetail` rows that the TUI details pane renders. Secret
|
||||
/// values are already redacted by the daemon so nothing sensitive crosses
|
||||
/// the wire here.
|
||||
pub fn spawn_fetch_skill_config(
|
||||
backend: BackendRef,
|
||||
skill_name: String,
|
||||
tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
use crate::tui::screens::skills::SkillConfigVarDetail;
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
let encoded: String = skill_name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' {
|
||||
c.to_string()
|
||||
} else {
|
||||
format!("%{:02X}", c as u32)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let url = format!("{base_url}/api/skills/{encoded}/config");
|
||||
if let Ok(resp) = client.get(&url).send() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let declared = body.get("declared").cloned().unwrap_or_default();
|
||||
let resolved = body.get("resolved").cloned().unwrap_or_default();
|
||||
let mut rows: Vec<SkillConfigVarDetail> = Vec::new();
|
||||
if let Some(obj) = declared.as_object() {
|
||||
// Sort keys for deterministic output.
|
||||
let mut keys: Vec<&String> = obj.keys().collect();
|
||||
keys.sort();
|
||||
for k in keys {
|
||||
let d = &obj[k];
|
||||
let r = resolved.get(k).cloned().unwrap_or_default();
|
||||
let value_hint = r
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default();
|
||||
rows.push(SkillConfigVarDetail {
|
||||
name: k.clone(),
|
||||
description: d
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
required: d
|
||||
.get("required")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
source: r
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unresolved")
|
||||
.to_string(),
|
||||
value_hint,
|
||||
is_secret: r
|
||||
.get("is_secret")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
let _ = tx.send(AppEvent::SkillConfigLoaded {
|
||||
skill: skill_name,
|
||||
rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(_) => {
|
||||
let _ = tx.send(AppEvent::SkillConfigLoaded {
|
||||
skill: skill_name,
|
||||
rows: Vec::new(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch provider auth status for templates screen.
|
||||
pub fn spawn_fetch_template_providers(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
|
||||
std::thread::spawn(move || match backend {
|
||||
@@ -2216,13 +2322,22 @@ pub fn spawn_fetch_active_hands(backend: BackendRef, tx: mpsc::Sender<AppEvent>)
|
||||
}
|
||||
|
||||
/// Activate a hand.
|
||||
pub fn spawn_activate_hand(backend: BackendRef, hand_id: String, tx: mpsc::Sender<AppEvent>) {
|
||||
pub fn spawn_activate_hand(
|
||||
backend: BackendRef,
|
||||
hand_id: String,
|
||||
instance_name: Option<String>,
|
||||
tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
let payload = match &instance_name {
|
||||
Some(n) => serde_json::json!({ "instance_name": n }),
|
||||
None => serde_json::json!({}),
|
||||
};
|
||||
match client
|
||||
.post(format!("{base_url}/api/hands/{hand_id}/activate"))
|
||||
.json(&serde_json::json!({}))
|
||||
.json(&payload)
|
||||
.send()
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
@@ -2242,7 +2357,7 @@ pub fn spawn_activate_hand(backend: BackendRef, hand_id: String, tx: mpsc::Sende
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(kernel) => {
|
||||
match kernel.activate_hand(&hand_id, std::collections::HashMap::new()) {
|
||||
match kernel.activate_hand(&hand_id, std::collections::HashMap::new(), instance_name) {
|
||||
Ok(_) => {
|
||||
let _ = tx.send(AppEvent::HandActivated(hand_id));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use event::{AppEvent, BackendRef};
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::llm_driver::StreamEvent;
|
||||
use openfang_types::agent::AgentId;
|
||||
use openfang_types::commands::{self, Surfaces};
|
||||
use screens::{
|
||||
agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers,
|
||||
security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
|
||||
@@ -434,6 +435,10 @@ impl App {
|
||||
}
|
||||
self.skills.loading = false;
|
||||
}
|
||||
AppEvent::SkillConfigLoaded { skill, rows } => {
|
||||
self.skills.selected_config_details = Some((skill.clone(), rows));
|
||||
self.skills.status_msg = format!("Loaded config for '{skill}'");
|
||||
}
|
||||
AppEvent::TemplateProvidersLoaded(providers) => {
|
||||
self.templates.providers = providers;
|
||||
}
|
||||
@@ -516,8 +521,7 @@ impl App {
|
||||
}
|
||||
AppEvent::CommsEventsLoaded(events) => {
|
||||
self.comms.events = events;
|
||||
if !self.comms.events.is_empty()
|
||||
&& self.comms.event_list_state.selected().is_none()
|
||||
if !self.comms.events.is_empty() && self.comms.event_list_state.selected().is_none()
|
||||
{
|
||||
self.comms.event_list_state.select(Some(0));
|
||||
}
|
||||
@@ -1184,6 +1188,7 @@ impl App {
|
||||
name,
|
||||
result_preview,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
self.chat.tool_result(&name, &result_preview, is_error);
|
||||
}
|
||||
@@ -1562,6 +1567,11 @@ impl App {
|
||||
event::spawn_fetch_mcp_servers(backend, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
skills::SkillsAction::LoadSkillConfig(name) => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_fetch_skill_config(backend, name, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1598,9 +1608,14 @@ impl App {
|
||||
event::spawn_fetch_active_hands(backend, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
hands::HandsAction::ActivateHand(hand_id) => {
|
||||
hands::HandsAction::ActivateHand(hand_id, instance_name) => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_activate_hand(backend, hand_id, self.event_tx.clone());
|
||||
event::spawn_activate_hand(
|
||||
backend,
|
||||
hand_id,
|
||||
instance_name,
|
||||
self.event_tx.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
hands::HandsAction::DeactivateHand(instance_id) => {
|
||||
@@ -1869,14 +1884,8 @@ impl App {
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tier: m["tier"]
|
||||
.as_str()
|
||||
.unwrap_or("Balanced")
|
||||
.to_string(),
|
||||
provider: m["provider"].as_str().unwrap_or("").to_string(),
|
||||
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
@@ -1935,8 +1944,7 @@ impl App {
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let provider = body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
@@ -1988,10 +1996,8 @@ impl App {
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
self.chat
|
||||
.push_message(chat::Role::System, format!("Switch failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2007,23 +2013,18 @@ impl App {
|
||||
|
||||
fn handle_slash_command(&mut self, cmd: &str) {
|
||||
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
||||
match parts[0] {
|
||||
"/exit" | "/quit" => self.handle_chat_action(chat::ChatAction::Back),
|
||||
// Canonicalise through the unified command registry: `/quit` -> `exit`,
|
||||
// `/NEW` -> `new`, etc. Unregistered commands fall through unchanged so
|
||||
// this refactor does not break any existing surface-specific behaviour.
|
||||
let canonical_head: String = commands::resolve(parts[0])
|
||||
.filter(|def| def.surfaces.contains(Surfaces::CLI))
|
||||
.map(|def| format!("/{}", def.name))
|
||||
.unwrap_or_else(|| parts[0].to_string());
|
||||
match canonical_head.as_str() {
|
||||
"/exit" => self.handle_chat_action(chat::ChatAction::Back),
|
||||
"/help" => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
self.chat
|
||||
.push_message(chat::Role::System, commands::render_help(Surfaces::CLI));
|
||||
}
|
||||
"/status" => {
|
||||
let mut s = Vec::new();
|
||||
@@ -2189,9 +2190,10 @@ impl App {
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
let help = commands::render_help(Surfaces::CLI);
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Unknown command: {}. Type /help", parts[0]),
|
||||
format!("Unknown command: {}\n\n{}", parts[0], help),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user