mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
618e83714c | ||
|
|
64631a31e6 | ||
|
|
9fef6d6c91 | ||
|
|
f98bc330d4 | ||
|
|
86694dd926 | ||
|
|
f8da17719e | ||
|
|
a72e6087d8 | ||
|
|
3a64e322ad | ||
|
|
9e2853a5f8 | ||
|
|
feecb60442 | ||
|
|
e53e238e81 | ||
|
|
991aea85ee | ||
|
|
bfbf0bb892 | ||
|
|
b6cb4cc2d9 | ||
|
|
827481633c | ||
|
|
ad780b9cb4 | ||
|
|
4582ed16b0 | ||
|
|
f56505258d | ||
|
|
ddd1536bcb | ||
|
|
9fa5234061 | ||
|
|
e21efa61ef | ||
|
|
3f72c5d918 | ||
|
|
c286b88d54 | ||
|
|
22c08c2325 | ||
|
|
f6493e8843 | ||
|
|
1d2bfff8ea | ||
|
|
b967852891 | ||
|
|
d95d9583b0 | ||
|
|
8c0cce3ac5 | ||
|
|
f036bd54e3 | ||
|
|
77da90f3f8 | ||
|
|
b0b6f84492 | ||
|
|
0da8e32a51 | ||
|
|
7410faa96d | ||
|
|
e880dfa3e7 | ||
|
|
7791b3f170 | ||
|
|
e58039c83e | ||
|
|
9b0a7d2f61 | ||
|
|
86fe4929e9 | ||
|
|
9993718d9c | ||
|
|
0bf2f61ab1 | ||
|
|
51eff0d75f | ||
|
|
a30cce129e | ||
|
|
54885d8a1c | ||
|
|
617b4f81d8 | ||
|
|
a0f829383c | ||
|
|
6083c24484 | ||
|
|
1964545f35 | ||
|
|
fc7e971d7e | ||
|
|
86309c8e40 | ||
|
|
282ad3a960 | ||
|
|
d6f857eee2 | ||
|
|
751b420b39 | ||
|
|
5b2be80399 | ||
|
|
6ae8dd4cfd | ||
|
|
25a66df41b | ||
|
|
5212730773 | ||
|
|
9b7496947b | ||
|
|
895e94ccac | ||
|
|
da9d8a84f1 | ||
|
|
e7b9143423 | ||
|
|
604e4ea7e3 | ||
|
|
c926372d81 | ||
|
|
da12f47369 | ||
|
|
b7c81965a1 | ||
|
|
f65dc775eb | ||
|
|
c59041a09d | ||
|
|
715f37effc | ||
|
|
570e1941b2 | ||
|
|
4c700c8d2d | ||
|
|
5ae554ed51 | ||
|
|
62b697c90e | ||
|
|
4b3b602457 | ||
|
|
d7bd5c6636 | ||
|
|
66e6eb2509 | ||
|
|
1365fc9635 | ||
|
|
78669863b7 | ||
|
|
316bbe11c3 | ||
|
|
ff00499e8e | ||
|
|
1a5ae4e3ce | ||
|
|
bf9066a602 | ||
|
|
acf51e02a8 | ||
|
|
1c9d53df11 | ||
|
|
b298c4c273 | ||
|
|
f407a41a98 | ||
|
|
173c843107 | ||
|
|
b3787e07ea | ||
|
|
935c8cad88 | ||
|
|
d95270da5a | ||
|
|
7a2211d0f4 | ||
|
|
e14885fa80 | ||
|
|
ccbaf90a24 | ||
|
|
f66c2525cb | ||
|
|
842d932ae6 | ||
|
|
37d1c822f2 | ||
|
|
865fd28704 | ||
|
|
43a92a764f | ||
|
|
17f783073e | ||
|
|
db86ff4ce3 | ||
|
|
ee042769e2 | ||
|
|
41ffb8537a | ||
|
|
80658c94e3 | ||
|
|
c35301e155 | ||
|
|
14f0421e7b | ||
|
|
3f772b5b27 | ||
|
|
a12547081a | ||
|
|
63f4befe80 | ||
|
|
0f25386e2e | ||
|
|
7f752dde99 | ||
|
|
93ef98a429 | ||
|
|
b71bd801fb | ||
|
|
9badeb243e | ||
|
|
9a683ec511 | ||
|
|
eaa89defd1 | ||
|
|
d245059a01 | ||
|
|
c30bf3e557 | ||
|
|
dd95f24980 | ||
|
|
1cf36241e4 | ||
|
|
2ab31f3d3e | ||
|
|
2915cb2113 | ||
|
|
7b1057df0c | ||
|
|
0b99ac4071 | ||
|
|
19260945bd | ||
|
|
93ea832394 | ||
|
|
38d42c4d9b | ||
|
|
2d02ba22fb | ||
|
|
91d8734198 | ||
|
|
b676b2975a | ||
|
|
44f37711cb | ||
|
|
cea4c3f452 | ||
|
|
12ab5f1a93 | ||
|
|
a5bc9f916a | ||
|
|
ad472d657e | ||
|
|
b4383b1626 | ||
|
|
ea287093c4 | ||
|
|
3688d86ef8 | ||
|
|
9f9903797e | ||
|
|
3cd8847a95 | ||
|
|
88bb55c8f2 | ||
|
|
935f3fac8e | ||
|
|
ad90d417cc | ||
|
|
4eae7502d2 | ||
|
|
d6326d8967 | ||
|
|
8ec3766da3 | ||
|
|
90fc171e26 | ||
|
|
972a52ff9c | ||
|
|
6b78838416 | ||
|
|
13051b2f06 | ||
|
|
aed4bf62ae | ||
|
|
a039e395f5 | ||
|
|
67b30c1549 | ||
|
|
40bdf4316b | ||
|
|
e3c05a9d47 | ||
|
|
6ab77612f5 | ||
|
|
a3073007a1 | ||
|
|
a95fb4a96b | ||
|
|
f1ca52714d | ||
|
|
77ed954d18 | ||
|
|
4fa2f9474b | ||
|
|
75c9c80679 | ||
|
|
3eaa9e02c9 | ||
|
|
f165263296 | ||
|
|
c122e1ddd7 | ||
|
|
d2ea030f03 | ||
|
|
eb87e3fd42 | ||
|
|
6d742e9081 | ||
|
|
317b947608 | ||
|
|
1cb8b989d1 | ||
|
|
bde1f5414c | ||
|
|
d15207fa51 | ||
|
|
e808ca0d08 | ||
|
|
c554adae0d | ||
|
|
590121d5f3 | ||
|
|
58a7a07942 | ||
|
|
72b87dfaa9 | ||
|
|
b8fb6987e0 | ||
|
|
dec081a326 | ||
|
|
52647b2996 | ||
|
|
905cfd2c21 | ||
|
|
7ad7489860 | ||
|
|
77c4c9add8 | ||
|
|
07019f764e | ||
|
|
80eed53305 | ||
|
|
14c4c1d1f5 | ||
|
|
fa7dd277e6 | ||
|
|
0e589e3f9a | ||
|
|
59703d50d6 | ||
|
|
5413269943 | ||
|
|
c5582ceb1e | ||
|
|
36dc62745c | ||
|
|
cb6e6909d4 | ||
|
|
b3be3f4940 | ||
|
|
7505007d8c | ||
|
|
fdd6c1a1f7 | ||
|
|
135c37fbf7 | ||
|
|
a7a96a7b0f | ||
|
|
52bacf0946 | ||
|
|
d55e1b8545 | ||
|
|
0c059d1dc1 | ||
|
|
b6b8b4ebe1 | ||
|
|
14f4845170 | ||
|
|
be8a589986 | ||
|
|
951e8d0feb | ||
|
|
98f8d1ca79 | ||
|
|
24f5717ae9 | ||
|
|
f10eefdc0e | ||
|
|
edd0fed518 | ||
|
|
86b50070e8 | ||
|
|
c6aab08faa | ||
|
|
62ec09d0ae | ||
|
|
f6f9cf7e9f | ||
|
|
56aeb499c9 |
@@ -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)
|
||||
]
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Install cargo-audit
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
name: Secrets Scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install trufflehog
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install system deps (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
archive: zip
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -212,22 +212,22 @@ jobs:
|
||||
name: Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU (for arm64 emulation)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -34,6 +34,7 @@ BUILD_LOG.md
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
._*
|
||||
Thumbs.db
|
||||
|
||||
# IDE & tools
|
||||
@@ -43,3 +44,4 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.serena/
|
||||
|
||||
@@ -56,6 +56,16 @@ Tests that require a real LLM key will skip gracefully if the env var is absent.
|
||||
cargo build --workspace
|
||||
```
|
||||
|
||||
### Fast Release Build (for development)
|
||||
|
||||
The default `--release` profile uses full LTO and single-codegen-unit, which produces the smallest/fastest binary but is slow to compile. For iterating locally, use the `release-fast` profile instead:
|
||||
|
||||
```bash
|
||||
cargo build --profile release-fast -p openfang-cli
|
||||
```
|
||||
|
||||
This cuts link time significantly (thin LTO, 8 codegen units, `opt-level=2`) while still producing a binary fast enough to run integration tests against. Use `--release` only for final binaries or CI.
|
||||
|
||||
### Run All Tests
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+763
-219
File diff suppressed because it is too large
Load Diff
+29
-9
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.38"
|
||||
version = "0.5.5"
|
||||
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
|
||||
@@ -52,7 +52,7 @@ 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"] }
|
||||
@@ -62,7 +62,7 @@ clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
|
||||
# HTTP client (for LLM drivers)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
|
||||
|
||||
# Async trait
|
||||
async-trait = "0.1"
|
||||
@@ -75,6 +75,7 @@ bytes = "1"
|
||||
|
||||
# Futures
|
||||
futures = "0.3"
|
||||
prost = "0.13"
|
||||
|
||||
# WebSocket client (for Discord/Slack gateway)
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
@@ -84,7 +85,7 @@ url = "2"
|
||||
wasmtime = "41"
|
||||
|
||||
# HTTP server (for API daemon)
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
axum = { version = "0.8", features = ["ws", "multipart"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "compression-br"] }
|
||||
|
||||
@@ -102,6 +103,9 @@ walkdir = "2"
|
||||
|
||||
# Security
|
||||
sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
subtle = "2"
|
||||
@@ -110,7 +114,7 @@ rand = "0.8"
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
|
||||
# Rate limiting
|
||||
governor = "0.8"
|
||||
governor = "0.10"
|
||||
|
||||
# Interactive CLI
|
||||
ratatui = "0.29"
|
||||
@@ -126,17 +130,26 @@ html-escape = "0.2"
|
||||
# Lightweight regex
|
||||
regex-lite = "0.1"
|
||||
|
||||
# MCP SDK (official Rust implementation)
|
||||
rmcp = { version = "1.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "reqwest"] }
|
||||
|
||||
# Socket options (SO_REUSEADDR)
|
||||
socket2 = "0.5"
|
||||
|
||||
# Zip archive extraction
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "4", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Email (SMTP + IMAP)
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
|
||||
imap = "2"
|
||||
native-tls = "0.2"
|
||||
mailparse = "0.15"
|
||||
native-tls = { version = "0.2", features = ["vendored"] }
|
||||
mailparse = "0.16"
|
||||
|
||||
# MQTT client
|
||||
rumqttc = "0.24"
|
||||
|
||||
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
# Testing
|
||||
tokio-test = "0.4"
|
||||
@@ -147,3 +160,10 @@ lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
opt-level = 3
|
||||
|
||||
[profile.release-fast]
|
||||
inherits = "release"
|
||||
lto = "thin"
|
||||
codegen-units = 8
|
||||
opt-level = 2
|
||||
strip = false
|
||||
|
||||
+16
-2
@@ -7,10 +7,24 @@ COPY crates ./crates
|
||||
COPY xtask ./xtask
|
||||
COPY agents ./agents
|
||||
COPY packages ./packages
|
||||
# Optional build args for dev environments to speed up compilation
|
||||
# Example: docker build --build-arg LTO=false --build-arg CODEGEN_UNITS=16 .
|
||||
ARG LTO=true
|
||||
ARG CODEGEN_UNITS=1
|
||||
ENV CARGO_PROFILE_RELEASE_LTO=${LTO} \
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=${CODEGEN_UNITS}
|
||||
RUN cargo build --release --bin openfang
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
FROM rust:1-slim-bookworm
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /build/target/release/openfang /usr/local/bin/
|
||||
COPY --from=builder /build/agents /opt/openfang/agents
|
||||
EXPOSE 4200
|
||||
|
||||
@@ -264,6 +264,97 @@ Connect your agents to every platform your users are on.
|
||||
|
||||
Each adapter supports per-channel model overrides, DM/group policies, rate limiting, and output formatting.
|
||||
|
||||
---
|
||||
|
||||
## WhatsApp Web Gateway (QR Code)
|
||||
|
||||
Connect your personal WhatsApp account to OpenFang via QR code — just like WhatsApp Web. No Meta Business account required.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js >= 18** installed ([download](https://nodejs.org/))
|
||||
- OpenFang installed and initialized
|
||||
|
||||
### Setup
|
||||
|
||||
**1. Install the gateway dependencies:**
|
||||
|
||||
```bash
|
||||
cd packages/whatsapp-gateway
|
||||
npm install
|
||||
```
|
||||
|
||||
**2. Configure `config.toml`:**
|
||||
|
||||
```toml
|
||||
[channels.whatsapp]
|
||||
mode = "web"
|
||||
default_agent = "assistant"
|
||||
```
|
||||
|
||||
**3. Set the gateway URL (choose one):**
|
||||
|
||||
Add to your shell profile for persistence:
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
echo 'export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
Or set it inline when starting the gateway:
|
||||
|
||||
```bash
|
||||
export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"
|
||||
```
|
||||
|
||||
**4. Start the gateway:**
|
||||
|
||||
```bash
|
||||
node packages/whatsapp-gateway/index.js
|
||||
```
|
||||
|
||||
The gateway listens on port `3009` by default. Override with `WHATSAPP_GATEWAY_PORT`.
|
||||
|
||||
**5. Start OpenFang:**
|
||||
|
||||
```bash
|
||||
openfang start
|
||||
# Dashboard at http://localhost:4200
|
||||
```
|
||||
|
||||
**6. Scan the QR code:**
|
||||
|
||||
Open the dashboard → **Channels** → **WhatsApp**. A QR code will appear. Scan it with your phone:
|
||||
|
||||
> **WhatsApp** → **Settings** → **Linked Devices** → **Link a Device**
|
||||
|
||||
Once scanned, the status changes to `connected` and incoming messages are routed to your configured agent.
|
||||
|
||||
### Gateway Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `WHATSAPP_WEB_GATEWAY_URL` | Gateway URL for OpenFang to connect to | _(empty = disabled)_ |
|
||||
| `WHATSAPP_GATEWAY_PORT` | Port the gateway listens on | `3009` |
|
||||
| `OPENFANG_URL` | OpenFang API URL the gateway reports to | `http://127.0.0.1:4200` |
|
||||
| `OPENFANG_DEFAULT_AGENT` | Agent that handles incoming messages | `assistant` |
|
||||
|
||||
### Gateway API Endpoints
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| `POST` | `/login/start` | Generate QR code (returns base64 PNG) |
|
||||
| `GET` | `/login/status` | Connection status (`disconnected`, `qr_ready`, `connected`) |
|
||||
| `POST` | `/message/send` | Send a message (`{ "to": "5511999999999", "text": "Hello" }`) |
|
||||
| `GET` | `/health` | Health check |
|
||||
|
||||
### Alternative: WhatsApp Cloud API
|
||||
|
||||
For production workloads, use the [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api) with a Meta Business account. See the [Cloud API configuration docs](https://openfang.sh/docs/channels/whatsapp).
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 27 LLM Providers — 123+ Models
|
||||
|
||||
@@ -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,6 +33,9 @@ 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 }
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ use openfang_channels::messenger::MessengerAdapter;
|
||||
use openfang_channels::reddit::RedditAdapter;
|
||||
use openfang_channels::revolt::RevoltAdapter;
|
||||
use openfang_channels::viber::ViberAdapter;
|
||||
use openfang_types::config::FeishuMode;
|
||||
// Wave 4
|
||||
use openfang_channels::flock::FlockAdapter;
|
||||
use openfang_channels::guilded::GuildedAdapter;
|
||||
@@ -43,19 +44,24 @@ use openfang_channels::webex::WebexAdapter;
|
||||
// Wave 5
|
||||
use async_trait::async_trait;
|
||||
use openfang_channels::dingtalk::DingTalkAdapter;
|
||||
use openfang_channels::dingtalk_stream::DingTalkStreamAdapter;
|
||||
use openfang_channels::discourse::DiscourseAdapter;
|
||||
use openfang_channels::gitter::GitterAdapter;
|
||||
use openfang_channels::gotify::GotifyAdapter;
|
||||
use openfang_channels::linkedin::LinkedInAdapter;
|
||||
use openfang_channels::mumble::MumbleAdapter;
|
||||
use openfang_channels::mqtt::MqttAdapter;
|
||||
use openfang_channels::ntfy::NtfyAdapter;
|
||||
use openfang_channels::webhook::WebhookAdapter;
|
||||
use openfang_channels::wecom::WeComAdapter;
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_types::agent::AgentId;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use openfang_runtime::str_utils::safe_truncate_str;
|
||||
|
||||
/// Wraps `OpenFangKernel` to implement `ChannelBridgeHandle`.
|
||||
pub struct KernelBridgeAdapter {
|
||||
kernel: Arc<OpenFangKernel>,
|
||||
@@ -70,6 +76,10 @@ 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)
|
||||
}
|
||||
|
||||
@@ -82,7 +92,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
let text: String = blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
openfang_types::message::ContentBlock::Text { text } => Some(text.as_str()),
|
||||
openfang_types::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -378,7 +388,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.map(|e| e.name.clone())
|
||||
.unwrap_or_else(|| t.agent_id.to_string());
|
||||
let status = if t.enabled { "on" } else { "off" };
|
||||
let id_short = &t.id.0.to_string()[..8];
|
||||
let id_str = t.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
msg.push_str(&format!(
|
||||
" [{}] {} -> {} ({:?}) fires:{} [{}]\n",
|
||||
id_short,
|
||||
@@ -417,7 +428,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.kernel
|
||||
.triggers
|
||||
.register(agent.id, pattern, prompt.to_string(), 0);
|
||||
let id_short = &trigger_id.0.to_string()[..8];
|
||||
let id_str = trigger_id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Trigger created [{id_short}] for agent '{agent_name}'.")
|
||||
}
|
||||
|
||||
@@ -432,7 +444,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
1 => {
|
||||
let t = matched[0];
|
||||
if self.kernel.triggers.remove(t.id) {
|
||||
format!("Trigger [{}] removed.", &t.id.0.to_string()[..8])
|
||||
let id_str = t.id.0.to_string();
|
||||
format!("Trigger [{}] removed.", safe_truncate_str(&id_str, 8))
|
||||
} else {
|
||||
"Failed to remove trigger.".to_string()
|
||||
}
|
||||
@@ -455,7 +468,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.map(|e| e.name.clone())
|
||||
.unwrap_or_else(|| job.agent_id.to_string());
|
||||
let status = if job.enabled { "on" } else { "off" };
|
||||
let id_short = &job.id.0.to_string()[..8];
|
||||
let id_str = job.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
let sched = match &job.schedule {
|
||||
openfang_types::scheduler::CronSchedule::Cron { expr, .. } => expr.clone(),
|
||||
openfang_types::scheduler::CronSchedule::Every { every_secs } => {
|
||||
@@ -477,6 +491,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
msg
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn manage_schedule_text(&self, action: &str, args: &[String]) -> String {
|
||||
match action {
|
||||
"add" => {
|
||||
@@ -515,7 +530,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
|
||||
match self.kernel.cron_scheduler.add_job(job, false) {
|
||||
Ok(id) => {
|
||||
let id_short = &id.0.to_string()[..8];
|
||||
let id_str = id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Job [{id_short}] created: '{cron_expr}' -> {agent_name}: \"{message}\"")
|
||||
}
|
||||
Err(e) => format!("Failed to create job: {e}"),
|
||||
@@ -537,7 +553,12 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
let j = matched[0];
|
||||
match self.kernel.cron_scheduler.remove_job(j.id) {
|
||||
Ok(_) => {
|
||||
format!("Job [{}] '{}' removed.", &j.id.0.to_string()[..8], j.name)
|
||||
let id_str = j.id.0.to_string();
|
||||
format!(
|
||||
"Job [{}] '{}' removed.",
|
||||
safe_truncate_str(&id_str, 8),
|
||||
j.name
|
||||
)
|
||||
}
|
||||
Err(e) => format!("Failed to remove job: {e}"),
|
||||
}
|
||||
@@ -566,10 +587,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
openfang_types::scheduler::CronAction::SystemEvent { text } => {
|
||||
text.clone()
|
||||
}
|
||||
openfang_types::scheduler::CronAction::WorkflowRun {
|
||||
workflow_id,
|
||||
input,
|
||||
..
|
||||
} => {
|
||||
format!(
|
||||
"Run workflow {workflow_id}{}",
|
||||
input
|
||||
.as_deref()
|
||||
.map(|i| format!(" with input: {i}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
};
|
||||
match self.kernel.send_message(j.agent_id, &message).await {
|
||||
Ok(result) => {
|
||||
let id_short = &j.id.0.to_string()[..8];
|
||||
let id_str = j.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Job [{id_short}] ran:\n{}", result.response)
|
||||
}
|
||||
Err(e) => format!("Failed to run job: {e}"),
|
||||
@@ -589,7 +624,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
}
|
||||
let mut msg = format!("Pending approvals ({}):\n", pending.len());
|
||||
for req in &pending {
|
||||
let id_short = &req.id.to_string()[..8];
|
||||
let id_str = req.id.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds();
|
||||
let age = if age_secs >= 60 {
|
||||
format!("{}m", age_secs / 60)
|
||||
@@ -630,10 +666,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
) {
|
||||
Ok(_) => {
|
||||
let verb = if approve { "Approved" } else { "Rejected" };
|
||||
let id_str = req.id.to_string();
|
||||
format!(
|
||||
"{} [{}] {} — {}",
|
||||
verb,
|
||||
&req.id.to_string()[..8],
|
||||
safe_truncate_str(&id_str, 8),
|
||||
req.tool_name,
|
||||
req.agent_id
|
||||
)
|
||||
@@ -673,7 +710,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
));
|
||||
}
|
||||
self.kernel
|
||||
.set_agent_model(agent_id, model)
|
||||
.set_agent_model(agent_id, model, None)
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
// Read back resolved model+provider from registry
|
||||
let entry = self
|
||||
@@ -763,12 +800,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
// Wave 5
|
||||
"mumble" => channels.mumble.as_ref().map(|c| c.overrides.clone()),
|
||||
"dingtalk" => channels.dingtalk.as_ref().map(|c| c.overrides.clone()),
|
||||
"dingtalk_stream" => channels
|
||||
.dingtalk_stream
|
||||
.as_ref()
|
||||
.map(|c| c.overrides.clone()),
|
||||
"discourse" => channels.discourse.as_ref().map(|c| c.overrides.clone()),
|
||||
"gitter" => channels.gitter.as_ref().map(|c| c.overrides.clone()),
|
||||
"ntfy" => channels.ntfy.as_ref().map(|c| c.overrides.clone()),
|
||||
"gotify" => channels.gotify.as_ref().map(|c| c.overrides.clone()),
|
||||
"webhook" => channels.webhook.as_ref().map(|c| c.overrides.clone()),
|
||||
"linkedin" => channels.linkedin.as_ref().map(|c| c.overrides.clone()),
|
||||
"wecom" => channels.wecom.as_ref().map(|c| c.overrides.clone()),
|
||||
"mqtt" => channels.mqtt.as_ref().map(|c| c.overrides.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -810,6 +853,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
recipient: &str,
|
||||
success: bool,
|
||||
error: Option<&str>,
|
||||
thread_id: Option<&str>,
|
||||
) {
|
||||
let receipt = if success {
|
||||
openfang_kernel::DeliveryTracker::sent_receipt(channel, recipient)
|
||||
@@ -822,9 +866,13 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
};
|
||||
self.kernel.delivery_tracker.record(agent_id, receipt);
|
||||
|
||||
// Persist last channel for cron CronDelivery::LastChannel
|
||||
// Persist last channel for cron CronDelivery::LastChannel.
|
||||
// Include thread_id when present so forum-topic context survives restarts.
|
||||
if success {
|
||||
let kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
|
||||
let mut kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
|
||||
if let Some(tid) = thread_id {
|
||||
kv_val["thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let _ = self
|
||||
.kernel
|
||||
.memory
|
||||
@@ -895,7 +943,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
return "OFP peer network is disabled. Set network_enabled = true in config.toml."
|
||||
.to_string();
|
||||
}
|
||||
match &self.kernel.peer_registry {
|
||||
match self.kernel.peer_registry.get() {
|
||||
Some(registry) => {
|
||||
let peers = registry.all_peers();
|
||||
if peers.is_empty() {
|
||||
@@ -970,16 +1018,39 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a token from an env var, returning None with a warning if missing/empty.
|
||||
fn read_token(env_var: &str, adapter_name: &str) -> Option<String> {
|
||||
match std::env::var(env_var) {
|
||||
/// Resolve a token: if the value looks like an actual secret (contains `:`,
|
||||
/// starts with `xoxb-`, `xapp-`, `sk-`, etc.), use it directly.
|
||||
/// Otherwise treat it as an env var name and look it up.
|
||||
fn read_token(env_var_or_token: &str, adapter_name: &str) -> Option<String> {
|
||||
// Heuristic: actual tokens contain `:` (Telegram, Discord) or start with
|
||||
// known prefixes. Env var names are uppercase ASCII identifiers.
|
||||
let looks_like_token = env_var_or_token.contains(':')
|
||||
|| env_var_or_token.starts_with("xoxb-")
|
||||
|| env_var_or_token.starts_with("xapp-")
|
||||
|| env_var_or_token.starts_with("sk-")
|
||||
|| env_var_or_token.starts_with("Bearer ")
|
||||
|| env_var_or_token.len() > 80; // Long random strings are tokens, not env var names
|
||||
|
||||
if looks_like_token {
|
||||
warn!(
|
||||
"{adapter_name}: config field contains what looks like an actual token \
|
||||
rather than an env var name — using it directly. \
|
||||
Tip: store the token in an env var and use the var name instead for security."
|
||||
);
|
||||
return Some(env_var_or_token.to_string());
|
||||
}
|
||||
|
||||
match std::env::var(env_var_or_token) {
|
||||
Ok(t) if !t.is_empty() => Some(t),
|
||||
Ok(_) => {
|
||||
warn!("{adapter_name} bot token env var '{env_var}' is empty, skipping");
|
||||
warn!("{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("{adapter_name} bot token env var '{env_var}' not set, skipping");
|
||||
warn!(
|
||||
"{adapter_name} token env var '{env_var_or_token}' not set, skipping. \
|
||||
Set it with: export {env_var_or_token}=<your-token>"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1039,6 +1110,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Wave 5
|
||||
|| config.mumble.is_some()
|
||||
|| config.dingtalk.is_some()
|
||||
|| config.dingtalk_stream.is_some()
|
||||
|| config.discourse.is_some()
|
||||
|| config.gitter.is_some()
|
||||
|| config.ntfy.is_some()
|
||||
@@ -1094,6 +1166,9 @@ pub async fn start_channel_bridge_with_config(
|
||||
app_token,
|
||||
bot_token,
|
||||
sl_config.allowed_channels.clone(),
|
||||
sl_config.auto_thread_reply,
|
||||
sl_config.thread_ttl_hours,
|
||||
sl_config.unfurl_links,
|
||||
));
|
||||
adapters.push((adapter, sl_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1103,7 +1178,9 @@ pub async fn start_channel_bridge_with_config(
|
||||
// WhatsApp — supports Cloud API mode (access token) or Web/QR mode (gateway URL)
|
||||
if let Some(ref wa_config) = config.whatsapp {
|
||||
let cloud_token = read_token(&wa_config.access_token_env, "WhatsApp");
|
||||
let gateway_url = std::env::var(&wa_config.gateway_url_env).ok().filter(|u| !u.is_empty());
|
||||
let gateway_url = std::env::var(&wa_config.gateway_url_env)
|
||||
.ok()
|
||||
.filter(|u| !u.is_empty());
|
||||
|
||||
if cloud_token.is_some() || gateway_url.is_some() {
|
||||
let token = cloud_token.unwrap_or_default();
|
||||
@@ -1145,6 +1222,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
mx_config.user_id.clone(),
|
||||
token,
|
||||
mx_config.allowed_rooms.clone(),
|
||||
mx_config.auto_accept_invites,
|
||||
));
|
||||
adapters.push((adapter, mx_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1351,11 +1429,27 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Feishu/Lark
|
||||
if let Some(ref fs_config) = config.feishu {
|
||||
if let Some(secret) = read_token(&fs_config.app_secret_env, "Feishu") {
|
||||
let adapter = Arc::new(FeishuAdapter::new(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
fs_config.webhook_port,
|
||||
));
|
||||
let region = openfang_channels::feishu::FeishuRegion::parse_region(&fs_config.region);
|
||||
let encrypt_key = fs_config
|
||||
.encrypt_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| read_token(env, "Feishu encrypt_key"));
|
||||
let adapter = match fs_config.mode {
|
||||
FeishuMode::Webhook => Arc::new(FeishuAdapter::with_config(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
fs_config.webhook_port,
|
||||
region,
|
||||
Some(fs_config.webhook_path.clone()),
|
||||
fs_config.verification_token.clone(),
|
||||
encrypt_key,
|
||||
fs_config.bot_names.clone(),
|
||||
)),
|
||||
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket(
|
||||
fs_config.app_id.clone(),
|
||||
secret,
|
||||
)),
|
||||
};
|
||||
adapters.push((adapter, fs_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
@@ -1368,6 +1462,21 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// WeCom/WeChat Work
|
||||
if let Some(ref wc_config) = config.wecom {
|
||||
if let Some(secret) = read_token(&wc_config.secret_env, "WeCom") {
|
||||
let adapter = Arc::new(WeComAdapter::with_verification(
|
||||
wc_config.corp_id.clone(),
|
||||
wc_config.agent_id.clone(),
|
||||
secret,
|
||||
wc_config.webhook_port,
|
||||
wc_config.encoding_aes_key.clone(),
|
||||
wc_config.token.clone(),
|
||||
));
|
||||
adapters.push((adapter, wc_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wave 4 ──────────────────────────────────────────────────
|
||||
|
||||
// Nextcloud Talk
|
||||
@@ -1474,7 +1583,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// DingTalk
|
||||
// DingTalk (webhook mode)
|
||||
if let Some(ref dt_config) = config.dingtalk {
|
||||
if let Some(token) = read_token(&dt_config.access_token_env, "DingTalk") {
|
||||
let secret = read_token(&dt_config.secret_env, "DingTalk (secret)").unwrap_or_default();
|
||||
@@ -1483,6 +1592,21 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// DingTalk (stream mode)
|
||||
if let Some(ref ds_config) = config.dingtalk_stream {
|
||||
if let Some(app_key) = read_token(&ds_config.app_key_env, "DingTalk Stream (app_key)") {
|
||||
if let Some(app_secret) =
|
||||
read_token(&ds_config.app_secret_env, "DingTalk Stream (app_secret)")
|
||||
{
|
||||
let robot_code =
|
||||
read_token(&ds_config.robot_code_env, "DingTalk Stream (robot_code)")
|
||||
.unwrap_or_else(|| app_key.clone());
|
||||
let adapter = Arc::new(DingTalkStreamAdapter::new(app_key, app_secret, robot_code));
|
||||
adapters.push((adapter, ds_config.default_agent.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discourse
|
||||
if let Some(ref dc_config) = config.discourse {
|
||||
if let Some(api_key) = read_token(&dc_config.api_key_env, "Discourse") {
|
||||
@@ -1556,6 +1680,25 @@ pub async fn start_channel_bridge_with_config(
|
||||
}
|
||||
}
|
||||
|
||||
// MQTT
|
||||
if let Some(ref mq_config) = config.mqtt {
|
||||
let username = read_token(&mq_config.username_env, "MQTT (username)");
|
||||
let password = read_token(&mq_config.password_env, "MQTT (password)");
|
||||
let adapter = Arc::new(MqttAdapter::new(
|
||||
mq_config.broker_url.clone(),
|
||||
mq_config.client_id.clone(),
|
||||
mq_config.subscribe_topic.clone(),
|
||||
mq_config.publish_topic.clone(),
|
||||
username,
|
||||
password,
|
||||
mq_config.use_tls,
|
||||
mq_config.keep_alive_secs,
|
||||
mq_config.clean_session,
|
||||
mq_config.qos,
|
||||
));
|
||||
adapters.push((adapter, mq_config.default_agent.clone()));
|
||||
}
|
||||
|
||||
if adapters.is_empty() {
|
||||
return (None, Vec::new());
|
||||
}
|
||||
@@ -1587,7 +1730,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
|
||||
adapter.name()
|
||||
);
|
||||
router.set_channel_default(channel_key, agent_id);
|
||||
router.set_channel_default_with_name(channel_key, agent_id, name.clone());
|
||||
// First configured default also becomes system-wide fallback
|
||||
if !system_default_set {
|
||||
router.set_default(agent_id);
|
||||
@@ -1760,4 +1903,35 @@ mod tests {
|
||||
assert!(config.channels.webhook.is_none());
|
||||
assert!(config.channels.linkedin.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feishu_bridge_mode_defaults_to_websocket() {
|
||||
let config: openfang_types::config::KernelConfig = toml::from_str(
|
||||
r#"
|
||||
[channels.feishu]
|
||||
app_id = "cli_test"
|
||||
app_secret_env = "FEISHU_APP_SECRET"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let feishu = config.channels.feishu.expect("feishu config should exist");
|
||||
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_feishu_bridge_mode_supports_websocket() {
|
||||
let config: openfang_types::config::KernelConfig = toml::from_str(
|
||||
r#"
|
||||
[channels.feishu]
|
||||
app_id = "cli_test"
|
||||
app_secret_env = "FEISHU_APP_SECRET"
|
||||
mode = "websocket"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let feishu = config.channels.feishu.expect("feishu config should exist");
|
||||
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod openai_compat;
|
||||
pub mod rate_limiter;
|
||||
pub mod routes;
|
||||
pub mod server;
|
||||
pub mod session_auth;
|
||||
pub mod stream_chunker;
|
||||
pub mod stream_dedup;
|
||||
pub mod types;
|
||||
|
||||
@@ -43,14 +43,24 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
|
||||
response
|
||||
}
|
||||
|
||||
/// Authentication state passed to the auth middleware.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub api_key: String,
|
||||
pub auth_enabled: bool,
|
||||
pub session_secret: String,
|
||||
}
|
||||
|
||||
/// Bearer token authentication middleware.
|
||||
///
|
||||
/// When `api_key` is non-empty (after trimming), requests to non-public
|
||||
/// endpoints must include `Authorization: Bearer <api_key>`.
|
||||
/// If the key is empty or whitespace-only, auth is disabled entirely
|
||||
/// (public/local development mode).
|
||||
///
|
||||
/// When dashboard auth is enabled, session cookies are also accepted.
|
||||
pub async fn auth(
|
||||
axum::extract::State(api_key): axum::extract::State<String>,
|
||||
axum::extract::State(auth_state): axum::extract::State<AuthState>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
@@ -114,7 +124,10 @@ pub async fn auth(
|
||||
|| (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.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;
|
||||
@@ -123,10 +136,11 @@ pub async fn auth(
|
||||
// If no API key configured (empty, whitespace-only, or missing), skip auth
|
||||
// entirely. Users who don't set api_key accept that all endpoints are open.
|
||||
// To secure the dashboard, set a non-empty api_key in config.toml.
|
||||
let api_key = api_key.trim();
|
||||
if api_key.is_empty() {
|
||||
let api_key_trimmed = auth_state.api_key.trim().to_string();
|
||||
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let api_key = api_key_trimmed.as_str();
|
||||
|
||||
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
|
||||
let bearer_token = request
|
||||
@@ -172,6 +186,17 @@ pub async fn auth(
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Check session cookie (dashboard login sessions)
|
||||
if auth_state.auth_enabled {
|
||||
if let Some(token) = extract_session_cookie(&request) {
|
||||
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
|
||||
.is_some()
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine error message: was a credential provided but wrong, or missing entirely?
|
||||
let credential_provided = header_auth.is_some() || query_auth.is_some();
|
||||
let error_msg = if credential_provided {
|
||||
@@ -189,6 +214,21 @@ pub async fn auth(
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extract the `openfang_session` cookie value from a request.
|
||||
fn extract_session_cookie(request: &Request<Body>) -> Option<String> {
|
||||
request
|
||||
.headers()
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies.split(';').find_map(|c| {
|
||||
c.trim()
|
||||
.strip_prefix("openfang_session=")
|
||||
.map(|v| v.to_string())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Security headers middleware — applied to ALL API responses.
|
||||
pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Body> {
|
||||
let mut response = next.run(request).await;
|
||||
@@ -196,13 +236,16 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
headers.insert("x-content-type-options", "nosniff".parse().unwrap());
|
||||
headers.insert("x-frame-options", "DENY".parse().unwrap());
|
||||
headers.insert("x-xss-protection", "1; mode=block".parse().unwrap());
|
||||
// All JS/CSS is bundled inline — only external resource is Google Fonts.
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
// The dashboard handler (webchat_page) sets its own nonce-based CSP.
|
||||
// For all other responses (API endpoints), apply a strict default.
|
||||
if !headers.contains_key("content-security-policy") {
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
headers.insert(
|
||||
"referrer-policy",
|
||||
"strict-origin-when-cross-origin".parse().unwrap(),
|
||||
|
||||
@@ -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:") {
|
||||
@@ -322,7 +323,7 @@ pub async fn chat_completions(
|
||||
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
|
||||
match state
|
||||
.kernel
|
||||
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle))
|
||||
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle), None, None)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -378,7 +379,7 @@ async fn stream_response(
|
||||
|
||||
let (mut rx, _handle) = state
|
||||
.kernel
|
||||
.send_message_streaming(agent_id, message, Some(kernel_handle))
|
||||
.send_message_streaming(agent_id, message, Some(kernel_handle), None, None, None)
|
||||
.map_err(|e| format!("Streaming setup failed: {e}"))?;
|
||||
|
||||
let (tx, stream_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(64);
|
||||
|
||||
@@ -29,6 +29,7 @@ pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
|
||||
("POST", p) if p.contains("/run") => NonZeroU32::new(100).unwrap(),
|
||||
("POST", "/api/skills/install") => NonZeroU32::new(50).unwrap(),
|
||||
("POST", "/api/skills/uninstall") => NonZeroU32::new(10).unwrap(),
|
||||
("POST", "/api/skills/reload") => NonZeroU32::new(5).unwrap(),
|
||||
("POST", "/api/migrate") => NonZeroU32::new(100).unwrap(),
|
||||
("PUT", p) if p.contains("/update") => NonZeroU32::new(10).unwrap(),
|
||||
_ => NonZeroU32::new(5).unwrap(),
|
||||
|
||||
+1382
-339
File diff suppressed because it is too large
Load Diff
@@ -45,12 +45,13 @@ pub async fn build_router(
|
||||
let state = Arc::new(AppState {
|
||||
kernel: kernel.clone(),
|
||||
started_at: Instant::now(),
|
||||
peer_registry: kernel.peer_registry.as_ref().map(|r| Arc::new(r.clone())),
|
||||
peer_registry: kernel.peer_registry.get().map(|r| Arc::new(r.clone())),
|
||||
bridge_manager: tokio::sync::Mutex::new(bridge),
|
||||
channels_config: tokio::sync::RwLock::new(channels_config),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(kernel.config.budget.clone())),
|
||||
});
|
||||
|
||||
// CORS: allow localhost origins by default. If API key is set, the API
|
||||
@@ -105,12 +106,25 @@ pub async fn build_router(
|
||||
|
||||
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
let auth_state = crate::middleware::AuthState {
|
||||
api_key: api_key.clone(),
|
||||
auth_enabled: state.kernel.config.auth.enabled,
|
||||
session_secret: if !api_key.is_empty() {
|
||||
api_key.clone()
|
||||
} else if state.kernel.config.auth.enabled {
|
||||
state.kernel.config.auth.password_hash.clone()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
};
|
||||
let gcra_limiter = rate_limiter::create_rate_limiter();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", axum::routing::get(webchat::webchat_page))
|
||||
.route("/logo.png", axum::routing::get(webchat::logo_png))
|
||||
.route("/favicon.ico", axum::routing::get(webchat::favicon_ico))
|
||||
.route("/manifest.json", axum::routing::get(webchat::manifest_json))
|
||||
.route("/sw.js", axum::routing::get(webchat::sw_js))
|
||||
.route(
|
||||
"/api/metrics",
|
||||
axum::routing::get(routes::prometheus_metrics),
|
||||
@@ -128,13 +142,23 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}",
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent),
|
||||
axum::routing::get(routes::get_agent)
|
||||
.delete(routes::kill_agent)
|
||||
.patch(routes::patch_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/mode",
|
||||
axum::routing::put(routes::set_agent_mode),
|
||||
)
|
||||
.route("/api/profiles", axum::routing::get(routes::list_profiles))
|
||||
.route(
|
||||
"/api/agents/{id}/restart",
|
||||
axum::routing::post(routes::restart_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/start",
|
||||
axum::routing::post(routes::restart_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/message",
|
||||
axum::routing::post(routes::send_message),
|
||||
@@ -288,6 +312,12 @@ pub async fn build_router(
|
||||
"/api/workflows",
|
||||
axum::routing::get(routes::list_workflows).post(routes::create_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/workflows/{id}",
|
||||
axum::routing::get(routes::get_workflow)
|
||||
.put(routes::update_workflow)
|
||||
.delete(routes::delete_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/workflows/{id}/run",
|
||||
axum::routing::post(routes::run_workflow),
|
||||
@@ -306,6 +336,10 @@ pub async fn build_router(
|
||||
"/api/skills/uninstall",
|
||||
axum::routing::post(routes::uninstall_skill),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/reload",
|
||||
axum::routing::post(routes::reload_skills),
|
||||
)
|
||||
.route(
|
||||
"/api/marketplace/search",
|
||||
axum::routing::get(routes::marketplace_search),
|
||||
@@ -337,6 +371,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),
|
||||
@@ -356,8 +394,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",
|
||||
@@ -414,14 +451,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
|
||||
@@ -466,8 +500,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))
|
||||
@@ -558,6 +591,10 @@ pub async fn build_router(
|
||||
"/api/cron/jobs/{id}/status",
|
||||
axum::routing::get(routes::cron_job_status),
|
||||
)
|
||||
.route(
|
||||
"/api/cron/jobs/{id}/run",
|
||||
axum::routing::post(routes::run_cron_job),
|
||||
)
|
||||
// Webhook trigger endpoints (external event injection)
|
||||
.route("/hooks/wake", axum::routing::post(routes::webhook_wake))
|
||||
.route("/hooks/agent", axum::routing::post(routes::webhook_agent))
|
||||
@@ -671,8 +708,12 @@ pub async fn build_router(
|
||||
"/v1/models",
|
||||
axum::routing::get(crate::openai_compat::list_models),
|
||||
)
|
||||
// Dashboard authentication endpoints
|
||||
.route("/api/auth/login", axum::routing::post(routes::auth_login))
|
||||
.route("/api/auth/logout", axum::routing::post(routes::auth_logout))
|
||||
.route("/api/auth/check", axum::routing::get(routes::auth_check))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
api_key,
|
||||
auth_state,
|
||||
middleware::auth,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
@@ -789,8 +830,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
|
||||
@@ -921,11 +961,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,109 @@
|
||||
//! Stateless session token authentication for the dashboard.
|
||||
//! Tokens are HMAC-SHA256 signed and contain username + expiry.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Create a session token: base64(username:expiry_unix:hmac_hex)
|
||||
pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> String {
|
||||
use base64::Engine;
|
||||
let expiry = chrono::Utc::now().timestamp() + (ttl_hours as i64 * 3600);
|
||||
let payload = format!("{username}:{expiry}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key");
|
||||
mac.update(payload.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
|
||||
}
|
||||
|
||||
/// Verify a session token. Returns the username if valid and not expired.
|
||||
pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
|
||||
use base64::Engine;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(token)
|
||||
.ok()?;
|
||||
let decoded_str = String::from_utf8(decoded).ok()?;
|
||||
let parts: Vec<&str> = decoded_str.splitn(3, ':').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let (username, expiry_str, provided_sig) = (parts[0], parts[1], parts[2]);
|
||||
|
||||
let expiry: i64 = expiry_str.parse().ok()?;
|
||||
if chrono::Utc::now().timestamp() > expiry {
|
||||
return None;
|
||||
}
|
||||
|
||||
let payload = format!("{username}:{expiry_str}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
|
||||
mac.update(payload.as_bytes());
|
||||
let expected_sig = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
if provided_sig.len() != expected_sig.len() {
|
||||
return None;
|
||||
}
|
||||
if provided_sig
|
||||
.as_bytes()
|
||||
.ct_eq(expected_sig.as_bytes())
|
||||
.into()
|
||||
{
|
||||
Some(username.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash a password with SHA256 for config storage.
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
use sha2::Digest;
|
||||
hex::encode(Sha256::digest(password.as_bytes()))
|
||||
}
|
||||
|
||||
/// Verify a password against a stored SHA256 hash (constant-time).
|
||||
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
|
||||
let computed = hash_password(password);
|
||||
use subtle::ConstantTimeEq;
|
||||
if computed.len() != stored_hash.len() {
|
||||
return false;
|
||||
}
|
||||
computed.as_bytes().ct_eq(stored_hash.as_bytes()).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify_password() {
|
||||
let hash = hash_password("secret123");
|
||||
assert!(verify_password("secret123", &hash));
|
||||
assert!(!verify_password("wrong", &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_verify_token() {
|
||||
let token = create_session_token("admin", "my-secret", 1);
|
||||
let user = verify_session_token(&token, "my-secret");
|
||||
assert_eq!(user, Some("admin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_wrong_secret() {
|
||||
let token = create_session_token("admin", "my-secret", 1);
|
||||
let user = verify_session_token(&token, "wrong-secret");
|
||||
assert_eq!(user, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_invalid_base64() {
|
||||
let user = verify_session_token("not-valid-base64!!!", "secret");
|
||||
assert_eq!(user, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_hash_length_mismatch() {
|
||||
assert!(!verify_password("x", "short"));
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,12 @@ pub struct MessageRequest {
|
||||
/// Optional file attachments (uploaded via /upload endpoint).
|
||||
#[serde(default)]
|
||||
pub attachments: Vec<AttachmentRef>,
|
||||
/// Sender identity (e.g. WhatsApp phone number, Telegram user ID).
|
||||
#[serde(default)]
|
||||
pub sender_id: Option<String>,
|
||||
/// Sender display name.
|
||||
#[serde(default)]
|
||||
pub sender_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from sending a message.
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
use axum::http::header;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
/// Nonce placeholder in compile-time HTML, replaced at request time.
|
||||
const NONCE_PLACEHOLDER: &str = "__NONCE__";
|
||||
|
||||
/// Compile-time ETag based on the crate version.
|
||||
/// Not used for the dashboard page (nonce prevents caching) but retained
|
||||
/// for potential future use by static asset handlers.
|
||||
#[allow(dead_code)]
|
||||
const ETAG: &str = concat!("\"openfang-", env!("CARGO_PKG_VERSION"), "\"");
|
||||
|
||||
/// Embedded logo PNG for single-binary deployment.
|
||||
@@ -46,20 +52,65 @@ pub async fn favicon_ico() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
/// GET / — Serve the OpenFang Dashboard single-page application.
|
||||
///
|
||||
/// Returns the full SPA with ETag header based on package version for caching.
|
||||
pub async fn webchat_page() -> impl IntoResponse {
|
||||
/// Embedded PWA manifest for installable web app support.
|
||||
const MANIFEST_JSON: &str = include_str!("../static/manifest.json");
|
||||
|
||||
/// Embedded service worker for PWA support.
|
||||
const SW_JS: &str = include_str!("../static/sw.js");
|
||||
|
||||
/// GET /manifest.json — Serve the PWA web app manifest.
|
||||
pub async fn manifest_json() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
|
||||
(header::ETAG, ETAG),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
"public, max-age=3600, must-revalidate",
|
||||
),
|
||||
(header::CONTENT_TYPE, "application/manifest+json"),
|
||||
(header::CACHE_CONTROL, "public, max-age=86400, immutable"),
|
||||
],
|
||||
WEBCHAT_HTML,
|
||||
MANIFEST_JSON,
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /sw.js — Serve the PWA service worker.
|
||||
pub async fn sw_js() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
SW_JS,
|
||||
)
|
||||
}
|
||||
|
||||
/// GET / — Serve the OpenFang Dashboard single-page application.
|
||||
///
|
||||
/// Generates a unique CSP nonce on every request and injects it into both
|
||||
/// the `<script>` tags and the `Content-Security-Policy` header. This
|
||||
/// replaces `'unsafe-inline'` so only our own scripts execute.
|
||||
pub async fn webchat_page() -> impl IntoResponse {
|
||||
let nonce = uuid::Uuid::new_v4().to_string();
|
||||
let html = WEBCHAT_HTML.replace(NONCE_PLACEHOLDER, &nonce);
|
||||
let csp = format!(
|
||||
"default-src 'self'; \
|
||||
script-src 'self' 'nonce-{nonce}' 'unsafe-eval'; \
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; \
|
||||
img-src 'self' data: blob:; \
|
||||
connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; \
|
||||
font-src 'self' https://fonts.gstatic.com; \
|
||||
media-src 'self' blob:; \
|
||||
frame-src 'self' blob:; \
|
||||
object-src 'none'; \
|
||||
base-uri 'self'; \
|
||||
form-action 'self'"
|
||||
);
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
|
||||
(
|
||||
header::HeaderName::from_static("content-security-policy"),
|
||||
csp,
|
||||
),
|
||||
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||
],
|
||||
html,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,21 +132,26 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/vendor/github-dark.min.css"),
|
||||
"\n</style>\n",
|
||||
include_str!("../static/index_body.html"),
|
||||
// Vendor libs: marked + highlight first (used by app.js)
|
||||
"<script>\n",
|
||||
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/marked.min.js"),
|
||||
"\n</script>\n",
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/highlight.min.js"),
|
||||
"\n</script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/chart.umd.min.js"),
|
||||
"\n</script>\n",
|
||||
// App code
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/js/api.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/app.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/overview.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/katex.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/chat.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/agents.js"),
|
||||
@@ -129,7 +185,7 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/js/pages/runtime.js"),
|
||||
"\n</script>\n",
|
||||
// Alpine.js MUST be last — it processes x-data and fires alpine:init
|
||||
"<script>\n",
|
||||
"<script nonce=\"__NONCE__\">\n",
|
||||
include_str!("../static/vendor/alpine.min.js"),
|
||||
"\n</script>\n",
|
||||
"</body></html>"
|
||||
|
||||
+157
-74
@@ -439,6 +439,7 @@ async fn handle_text_message(
|
||||
|
||||
// Resolve file attachments into image content blocks
|
||||
let mut has_images = false;
|
||||
let mut ws_content_blocks: Option<Vec<openfang_types::message::ContentBlock>> = None;
|
||||
if let Some(attachments) = parsed["attachments"].as_array() {
|
||||
let refs: Vec<crate::types::AttachmentRef> = attachments
|
||||
.iter()
|
||||
@@ -448,11 +449,7 @@ async fn handle_text_message(
|
||||
let image_blocks = crate::routes::resolve_attachments(&refs);
|
||||
if !image_blocks.is_empty() {
|
||||
has_images = true;
|
||||
crate::routes::inject_attachments_into_session(
|
||||
&state.kernel,
|
||||
agent_id,
|
||||
image_blocks,
|
||||
);
|
||||
ws_content_blocks = Some(image_blocks);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,16 +499,30 @@ async fn handle_text_message(
|
||||
// Send message to agent with streaming
|
||||
let kernel_handle: Arc<dyn KernelHandle> =
|
||||
state.kernel.clone() as Arc<dyn KernelHandle>;
|
||||
match state
|
||||
.kernel
|
||||
.send_message_streaming(agent_id, &content, Some(kernel_handle))
|
||||
{
|
||||
match state.kernel.send_message_streaming(
|
||||
agent_id,
|
||||
&content,
|
||||
Some(kernel_handle),
|
||||
None,
|
||||
None,
|
||||
ws_content_blocks,
|
||||
) {
|
||||
Ok((mut rx, handle)) => {
|
||||
// Forward stream events to WebSocket with debouncing
|
||||
// Forward stream events to WebSocket with debouncing.
|
||||
//
|
||||
// The stream_task also accumulates the full response text and
|
||||
// captures ContentComplete usage data. This lets us send the
|
||||
// `response` event immediately when the stream channel closes
|
||||
// (after `drop(phase_cb)` in the kernel), WITHOUT waiting for
|
||||
// post-processing (canonical session writes, JSONL, compaction)
|
||||
// that happens in the kernel task after the loop.
|
||||
let sender_stream = Arc::clone(sender);
|
||||
let verbose_clone = Arc::clone(verbose);
|
||||
let stream_task = tokio::spawn(async move {
|
||||
let mut text_buffer = String::new();
|
||||
let mut accumulated_text = String::new();
|
||||
let mut stream_usage: Option<openfang_types::message::TokenUsage> = None;
|
||||
let mut is_silent = false;
|
||||
let far_future = tokio::time::Instant::now() + Duration::from_secs(86400);
|
||||
let mut flush_deadline = far_future;
|
||||
|
||||
@@ -535,7 +546,15 @@ async fn handle_text_message(
|
||||
break;
|
||||
}
|
||||
Some(ev) => {
|
||||
// Capture ContentComplete for immediate response
|
||||
if let StreamEvent::ContentComplete { usage, .. } = &ev {
|
||||
stream_usage = Some(*usage);
|
||||
// Don't forward — handled below
|
||||
continue;
|
||||
}
|
||||
|
||||
if let StreamEvent::TextDelta { ref text } = ev {
|
||||
accumulated_text.push_str(text);
|
||||
text_buffer.push_str(text);
|
||||
if text_buffer.len() >= DEBOUNCE_CHARS {
|
||||
let _ = flush_text_buffer(
|
||||
@@ -600,14 +619,62 @@ async fn handle_text_message(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the agent signalled NO_REPLY via the stream
|
||||
// (PhaseChange with a "silent" marker — currently the
|
||||
// kernel sets result.silent after the loop, so we detect
|
||||
// it from empty accumulated text when ContentComplete
|
||||
// had no text deltas at all).
|
||||
if accumulated_text.is_empty() && stream_usage.is_some() {
|
||||
is_silent = true;
|
||||
}
|
||||
|
||||
(accumulated_text, stream_usage, is_silent)
|
||||
});
|
||||
|
||||
// Wait for the agent loop to complete
|
||||
match handle.await {
|
||||
Ok(Ok(result)) => {
|
||||
// Cancel the stream forwarder (should be done by now)
|
||||
stream_task.abort();
|
||||
// Wait for the stream to finish (fast — closes as soon as
|
||||
// drop(phase_cb) runs after the agent loop). This does NOT
|
||||
// wait for post-processing.
|
||||
let stream_result = stream_task.await;
|
||||
|
||||
// Spawn the kernel task in the background for cleanup
|
||||
// (canonical session writes, JSONL mirror, compaction).
|
||||
// We don't need its result for the response event.
|
||||
let sender_bg = Arc::clone(sender);
|
||||
tokio::spawn(async move {
|
||||
match handle.await {
|
||||
Ok(Err(e)) => {
|
||||
warn!("Agent post-processing failed: {e}");
|
||||
let user_msg = classify_streaming_error(&e);
|
||||
let _ = send_json(
|
||||
&sender_bg,
|
||||
&serde_json::json!({
|
||||
"type": "error",
|
||||
"content": user_msg,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Agent task panicked: {e}");
|
||||
let _ = send_json(
|
||||
&sender_bg,
|
||||
&serde_json::json!({
|
||||
"type": "error",
|
||||
"content": "Internal error occurred",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(Ok(_)) => {
|
||||
// Post-processing completed successfully — nothing to send
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send the response immediately from stream data
|
||||
match stream_result {
|
||||
Ok((accumulated_text, stream_usage, is_silent)) => {
|
||||
// Send typing lifecycle: stop
|
||||
let _ = send_json(
|
||||
sender,
|
||||
@@ -618,43 +685,36 @@ async fn handle_text_message(
|
||||
)
|
||||
.await;
|
||||
|
||||
// NO_REPLY: agent intentionally chose not to reply
|
||||
if result.silent {
|
||||
let usage = stream_usage.unwrap_or_default();
|
||||
|
||||
if is_silent {
|
||||
let _ = send_json(
|
||||
sender,
|
||||
&serde_json::json!({
|
||||
"type": "silent_complete",
|
||||
"input_tokens": result.total_usage.input_tokens,
|
||||
"output_tokens": result.total_usage.output_tokens,
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip <think>...</think> blocks from model output
|
||||
// (e.g. MiniMax, DeepSeek reasoning tokens)
|
||||
let cleaned_response = strip_think_tags(&result.response);
|
||||
// Strip <think>...</think> blocks
|
||||
let cleaned = strip_think_tags(&accumulated_text);
|
||||
|
||||
// Guard: ensure we never send an empty response
|
||||
let content = if cleaned_response.trim().is_empty() {
|
||||
let content = if cleaned.trim().is_empty() {
|
||||
format!(
|
||||
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
|
||||
result.total_usage.input_tokens,
|
||||
result.total_usage.output_tokens,
|
||||
result.iterations,
|
||||
"[The agent completed processing but returned no text response. ({} in / {} out)]",
|
||||
usage.input_tokens, usage.output_tokens,
|
||||
)
|
||||
} else {
|
||||
cleaned_response
|
||||
cleaned
|
||||
};
|
||||
|
||||
// Estimate context pressure from last call
|
||||
let per_call = if result.iterations > 0 {
|
||||
result.total_usage.input_tokens / result.iterations as u64
|
||||
} else {
|
||||
result.total_usage.input_tokens
|
||||
};
|
||||
let ctx_pct = (per_call as f64 / 200_000.0 * 100.0).min(100.0);
|
||||
// Estimate context pressure
|
||||
let ctx_pct =
|
||||
(usage.input_tokens as f64 / 200_000.0 * 100.0).min(100.0);
|
||||
let pressure = if ctx_pct > 85.0 {
|
||||
"critical"
|
||||
} else if ctx_pct > 70.0 {
|
||||
@@ -670,38 +730,17 @@ async fn handle_text_message(
|
||||
&serde_json::json!({
|
||||
"type": "response",
|
||||
"content": content,
|
||||
"input_tokens": result.total_usage.input_tokens,
|
||||
"output_tokens": result.total_usage.output_tokens,
|
||||
"iterations": result.iterations,
|
||||
"cost_usd": result.cost_usd,
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
"iterations": 0, // Not available from stream; handle updates later if needed
|
||||
"cost_usd": null,
|
||||
"context_pressure": pressure,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
stream_task.abort();
|
||||
warn!("Agent message failed: {e}");
|
||||
let _ = send_json(
|
||||
sender,
|
||||
&serde_json::json!({
|
||||
"type": "typing", "state": "stop",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let user_msg = classify_streaming_error(&e);
|
||||
let _ = send_json(
|
||||
sender,
|
||||
&serde_json::json!({
|
||||
"type": "error",
|
||||
"content": user_msg,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
stream_task.abort();
|
||||
warn!("Agent task panicked: {e}");
|
||||
warn!("Stream task panicked: {e}");
|
||||
let _ = send_json(
|
||||
sender,
|
||||
&serde_json::json!({
|
||||
@@ -809,7 +848,7 @@ async fn handle_command(
|
||||
serde_json::json!({"type": "error", "content": "Agent not found"})
|
||||
}
|
||||
} else {
|
||||
match state.kernel.set_agent_model(agent_id, args) {
|
||||
match state.kernel.set_agent_model(agent_id, args, None) {
|
||||
Ok(()) => {
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
let model = &entry.manifest.model.model;
|
||||
@@ -912,7 +951,7 @@ async fn handle_command(
|
||||
let msg = if !state.kernel.config.network_enabled {
|
||||
"OFP network disabled.".to_string()
|
||||
} else {
|
||||
match &state.kernel.peer_registry {
|
||||
match state.kernel.peer_registry.get() {
|
||||
Some(registry) => {
|
||||
let peers = registry.all_peers();
|
||||
if peers.is_empty() {
|
||||
@@ -959,11 +998,14 @@ async fn handle_command(
|
||||
fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_json::Value> {
|
||||
match event {
|
||||
StreamEvent::TextDelta { .. } => None, // Handled by debounce buffer
|
||||
StreamEvent::ToolUseStart { name, .. } => Some(serde_json::json!({
|
||||
StreamEvent::ToolUseStart { id, name, .. } => Some(serde_json::json!({
|
||||
"type": "tool_start",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
})),
|
||||
StreamEvent::ToolUseEnd { name, input, .. } if name == "canvas_present" => {
|
||||
StreamEvent::ToolUseEnd {
|
||||
id, name, input, ..
|
||||
} if name == "canvas_present" => {
|
||||
let html = input.get("html").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let title = input
|
||||
.get("title")
|
||||
@@ -971,12 +1013,15 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
.unwrap_or("Canvas");
|
||||
Some(serde_json::json!({
|
||||
"type": "canvas",
|
||||
"id": id,
|
||||
"canvas_id": uuid::Uuid::new_v4().to_string(),
|
||||
"html": html,
|
||||
"title": title,
|
||||
}))
|
||||
}
|
||||
StreamEvent::ToolUseEnd { name, input, .. } => match verbose {
|
||||
StreamEvent::ToolUseEnd {
|
||||
id, name, input, ..
|
||||
} => match verbose {
|
||||
VerboseLevel::Off => None,
|
||||
VerboseLevel::On => {
|
||||
let input_preview: String = serde_json::to_string(input)
|
||||
@@ -986,6 +1031,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
.collect();
|
||||
Some(serde_json::json!({
|
||||
"type": "tool_end",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"input": input_preview,
|
||||
}))
|
||||
@@ -998,18 +1044,21 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
.collect();
|
||||
Some(serde_json::json!({
|
||||
"type": "tool_end",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"input": input_preview,
|
||||
}))
|
||||
}
|
||||
},
|
||||
StreamEvent::ToolExecutionResult {
|
||||
id,
|
||||
name,
|
||||
result_preview,
|
||||
is_error,
|
||||
} => match verbose {
|
||||
VerboseLevel::Off => Some(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"is_error": is_error,
|
||||
})),
|
||||
@@ -1017,6 +1066,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
let truncated: String = result_preview.chars().take(200).collect();
|
||||
Some(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"result": truncated,
|
||||
"is_error": is_error,
|
||||
@@ -1024,6 +1074,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
|
||||
}
|
||||
VerboseLevel::Full => Some(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"id": id,
|
||||
"tool": name,
|
||||
"result": result_preview,
|
||||
"is_error": is_error,
|
||||
@@ -1119,6 +1170,9 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
let status = extract_status_code(&inner);
|
||||
let classified = llm_errors::classify_error(&inner, status);
|
||||
|
||||
// Build a user-facing message. The classified.sanitized_message now
|
||||
// includes a redacted excerpt of the raw error (issue #493 fix), so we
|
||||
// use it as the base and only override for cases that need extra context.
|
||||
match classified.category {
|
||||
llm_errors::LlmErrorCategory::ContextOverflow => {
|
||||
"Context is full. Try /compact or /new.".to_string()
|
||||
@@ -1126,24 +1180,36 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
llm_errors::LlmErrorCategory::RateLimit => {
|
||||
if let Some(delay_ms) = classified.suggested_delay_ms {
|
||||
let secs = (delay_ms / 1000).max(1);
|
||||
format!("Provider rate limited. Wait ~{secs}s and try again.")
|
||||
format!("Rate limited. Wait ~{secs}s and try again.")
|
||||
} else {
|
||||
"Provider rate limited. Wait a moment and try again.".to_string()
|
||||
"Rate limited. Wait a moment and try again.".to_string()
|
||||
}
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Billing => {
|
||||
"Check provider account status (billing issue detected).".to_string()
|
||||
format!("Billing issue. {}", classified.sanitized_message)
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Auth => {
|
||||
// Show the actual error detail so users can diagnose (issue #493).
|
||||
// The sanitized_message already redacts secrets.
|
||||
classified.sanitized_message.clone()
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Auth => "Verify your API key in config.".to_string(),
|
||||
llm_errors::LlmErrorCategory::ModelNotFound => {
|
||||
if inner.contains("localhost:11434") || inner.contains("ollama") {
|
||||
"Model not found on Ollama. Run `ollama pull <model>` to download it, then try again. Use /model to see options.".to_string()
|
||||
"Model not found on Ollama. Run `ollama pull <model>` first. Use /model to see options.".to_string()
|
||||
} else {
|
||||
"Model unavailable. Use /model to see options or check your provider configuration.".to_string()
|
||||
format!(
|
||||
"{}. Use /model to see options.",
|
||||
classified.sanitized_message
|
||||
)
|
||||
}
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Format => {
|
||||
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
|
||||
// Claude Code CLI errors have actionable messages — pass them through
|
||||
if inner.contains("Claude Code CLI") || inner.contains("claude auth") {
|
||||
classified.raw_message.clone()
|
||||
} else {
|
||||
classified.sanitized_message.clone()
|
||||
}
|
||||
}
|
||||
_ => classified.sanitized_message,
|
||||
}
|
||||
@@ -1151,6 +1217,14 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
|
||||
/// Try to extract an HTTP status code from an error string.
|
||||
fn extract_status_code(s: &str) -> Option<u16> {
|
||||
// "API error (NNN):" — the format produced by LlmError::Api Display impl
|
||||
if let Some(idx) = s.find("API error (") {
|
||||
let after = &s[idx + 11..];
|
||||
let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(code) = num.parse::<u16>() {
|
||||
return Some(code);
|
||||
}
|
||||
}
|
||||
// "status: NNN"
|
||||
if let Some(idx) = s.find("status: ") {
|
||||
let after = &s[idx + 8..];
|
||||
@@ -1269,6 +1343,15 @@ mod tests {
|
||||
);
|
||||
assert_eq!(extract_status_code("StatusCode(401)"), Some(401));
|
||||
assert_eq!(extract_status_code("some random error"), None);
|
||||
// LlmError::Api Display format (issue #493 fix)
|
||||
assert_eq!(
|
||||
extract_status_code("LLM driver error: API error (403): quota exceeded"),
|
||||
Some(403)
|
||||
);
|
||||
assert_eq!(
|
||||
extract_status_code("API error (401): invalid api key"),
|
||||
Some(401)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -647,6 +647,11 @@ mark.search-highlight {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.message-bubble.markdown-body ul,
|
||||
.message-bubble.markdown-body ol {
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -1274,8 +1279,11 @@ mark.search-highlight {
|
||||
/* Utility */
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.grid { display: grid; }
|
||||
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
@@ -1283,6 +1291,7 @@ mark.search-highlight {
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mb-2 { margin-bottom: 8px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
.text-dim { color: var(--text-dim); }
|
||||
.text-sm { font-size: 11px; }
|
||||
.text-xs { font-size: 10px; }
|
||||
@@ -3238,3 +3247,206 @@ mark.search-highlight {
|
||||
.comms-event-row:hover { background: var(--bg-hover); }
|
||||
.comms-event-time { min-width: 50px; text-align: right; }
|
||||
.comms-event-detail { margin-left: auto; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Trader Dashboard
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.trader-dashboard {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 96vw;
|
||||
max-width: 1200px;
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.trader-dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-card);
|
||||
z-index: 10;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
.trader-dashboard-body {
|
||||
padding: 16px 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* KPI Cards */
|
||||
.trader-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.trader-kpi-row { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.trader-kpi-row { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
.trader-kpi-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.trader-kpi-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.trader-kpi-value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.kpi-positive { color: var(--success) !important; }
|
||||
.kpi-negative { color: var(--error) !important; }
|
||||
|
||||
/* Chart Rows */
|
||||
.trader-chart-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.trader-chart-row { flex-direction: column; }
|
||||
}
|
||||
.trader-chart-panel {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.trader-chart-title {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.trader-chart-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
}
|
||||
.trader-chart-wrap canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
.trader-chart-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Heatmap Table */
|
||||
.trader-heatmap-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.trader-heatmap-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.trader-heatmap-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.trader-heatmap-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.heatmap-positive { color: var(--success); font-weight: 600; }
|
||||
.heatmap-negative { color: var(--error); font-weight: 600; }
|
||||
|
||||
/* Signal Badges */
|
||||
.signal-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.signal-strong_buy, .signal-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.signal-sell, .signal-strong_sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
.signal-hold { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
|
||||
|
||||
/* Confidence Bar */
|
||||
.confidence-bar-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 100px;
|
||||
}
|
||||
.confidence-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.conf-high { background: var(--success); }
|
||||
.conf-mid { background: var(--warning); }
|
||||
.conf-low { background: var(--error); }
|
||||
.confidence-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
min-width: 32px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Trades Table */
|
||||
.trader-trades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.trader-trades-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.trader-trades-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.trade-side-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.trade-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.trade-sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
|
||||
@@ -243,6 +243,14 @@
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
position: fixed !important;
|
||||
top: 12px;
|
||||
left: 16px;
|
||||
z-index: 98;
|
||||
padding: 6px 10px !important;
|
||||
}
|
||||
|
||||
/* Wide desktop — larger card grids */
|
||||
@media (min-width: 1400px) {
|
||||
.card-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||
@@ -277,6 +285,8 @@
|
||||
left: -300px;
|
||||
}
|
||||
.mobile-menu-btn { display: flex !important; }
|
||||
/* Offset header content so it does not overlap the fixed mobile menu button. */
|
||||
.page-header > :first-child { margin-left: 52px; }
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
@@ -285,7 +295,14 @@
|
||||
|
||||
/* Mobile small screen */
|
||||
@media (max-width: 480px) {
|
||||
.page-header { flex-direction: column; gap: 8px; align-items: flex-start; padding: 12px 16px; }
|
||||
.page-header {
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-header h2 {
|
||||
line-height: 44px;
|
||||
}
|
||||
.page-body { padding: 12px; }
|
||||
.stats-row { flex-wrap: wrap; }
|
||||
.stat-card { min-width: 80px; flex: 1 1 40%; }
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
<body x-data="app" :data-theme="theme">
|
||||
|
||||
<!-- API Key Auth Prompt -->
|
||||
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '' }">
|
||||
<!-- Auth Prompt (API Key or Username/Password) -->
|
||||
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '', loginUser: '', loginPass: '' }">
|
||||
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
|
||||
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
|
||||
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
|
||||
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
|
||||
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
|
||||
<!-- Session login mode -->
|
||||
<template x-if="$store.app.authMode === 'session'">
|
||||
<div>
|
||||
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">Sign In</h3>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">Enter your dashboard credentials.</p>
|
||||
<input type="text" x-model="loginUser" placeholder="Username" autocomplete="username" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.5rem">
|
||||
<input type="password" x-model="loginPass" placeholder="Password" autocomplete="current-password" @keydown.enter="$store.app.sessionLogin(loginUser, loginPass)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
|
||||
<button @click="$store.app.sessionLogin(loginUser, loginPass)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Sign In</button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- API key mode -->
|
||||
<template x-if="$store.app.authMode === 'apikey'">
|
||||
<div>
|
||||
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
|
||||
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
|
||||
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
|
||||
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,6 +111,7 @@
|
||||
<a class="nav-item" :class="{ active: page === 'approvals' }" @click="navigate('approvals')" :aria-current="page === 'approvals' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
|
||||
<span class="nav-label">Approvals</span>
|
||||
<span class="badge badge-warn" x-show="$store.app.pendingApprovalCount > 0" x-text="$store.app.pendingApprovalCount"></span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'comms' }" @click="navigate('comms')" :aria-current="page === 'comms' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
|
||||
@@ -171,6 +187,10 @@
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div x-show="$store.app.sessionUser" style="padding:4px 16px;display:flex;align-items:center;justify-content:space-between">
|
||||
<span class="text-xs text-dim" x-text="$store.app.sessionUser" style="letter-spacing:0.5px"></span>
|
||||
<button @click="$store.app.sessionLogout()" class="btn btn-ghost btn-sm" style="font-size:11px;padding:2px 8px;opacity:0.7" title="Sign out">Logout</button>
|
||||
</div>
|
||||
<div class="sidebar-label text-xs text-dim" style="padding:0 16px 4px;letter-spacing:0.5px">Ctrl+K agents | Ctrl+N new</div>
|
||||
</div>
|
||||
<div class="sidebar-toggle" @click="toggleSidebar()" x-text="sidebarCollapsed ? '\u276F' : '\u276E'"></div>
|
||||
@@ -181,7 +201,7 @@
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Mobile menu button -->
|
||||
<button class="mobile-menu-btn btn btn-ghost" @click="mobileMenuOpen = !mobileMenuOpen" style="position:fixed;top:8px;left:8px;z-index:98;padding:6px 10px">
|
||||
<button class="mobile-menu-btn btn btn-ghost" @click="mobileMenuOpen = !mobileMenuOpen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -736,7 +756,7 @@
|
||||
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
|
||||
</div>
|
||||
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
|
||||
@keydown.enter.prevent="if(!$event.isComposing && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.enter.prevent="if(!$event.isComposing && $event.keyCode !== 229 && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false; showModelPicker = false"
|
||||
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
@@ -769,7 +789,7 @@
|
||||
<div class="model-switcher-dropdown" x-show="showModelSwitcher" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform translate-y-1" x-transition:enter-end="opacity-100 transform translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
||||
<div class="model-switcher-search">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
|
||||
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="!$event.isComposing && $event.keyCode !== 229 && filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
|
||||
<select x-model="modelSwitcherProviderFilter" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;padding:2px 6px;cursor:pointer;font-family:var(--font-mono);flex-shrink:0">
|
||||
<option value="">All</option>
|
||||
<template x-for="pn in switcherProviders" :key="pn">
|
||||
@@ -792,7 +812,7 @@
|
||||
<div class="model-switcher-item" :class="{'active': currentAgent && m.id === currentAgent.model_name}" @click="switchModel(m)" @mouseenter="modelSwitcherIdx = filteredSwitcherModels.indexOf(m)">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span class="model-switcher-item-name" x-text="m.display_name || m.id"></span>
|
||||
<span class="model-switcher-item-name" x-text="m.provider + ':' + (m.display_name || m.id)"></span>
|
||||
<span class="model-switcher-tier" :class="'tier-' + (m.tier || 'balanced').toLowerCase()" x-text="m.tier || 'Balanced'"></span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
|
||||
@@ -861,7 +881,7 @@
|
||||
<div class="text-sm font-bold mb-2" style="color:var(--text-dim);letter-spacing:0.5px;font-size:11px;text-transform:uppercase" x-text="agents.length ? 'Or Start a New Agent' : 'Start Chatting'"></div>
|
||||
<div class="card-grid">
|
||||
<template x-for="t in builtinTemplates" :key="t.name">
|
||||
<div class="card" style="cursor:pointer" @click="spawnBuiltin(t)">
|
||||
<div class="card" style="cursor:pointer" @click="t.manifest_toml ? spawnFromTemplate(t) : spawnBuiltin(t)">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<div class="card-header" style="margin:0;font-size:14px;font-weight:600" x-text="t.name"></div>
|
||||
<span class="badge badge-dim" x-text="t.category"></span>
|
||||
@@ -878,7 +898,7 @@
|
||||
<!-- Agent detail modal with tabs (Info / Files / Config) -->
|
||||
<template x-if="showDetailModal && detailAgent">
|
||||
<div class="modal-overlay" @click.self="showDetailModal = false" @keydown.escape.window="showDetailModal = false">
|
||||
<div class="modal" style="max-width:600px">
|
||||
<div class="modal" style="max-width:700px">
|
||||
<div class="modal-header">
|
||||
<h3>
|
||||
<span x-show="detailAgent.identity && detailAgent.identity.emoji" x-text="detailAgent.identity && detailAgent.identity.emoji" style="margin-right:6px"></span>
|
||||
@@ -903,7 +923,23 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="detail-row" x-show="detailAgent.profile"><span class="detail-label">Profile</span><span class="detail-value" style="text-transform:capitalize" x-text="detailAgent.profile || '-'"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Provider</span><span class="detail-value" x-text="detailAgent.model_provider"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Provider</span>
|
||||
<template x-if="!editingProvider">
|
||||
<span>
|
||||
<span class="detail-value" x-text="detailAgent.model_provider"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="margin-left:8px;padding:2px 8px;font-size:11px" @click="editingProvider = true; newProviderValue = detailAgent.model_provider">Change</button>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="editingProvider">
|
||||
<span class="flex gap-1" style="align-items:center">
|
||||
<input class="form-input" style="width:160px;font-size:12px" x-model="newProviderValue" placeholder="provider" @keydown.enter="changeProvider()" @keydown.escape="editingProvider = false">
|
||||
<button class="btn btn-primary btn-sm" @click="changeProvider()" :disabled="modelSaving" style="padding:2px 10px">
|
||||
<span x-show="!modelSaving">Save</span><span x-show="modelSaving">...</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingProvider = false" style="padding:2px 8px">Cancel</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="detail-row"><span class="detail-label">Model</span>
|
||||
<template x-if="!editingModel">
|
||||
<span>
|
||||
@@ -926,13 +962,13 @@
|
||||
<!-- Fallback Model Chain -->
|
||||
<div class="detail-row" style="align-items:flex-start">
|
||||
<span class="detail-label">Fallbacks</span>
|
||||
<div style="flex:1">
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;margin-left:16px">
|
||||
<template x-if="detailAgent._fallbacks && detailAgent._fallbacks.length > 0">
|
||||
<div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
<template x-for="(fb, idx) in detailAgent._fallbacks" :key="idx">
|
||||
<div class="flex gap-1 items-center" style="margin-bottom:4px">
|
||||
<span class="badge" style="font-size:11px;font-family:var(--font-mono)" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger)" @click="removeFallback(idx)">×</button>
|
||||
<div class="flex gap-1 items-center" style="min-width:0">
|
||||
<span class="badge" style="font-size:11px;font-family:var(--font-mono);word-break:break-all;white-space:normal" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger);flex-shrink:0" @click="removeFallback(idx)">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -941,10 +977,12 @@
|
||||
<span class="text-dim" style="font-size:12px">None — add a fallback chain</span>
|
||||
</template>
|
||||
<template x-if="!editingFallback">
|
||||
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px;margin-top:4px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
|
||||
<div>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="editingFallback">
|
||||
<div class="flex gap-1 mt-1" style="align-items:center">
|
||||
<div class="flex gap-1" style="align-items:center">
|
||||
<input class="form-input" style="width:220px;font-size:12px" x-model="newFallbackValue" placeholder="provider/model" @keydown.enter="addFallback()" @keydown.escape="editingFallback = false">
|
||||
<button class="btn btn-primary btn-sm" @click="addFallback()" style="padding:2px 10px;font-size:11px">Add</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingFallback = false" style="padding:2px 8px;font-size:11px">Cancel</button>
|
||||
@@ -1091,7 +1129,7 @@
|
||||
<div x-show="spawnStep === 1">
|
||||
<div class="form-group">
|
||||
<label>Agent Name</label>
|
||||
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="nextStep()">
|
||||
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) nextStep()">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Emoji</label>
|
||||
@@ -1119,12 +1157,26 @@
|
||||
<div class="form-group">
|
||||
<label>Provider</label>
|
||||
<select class="form-select" x-model="spawnForm.provider">
|
||||
<option value="anthropic">Anthropic</option><option value="openai">OpenAI</option>
|
||||
<option value="groq">Groq</option><option value="ollama">Ollama</option>
|
||||
<option value="google">Google</option><option value="mistral">Mistral</option>
|
||||
<option value="xai">xAI</option><option value="deepseek">DeepSeek</option>
|
||||
<option value="cerebras">Cerebras</option><option value="sambanova">SambaNova</option>
|
||||
<option value="together">Together</option>
|
||||
<template x-if="spawnProvidersLoading">
|
||||
<option disabled>Loading providers…</option>
|
||||
</template>
|
||||
<template x-if="!spawnProvidersLoading && spawnProviders.length === 0">
|
||||
<option disabled>No providers available</option>
|
||||
</template>
|
||||
<template x-if="!spawnProvidersLoading && spawnProviders.filter(p => !p.is_local).length > 0">
|
||||
<optgroup label="Cloud">
|
||||
<template x-for="p in spawnProviders.filter(p => !p.is_local)" :key="p.id">
|
||||
<option :value="p.id" x-text="p.display_name"></option>
|
||||
</template>
|
||||
</optgroup>
|
||||
</template>
|
||||
<template x-if="!spawnProvidersLoading && spawnProviders.filter(p => p.is_local).length > 0">
|
||||
<optgroup label="Local">
|
||||
<template x-for="p in spawnProviders.filter(p => p.is_local)" :key="p.id">
|
||||
<option :value="p.id" x-text="p.display_name"></option>
|
||||
</template>
|
||||
</optgroup>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -1225,7 +1277,7 @@
|
||||
|
||||
<!-- Page: Approvals -->
|
||||
<template x-if="page === 'approvals'">
|
||||
<div x-data="approvalsPage" x-init="loadData()">
|
||||
<div x-data="approvalsPage()" x-init="init()">
|
||||
<div class="page-header">
|
||||
<h2>Execution Approvals</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -1247,6 +1299,7 @@
|
||||
<button class="filter-pill" :class="{ active: filterStatus === 'pending' }" @click="filterStatus = 'pending'">Pending</button>
|
||||
<button class="filter-pill" :class="{ active: filterStatus === 'approved' }" @click="filterStatus = 'approved'">Approved</button>
|
||||
<button class="filter-pill" :class="{ active: filterStatus === 'rejected' }" @click="filterStatus = 'rejected'">Rejected</button>
|
||||
<button class="filter-pill" :class="{ active: filterStatus === 'expired' }" @click="filterStatus = 'expired'">Expired</button>
|
||||
</div>
|
||||
<div x-show="filtered.length === 0" class="empty-state">
|
||||
<h4>No approvals</h4>
|
||||
@@ -1289,7 +1342,7 @@
|
||||
|
||||
<!-- Tab: List -->
|
||||
<template x-if="wfTab === 'list'">
|
||||
<div x-data="workflowsPage">
|
||||
<div x-data="workflowsPage" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden">
|
||||
<div class="page-body" x-init="loadWorkflows()">
|
||||
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading workflows...</span></div>
|
||||
<div x-show="!loading && loadError" class="error-state">
|
||||
@@ -1320,7 +1373,9 @@
|
||||
<td class="text-xs" x-text="new Date(wf.created_at).toLocaleDateString()"></td>
|
||||
<td>
|
||||
<button class="btn btn-primary btn-sm" @click="showRunModal(wf)">Run</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="showEditModal(wf)">Edit</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="viewRuns(wf)">History</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteWorkflow(wf)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -1385,6 +1440,39 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Edit modal -->
|
||||
<template x-if="editModal">
|
||||
<div class="modal-overlay" @click.self="editModal = null" @keydown.escape.window="editModal = null">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3 x-text="'Edit: ' + editModal.name"></h3><button class="modal-close" @click="editModal = null">×</button></div>
|
||||
<div class="form-group"><label>Name</label><input class="form-input" x-model="editWf.name" placeholder="Workflow name"></div>
|
||||
<div class="form-group"><label>Description</label><input class="form-input" x-model="editWf.description" placeholder="What does this workflow do?"></div>
|
||||
<div class="mb-4">
|
||||
<div class="form-group" style="margin:0"><label>Steps</label></div>
|
||||
<div class="text-xs text-dim mb-2">Each step runs an agent. Use <code style="color:var(--accent)">{{input}}</code> in prompts to pass the previous step's output.</div>
|
||||
<template x-for="(step, i) in editWf.steps" :key="i">
|
||||
<div class="card mt-2" style="padding:10px">
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-xs text-dim font-bold" x-text="'#' + (i+1)" style="width:24px"></span>
|
||||
<input class="form-input" style="flex:1" x-model="step.name" placeholder="Step name">
|
||||
<input class="form-input" style="flex:1" x-model="step.agent_name" placeholder="Agent name">
|
||||
<select class="form-select" style="width:120px" x-model="step.mode">
|
||||
<option value="sequential">Sequential</option>
|
||||
<option value="fan_out">Fan Out</option>
|
||||
<option value="conditional">Conditional</option>
|
||||
<option value="loop">Loop</option>
|
||||
</select>
|
||||
<button class="btn btn-danger btn-sm" @click="editWf.steps.splice(i,1)">×</button>
|
||||
</div>
|
||||
<input class="form-input mt-2" x-model="step.prompt" placeholder="Prompt template (use {{input}})">
|
||||
</div>
|
||||
</template>
|
||||
<button class="btn btn-ghost btn-sm mt-2" @click="editWf.steps.push({name:'',agent_name:'',mode:'sequential',prompt:'{{input}}'})">+ Add Step</button>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-block" @click="saveWorkflow()">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2172,7 +2260,7 @@
|
||||
<!-- Search bar with live search and clear button -->
|
||||
<div class="search-input mb-4" style="position:relative">
|
||||
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
|
||||
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
|
||||
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
|
||||
<button x-show="clawhubSearch" @click="clearSearch()" class="search-clear-btn" title="Clear search (Esc)">×</button>
|
||||
</div>
|
||||
|
||||
@@ -2223,7 +2311,7 @@
|
||||
<div class="flex gap-3 items-center">
|
||||
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || isSkillInstalled(skill.slug)" x-text="isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
|
||||
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || skill.installed || isSkillInstalled(skill.slug)" x-text="skill.installed || isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2246,7 +2334,7 @@
|
||||
<span class="text-xs text-dim" x-show="skill.stars" x-text="skill.stars + ' stars'"></span>
|
||||
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || isSkillInstalled(skill.slug)" x-text="isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
|
||||
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || skill.installed || isSkillInstalled(skill.slug)" x-text="skill.installed || isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2530,6 +2618,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button class="btn btn-ghost btn-sm" @click="loadStats(inst)">Stats</button>
|
||||
<template x-if="isTraderHand(inst)">
|
||||
<button class="btn btn-primary btn-sm" @click="openDashboard(inst)">Dashboard</button>
|
||||
</template>
|
||||
<template x-if="isBrowserHand(inst)">
|
||||
<button class="btn btn-ghost btn-sm" @click="openBrowserViewer(inst)">View Browser</button>
|
||||
</template>
|
||||
@@ -2642,9 +2733,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<!-- ═══ Step 1: Dependencies ═══ -->
|
||||
<div class="hand-wizard-body" x-show="setupStep === 1">
|
||||
<template x-for="req in (setupWizard.requirements || [])" :key="req.key">
|
||||
<div class="dep-card" :class="req.satisfied ? 'dep-met' : 'dep-missing'">
|
||||
<div class="dep-card" :class="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'dep-met' : 'dep-missing'">
|
||||
<div class="dep-card-header">
|
||||
<div class="dep-status-icon" :class="[req.satisfied ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="req.satisfied ? '\u2713' : '\u2717'"></div>
|
||||
<div class="dep-status-icon" :class="[(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? '\u2713' : '\u2717'"></div>
|
||||
<span class="dep-card-title" x-text="req.label"></span>
|
||||
<template x-if="req.install && req.install.estimated_time">
|
||||
<span class="dep-time-badge" x-text="req.install.estimated_time"></span>
|
||||
@@ -2687,24 +2778,32 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</ol>
|
||||
</template>
|
||||
|
||||
<!-- API Key: numbered steps + signup link -->
|
||||
<!-- API Key: input field + numbered steps + signup link -->
|
||||
<template x-if="req.type === 'ApiKey' && req.install">
|
||||
<div>
|
||||
<template x-if="req.install.steps && req.install.steps.length">
|
||||
<ol class="api-key-steps">
|
||||
<template x-for="step in req.install.steps" :key="step">
|
||||
<li x-text="step"></li>
|
||||
</template>
|
||||
</ol>
|
||||
</template>
|
||||
<template x-if="req.install.env_example">
|
||||
<div class="install-block" style="margin-top:8px">
|
||||
<div class="install-cmd">
|
||||
<code x-text="req.install.env_example"></code>
|
||||
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
|
||||
<div style="margin-bottom:10px">
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px" x-text="'Paste your ' + req.label + ':'"></label>
|
||||
<input type="password" class="form-input" x-model="apiKeyInputs[req.key]" :placeholder="req.label" style="width:100%;font-family:var(--font-mono);font-size:12px">
|
||||
<div class="text-xs" style="margin-top:4px;color:var(--green)" x-show="apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== ''">✓ Token entered</div>
|
||||
</div>
|
||||
<details style="margin-bottom:8px">
|
||||
<summary class="text-xs text-dim" style="cursor:pointer;user-select:none">Or set as environment variable</summary>
|
||||
<template x-if="req.install.steps && req.install.steps.length">
|
||||
<ol class="api-key-steps">
|
||||
<template x-for="step in req.install.steps" :key="step">
|
||||
<li x-text="step"></li>
|
||||
</template>
|
||||
</ol>
|
||||
</template>
|
||||
<template x-if="req.install.env_example">
|
||||
<div class="install-block" style="margin-top:8px">
|
||||
<div class="install-cmd">
|
||||
<code x-text="req.install.env_example"></code>
|
||||
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</details>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<template x-if="req.install.signup_url">
|
||||
<a :href="req.install.signup_url" target="_blank" rel="noopener" class="btn btn-primary btn-sm">Get API Key →</a>
|
||||
@@ -2937,6 +3036,152 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trader Dashboard Modal -->
|
||||
<template x-if="dashboardOpen">
|
||||
<div class="modal-overlay" @click.self="closeDashboard()" @keydown.escape.window="closeDashboard()">
|
||||
<div class="trader-dashboard">
|
||||
<!-- Header -->
|
||||
<div class="trader-dashboard-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="font-size:1.4rem">📈</span>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:1.1rem" x-text="dashboardData ? (dashboardData.agent_name || 'Trading Hand') : 'Trading Hand'"></div>
|
||||
<div class="text-xs text-dim">Live Trading Dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-ghost btn-sm" @click="refreshDashboard()">Refresh</button>
|
||||
<button class="modal-close" @click="closeDashboard()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="dashboardLoading" class="text-center" style="padding:60px 0">
|
||||
<div class="spinner"></div>
|
||||
<div class="text-dim mt-2">Loading dashboard data...</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<div class="trader-dashboard-body" x-show="!dashboardLoading && dashboardData">
|
||||
<!-- KPI Row -->
|
||||
<div class="trader-kpi-row">
|
||||
<div class="trader-kpi-card">
|
||||
<div class="trader-kpi-label">Portfolio Value</div>
|
||||
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.portfolio_value || '-') : '-'"></div>
|
||||
</div>
|
||||
<div class="trader-kpi-card">
|
||||
<div class="trader-kpi-label">Total P&L</div>
|
||||
<div class="trader-kpi-value" :class="dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('+') ? 'kpi-positive' : (dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('-') ? 'kpi-negative' : '')" x-text="dashboardData ? (dashboardData.total_pnl || '-') : '-'"></div>
|
||||
</div>
|
||||
<div class="trader-kpi-card">
|
||||
<div class="trader-kpi-label">Win Rate</div>
|
||||
<div class="trader-kpi-value" x-text="dashboardData && dashboardData.win_rate ? (dashboardData.win_rate + '%') : '-'"></div>
|
||||
</div>
|
||||
<div class="trader-kpi-card">
|
||||
<div class="trader-kpi-label">Sharpe Ratio</div>
|
||||
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.sharpe_ratio || '-') : '-'"></div>
|
||||
</div>
|
||||
<div class="trader-kpi-card">
|
||||
<div class="trader-kpi-label">Max Drawdown</div>
|
||||
<div class="trader-kpi-value kpi-negative" x-text="dashboardData && dashboardData.max_drawdown ? (dashboardData.max_drawdown + '%') : '-'"></div>
|
||||
</div>
|
||||
<div class="trader-kpi-card">
|
||||
<div class="trader-kpi-label">Trades</div>
|
||||
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.trades_count || '0') : '0'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 1: Equity Curve + Daily P&L -->
|
||||
<div class="trader-chart-row">
|
||||
<div class="trader-chart-panel" style="flex:2">
|
||||
<div class="trader-chart-title">Equity Curve</div>
|
||||
<div class="trader-chart-wrap">
|
||||
<canvas id="traderEquityChart"></canvas>
|
||||
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.equity_curve || !dashboardData.equity_curve.length">No equity data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trader-chart-panel" style="flex:1">
|
||||
<div class="trader-chart-title">Daily P&L</div>
|
||||
<div class="trader-chart-wrap">
|
||||
<canvas id="traderPnlChart"></canvas>
|
||||
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.daily_pnl || !dashboardData.daily_pnl.length">No P&L data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 2: Signal Radar + Watchlist Heatmap -->
|
||||
<div class="trader-chart-row">
|
||||
<div class="trader-chart-panel" style="flex:1;max-width:320px">
|
||||
<div class="trader-chart-title">Signal Radar</div>
|
||||
<div class="trader-chart-wrap" style="max-height:280px">
|
||||
<canvas id="traderRadarChart"></canvas>
|
||||
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.signal_radar">No signal data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trader-chart-panel" style="flex:2">
|
||||
<div class="trader-chart-title">Watchlist Heatmap</div>
|
||||
<div class="trader-heatmap-wrap" x-show="dashboardData && dashboardData.watchlist_heatmap && dashboardData.watchlist_heatmap.length">
|
||||
<table class="trader-heatmap-table">
|
||||
<thead>
|
||||
<tr><th>Ticker</th><th>Change</th><th>Signal</th><th>Confidence</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="item in (dashboardData ? dashboardData.watchlist_heatmap || [] : [])" :key="item.ticker">
|
||||
<tr>
|
||||
<td style="font-weight:600" x-text="item.ticker"></td>
|
||||
<td :class="item.change_pct >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(item.change_pct >= 0 ? '+' : '') + item.change_pct + '%'"></td>
|
||||
<td><span class="signal-badge" :class="'signal-' + (item.signal || 'hold').toLowerCase()" x-text="item.signal || 'HOLD'"></span></td>
|
||||
<td>
|
||||
<div class="confidence-bar-wrap">
|
||||
<div class="confidence-bar" :style="'width:' + (item.confidence || 0) + '%'" :class="item.confidence >= 70 ? 'conf-high' : (item.confidence >= 40 ? 'conf-mid' : 'conf-low')"></div>
|
||||
<span class="confidence-label" x-text="(item.confidence || 0) + '%'"></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.watchlist_heatmap || !dashboardData.watchlist_heatmap.length">No watchlist data yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Trades Table -->
|
||||
<div class="trader-chart-panel">
|
||||
<div class="trader-chart-title">Recent Trades</div>
|
||||
<div x-show="dashboardData && dashboardData.recent_trades && dashboardData.recent_trades.length">
|
||||
<table class="trader-trades-table">
|
||||
<thead>
|
||||
<tr><th>Date</th><th>Ticker</th><th>Side</th><th>Price</th><th>Qty</th><th>P&L</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="trade in (dashboardData ? dashboardData.recent_trades || [] : [])" :key="trade.date + trade.ticker">
|
||||
<tr>
|
||||
<td class="text-dim" x-text="trade.date"></td>
|
||||
<td style="font-weight:600" x-text="trade.ticker"></td>
|
||||
<td><span class="trade-side-badge" :class="trade.side === 'BUY' ? 'trade-buy' : 'trade-sell'" x-text="trade.side"></span></td>
|
||||
<td x-text="'$' + Number(trade.price || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
|
||||
<td x-text="trade.qty"></td>
|
||||
<td :class="(trade.pnl || 0) >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(trade.pnl >= 0 ? '+$' : '-$') + Math.abs(trade.pnl || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.recent_trades || !dashboardData.recent_trades.length">No trades yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No data state -->
|
||||
<div x-show="!dashboardLoading && !dashboardData" class="text-center" style="padding:60px 0">
|
||||
<div style="font-size:2rem;margin-bottom:8px">📈</div>
|
||||
<div class="text-dim">Could not load dashboard data.</div>
|
||||
<button class="btn btn-ghost btn-sm mt-3" @click="refreshDashboard()">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Activation result toast -->
|
||||
<div x-show="activateResult" x-transition class="info-card" style="position:fixed;bottom:24px;right:24px;z-index:200;max-width:360px" @click="activateResult = null">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -3123,7 +3368,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
|
||||
<div x-show="!filteredModels.length && !settingsLoading" style="text-align:center;padding:32px 16px">
|
||||
<div x-show="!filteredModels.length && !loading" style="text-align:center;padding:32px 16px">
|
||||
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">🤖</div>
|
||||
<h3 style="margin:0 0 4px;font-size:14px" x-text="models.length ? 'No models match your search' : 'No models available'"></h3>
|
||||
<p class="text-xs text-dim" x-text="models.length ? 'Try a different search term or clear filters.' : 'Configure an LLM provider to see available models.'"></p>
|
||||
@@ -3157,7 +3402,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<input placeholder="Search tools..." x-model="toolSearch">
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredTools.length + ' of ' + tools.length + ' tools'"></div>
|
||||
<div x-show="!filteredTools.length && !settingsLoading" style="text-align:center;padding:32px 16px">
|
||||
<div x-show="!filteredTools.length && !loading" style="text-align:center;padding:32px 16px">
|
||||
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">🔧</div>
|
||||
<h3 style="margin:0 0 4px;font-size:14px" x-text="tools.length ? 'No tools match your search' : 'No tools available'"></h3>
|
||||
<p class="text-xs text-dim" x-text="tools.length ? 'Try a different search term.' : 'Tools will appear once agents are configured.'"></p>
|
||||
@@ -3206,7 +3451,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header" style="text-transform:capitalize" x-text="section.replace(/_/g, ' ')"></div>
|
||||
<div class="detail-grid" style="margin-top:12px">
|
||||
<template x-for="field in fields" :key="section + '.' + field.name">
|
||||
<template x-for="field in (fields.fields || [])" :key="section + '.' + field.name">
|
||||
<div class="detail-row" style="align-items:center">
|
||||
<span class="detail-label" x-text="field.label || field.name"></span>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
||||
@@ -3226,8 +3471,8 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<select class="form-select" style="width:180px"
|
||||
:value="configValues[section] && configValues[section][field.name]"
|
||||
@change="configValues[section] = configValues[section] || {}; configValues[section][field.name] = $event.target.value; markConfigDirty(section, field.name)">
|
||||
<template x-for="opt in field.options" :key="opt">
|
||||
<option :value="opt" x-text="opt" :selected="configValues[section] && configValues[section][field.name] === opt"></option>
|
||||
<template x-for="opt in field.options" :key="typeof opt === 'object' ? opt.id : opt">
|
||||
<option :value="typeof opt === 'object' ? opt.id : opt" x-text="typeof opt === 'object' ? (opt.name || opt.display_name || opt.id) : opt" :selected="configValues[section] && configValues[section][field.name] === (typeof opt === 'object' ? opt.id : opt)"></option>
|
||||
</template>
|
||||
</select>
|
||||
</template>
|
||||
@@ -3458,7 +3703,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div x-show="tab === 'budget'" x-data="{
|
||||
budgetData: null, agentRanking: [], budgetLoading: true,
|
||||
editMode: false,
|
||||
editHourly: '', editDaily: '', editMonthly: '', editAlert: '',
|
||||
editHourly: '', editDaily: '', editMonthly: '', editAlert: '', editTokenLimit: '',
|
||||
saving: false,
|
||||
async loadBudget() {
|
||||
this.budgetLoading = true;
|
||||
@@ -3477,6 +3722,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
this.editDaily = this.budgetData.daily_limit || 0;
|
||||
this.editMonthly = this.budgetData.monthly_limit || 0;
|
||||
this.editAlert = ((this.budgetData.alert_threshold || 0.8) * 100).toFixed(0);
|
||||
this.editTokenLimit = this.budgetData.default_max_llm_tokens_per_hour || 0;
|
||||
this.editMode = true;
|
||||
},
|
||||
async saveBudget() {
|
||||
@@ -3488,12 +3734,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
if (+this.editMonthly !== this.budgetData.monthly_limit) body.max_monthly_usd = +this.editMonthly;
|
||||
let alertVal = (+this.editAlert) / 100;
|
||||
if (Math.abs(alertVal - this.budgetData.alert_threshold) > 0.001) body.alert_threshold = alertVal;
|
||||
if (+this.editTokenLimit !== (this.budgetData.default_max_llm_tokens_per_hour || 0)) body.default_max_llm_tokens_per_hour = +this.editTokenLimit;
|
||||
await OpenFangAPI.put('/api/budget', body);
|
||||
this.editMode = false;
|
||||
await this.loadBudget();
|
||||
} catch(e) { OpenFangToast.error('Failed to save: ' + (e.message || e)); }
|
||||
this.saving = false;
|
||||
},
|
||||
fmtTokens(v) { return v > 0 ? (v >= 1000000 ? (v/1000000).toFixed(1)+'M' : v >= 1000 ? (v/1000).toFixed(0)+'K' : v) : 'per-agent'; },
|
||||
pctColor(pct) { return pct >= 0.8 ? '#ef4444' : pct >= 0.5 ? '#eab308' : '#22c55e'; },
|
||||
fmtUsd(v) { return v > 0 ? '$' + v.toFixed(4) : 'unlimited'; }
|
||||
}" x-init="loadBudget()">
|
||||
@@ -3533,9 +3781,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-3" x-show="budgetData.alert_threshold > 0 && !editMode">
|
||||
<div class="text-xs text-dim mb-1" x-show="budgetData.alert_threshold > 0 && !editMode">
|
||||
Alert threshold: <span x-text="(budgetData.alert_threshold * 100).toFixed(0) + '%'"></span> of any limit
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-3" x-show="!editMode">
|
||||
Hourly token limit (per agent): <span x-text="fmtTokens(budgetData.default_max_llm_tokens_per_hour || 0)"></span>
|
||||
</div>
|
||||
|
||||
<!-- Edit limits form -->
|
||||
<div x-show="editMode" class="card" style="margin:12px 0;padding:12px;border:1px solid var(--accent);border-radius:6px">
|
||||
@@ -3557,7 +3808,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<input type="number" step="5" min="0" max="100" x-model="editAlert" class="input" style="width:100%;margin-top:2px" placeholder="80">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-dim">Set to 0 for unlimited. Changes apply immediately (in-memory, not persisted to config.toml).</div>
|
||||
<div style="margin-bottom:8px">
|
||||
<label class="text-xs text-dim">Hourly Token Limit (per agent, 0 = use per-agent values)</label>
|
||||
<input type="number" step="100000" min="0" x-model="editTokenLimit" class="input" style="width:260px;margin-top:2px" placeholder="0 = per-agent default">
|
||||
</div>
|
||||
<div class="text-xs text-dim">Set to 0 for unlimited/per-agent default. Changes apply immediately (in-memory, not persisted to config.toml).</div>
|
||||
<button class="btn btn-sm mt-2" @click="editMode = false" style="margin-right:8px">Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -3565,7 +3820,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
|
||||
<div class="table-wrap" x-show="agentRanking.length">
|
||||
<table>
|
||||
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th></tr></thead>
|
||||
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th><th>Token Limit/hr</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="a in agentRanking" :key="a.agent_id">
|
||||
<tr>
|
||||
@@ -3574,6 +3829,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<td class="text-dim" x-text="fmtUsd(a.hourly_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtUsd(a.daily_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtUsd(a.monthly_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtTokens(a.max_llm_tokens_per_hour || 0)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
@@ -4058,7 +4314,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div x-data="logsPage">
|
||||
<div class="page-header">
|
||||
<h2>Logs</h2>
|
||||
<div class="flex gap-2 items-center" x-show="tab === 'live'">
|
||||
<div class="flex gap-2 items-center flex-wrap" x-show="tab === 'live'">
|
||||
<!-- Connection status indicator -->
|
||||
<span class="live-indicator" :class="connectionClass">
|
||||
<span class="live-dot"></span>
|
||||
@@ -4565,7 +4821,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div class="card" style="border-left:3px solid var(--accent)">
|
||||
<div class="form-group" style="margin-bottom:8px">
|
||||
<label>Agent Name</label>
|
||||
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="createAgent()">
|
||||
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) createAgent()">
|
||||
</div>
|
||||
<div class="text-xs text-dim" x-text="'Will use ' + templates[selectedTemplate].provider + ' / ' + templates[selectedTemplate].model + ' with ' + profileInfo(templates[selectedTemplate].profile).label + ' profile'"></div>
|
||||
<div class="mt-2">
|
||||
@@ -4611,7 +4867,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<!-- Input -->
|
||||
<div style="display:flex;gap:8px;margin-top:12px">
|
||||
<input class="form-input" type="text" x-model="tryItInput" placeholder="Type a message..."
|
||||
@keydown.enter="sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
|
||||
@keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
|
||||
<button class="btn btn-primary btn-sm" @click="sendTryItMessage(tryItInput)" :disabled="tryItSending || !tryItInput.trim()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4734,7 +4990,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div class="page-body" x-init="loadData()">
|
||||
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading runtime info...</span></div>
|
||||
<div x-show="!loading">
|
||||
<div class="grid grid-cols-4" style="gap:16px;margin-bottom:24px">
|
||||
<div class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Uptime</div>
|
||||
<div class="stat-value" x-text="uptime"></div>
|
||||
@@ -4797,3 +5053,5 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
|
||||
<!-- Toast notification container -->
|
||||
<div id="toast-container" class="toast-container" aria-live="polite"></div>
|
||||
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<title>OpenFang Dashboard</title>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/png" href="/logo.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#6366f1">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -224,9 +224,12 @@ var OpenFangAPI = (function() {
|
||||
try {
|
||||
var url = WS_BASE + '/api/agents/' + agentId + '/ws';
|
||||
if (_authToken) url += '?token=' + encodeURIComponent(_authToken);
|
||||
_ws = new WebSocket(url);
|
||||
var socket = new WebSocket(url);
|
||||
_ws = socket;
|
||||
|
||||
_ws.onopen = function() {
|
||||
socket.onopen = function() {
|
||||
// Guard: ignore if this socket was superseded by a newer connection
|
||||
if (_ws !== socket) return;
|
||||
_wsConnected = true;
|
||||
_reconnectAttempts = 0;
|
||||
setConnectionState('connected');
|
||||
@@ -237,14 +240,20 @@ var OpenFangAPI = (function() {
|
||||
if (_wsCallbacks.onOpen) _wsCallbacks.onOpen();
|
||||
};
|
||||
|
||||
_ws.onmessage = function(e) {
|
||||
socket.onmessage = function(e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
|
||||
} catch(err) { /* ignore parse errors */ }
|
||||
} catch(parseErr) {
|
||||
return; // Ignore malformed JSON frames
|
||||
}
|
||||
// Dispatch outside try/catch so handler errors are not swallowed
|
||||
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
|
||||
};
|
||||
|
||||
_ws.onclose = function(e) {
|
||||
socket.onclose = function(e) {
|
||||
// Guard: only update state if this is still the active socket.
|
||||
// A superseded socket closing must not null-out the new connection.
|
||||
if (_ws !== socket) return;
|
||||
_wsConnected = false;
|
||||
_ws = null;
|
||||
if (_wsAgentId && _reconnectAttempts < MAX_RECONNECT && e.code !== 1000) {
|
||||
@@ -265,7 +274,9 @@ var OpenFangAPI = (function() {
|
||||
if (_wsCallbacks.onClose) _wsCallbacks.onClose();
|
||||
};
|
||||
|
||||
_ws.onerror = function() {
|
||||
socket.onerror = function() {
|
||||
// Guard: ignore errors from superseded sockets
|
||||
if (_ws !== socket) return;
|
||||
_wsConnected = false;
|
||||
if (_wsCallbacks.onError) _wsCallbacks.onError();
|
||||
};
|
||||
@@ -297,15 +308,15 @@ var OpenFangAPI = (function() {
|
||||
function getToken() { return _authToken; }
|
||||
|
||||
function upload(agentId, file) {
|
||||
var hdrs = {
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
'X-Filename': file.name
|
||||
};
|
||||
var hdrs = {};
|
||||
if (_authToken) hdrs['Authorization'] = 'Bearer ' + _authToken;
|
||||
var form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('filename', file.name);
|
||||
return fetch(BASE + '/api/agents/' + agentId + '/upload', {
|
||||
method: 'POST',
|
||||
headers: hdrs,
|
||||
body: file
|
||||
body: form
|
||||
}).then(function(r) {
|
||||
if (!r.ok) throw new Error('Upload failed');
|
||||
return r.json();
|
||||
|
||||
@@ -24,9 +24,43 @@ function escapeHtml(text) {
|
||||
function renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
if (typeof marked !== 'undefined') {
|
||||
var html = marked.parse(text);
|
||||
// Protect LaTeX blocks from marked.js mangling (underscores, backslashes, etc.)
|
||||
var latexBlocks = [];
|
||||
var protected_ = text;
|
||||
// Protect display math $$...$$ first (greedy across lines)
|
||||
protected_ = protected_.replace(/\$\$([\s\S]+?)\$\$/g, function(match) {
|
||||
var idx = latexBlocks.length;
|
||||
latexBlocks.push(match);
|
||||
return '\x00LATEX' + idx + '\x00';
|
||||
});
|
||||
// Protect inline math $...$ (single line, not empty, not starting/ending with space)
|
||||
protected_ = protected_.replace(/\$([^\s$](?:[^$]*[^\s$])?)\$/g, function(match) {
|
||||
var idx = latexBlocks.length;
|
||||
latexBlocks.push(match);
|
||||
return '\x00LATEX' + idx + '\x00';
|
||||
});
|
||||
// Protect \[...\] display math
|
||||
protected_ = protected_.replace(/\\\[([\s\S]+?)\\\]/g, function(match) {
|
||||
var idx = latexBlocks.length;
|
||||
latexBlocks.push(match);
|
||||
return '\x00LATEX' + idx + '\x00';
|
||||
});
|
||||
// Protect \(...\) inline math
|
||||
protected_ = protected_.replace(/\\\(([\s\S]+?)\\\)/g, function(match) {
|
||||
var idx = latexBlocks.length;
|
||||
latexBlocks.push(match);
|
||||
return '\x00LATEX' + idx + '\x00';
|
||||
});
|
||||
|
||||
var html = marked.parse(protected_);
|
||||
// Restore LaTeX blocks
|
||||
for (var i = 0; i < latexBlocks.length; i++) {
|
||||
html = html.replace('\x00LATEX' + i + '\x00', latexBlocks[i]);
|
||||
}
|
||||
// Add copy buttons to code blocks
|
||||
html = html.replace(/<pre><code/g, '<pre><button class="copy-btn" onclick="copyCode(this)">Copy</button><code');
|
||||
// Open external links in new tab
|
||||
html = html.replace(/<a\s+href="(https?:\/\/[^"]*)"(?![^>]*target=)([^>]*)>/gi, '<a href="$1" target="_blank" rel="noopener"$2>');
|
||||
return html;
|
||||
}
|
||||
return escapeHtml(text);
|
||||
@@ -100,10 +134,14 @@ document.addEventListener('alpine:init', function() {
|
||||
lastError: '',
|
||||
version: '0.1.0',
|
||||
agentCount: 0,
|
||||
pendingApprovalCount: 0,
|
||||
lastPendingApprovalSignature: '',
|
||||
pendingAgent: null,
|
||||
focusMode: localStorage.getItem('openfang-focus') === 'true',
|
||||
showOnboarding: false,
|
||||
showAuthPrompt: false,
|
||||
authMode: 'apikey',
|
||||
sessionUser: null,
|
||||
|
||||
toggleFocusMode() {
|
||||
this.focusMode = !this.focusMode;
|
||||
@@ -118,6 +156,23 @@ document.addEventListener('alpine:init', function() {
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
async refreshApprovals() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/approvals');
|
||||
var approvals = Array.isArray(data) ? data : (data.approvals || []);
|
||||
var pending = approvals.filter(function(a) { return a.status === 'pending'; });
|
||||
var signature = pending
|
||||
.map(function(a) { return a.id; })
|
||||
.sort()
|
||||
.join(',');
|
||||
if (pending.length > 0 && signature !== this.lastPendingApprovalSignature && typeof OpenFangToast !== 'undefined') {
|
||||
OpenFangToast.warn('An agent is waiting for approval. Open Approvals to review.');
|
||||
}
|
||||
this.pendingApprovalCount = pending.length;
|
||||
this.lastPendingApprovalSignature = signature;
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
async checkStatus() {
|
||||
try {
|
||||
var s = await OpenFangAPI.get('/api/status');
|
||||
@@ -155,16 +210,33 @@ document.addEventListener('alpine:init', function() {
|
||||
|
||||
async checkAuth() {
|
||||
try {
|
||||
// Use a protected endpoint (not in the public allowlist) to detect
|
||||
// whether the server requires an API key.
|
||||
// First check if session-based auth is configured
|
||||
var authInfo = await OpenFangAPI.get('/api/auth/check');
|
||||
if (authInfo.mode === 'none') {
|
||||
// No session auth — fall back to API key detection
|
||||
this.authMode = 'apikey';
|
||||
this.sessionUser = null;
|
||||
} else if (authInfo.mode === 'session') {
|
||||
this.authMode = 'session';
|
||||
if (authInfo.authenticated) {
|
||||
this.sessionUser = authInfo.username;
|
||||
this.showAuthPrompt = false;
|
||||
return;
|
||||
}
|
||||
// Session auth enabled but not authenticated — show login prompt
|
||||
this.showAuthPrompt = true;
|
||||
return;
|
||||
}
|
||||
} catch(e) { /* ignore — fall through to API key check */ }
|
||||
|
||||
// API key mode detection
|
||||
try {
|
||||
await OpenFangAPI.get('/api/tools');
|
||||
this.showAuthPrompt = false;
|
||||
} catch(e) {
|
||||
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
|
||||
// Only show prompt if we don't already have a saved key
|
||||
var saved = localStorage.getItem('openfang-api-key');
|
||||
if (saved) {
|
||||
// Saved key might be stale — clear it and show prompt
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
}
|
||||
@@ -181,6 +253,29 @@ document.addEventListener('alpine:init', function() {
|
||||
this.refreshAgents();
|
||||
},
|
||||
|
||||
async sessionLogin(username, password) {
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/auth/login', { username: username, password: password });
|
||||
if (result.status === 'ok') {
|
||||
this.sessionUser = result.username;
|
||||
this.showAuthPrompt = false;
|
||||
this.refreshAgents();
|
||||
} else {
|
||||
OpenFangToast.error(result.error || 'Login failed');
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Login failed');
|
||||
}
|
||||
},
|
||||
|
||||
async sessionLogout() {
|
||||
try {
|
||||
await OpenFangAPI.post('/api/auth/logout');
|
||||
} catch(e) { /* ignore */ }
|
||||
this.sessionUser = null;
|
||||
this.showAuthPrompt = true;
|
||||
},
|
||||
|
||||
clearApiKey() {
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
@@ -274,9 +369,13 @@ function app() {
|
||||
|
||||
// Initial data load
|
||||
this.pollStatus();
|
||||
Alpine.store('app').refreshApprovals();
|
||||
Alpine.store('app').checkOnboarding();
|
||||
Alpine.store('app').checkAuth();
|
||||
setInterval(function() { self.pollStatus(); }, 5000);
|
||||
setInterval(function() {
|
||||
self.pollStatus();
|
||||
Alpine.store('app').refreshApprovals();
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
navigate(p) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// On-demand KaTeX loader and renderer for chat messages.
|
||||
|
||||
var KATEX_VERSION = '0.16.21';
|
||||
var KATEX_CSS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.css';
|
||||
var KATEX_JS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.js';
|
||||
var KATEX_AUTORENDER_URL =
|
||||
'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/contrib/auto-render.min.js';
|
||||
var katexLoadPromise = null;
|
||||
|
||||
function hasLatexDelimiters(text) {
|
||||
if (!text) return false;
|
||||
return /\$\$|\\\[|\\\(|\$(?=\S)[^$\n]+\$/.test(text);
|
||||
}
|
||||
|
||||
function loadScript(url) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.onload = function () {
|
||||
resolve();
|
||||
};
|
||||
script.onerror = function () {
|
||||
reject(new Error('Failed to load script: ' + url));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function ensureKatexLoaded() {
|
||||
if (typeof renderMathInElement === 'function') return Promise.resolve(true);
|
||||
if (katexLoadPromise) return katexLoadPromise;
|
||||
|
||||
katexLoadPromise = new Promise(function (resolve) {
|
||||
var cssId = 'openfang-katex-css';
|
||||
if (!document.getElementById(cssId)) {
|
||||
var link = document.createElement('link');
|
||||
link.id = cssId;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = KATEX_CSS_URL;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
loadScript(KATEX_JS_URL)
|
||||
.then(function () {
|
||||
return loadScript(KATEX_AUTORENDER_URL);
|
||||
})
|
||||
.then(function () {
|
||||
resolve(typeof renderMathInElement === 'function');
|
||||
})
|
||||
.catch(function () {
|
||||
katexLoadPromise = null;
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
return katexLoadPromise;
|
||||
}
|
||||
|
||||
// Render LaTeX math in the chat message container using KaTeX auto-render.
|
||||
// Call this after new messages are inserted into the DOM.
|
||||
function renderLatex(el) {
|
||||
var target = el || document.getElementById('messages');
|
||||
if (!target) return;
|
||||
if (!hasLatexDelimiters(target.textContent || '')) return;
|
||||
|
||||
ensureKatexLoaded().then(function (ok) {
|
||||
if (!ok || typeof renderMathInElement !== 'function') return;
|
||||
try {
|
||||
renderMathInElement(target, {
|
||||
delimiters: [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
],
|
||||
throwOnError: false,
|
||||
trust: false,
|
||||
});
|
||||
} catch (e) {
|
||||
/* KaTeX render error — ignore gracefully */
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,21 @@
|
||||
// OpenFang Agents Page — Multi-step spawn wizard, detail view with tabs, file editor, personality presets
|
||||
'use strict';
|
||||
|
||||
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n""").
|
||||
* Backslashes are escaped, and runs of 3+ consecutive double-quotes are
|
||||
* broken up so the TOML parser never sees an unintended closing delimiter.
|
||||
*/
|
||||
function tomlMultilineEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
|
||||
}
|
||||
|
||||
/** Escape a string for use inside a TOML basic (single-line) string ("...").
|
||||
* Backslashes, double-quotes, and common control chars are escaped.
|
||||
*/
|
||||
function tomlBasicEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
function agentsPage() {
|
||||
return {
|
||||
tab: 'agents',
|
||||
@@ -25,6 +40,8 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// -- Multi-step wizard state --
|
||||
spawnProviders: [], // populated from /api/providers on wizard open
|
||||
spawnProvidersLoading: false,
|
||||
spawnStep: 1,
|
||||
spawnIdentity: { emoji: '', color: '#FF5C00', archetype: '' },
|
||||
selectedPreset: '',
|
||||
@@ -62,6 +79,8 @@ function agentsPage() {
|
||||
// -- Model switch --
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
editingProvider: false,
|
||||
newProviderValue: '',
|
||||
modelSaving: false,
|
||||
// -- Fallback chain --
|
||||
editingFallback: false,
|
||||
@@ -75,98 +94,12 @@ function agentsPage() {
|
||||
selectedCategory: 'All',
|
||||
searchQuery: '',
|
||||
|
||||
builtinTemplates: [
|
||||
{
|
||||
name: 'General Assistant',
|
||||
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
|
||||
category: 'General',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.'
|
||||
},
|
||||
{
|
||||
name: 'Code Helper',
|
||||
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.'
|
||||
},
|
||||
{
|
||||
name: 'Researcher',
|
||||
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
|
||||
category: 'Research',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'research',
|
||||
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.'
|
||||
},
|
||||
{
|
||||
name: 'Writer',
|
||||
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
|
||||
category: 'Writing',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.'
|
||||
},
|
||||
{
|
||||
name: 'Data Analyst',
|
||||
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.'
|
||||
},
|
||||
{
|
||||
name: 'DevOps Engineer',
|
||||
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'automation',
|
||||
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.'
|
||||
},
|
||||
{
|
||||
name: 'Customer Support',
|
||||
description: 'A professional, empathetic agent for handling customer inquiries and resolving issues.',
|
||||
category: 'Business',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'messaging',
|
||||
system_prompt: 'You are a professional customer support representative. Be empathetic, patient, and solution-oriented. Acknowledge concerns before offering solutions. Escalate complex issues appropriately.'
|
||||
},
|
||||
{
|
||||
name: 'Tutor',
|
||||
description: 'A patient educational agent that explains concepts step-by-step and adapts to the learner\'s level.',
|
||||
category: 'General',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a patient and encouraging tutor. Explain concepts step by step, starting from fundamentals. Use analogies and examples. Check understanding before moving on. Adapt to the learner\'s pace.'
|
||||
},
|
||||
{
|
||||
name: 'API Designer',
|
||||
description: 'An agent specialized in RESTful API design, OpenAPI specs, and integration architecture.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an API design expert. Help users design clean, consistent RESTful APIs following best practices. Cover endpoint naming, request/response schemas, error handling, and versioning.'
|
||||
},
|
||||
{
|
||||
name: 'Meeting Notes',
|
||||
description: 'Summarizes meeting transcripts into structured notes with action items and key decisions.',
|
||||
category: 'Business',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'minimal',
|
||||
system_prompt: 'You are a meeting summarizer. When given a meeting transcript or notes, produce a structured summary with: key decisions, action items (with owners), discussion highlights, and follow-up questions.'
|
||||
}
|
||||
],
|
||||
builtinTemplates: [],
|
||||
|
||||
// Load templates from API
|
||||
async init() {
|
||||
await this.loadTemplates();
|
||||
},
|
||||
|
||||
// ── Profile Descriptions ──
|
||||
profileDescriptions: {
|
||||
@@ -263,6 +196,7 @@ function agentsPage() {
|
||||
this.loadError = '';
|
||||
try {
|
||||
await Alpine.store('app').refreshAgents();
|
||||
await this.loadTemplates();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load agents. Is the daemon running?';
|
||||
}
|
||||
@@ -300,10 +234,73 @@ function agentsPage() {
|
||||
OpenFangAPI.get('/api/templates'),
|
||||
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
|
||||
]);
|
||||
this.tplTemplates = results[0].templates || [];
|
||||
// Combine static and dynamic templates
|
||||
this.builtinTemplates = [
|
||||
{
|
||||
name: 'General Assistant',
|
||||
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
|
||||
category: 'General',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.',
|
||||
manifest_toml: 'name = "General Assistant"\ndescription = "A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Code Helper',
|
||||
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.',
|
||||
manifest_toml: 'name = "Code Helper"\ndescription = "A programming-focused agent that writes, reviews, and debugs code across multiple languages."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Researcher',
|
||||
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
|
||||
category: 'Research',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'research',
|
||||
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.',
|
||||
manifest_toml: 'name = "Researcher"\ndescription = "An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries."\nmodule = "builtin:chat"\nprofile = "research"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Writer',
|
||||
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
|
||||
category: 'Writing',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'full',
|
||||
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.',
|
||||
manifest_toml: 'name = "Writer"\ndescription = "A creative writing agent that helps with drafting, editing, and improving written content of all kinds."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'Data Analyst',
|
||||
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'coding',
|
||||
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.',
|
||||
manifest_toml: 'name = "Data Analyst"\ndescription = "A data-focused agent that helps analyze datasets, create queries, and interpret statistical results."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.\n"""'
|
||||
},
|
||||
{
|
||||
name: 'DevOps Engineer',
|
||||
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
|
||||
category: 'Development',
|
||||
provider: 'groq',
|
||||
model: 'llama-3.3-70b-versatile',
|
||||
profile: 'automation',
|
||||
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.',
|
||||
manifest_toml: 'name = "DevOps Engineer"\ndescription = "A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting."\nmodule = "builtin:chat"\nprofile = "automation"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.\n"""'
|
||||
},
|
||||
...results[0].templates || []
|
||||
];
|
||||
this.tplProviders = results[1].providers || [];
|
||||
} catch(e) {
|
||||
this.tplTemplates = [];
|
||||
this.builtinTemplates = [];
|
||||
this.tplLoadError = e.message || 'Could not load templates.';
|
||||
}
|
||||
this.tplLoading = false;
|
||||
@@ -378,7 +375,7 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// ── Multi-step wizard navigation ──
|
||||
openSpawnWizard() {
|
||||
async openSpawnWizard() {
|
||||
this.showSpawnModal = true;
|
||||
this.spawnStep = 1;
|
||||
this.spawnMode = 'wizard';
|
||||
@@ -386,8 +383,26 @@ function agentsPage() {
|
||||
this.selectedPreset = '';
|
||||
this.soulContent = '';
|
||||
this.spawnForm.name = '';
|
||||
this.spawnForm.provider = 'groq';
|
||||
this.spawnForm.model = 'llama-3.3-70b-versatile';
|
||||
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
|
||||
this.spawnForm.profile = 'full';
|
||||
// Fetch status defaults and dynamic provider list concurrently
|
||||
this.spawnProvidersLoading = true;
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/status').catch(function() { return {}; }),
|
||||
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
|
||||
]);
|
||||
var status = results[0];
|
||||
var provData = results[1];
|
||||
if (status.default_provider) this.spawnForm.provider = status.default_provider;
|
||||
if (status.default_model) this.spawnForm.model = status.default_model;
|
||||
this.spawnProviders = provData.providers || [];
|
||||
} catch(e) {
|
||||
this.spawnProviders = [];
|
||||
}
|
||||
this.spawnProvidersLoading = false;
|
||||
},
|
||||
|
||||
nextStep() {
|
||||
@@ -411,7 +426,7 @@ function agentsPage() {
|
||||
var f = this.spawnForm;
|
||||
var si = this.spawnIdentity;
|
||||
var lines = [
|
||||
'name = "' + f.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"',
|
||||
'name = "' + tomlBasicEscape(f.name) + '"',
|
||||
'module = "builtin:chat"'
|
||||
];
|
||||
if (f.profile && f.profile !== 'custom') {
|
||||
@@ -420,7 +435,7 @@ function agentsPage() {
|
||||
lines.push('', '[model]');
|
||||
lines.push('provider = "' + f.provider + '"');
|
||||
lines.push('model = "' + f.model + '"');
|
||||
lines.push('system_prompt = """\n' + f.systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"') + '\n"""');
|
||||
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 +578,20 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// -- Template methods --
|
||||
async spawnFromTemplate(name) {
|
||||
async spawnFromTemplate(template) {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(name));
|
||||
if (data.manifest_toml) {
|
||||
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: data.manifest_toml });
|
||||
var manifestToml = template.manifest_toml;
|
||||
if (!manifestToml) {
|
||||
// If template doesn't have manifest_toml, fetch it from the API
|
||||
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(template.name));
|
||||
manifestToml = data.manifest_toml;
|
||||
}
|
||||
if (manifestToml) {
|
||||
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: manifestToml });
|
||||
if (res.agent_id) {
|
||||
OpenFangToast.success('Agent "' + (res.name || name) + '" spawned from template');
|
||||
OpenFangToast.success('Agent "' + (res.name || template.name) + '" spawned from template');
|
||||
await Alpine.store('app').refreshAgents();
|
||||
this.chatWithAgent({ id: res.agent_id, name: res.name || name, model_provider: '?', model_name: '?' });
|
||||
this.chatWithAgent({ id: res.agent_id, name: res.name || template.name, model_provider: '?', model_name: '?' });
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
@@ -613,6 +633,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;
|
||||
@@ -698,12 +738,12 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
async spawnBuiltin(t) {
|
||||
var toml = 'name = "' + t.name + '"\n';
|
||||
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
|
||||
var toml = 'name = "' + tomlBasicEscape(t.name) + '"\n';
|
||||
toml += 'description = "' + tomlBasicEscape(t.description) + '"\n';
|
||||
toml += 'module = "builtin:chat"\n';
|
||||
toml += 'profile = "' + t.profile + '"\n\n';
|
||||
toml += '[model]\nprovider = "' + t.provider + '"\nmodel = "' + t.model + '"\n';
|
||||
toml += 'system_prompt = """\n' + t.system_prompt + '\n"""\n';
|
||||
toml += 'system_prompt = """\n' + tomlMultilineEscape(t.system_prompt) + '\n"""\n';
|
||||
|
||||
try {
|
||||
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
|
||||
|
||||
@@ -7,6 +7,22 @@ function approvalsPage() {
|
||||
filterStatus: 'all',
|
||||
loading: true,
|
||||
loadError: '',
|
||||
refreshTimer: null,
|
||||
|
||||
init() {
|
||||
var self = this;
|
||||
this.loadData();
|
||||
this.refreshTimer = setInterval(function() {
|
||||
self.loadData();
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
get filtered() {
|
||||
var f = this.filterStatus;
|
||||
|
||||
@@ -530,7 +530,7 @@ 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
|
||||
@@ -622,8 +622,12 @@ function chatPage() {
|
||||
this.scrollToBottom();
|
||||
this._resetTypingTimeout();
|
||||
} else if (data.level) {
|
||||
var lastThink = this.messages[this.messages.length - 1];
|
||||
if (lastThink && lastThink.thinking) lastThink.text = 'Thinking (' + data.level + ')...';
|
||||
var thinkIdx = this.messages.length - 1;
|
||||
var lastThink = thinkIdx >= 0 ? this.messages[thinkIdx] : null;
|
||||
if (lastThink && lastThink.thinking) {
|
||||
lastThink.text = 'Thinking (' + data.level + ')...';
|
||||
this.messages.splice(thinkIdx, 1, lastThink);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -636,9 +640,11 @@ function chatPage() {
|
||||
}
|
||||
this._resetTypingTimeout();
|
||||
} else if (data.state === 'tool') {
|
||||
var typingMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var toolTypIdx = this.messages.length - 1;
|
||||
var typingMsg = toolTypIdx >= 0 ? this.messages[toolTypIdx] : null;
|
||||
if (typingMsg && (typingMsg.thinking || typingMsg.streaming)) {
|
||||
typingMsg.text = 'Using ' + (data.tool || 'tool') + '...';
|
||||
this.messages.splice(toolTypIdx, 1, typingMsg);
|
||||
}
|
||||
this._resetTypingTimeout();
|
||||
} else if (data.state === 'stop') {
|
||||
@@ -648,26 +654,45 @@ function chatPage() {
|
||||
|
||||
case 'phase':
|
||||
// Show tool/phase progress so the user sees the agent is working
|
||||
var phaseMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var phaseIdx = this.messages.length - 1;
|
||||
var phaseMsg = phaseIdx >= 0 ? this.messages[phaseIdx] : null;
|
||||
if (phaseMsg && (phaseMsg.thinking || phaseMsg.streaming)) {
|
||||
var detail = data.detail || data.phase || 'Working...';
|
||||
// Context warning: show prominently
|
||||
// Skip phases that have no user-meaningful display text — "streaming"
|
||||
// and "done" are lifecycle signals, not status to show in the chat bubble.
|
||||
if (data.phase === 'streaming' || data.phase === 'done') {
|
||||
break;
|
||||
}
|
||||
// Context warning: show prominently as a separate system message
|
||||
if (data.phase === 'context_warning') {
|
||||
this.messages.push({ id: ++msgId, role: 'system', text: detail, meta: '', tools: [] });
|
||||
var cwDetail = data.detail || 'Context limit reached.';
|
||||
this.messages.push({ id: ++msgId, role: 'system', text: cwDetail, meta: '', tools: [] });
|
||||
} else if (data.phase === 'thinking' && this.thinkingMode === 'stream') {
|
||||
// Stream reasoning tokens to a collapsible panel
|
||||
if (!phaseMsg._reasoning) phaseMsg._reasoning = '';
|
||||
phaseMsg._reasoning += (detail || '') + '\n';
|
||||
phaseMsg._reasoning += (data.detail || '') + '\n';
|
||||
phaseMsg.text = '<details><summary>Reasoning...</summary>\n\n' + phaseMsg._reasoning + '</details>';
|
||||
} else {
|
||||
phaseMsg.text = detail;
|
||||
this.messages.splice(phaseIdx, 1, phaseMsg);
|
||||
} else if (phaseMsg.thinking) {
|
||||
// Only update text on messages still in thinking state (not yet
|
||||
// receiving streamed content) to avoid overwriting accumulated text.
|
||||
var phaseDetail;
|
||||
if (data.phase === 'tool_use') {
|
||||
phaseDetail = 'Using ' + (data.detail || 'tool') + '...';
|
||||
} else if (data.phase === 'thinking') {
|
||||
phaseDetail = 'Thinking...';
|
||||
} else {
|
||||
phaseDetail = data.detail || 'Working...';
|
||||
}
|
||||
phaseMsg.text = phaseDetail;
|
||||
this.messages.splice(phaseIdx, 1, phaseMsg);
|
||||
}
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
|
||||
case 'text_delta':
|
||||
var last = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var lastIdx = this.messages.length - 1;
|
||||
var last = lastIdx >= 0 ? this.messages[lastIdx] : null;
|
||||
if (last && last.streaming) {
|
||||
if (last.thinking) { last.text = ''; last.thinking = false; }
|
||||
// If we already detected a text-based tool call, skip further text
|
||||
@@ -688,7 +713,7 @@ function chatPage() {
|
||||
id: toolMatch[1] + '-txt-' + Date.now(),
|
||||
name: toolMatch[1],
|
||||
running: true,
|
||||
expanded: false,
|
||||
expanded: true,
|
||||
input: inputMatch ? inputMatch[1].replace(/<\/function>?\s*$/, '').trim() : '',
|
||||
result: '',
|
||||
is_error: false
|
||||
@@ -696,6 +721,10 @@ function chatPage() {
|
||||
}
|
||||
}
|
||||
this.tokenCount = Math.round(last.text.length / 4);
|
||||
// Force Alpine reactivity: splice-in-place so x-for re-renders
|
||||
// this item. Direct property mutation on array elements may not
|
||||
// trigger DOM updates from async WebSocket callbacks.
|
||||
this.messages.splice(lastIdx, 1, last);
|
||||
} else {
|
||||
this.messages.push({ id: ++msgId, role: 'agent', text: data.content, meta: '', streaming: true, tools: [] });
|
||||
}
|
||||
@@ -703,17 +732,20 @@ function chatPage() {
|
||||
break;
|
||||
|
||||
case 'tool_start':
|
||||
var lastMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var tsIdx = this.messages.length - 1;
|
||||
var lastMsg = tsIdx >= 0 ? this.messages[tsIdx] : null;
|
||||
if (lastMsg && lastMsg.streaming) {
|
||||
if (!lastMsg.tools) lastMsg.tools = [];
|
||||
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: false, input: '', result: '', is_error: false });
|
||||
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: true, input: '', result: '', is_error: false });
|
||||
this.messages.splice(tsIdx, 1, lastMsg);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
|
||||
case 'tool_end':
|
||||
// Tool call parsed by LLM — update tool card with input params
|
||||
var lastMsg2 = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var teIdx = this.messages.length - 1;
|
||||
var lastMsg2 = teIdx >= 0 ? this.messages[teIdx] : null;
|
||||
if (lastMsg2 && lastMsg2.tools) {
|
||||
for (var ti = lastMsg2.tools.length - 1; ti >= 0; ti--) {
|
||||
if (lastMsg2.tools[ti].name === data.tool && lastMsg2.tools[ti].running) {
|
||||
@@ -721,12 +753,14 @@ function chatPage() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.messages.splice(teIdx, 1, lastMsg2);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result':
|
||||
// Tool execution completed — update tool card with result
|
||||
var lastMsg3 = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
var trIdx = this.messages.length - 1;
|
||||
var lastMsg3 = trIdx >= 0 ? this.messages[trIdx] : null;
|
||||
if (lastMsg3 && lastMsg3.tools) {
|
||||
for (var ri = lastMsg3.tools.length - 1; ri >= 0; ri--) {
|
||||
if (lastMsg3.tools[ri].name === data.tool && lastMsg3.tools[ri].running) {
|
||||
@@ -755,6 +789,7 @@ function chatPage() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.messages.splice(trIdx, 1, lastMsg3);
|
||||
}
|
||||
this.scrollToBottom();
|
||||
break;
|
||||
@@ -1031,10 +1066,16 @@ function chatPage() {
|
||||
});
|
||||
},
|
||||
|
||||
_latexTimer: null,
|
||||
scrollToBottom() {
|
||||
var self = this;
|
||||
var el = document.getElementById('messages');
|
||||
if (el) self.$nextTick(function() { el.scrollTop = el.scrollHeight; });
|
||||
if (el) self.$nextTick(function() {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
// Debounce LaTeX rendering to avoid running on every streaming token
|
||||
if (self._latexTimer) clearTimeout(self._latexTimer);
|
||||
self._latexTimer = setTimeout(function() { renderLatex(el); }, 150);
|
||||
});
|
||||
},
|
||||
|
||||
addFiles(files) {
|
||||
@@ -1104,6 +1145,9 @@ function chatPage() {
|
||||
|
||||
formatToolJson: function(text) {
|
||||
if (!text) return '';
|
||||
if (typeof text === 'object') {
|
||||
return JSON.stringify(text, null, 2);
|
||||
}
|
||||
try { return JSON.stringify(JSON.parse(text), null, 2); }
|
||||
catch(e) { return text; }
|
||||
},
|
||||
|
||||
@@ -18,6 +18,15 @@ function handsPage() {
|
||||
browserViewerOpen: false,
|
||||
_browserPollTimer: null,
|
||||
|
||||
// ── Trader Dashboard State ────────────────────────────────────────────
|
||||
dashboardOpen: false,
|
||||
dashboardLoading: false,
|
||||
dashboardData: null,
|
||||
_dashboardInst: null,
|
||||
_chartEquity: null,
|
||||
_chartPnl: null,
|
||||
_chartRadar: null,
|
||||
|
||||
// ── Setup Wizard State ──────────────────────────────────────────────
|
||||
setupWizard: null,
|
||||
setupStep: 1,
|
||||
@@ -27,6 +36,7 @@ function handsPage() {
|
||||
_clipboardTimer: null,
|
||||
detectedPlatform: 'linux',
|
||||
installPlatforms: {},
|
||||
apiKeyInputs: {},
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
@@ -101,11 +111,15 @@ function handsPage() {
|
||||
} else {
|
||||
this._detectClientPlatform();
|
||||
}
|
||||
// Initialize per-requirement platform selections
|
||||
// Initialize per-requirement platform selections and API key inputs
|
||||
this.installPlatforms = {};
|
||||
this.apiKeyInputs = {};
|
||||
if (data.requirements) {
|
||||
for (var j = 0; j < data.requirements.length; j++) {
|
||||
this.installPlatforms[data.requirements[j].key] = this.detectedPlatform;
|
||||
if (data.requirements[j].type === 'ApiKey') {
|
||||
this.apiKeyInputs[data.requirements[j].key] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
this.setupWizard = data;
|
||||
@@ -274,7 +288,10 @@ function handsPage() {
|
||||
if (!this.setupWizard || !this.setupWizard.requirements) return 0;
|
||||
var count = 0;
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
if (this.setupWizard.requirements[i].satisfied) count++;
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.satisfied) { count++; continue; }
|
||||
// Count API key reqs as met if user entered a value
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') count++;
|
||||
}
|
||||
return count;
|
||||
},
|
||||
@@ -285,7 +302,34 @@ function handsPage() {
|
||||
},
|
||||
|
||||
get setupAllReqsMet() {
|
||||
return this.setupReqsTotal > 0 && this.setupReqsMet === this.setupReqsTotal;
|
||||
if (!this.setupWizard || !this.setupWizard.requirements) return false;
|
||||
if (this.setupReqsTotal === 0) return false;
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.satisfied) continue;
|
||||
// API key reqs are satisfied if the user entered a value in the input
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') continue;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
getSettingKeyForReq(req) {
|
||||
// Find the matching setting key for an API key requirement.
|
||||
// Convention: setting key is the lowercase version of the requirement key.
|
||||
if (!this.setupWizard || !this.setupWizard.settings) return null;
|
||||
var lowerKey = req.key.toLowerCase();
|
||||
for (var i = 0; i < this.setupWizard.settings.length; i++) {
|
||||
if (this.setupWizard.settings[i].key === lowerKey) return lowerKey;
|
||||
}
|
||||
// Fallback: try matching by check_value lowercased
|
||||
if (req.check_value) {
|
||||
var lowerCheck = req.check_value.toLowerCase();
|
||||
for (var j = 0; j < this.setupWizard.settings.length; j++) {
|
||||
if (this.setupWizard.settings[j].key === lowerCheck) return lowerCheck;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
get setupHasReqs() {
|
||||
@@ -297,6 +341,10 @@ function handsPage() {
|
||||
},
|
||||
|
||||
setupNextStep() {
|
||||
// When leaving step 1, sync API key inputs into settings values
|
||||
if (this.setupStep === 1) {
|
||||
this._syncApiKeysToSettings();
|
||||
}
|
||||
if (this.setupStep === 1 && this.setupHasSettings) {
|
||||
this.setupStep = 2;
|
||||
} else if (this.setupStep === 1) {
|
||||
@@ -306,6 +354,19 @@ function handsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
_syncApiKeysToSettings() {
|
||||
if (!this.setupWizard || !this.setupWizard.requirements) return;
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
|
||||
var settingKey = this.getSettingKeyForReq(req);
|
||||
if (settingKey) {
|
||||
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setupPrevStep() {
|
||||
if (this.setupStep === 3 && this.setupHasSettings) {
|
||||
this.setupStep = 2;
|
||||
@@ -323,11 +384,24 @@ function handsPage() {
|
||||
this.setupChecking = false;
|
||||
this.clipboardMsg = null;
|
||||
this.installPlatforms = {};
|
||||
this.apiKeyInputs = {};
|
||||
},
|
||||
|
||||
async launchHand() {
|
||||
if (!this.setupWizard) return;
|
||||
var handId = this.setupWizard.id;
|
||||
// Sync API key inputs from step 1 into settings values
|
||||
if (this.setupWizard.requirements) {
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
|
||||
var settingKey = this.getSettingKeyForReq(req);
|
||||
if (settingKey) {
|
||||
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var config = {};
|
||||
for (var key in this.settingsValues) {
|
||||
config[key] = this.settingsValues[key];
|
||||
@@ -499,6 +573,382 @@ function handsPage() {
|
||||
this.stopBrowserPolling();
|
||||
this.browserViewerOpen = false;
|
||||
this.browserViewer = null;
|
||||
},
|
||||
|
||||
// ── Trader Dashboard ──────────────────────────────────────────────────
|
||||
|
||||
isTraderHand(inst) {
|
||||
return inst.hand_id === 'trader';
|
||||
},
|
||||
|
||||
async openDashboard(inst) {
|
||||
this._dashboardInst = inst;
|
||||
this.dashboardOpen = true;
|
||||
this.dashboardLoading = true;
|
||||
this.dashboardData = null;
|
||||
await this._fetchDashboardData(inst);
|
||||
this.dashboardLoading = false;
|
||||
// Render charts after DOM update
|
||||
var self = this;
|
||||
setTimeout(function() { self._renderCharts(); }, 60);
|
||||
},
|
||||
|
||||
async refreshDashboard() {
|
||||
if (!this._dashboardInst) return;
|
||||
this.dashboardLoading = true;
|
||||
await this._fetchDashboardData(this._dashboardInst);
|
||||
this.dashboardLoading = false;
|
||||
var self = this;
|
||||
setTimeout(function() { self._renderCharts(); }, 60);
|
||||
},
|
||||
|
||||
closeDashboard() {
|
||||
this.dashboardOpen = false;
|
||||
this._destroyCharts();
|
||||
this.dashboardData = null;
|
||||
this._dashboardInst = null;
|
||||
},
|
||||
|
||||
async _fetchDashboardData(inst) {
|
||||
var data = {
|
||||
agent_name: inst.agent_name || inst.hand_id,
|
||||
portfolio_value: null,
|
||||
total_pnl: null,
|
||||
win_rate: null,
|
||||
sharpe_ratio: null,
|
||||
max_drawdown: null,
|
||||
trades_count: null,
|
||||
equity_curve: [],
|
||||
daily_pnl: [],
|
||||
watchlist_heatmap: [],
|
||||
signal_radar: null,
|
||||
recent_trades: []
|
||||
};
|
||||
|
||||
// Fetch basic stats from the hand stats endpoint
|
||||
try {
|
||||
var stats = await OpenFangAPI.get('/api/hands/instances/' + inst.instance_id + '/stats');
|
||||
var m = stats.metrics || {};
|
||||
if (m['Portfolio Value']) data.portfolio_value = this._metricVal(m['Portfolio Value']);
|
||||
if (m['Total P&L']) data.total_pnl = this._metricVal(m['Total P&L']);
|
||||
if (m['Win Rate']) data.win_rate = this._metricVal(m['Win Rate']);
|
||||
if (m['Sharpe Ratio']) data.sharpe_ratio = this._metricVal(m['Sharpe Ratio']);
|
||||
if (m['Max Drawdown']) data.max_drawdown = this._metricVal(m['Max Drawdown']);
|
||||
if (m['Trades Executed']) data.trades_count = this._metricVal(m['Trades Executed']);
|
||||
} catch(e) {
|
||||
// Stats endpoint might fail — continue with KV data
|
||||
}
|
||||
|
||||
// Fetch rich chart data from agent memory KV
|
||||
var agentId = inst.agent_id || 'shared';
|
||||
var kvKeys = [
|
||||
'trader_hand_equity_curve',
|
||||
'trader_hand_daily_pnl',
|
||||
'trader_hand_watchlist_heatmap',
|
||||
'trader_hand_signal_radar',
|
||||
'trader_hand_recent_trades',
|
||||
'trader_hand_portfolio_value',
|
||||
'trader_hand_total_pnl',
|
||||
'trader_hand_win_rate',
|
||||
'trader_hand_sharpe_ratio',
|
||||
'trader_hand_max_drawdown',
|
||||
'trader_hand_trades_count'
|
||||
];
|
||||
|
||||
for (var i = 0; i < kvKeys.length; i++) {
|
||||
try {
|
||||
var resp = await OpenFangAPI.get('/api/memory/agents/' + agentId + '/kv/' + kvKeys[i]);
|
||||
if (resp && resp.value !== null && resp.value !== undefined) {
|
||||
var val = resp.value;
|
||||
this._applyKvToData(data, kvKeys[i], val);
|
||||
}
|
||||
} catch(e) {
|
||||
// Key might not exist yet — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
this.dashboardData = data;
|
||||
},
|
||||
|
||||
_metricVal(metric) {
|
||||
if (!metric) return null;
|
||||
var v = metric.value;
|
||||
if (v === null || v === undefined) return null;
|
||||
// Values come as JSON values — could be string, number, etc.
|
||||
if (typeof v === 'string') return v;
|
||||
return String(v);
|
||||
},
|
||||
|
||||
_applyKvToData(data, key, val) {
|
||||
// Values from KV can be strings (JSON-encoded) or already parsed
|
||||
var parsed = val;
|
||||
if (typeof val === 'string') {
|
||||
try { parsed = JSON.parse(val); } catch(e) { parsed = val; }
|
||||
}
|
||||
|
||||
switch(key) {
|
||||
case 'trader_hand_portfolio_value':
|
||||
if (!data.portfolio_value) data.portfolio_value = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_total_pnl':
|
||||
if (!data.total_pnl) data.total_pnl = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_win_rate':
|
||||
if (!data.win_rate) data.win_rate = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_sharpe_ratio':
|
||||
if (!data.sharpe_ratio) data.sharpe_ratio = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_max_drawdown':
|
||||
if (!data.max_drawdown) data.max_drawdown = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_trades_count':
|
||||
if (!data.trades_count) data.trades_count = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_equity_curve':
|
||||
if (Array.isArray(parsed)) data.equity_curve = parsed;
|
||||
break;
|
||||
case 'trader_hand_daily_pnl':
|
||||
if (Array.isArray(parsed)) data.daily_pnl = parsed;
|
||||
break;
|
||||
case 'trader_hand_watchlist_heatmap':
|
||||
if (Array.isArray(parsed)) data.watchlist_heatmap = parsed;
|
||||
break;
|
||||
case 'trader_hand_signal_radar':
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data.signal_radar = parsed;
|
||||
break;
|
||||
case 'trader_hand_recent_trades':
|
||||
if (Array.isArray(parsed)) data.recent_trades = parsed;
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_destroyCharts() {
|
||||
if (this._chartEquity) { this._chartEquity.destroy(); this._chartEquity = null; }
|
||||
if (this._chartPnl) { this._chartPnl.destroy(); this._chartPnl = null; }
|
||||
if (this._chartRadar) { this._chartRadar.destroy(); this._chartRadar = null; }
|
||||
},
|
||||
|
||||
_renderCharts() {
|
||||
if (typeof Chart === 'undefined') return;
|
||||
this._destroyCharts();
|
||||
if (!this.dashboardData) return;
|
||||
|
||||
var d = this.dashboardData;
|
||||
|
||||
// Detect theme
|
||||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
var gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
||||
var textColor = isDark ? '#8A8380' : '#6B6560';
|
||||
var accentColor = '#FF5C00';
|
||||
var successColor = isDark ? '#4ADE80' : '#22C55E';
|
||||
var errorColor = '#EF4444';
|
||||
|
||||
// ── Equity Curve ──
|
||||
if (d.equity_curve && d.equity_curve.length > 0) {
|
||||
var eqCanvas = document.getElementById('traderEquityChart');
|
||||
if (eqCanvas) {
|
||||
var labels = [];
|
||||
var values = [];
|
||||
for (var i = 0; i < d.equity_curve.length; i++) {
|
||||
labels.push(d.equity_curve[i].date || '');
|
||||
values.push(parseFloat(d.equity_curve[i].value) || 0);
|
||||
}
|
||||
// Determine gradient
|
||||
var eqCtx = eqCanvas.getContext('2d');
|
||||
var gradient = eqCtx.createLinearGradient(0, 0, 0, eqCanvas.parentElement.clientHeight || 180);
|
||||
gradient.addColorStop(0, isDark ? 'rgba(255, 92, 0, 0.25)' : 'rgba(255, 92, 0, 0.15)');
|
||||
gradient.addColorStop(1, 'rgba(255, 92, 0, 0)');
|
||||
|
||||
this._chartEquity = new Chart(eqCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
borderColor: accentColor,
|
||||
backgroundColor: gradient,
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: d.equity_curve.length > 20 ? 0 : 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: accentColor
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
||||
titleColor: textColor,
|
||||
bodyColor: isDark ? '#e0e0e0' : '#333',
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
return '$' + ctx.parsed.y.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: gridColor },
|
||||
ticks: { color: textColor, maxTicksLimit: 8, font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 10 },
|
||||
callback: function(v) { return '$' + v.toLocaleString(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Daily P&L Bar Chart ──
|
||||
if (d.daily_pnl && d.daily_pnl.length > 0) {
|
||||
var pnlCanvas = document.getElementById('traderPnlChart');
|
||||
if (pnlCanvas) {
|
||||
var pnlLabels = [];
|
||||
var pnlValues = [];
|
||||
var pnlColors = [];
|
||||
for (var j = 0; j < d.daily_pnl.length; j++) {
|
||||
pnlLabels.push(d.daily_pnl[j].date || '');
|
||||
var pnlVal = parseFloat(d.daily_pnl[j].pnl) || 0;
|
||||
pnlValues.push(pnlVal);
|
||||
pnlColors.push(pnlVal >= 0 ? successColor : errorColor);
|
||||
}
|
||||
|
||||
this._chartPnl = new Chart(pnlCanvas.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: pnlLabels,
|
||||
datasets: [{
|
||||
data: pnlValues,
|
||||
backgroundColor: pnlColors,
|
||||
borderRadius: 3,
|
||||
borderSkipped: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
||||
titleColor: textColor,
|
||||
bodyColor: isDark ? '#e0e0e0' : '#333',
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
var v = ctx.parsed.y;
|
||||
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: textColor, maxTicksLimit: 7, font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 10 },
|
||||
callback: function(v) {
|
||||
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signal Radar Chart ──
|
||||
if (d.signal_radar) {
|
||||
var radarCanvas = document.getElementById('traderRadarChart');
|
||||
if (radarCanvas) {
|
||||
var radarLabels = [];
|
||||
var radarValues = [];
|
||||
var keys = ['technical', 'fundamental', 'sentiment', 'macro'];
|
||||
var displayLabels = ['Technical', 'Fundamental', 'Sentiment', 'Macro'];
|
||||
for (var k = 0; k < keys.length; k++) {
|
||||
radarLabels.push(displayLabels[k]);
|
||||
radarValues.push(parseFloat(d.signal_radar[keys[k]]) || 0);
|
||||
}
|
||||
|
||||
this._chartRadar = new Chart(radarCanvas.getContext('2d'), {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: radarLabels,
|
||||
datasets: [{
|
||||
data: radarValues,
|
||||
borderColor: accentColor,
|
||||
backgroundColor: isDark ? 'rgba(255, 92, 0, 0.2)' : 'rgba(255, 92, 0, 0.12)',
|
||||
borderWidth: 2,
|
||||
pointBackgroundColor: accentColor,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
||||
titleColor: textColor,
|
||||
bodyColor: isDark ? '#e0e0e0' : '#333',
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function(ctx) { return ctx.parsed.r + '/100'; }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
beginAtZero: true,
|
||||
grid: { color: gridColor },
|
||||
angleLines: { color: gridColor },
|
||||
pointLabels: {
|
||||
color: textColor,
|
||||
font: { size: 11, weight: '600' }
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
backdropColor: 'transparent',
|
||||
stepSize: 25,
|
||||
font: { size: 9 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -201,15 +201,17 @@ function schedulerPage() {
|
||||
async runNow(job) {
|
||||
this.runningJobId = job.id;
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
|
||||
if (result.status === 'completed') {
|
||||
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
|
||||
job.last_run = new Date().toISOString();
|
||||
var result = await OpenFangAPI.post('/api/cron/jobs/' + job.id + '/run', {});
|
||||
if (result.status === 'triggered' || result.status === 'completed') {
|
||||
OpenFangToast.success('Job "' + (job.name || 'job') + '" triggered');
|
||||
// Don't update job.last_run here — the job runs asynchronously in the
|
||||
// background. The real last_run is set by the server on completion and
|
||||
// will appear on the next data refresh.
|
||||
} else {
|
||||
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
|
||||
OpenFangToast.error('Run failed: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Run Now is not yet available for cron jobs');
|
||||
OpenFangToast.error('Run failed: ' + (e.message || e));
|
||||
}
|
||||
this.runningJobId = '';
|
||||
},
|
||||
|
||||
@@ -401,8 +401,12 @@ function settingsPage() {
|
||||
var key = this.providerKeyInputs[provider.id];
|
||||
if (!key || !key.trim()) { OpenFangToast.error('Please enter an API key'); return; }
|
||||
try {
|
||||
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
|
||||
OpenFangToast.success('API key saved for ' + provider.display_name);
|
||||
var resp = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
|
||||
if (resp && resp.switched_default) {
|
||||
OpenFangToast.warning(resp.message || 'Default provider was switched to ' + provider.display_name);
|
||||
} else {
|
||||
OpenFangToast.success('API key saved for ' + provider.display_name);
|
||||
}
|
||||
this.providerKeyInputs[provider.id] = '';
|
||||
await this.loadProviders();
|
||||
await this.loadModels();
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
// OpenFang Setup Wizard — First-run guided setup (Provider + Agent + Channel)
|
||||
'use strict';
|
||||
|
||||
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n"""). */
|
||||
function wizardTomlMultilineEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
|
||||
}
|
||||
|
||||
/** Escape a string for use inside a TOML basic (single-line) string ("..."). */
|
||||
function wizardTomlBasicEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
function wizardPage() {
|
||||
return {
|
||||
step: 1,
|
||||
@@ -244,6 +254,15 @@ function wizardPage() {
|
||||
this.error = '';
|
||||
try {
|
||||
await this.loadProviders();
|
||||
// Pre-select first unconfigured provider, or first one
|
||||
var unconfigured = this.providers.filter(function(p) {
|
||||
return p.auth_status !== 'configured' && p.api_key_env;
|
||||
});
|
||||
if (unconfigured.length > 0) {
|
||||
this.selectedProvider = unconfigured[0].id;
|
||||
} else if (this.providers.length > 0) {
|
||||
this.selectedProvider = this.providers[0].id;
|
||||
}
|
||||
} catch(e) {
|
||||
this.error = e.message || 'Could not load setup data.';
|
||||
}
|
||||
@@ -303,15 +322,6 @@ function wizardPage() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/providers');
|
||||
this.providers = data.providers || [];
|
||||
// Pre-select first unconfigured provider, or first one
|
||||
var unconfigured = this.providers.filter(function(p) {
|
||||
return p.auth_status !== 'configured' && p.api_key_env;
|
||||
});
|
||||
if (unconfigured.length > 0) {
|
||||
this.selectedProvider = unconfigured[0].id;
|
||||
} else if (this.providers.length > 0) {
|
||||
this.selectedProvider = this.providers[0].id;
|
||||
}
|
||||
} catch(e) { this.providers = []; }
|
||||
},
|
||||
|
||||
@@ -462,12 +472,12 @@ function wizardPage() {
|
||||
}
|
||||
|
||||
var toml = '[agent]\n';
|
||||
toml += 'name = "' + name.replace(/"/g, '\\"') + '"\n';
|
||||
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
|
||||
toml += 'name = "' + wizardTomlBasicEscape(name) + '"\n';
|
||||
toml += 'description = "' + wizardTomlBasicEscape(tpl.description) + '"\n';
|
||||
toml += 'profile = "' + tpl.profile + '"\n\n';
|
||||
toml += '[model]\nprovider = "' + provider + '"\n';
|
||||
toml += '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 {
|
||||
|
||||
@@ -13,6 +13,8 @@ function workflowsPage() {
|
||||
loading: true,
|
||||
loadError: '',
|
||||
newWf: { name: '', description: '', steps: [{ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' }] },
|
||||
editModal: null,
|
||||
editWf: { name: '', description: '', steps: [] },
|
||||
|
||||
// -- Workflows methods --
|
||||
async loadWorkflows() {
|
||||
@@ -74,6 +76,57 @@ function workflowsPage() {
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to load run history: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWorkflow(wf) {
|
||||
if (!confirm('Delete workflow "' + wf.name + '"? This cannot be undone.')) return;
|
||||
try {
|
||||
await OpenFangAPI.delete('/api/workflows/' + wf.id);
|
||||
OpenFangToast.success('Workflow "' + wf.name + '" deleted');
|
||||
await this.loadWorkflows();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete workflow: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async showEditModal(wf) {
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/workflows/' + wf.id);
|
||||
this.editWf = {
|
||||
name: full.name || '',
|
||||
description: full.description || '',
|
||||
steps: (full.steps || []).map(function(s) {
|
||||
return {
|
||||
name: s.name || '',
|
||||
agent_name: (s.agent && s.agent.name) || '',
|
||||
mode: s.mode || 'sequential',
|
||||
prompt: s.prompt_template || '{{input}}'
|
||||
};
|
||||
})
|
||||
};
|
||||
if (this.editWf.steps.length === 0) {
|
||||
this.editWf.steps.push({ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' });
|
||||
}
|
||||
this.editModal = wf;
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to load workflow: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async saveWorkflow() {
|
||||
if (!this.editModal) return;
|
||||
var steps = this.editWf.steps.map(function(s) {
|
||||
return { name: s.name || 'step', agent_name: s.agent_name, mode: s.mode, prompt: s.prompt || '{{input}}' };
|
||||
});
|
||||
try {
|
||||
var wfName = this.editWf.name;
|
||||
await OpenFangAPI.put('/api/workflows/' + this.editModal.id, { name: wfName, description: this.editWf.description, steps: steps });
|
||||
this.editModal = null;
|
||||
OpenFangToast.success('Workflow "' + wfName + '" updated');
|
||||
await this.loadWorkflows();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update workflow: ' + e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "OpenFang Agent OS",
|
||||
"short_name": "OpenFang",
|
||||
"description": "Open-source Agent Operating System",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#6366f1",
|
||||
"icons": [
|
||||
{"src": "/logo.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/logo.png", "sizes": "512x512", "type": "image/png"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -78,6 +78,7 @@ async fn start_test_server_with_provider(
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -707,9 +708,21 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let api_key_state = state.kernel.config.api_key.clone();
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
let auth_state = middleware::AuthState {
|
||||
api_key: api_key.clone(),
|
||||
auth_enabled: state.kernel.config.auth.enabled,
|
||||
session_secret: if !api_key.is_empty() {
|
||||
api_key.clone()
|
||||
} else if state.kernel.config.auth.enabled {
|
||||
state.kernel.config.auth.password_hash.clone()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/health", axum::routing::get(routes::health))
|
||||
@@ -753,7 +766,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
)
|
||||
.route("/api/shutdown", axum::routing::post(routes::shutdown))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
api_key_state,
|
||||
auth_state,
|
||||
middleware::auth,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(middleware::request_logging))
|
||||
|
||||
@@ -115,6 +115,7 @@ async fn test_full_daemon_lifecycle() {
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -240,6 +241,7 @@ async fn test_server_immediate_responsiveness() {
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -59,6 +59,7 @@ async fn start_test_server() -> TestServer {
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -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,14 +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 }
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -318,9 +318,14 @@ impl ChannelAdapter for DiscordAdapter {
|
||||
}
|
||||
|
||||
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
|
||||
if let Some(msg) =
|
||||
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users, ignore_bots)
|
||||
.await
|
||||
if let Some(msg) = parse_discord_message(
|
||||
d,
|
||||
&bot_user_id,
|
||||
&allowed_guilds,
|
||||
&allowed_users,
|
||||
ignore_bots,
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
"Discord {event_name} from {}: {:?}",
|
||||
@@ -517,8 +522,8 @@ async fn parse_discord_message(
|
||||
.map(|arr| arr.iter().any(|m| m["id"].as_str() == Some(bid.as_str())))
|
||||
.unwrap_or(false);
|
||||
// Also check content for <@bot_id> or <@!bot_id> patterns
|
||||
let mentioned_in_content =
|
||||
content_text.contains(&format!("<@{bid}>")) || content_text.contains(&format!("<@!{bid}>"));
|
||||
let mentioned_in_content = content_text.contains(&format!("<@{bid}>"))
|
||||
|| content_text.contains(&format!("<@!{bid}>"));
|
||||
mentioned_in_array || mentioned_in_content
|
||||
} else {
|
||||
false
|
||||
@@ -566,7 +571,9 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
assert_eq!(msg.sender.display_name, "alice");
|
||||
assert_eq!(msg.sender.platform_id, "ch1");
|
||||
@@ -674,7 +681,8 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed guilds
|
||||
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
|
||||
let msg =
|
||||
parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed guilds
|
||||
@@ -697,7 +705,9 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -741,7 +751,9 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "alice#1234");
|
||||
}
|
||||
|
||||
@@ -763,7 +775,9 @@ mod tests {
|
||||
});
|
||||
|
||||
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
assert!(
|
||||
matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content")
|
||||
@@ -786,7 +800,14 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed users
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()], true).await;
|
||||
let msg = parse_discord_message(
|
||||
&d,
|
||||
&bot_id,
|
||||
&[],
|
||||
&["user111".into(), "user222".into()],
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed users
|
||||
@@ -817,9 +838,14 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg.is_group);
|
||||
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
|
||||
assert_eq!(
|
||||
msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()),
|
||||
Some(true)
|
||||
);
|
||||
|
||||
// Message without mention in group
|
||||
let d2 = serde_json::json!({
|
||||
@@ -835,7 +861,9 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(msg2.is_group);
|
||||
assert!(!msg2.metadata.contains_key("was_mentioned"));
|
||||
}
|
||||
@@ -855,13 +883,21 @@ mod tests {
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!msg.is_group);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discord_adapter_creation() {
|
||||
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], true, 37376);
|
||||
let adapter = DiscordAdapter::new(
|
||||
"test-token".to_string(),
|
||||
vec!["123".to_string(), "456".to_string()],
|
||||
vec![],
|
||||
true,
|
||||
37376,
|
||||
);
|
||||
assert_eq!(adapter.name(), "discord");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Discord);
|
||||
}
|
||||
|
||||
@@ -139,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)
|
||||
@@ -215,8 +214,8 @@ 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}"))?;
|
||||
|
||||
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
|
||||
// that reject LOGIN and only support AUTH=PLAIN (SASL).
|
||||
@@ -390,8 +389,7 @@ impl ChannelAdapter for EmailAdapter {
|
||||
}
|
||||
|
||||
// Extract target agent from subject brackets (stored in metadata for router)
|
||||
let _target_agent =
|
||||
EmailAdapter::extract_agent_from_subject(&subject);
|
||||
let _target_agent = EmailAdapter::extract_agent_from_subject(&subject);
|
||||
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
|
||||
|
||||
// Build the message body: prepend subject context
|
||||
|
||||
+1430
-302
File diff suppressed because it is too large
Load Diff
@@ -17,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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,13 @@ pub mod twist;
|
||||
pub mod webex;
|
||||
// Wave 5 — Niche & differentiating channels
|
||||
pub mod dingtalk;
|
||||
pub mod dingtalk_stream;
|
||||
pub mod discourse;
|
||||
pub mod gitter;
|
||||
pub mod gotify;
|
||||
pub mod linkedin;
|
||||
pub mod mumble;
|
||||
pub mod mqtt;
|
||||
pub mod ntfy;
|
||||
pub mod webhook;
|
||||
pub mod wecom;
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// LINE push message API endpoint.
|
||||
@@ -62,8 +62,8 @@ impl LineAdapter {
|
||||
pub fn new(channel_secret: String, access_token: String, webhook_port: u16) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
channel_secret: Zeroizing::new(channel_secret),
|
||||
access_token: Zeroizing::new(access_token),
|
||||
channel_secret: Zeroizing::new(channel_secret.trim().to_string()),
|
||||
access_token: Zeroizing::new(access_token.trim().to_string()),
|
||||
webhook_port,
|
||||
client: reqwest::Client::new(),
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
@@ -96,12 +96,35 @@ impl LineAdapter {
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
if result.len() != expected.len() {
|
||||
debug!(
|
||||
"LINE: signature length mismatch: computed={} received={}",
|
||||
result.len(),
|
||||
expected.len()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (a, b) in result.iter().zip(expected.iter()) {
|
||||
diff |= a ^ b;
|
||||
}
|
||||
if diff != 0 {
|
||||
let computed = base64::engine::general_purpose::STANDARD.encode(&result);
|
||||
// Log first/last 4 chars of each signature for debugging without leaking full HMAC
|
||||
let comp_redacted = format!(
|
||||
"{}...{}",
|
||||
&computed[..4.min(computed.len())],
|
||||
&computed[computed.len().saturating_sub(4)..]
|
||||
);
|
||||
let recv_redacted = format!(
|
||||
"{}...{}",
|
||||
&signature[..4.min(signature.len())],
|
||||
&signature[signature.len().saturating_sub(4)..]
|
||||
);
|
||||
debug!(
|
||||
"LINE: signature mismatch: computed={comp_redacted} received={recv_redacted} body_len={}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
@@ -359,18 +382,18 @@ impl ChannelAdapter for LineAdapter {
|
||||
let secret = Arc::clone(&channel_secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
move |headers: axum::http::HeaderMap,
|
||||
body: axum::extract::Json<serde_json::Value>| {
|
||||
body: axum::body::Bytes| {
|
||||
let secret = Arc::clone(&secret);
|
||||
let tx = Arc::clone(&tx);
|
||||
async move {
|
||||
// Verify X-Line-Signature
|
||||
// Verify X-Line-Signature using the raw request
|
||||
// body bytes — NOT re-serialized JSON — because the
|
||||
// HMAC must be computed over the exact bytes LINE sent.
|
||||
let signature = headers
|
||||
.get("x-line-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
let body_bytes = serde_json::to_vec(&body.0).unwrap_or_default();
|
||||
|
||||
// Create a temporary adapter-like verifier
|
||||
let adapter = LineAdapter {
|
||||
channel_secret: secret.as_ref().clone(),
|
||||
@@ -382,14 +405,23 @@ impl ChannelAdapter for LineAdapter {
|
||||
};
|
||||
|
||||
if !signature.is_empty()
|
||||
&& !adapter.verify_signature(&body_bytes, signature)
|
||||
&& !adapter.verify_signature(&body, signature)
|
||||
{
|
||||
warn!("LINE: invalid webhook signature");
|
||||
return axum::http::StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
|
||||
// Parse the raw bytes into JSON after signature verification
|
||||
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("LINE: failed to parse webhook body as JSON: {e}");
|
||||
return axum::http::StatusCode::BAD_REQUEST;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse events array
|
||||
if let Some(events) = body.0["events"].as_array() {
|
||||
if let Some(events) = parsed["events"].as_array() {
|
||||
for event in events {
|
||||
if let Some(msg) = parse_line_event(event) {
|
||||
let _ = tx.send(msg).await;
|
||||
@@ -626,6 +658,71 @@ mod tests {
|
||||
assert!(parse_line_event(&event).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_with_raw_body() {
|
||||
// Verify that HMAC-SHA256 signature validation works with raw body bytes
|
||||
let secret = "test-channel-secret";
|
||||
let adapter = LineAdapter::new(secret.to_string(), "token".to_string(), 9000);
|
||||
|
||||
// Compute the expected signature manually
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let body = br#"{"events":[{"type":"message"}]}"#;
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let expected_sig =
|
||||
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
assert!(adapter.verify_signature(body, &expected_sig));
|
||||
|
||||
// Re-serialized JSON should NOT match (this was the bug)
|
||||
let parsed: serde_json::Value = serde_json::from_slice(body).unwrap();
|
||||
let reserialized = serde_json::to_vec(&parsed).unwrap();
|
||||
// The re-serialized form may differ in whitespace/key order
|
||||
// If it happens to be identical for this input, the test still validates
|
||||
// the core mechanism works with raw bytes
|
||||
if reserialized != body.to_vec() {
|
||||
assert!(!adapter.verify_signature(&reserialized, &expected_sig));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_secret_trimmed() {
|
||||
// Environment variables often have trailing newlines or spaces
|
||||
let adapter = LineAdapter::new(
|
||||
" my-secret\n".to_string(),
|
||||
" my-token\r\n".to_string(),
|
||||
9000,
|
||||
);
|
||||
assert_eq!(adapter.channel_secret.as_str(), "my-secret");
|
||||
assert_eq!(adapter.access_token.as_str(), "my-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_bad_base64() {
|
||||
let adapter = LineAdapter::new("secret".to_string(), "token".to_string(), 9000);
|
||||
assert!(!adapter.verify_signature(b"body", "not-valid-base64!!!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_signature_wrong_secret() {
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let body = b"test body";
|
||||
let mut mac = HmacSha256::new_from_slice(b"wrong-secret").unwrap();
|
||||
mac.update(body);
|
||||
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
|
||||
|
||||
let adapter = LineAdapter::new("correct-secret".to_string(), "token".to_string(), 9000);
|
||||
assert!(!adapter.verify_signature(body, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_line_event_room_source() {
|
||||
let event = serde_json::json!({
|
||||
|
||||
@@ -456,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;
|
||||
@@ -518,6 +526,10 @@ impl ChannelAdapter for MastodonAdapter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn suppress_error_responses(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
Ok(())
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
const SYNC_TIMEOUT_MS: u64 = 30000;
|
||||
@@ -35,6 +35,8 @@ pub struct MatrixAdapter {
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Sync token for resuming /sync.
|
||||
since_token: Arc<RwLock<Option<String>>>,
|
||||
/// Whether to auto-accept room invites.
|
||||
auto_accept_invites: bool,
|
||||
}
|
||||
|
||||
impl MatrixAdapter {
|
||||
@@ -44,6 +46,7 @@ impl MatrixAdapter {
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
allowed_rooms: Vec<String>,
|
||||
auto_accept_invites: bool,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
@@ -55,6 +58,7 @@ impl MatrixAdapter {
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
since_token: Arc::new(RwLock::new(None)),
|
||||
auto_accept_invites,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,12 +120,84 @@ impl MatrixAdapter {
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(test)]
|
||||
fn is_allowed_room(&self, room_id: &str) -> bool {
|
||||
self.allowed_rooms.is_empty() || self.allowed_rooms.iter().any(|r| r == room_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
|
||||
async fn accept_invite(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
access_token: &str,
|
||||
room_id: &str,
|
||||
) {
|
||||
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/join");
|
||||
match client
|
||||
.post(&url)
|
||||
.bearer_auth(access_token)
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
info!("Matrix: auto-accepted invite to {room_id}");
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
warn!("Matrix: failed to accept invite to {room_id}: {status}");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Matrix: error accepting invite to {room_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the number of joined members in a room.
|
||||
async fn get_room_member_count(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
access_token: &str,
|
||||
room_id: &str,
|
||||
) -> Option<usize> {
|
||||
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/joined_members");
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body["joined"].as_object().map(|m| m.len())
|
||||
}
|
||||
|
||||
/// Do an initial /sync with timeout=0 to get the since token without processing events.
|
||||
/// This prevents replaying old messages when the adapter first connects.
|
||||
async fn initial_sync(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
access_token: &str,
|
||||
) -> Option<String> {
|
||||
let url = format!(
|
||||
"{homeserver}/_matrix/client/v3/sync?timeout=0&filter={{\"room\":{{\"timeline\":{{\"limit\":0}}}}}}"
|
||||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body["next_batch"].as_str().map(String::from)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for MatrixAdapter {
|
||||
fn name(&self) -> &str {
|
||||
@@ -143,14 +219,32 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let homeserver = self.homeserver_url.clone();
|
||||
let access_token = self.access_token.clone();
|
||||
let user_id = self.user_id.clone();
|
||||
// Use the validated user ID from /whoami instead of the config value.
|
||||
// Matrix server delegation or casing differences can cause self.user_id
|
||||
// to not match the sender field in timeline events, making the bot
|
||||
// process its own replies in an infinite loop (see #757).
|
||||
let user_id = validated_user;
|
||||
let allowed_rooms = self.allowed_rooms.clone();
|
||||
let client = self.client.clone();
|
||||
let since_token = Arc::clone(&self.since_token);
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
let auto_accept = self.auto_accept_invites;
|
||||
|
||||
// FIX #4: Do an initial sync to get the since token, skipping old messages.
|
||||
if since_token.read().await.is_none() {
|
||||
if let Some(token) = initial_sync(&client, &homeserver, access_token.as_str()).await {
|
||||
info!("Matrix: initial sync complete, skipping old messages");
|
||||
*since_token.write().await = Some(token);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
// Track recently seen event IDs to prevent duplicate processing
|
||||
// on sync token races or reconnects.
|
||||
let mut seen_events: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::new();
|
||||
const MAX_SEEN: usize = 500;
|
||||
|
||||
loop {
|
||||
// Build /sync URL
|
||||
@@ -168,7 +262,7 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
info!("Matrix adapter shutting down");
|
||||
break;
|
||||
}
|
||||
result = client.get(&url).bearer_auth(&*access_token).send() => {
|
||||
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
|
||||
match result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -203,6 +297,24 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
*since_token.write().await = Some(next.to_string());
|
||||
}
|
||||
|
||||
// FIX #1: Auto-accept room invites.
|
||||
if auto_accept {
|
||||
if let Some(invites) = body["rooms"]["invite"].as_object() {
|
||||
for (room_id, _invite_data) in invites {
|
||||
if !allowed_rooms.is_empty()
|
||||
&& !allowed_rooms.iter().any(|r| r == room_id)
|
||||
{
|
||||
debug!(
|
||||
"Matrix: ignoring invite to {room_id} (not in allowed_rooms)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accept_invite(&client, &homeserver, access_token.as_str(), room_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process room events
|
||||
if let Some(rooms) = body["rooms"]["join"].as_object() {
|
||||
for (room_id, room_data) in rooms {
|
||||
@@ -223,6 +335,21 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
continue; // Skip own messages
|
||||
}
|
||||
|
||||
// Dedup: skip events we've already processed.
|
||||
let event_id_str =
|
||||
event["event_id"].as_str().unwrap_or("").to_string();
|
||||
if !event_id_str.is_empty() {
|
||||
if seen_events.contains(&event_id_str) {
|
||||
debug!("Matrix: skipping duplicate event {event_id_str}");
|
||||
continue;
|
||||
}
|
||||
seen_events.insert(event_id_str.clone());
|
||||
// Prevent unbounded growth
|
||||
if seen_events.len() > MAX_SEEN {
|
||||
seen_events.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let content = event["content"]["body"].as_str().unwrap_or("");
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
@@ -243,11 +370,67 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
ChannelContent::Text(content.to_string())
|
||||
};
|
||||
|
||||
let event_id = event["event_id"].as_str().unwrap_or("").to_string();
|
||||
// FIX #2: Detect @mentions in message text.
|
||||
let mut metadata = HashMap::new();
|
||||
if content.contains(&user_id) {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
access_token.as_str(),
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
.map(|count| count > 2)
|
||||
.unwrap_or(true);
|
||||
|
||||
// For DMs, auto-set was_mentioned so dm_policy works.
|
||||
if !is_group {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
metadata.insert("is_dm".to_string(), serde_json::json!(true));
|
||||
}
|
||||
|
||||
// FIX #2: Detect @mentions in message text.
|
||||
let mut metadata = HashMap::new();
|
||||
if content.contains(&user_id) {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
access_token.as_str(),
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
.map(|count| count > 2)
|
||||
.unwrap_or(true);
|
||||
|
||||
// For DMs, auto-set was_mentioned so dm_policy works.
|
||||
if !is_group {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
metadata.insert("is_dm".to_string(), serde_json::json!(true));
|
||||
}
|
||||
|
||||
let channel_msg = ChannelMessage {
|
||||
channel: ChannelType::Matrix,
|
||||
platform_message_id: event_id,
|
||||
platform_message_id: event_id_str,
|
||||
sender: ChannelUser {
|
||||
platform_id: room_id.clone(),
|
||||
display_name: sender.to_string(),
|
||||
@@ -256,9 +439,9 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
content: msg_content,
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: true,
|
||||
is_group,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
metadata,
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
@@ -330,6 +513,7 @@ mod tests {
|
||||
"@bot:matrix.org".to_string(),
|
||||
"access_token".to_string(),
|
||||
vec![],
|
||||
false,
|
||||
);
|
||||
assert_eq!(adapter.name(), "matrix");
|
||||
}
|
||||
@@ -341,6 +525,7 @@ mod tests {
|
||||
"@bot:matrix.org".to_string(),
|
||||
"token".to_string(),
|
||||
vec!["!room1:matrix.org".to_string()],
|
||||
false,
|
||||
);
|
||||
assert!(adapter.is_allowed_room("!room1:matrix.org"));
|
||||
assert!(!adapter.is_allowed_room("!room2:matrix.org"));
|
||||
@@ -350,6 +535,7 @@ mod tests {
|
||||
"@bot:matrix.org".to_string(),
|
||||
"token".to_string(),
|
||||
vec![],
|
||||
false,
|
||||
);
|
||||
assert!(open.is_allowed_room("!any:matrix.org"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
//! MQTT channel adapter.
|
||||
//!
|
||||
//! Provides a generic MQTT pub/sub interface for IoT and messaging integration.
|
||||
//! Supports standard MQTT 3.1.1/5.0 brokers with optional TLS and authentication.
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! ```toml
|
||||
//! [channels.mqtt]
|
||||
//! broker_url = "tcp://broker.hivemq.com:1883"
|
||||
//! subscribe_topic = "openfang/inbox"
|
||||
//! publish_topic = "openfang/outbox"
|
||||
//! username_env = "MQTT_USERNAME"
|
||||
//! password_env = "MQTT_PASSWORD"
|
||||
//! use_tls = false
|
||||
//! qos = 1
|
||||
//! ```
|
||||
//!
|
||||
//! # Message Format
|
||||
//!
|
||||
//! Incoming messages are expected as UTF-8 text. The adapter supports:
|
||||
//! - Plain text messages
|
||||
//! - JSON payloads with `{"text": "message"}` format
|
||||
//! - Command messages starting with `/`
|
||||
|
||||
use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use futures::Stream;
|
||||
use rumqttc::{AsyncClient, Event, Incoming, MqttOptions, QoS};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Maximum MQTT message length.
|
||||
const MAX_MESSAGE_LEN: usize = 4096;
|
||||
|
||||
/// Type alias for the publish channel sender.
|
||||
type PublishSender = Arc<RwLock<Option<mpsc::Sender<(String, String)>>>>;
|
||||
|
||||
/// MQTT pub/sub channel adapter.
|
||||
///
|
||||
/// Connects to an MQTT broker, subscribes to a topic for incoming messages,
|
||||
/// and publishes responses to another topic.
|
||||
pub struct MqttAdapter {
|
||||
/// MQTT broker URL (e.g., `"tcp://broker.hivemq.com:1883"`).
|
||||
broker_url: String,
|
||||
/// Client identifier (auto-generated if empty).
|
||||
client_id: String,
|
||||
/// Topic to subscribe to for incoming messages.
|
||||
subscribe_topic: String,
|
||||
/// Topic to publish responses to.
|
||||
publish_topic: String,
|
||||
/// Optional username for authentication.
|
||||
username: Option<String>,
|
||||
/// Optional password for authentication.
|
||||
password: Option<String>,
|
||||
/// Use TLS/SSL connection.
|
||||
use_tls: bool,
|
||||
/// Keep-alive interval in seconds.
|
||||
keep_alive: u16,
|
||||
/// Clean session flag.
|
||||
clean_session: bool,
|
||||
/// QoS level for subscriptions.
|
||||
qos: QoS,
|
||||
/// Shutdown signal.
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Sender for publishing messages (used to communicate with the event loop task).
|
||||
publish_tx: PublishSender,
|
||||
}
|
||||
|
||||
impl MqttAdapter {
|
||||
/// Create a new MQTT adapter.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `broker_url` - MQTT broker URL (e.g., `"tcp://broker.hivemq.com:1883"`).
|
||||
/// * `client_id` - Client identifier (auto-generated if empty).
|
||||
/// * `subscribe_topic` - Topic to subscribe to for incoming messages.
|
||||
/// * `publish_topic` - Topic to publish responses to (defaults to subscribe_topic if empty).
|
||||
/// * `username` - Optional username for authentication.
|
||||
/// * `password` - Optional password for authentication.
|
||||
/// * `use_tls` - Use TLS/SSL connection.
|
||||
/// * `keep_alive` - Keep-alive interval in seconds.
|
||||
/// * `clean_session` - Clean session flag.
|
||||
/// * `qos` - QoS level (0, 1, or 2).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
broker_url: String,
|
||||
client_id: String,
|
||||
subscribe_topic: String,
|
||||
publish_topic: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
use_tls: bool,
|
||||
keep_alive: u16,
|
||||
clean_session: bool,
|
||||
qos: u8,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let publish_topic = if publish_topic.is_empty() {
|
||||
subscribe_topic.clone()
|
||||
} else {
|
||||
publish_topic
|
||||
};
|
||||
let qos = match qos {
|
||||
0 => QoS::AtMostOnce,
|
||||
2 => QoS::ExactlyOnce,
|
||||
_ => QoS::AtLeastOnce,
|
||||
};
|
||||
|
||||
Self {
|
||||
broker_url,
|
||||
client_id,
|
||||
subscribe_topic,
|
||||
publish_topic,
|
||||
username,
|
||||
password,
|
||||
use_tls,
|
||||
keep_alive,
|
||||
clean_session,
|
||||
qos,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
publish_tx: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse broker URL into host and port.
|
||||
fn parse_broker_url(&self) -> Result<(String, u16), Box<dyn std::error::Error>> {
|
||||
let url = self.broker_url.trim();
|
||||
|
||||
// Handle different URL schemes
|
||||
if let Some(rest) = url.strip_prefix("tcp://") {
|
||||
Self::parse_host_port(rest, 1883)
|
||||
} else if let Some(rest) = url.strip_prefix("ssl://") {
|
||||
Self::parse_host_port(rest, 8883)
|
||||
} else if self.use_tls && !url.contains("://") {
|
||||
// Plain host with TLS flag
|
||||
Self::parse_host_port(url, 8883)
|
||||
} else if url.contains("://") {
|
||||
Err(format!("Unsupported MQTT URL scheme: {url}").into())
|
||||
} else {
|
||||
// Plain host:port or just host (no TLS)
|
||||
Self::parse_host_port(url, 1883)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse host:port string.
|
||||
fn parse_host_port(s: &str, default_port: u16) -> Result<(String, u16), Box<dyn std::error::Error>> {
|
||||
let s = s.trim();
|
||||
if let Some(colon_pos) = s.rfind(':') {
|
||||
let host = s[..colon_pos].to_string();
|
||||
let port = s[colon_pos + 1..].parse::<u16>()?;
|
||||
Ok((host, port))
|
||||
} else {
|
||||
Ok((s.to_string(), default_port))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build MQTT options.
|
||||
fn build_mqtt_options(&self) -> Result<MqttOptions, Box<dyn std::error::Error>> {
|
||||
let (host, port) = self.parse_broker_url()?;
|
||||
let client_id = if self.client_id.is_empty() {
|
||||
format!("openfang-{}", uuid::Uuid::new_v4())
|
||||
} else {
|
||||
self.client_id.clone()
|
||||
};
|
||||
|
||||
let mut options = MqttOptions::new(client_id, host, port);
|
||||
options.set_keep_alive(Duration::from_secs(self.keep_alive as u64));
|
||||
options.set_clean_session(self.clean_session);
|
||||
|
||||
if let (Some(user), Some(pass)) = (&self.username, &self.password) {
|
||||
options.set_credentials(user, pass);
|
||||
}
|
||||
|
||||
// Note: TLS support requires additional configuration with rustls
|
||||
// For now, we use native TLS through the use_tls flag
|
||||
if self.use_tls {
|
||||
// rumqttc handles TLS automatically when using ssl:// or with explicit config
|
||||
// This is a simplified approach; production use may need custom TLS config
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
/// Parse incoming MQTT payload.
|
||||
fn parse_payload(payload: &[u8]) -> Option<String> {
|
||||
if payload.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try UTF-8 first
|
||||
if let Ok(text) = std::str::from_utf8(payload) {
|
||||
// Check for JSON format {"text": "message"}
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(text) {
|
||||
if let Some(text_val) = json.get("text").and_then(|v| v.as_str()) {
|
||||
return Some(text_val.to_string());
|
||||
}
|
||||
}
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Publish a message to the configured topic.
|
||||
async fn publish_message(&self, text: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tx_guard = self.publish_tx.read().await;
|
||||
if let Some(tx) = tx_guard.as_ref() {
|
||||
let chunks = split_message(text, MAX_MESSAGE_LEN);
|
||||
for chunk in chunks {
|
||||
tx.send((self.publish_topic.clone(), chunk.to_string()))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send publish request: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err("MQTT client not connected".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for MqttAdapter {
|
||||
fn name(&self) -> &str {
|
||||
"mqtt"
|
||||
}
|
||||
|
||||
fn channel_type(&self) -> ChannelType {
|
||||
ChannelType::Mqtt
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>> {
|
||||
let options = self.build_mqtt_options()?;
|
||||
let (client, mut eventloop) = AsyncClient::new(options, 10);
|
||||
|
||||
info!(
|
||||
"MQTT adapter connecting to {} (subscribe: {}, publish: {})",
|
||||
self.broker_url, self.subscribe_topic, self.publish_topic
|
||||
);
|
||||
|
||||
// Subscribe to topic
|
||||
client.subscribe(&self.subscribe_topic, self.qos).await?;
|
||||
|
||||
// Channel for incoming messages
|
||||
let (msg_tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
|
||||
// Channel for outgoing publish requests
|
||||
let (publish_tx, mut publish_rx) = mpsc::channel::<(String, String)>(64);
|
||||
|
||||
// Store the publish sender
|
||||
{
|
||||
let mut tx_guard = self.publish_tx.write().await;
|
||||
*tx_guard = Some(publish_tx);
|
||||
}
|
||||
|
||||
let subscribe_topic = self.subscribe_topic.clone();
|
||||
let qos = self.qos;
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
|
||||
// Spawn the event loop task
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
let max_backoff = Duration::from_secs(60);
|
||||
|
||||
loop {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("MQTT adapter shutting down");
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
if *shutdown_rx.borrow() {
|
||||
info!("MQTT adapter shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
publish_req = publish_rx.recv() => {
|
||||
if let Some((topic, payload)) = publish_req {
|
||||
if let Err(e) = client.publish(&topic, qos, false, payload).await {
|
||||
warn!("MQTT publish error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
event = eventloop.poll() => {
|
||||
match event {
|
||||
Ok(Event::Incoming(Incoming::Publish(publish))) => {
|
||||
backoff = Duration::from_secs(1); // Reset backoff on success
|
||||
|
||||
let topic = publish.topic.clone();
|
||||
if topic != subscribe_topic {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(text) = Self::parse_payload(&publish.payload) {
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = if text.starts_with('/') {
|
||||
let parts: Vec<&str> = text.splitn(2, ' ').collect();
|
||||
let cmd = parts[0].trim_start_matches('/');
|
||||
let args: Vec<String> = parts
|
||||
.get(1)
|
||||
.map(|a| {
|
||||
a.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ChannelContent::Command {
|
||||
name: cmd.to_string(),
|
||||
args,
|
||||
}
|
||||
} else {
|
||||
ChannelContent::Text(text)
|
||||
};
|
||||
|
||||
let msg = ChannelMessage {
|
||||
channel: ChannelType::Mqtt,
|
||||
platform_message_id: format!("{:?}", publish.pkid),
|
||||
sender: ChannelUser {
|
||||
platform_id: "mqtt-user".to_string(),
|
||||
display_name: "MQTT User".to_string(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content,
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: true,
|
||||
thread_id: None,
|
||||
metadata: {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(
|
||||
"topic".to_string(),
|
||||
serde_json::Value::String(topic.clone()),
|
||||
);
|
||||
m.insert(
|
||||
"qos".to_string(),
|
||||
serde_json::Value::Number((publish.qos as i64).into()),
|
||||
);
|
||||
m
|
||||
},
|
||||
};
|
||||
|
||||
if msg_tx.send(msg).await.is_err() {
|
||||
info!("MQTT receiver dropped, stopping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::Incoming(Incoming::ConnAck(_))) => {
|
||||
info!("MQTT connected to broker");
|
||||
backoff = Duration::from_secs(1);
|
||||
}
|
||||
Ok(Event::Incoming(Incoming::Disconnect)) => {
|
||||
warn!("MQTT disconnected from broker");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("MQTT connection error: {}, backing off for {:?}", e, backoff);
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(max_backoff);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("MQTT event loop stopped");
|
||||
});
|
||||
|
||||
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn send(
|
||||
&self,
|
||||
_user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let text = match content {
|
||||
ChannelContent::Text(t) => t,
|
||||
ChannelContent::Command { name, args } => {
|
||||
if args.is_empty() {
|
||||
format!("/{name}")
|
||||
} else {
|
||||
format!("/{} {}", name, args.join(" "))
|
||||
}
|
||||
}
|
||||
_ => "(Unsupported content type)".to_string(),
|
||||
};
|
||||
self.publish_message(&text).await
|
||||
}
|
||||
|
||||
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// MQTT has no typing indicator concept.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
|
||||
// Clear the publish channel
|
||||
let mut tx_guard = self.publish_tx.write().await;
|
||||
*tx_guard = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mqtt_adapter_creation() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.hivemq.com:1883".to_string(),
|
||||
"test-client".to_string(),
|
||||
"test/topic".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
assert_eq!(adapter.name(), "mqtt");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Mqtt);
|
||||
assert_eq!(adapter.subscribe_topic, "test/topic");
|
||||
assert_eq!(adapter.publish_topic, "test/topic"); // Falls back to subscribe_topic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mqtt_adapter_with_separate_publish_topic() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.hivemq.com:1883".to_string(),
|
||||
String::new(),
|
||||
"inbox".to_string(),
|
||||
"outbox".to_string(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
assert_eq!(adapter.subscribe_topic, "inbox");
|
||||
assert_eq!(adapter.publish_topic, "outbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_tcp() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com:1883".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 1883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_tcp_default_port() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 1883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_ssl() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"ssl://broker.example.com:8883".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 8883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_broker_url_plain_host() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
let (host, port) = adapter.parse_broker_url().unwrap();
|
||||
assert_eq!(host, "broker.example.com");
|
||||
assert_eq!(port, 1883);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_payload_text() {
|
||||
let payload = b"Hello, MQTT!";
|
||||
let result = MqttAdapter::parse_payload(payload);
|
||||
assert_eq!(result, Some("Hello, MQTT!".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_payload_json() {
|
||||
let payload = br#"{"text": "Hello from JSON"}"#;
|
||||
let result = MqttAdapter::parse_payload(payload);
|
||||
assert_eq!(result, Some("Hello from JSON".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_payload_empty() {
|
||||
let payload = b"";
|
||||
let result = MqttAdapter::parse_payload(payload);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qos_conversion() {
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
0,
|
||||
);
|
||||
assert_eq!(adapter.qos, QoS::AtMostOnce);
|
||||
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
assert_eq!(adapter.qos, QoS::AtLeastOnce);
|
||||
|
||||
let adapter = MqttAdapter::new(
|
||||
"tcp://broker.example.com".to_string(),
|
||||
String::new(),
|
||||
"test".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
60,
|
||||
true,
|
||||
2,
|
||||
);
|
||||
assert_eq!(adapter.qos, QoS::ExactlyOnce);
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,10 @@ impl ChannelAdapter for NostrAdapter {
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
let pubkey = self.derive_pubkey();
|
||||
info!("Nostr adapter starting (pubkey: {}...)", openfang_types::truncate_str(&pubkey, 16));
|
||||
info!(
|
||||
"Nostr adapter starting (pubkey: {}...)",
|
||||
openfang_types::truncate_str(&pubkey, 16)
|
||||
);
|
||||
|
||||
if self.relays.is_empty() {
|
||||
return Err("Nostr: no relay URLs configured".into());
|
||||
@@ -339,7 +342,7 @@ impl ChannelAdapter for NostrAdapter {
|
||||
platform_id: sender_pubkey.clone(),
|
||||
display_name: format!(
|
||||
"{}...",
|
||||
&sender_pubkey[..8.min(sender_pubkey.len())]
|
||||
openfang_types::truncate_str(&sender_pubkey, 8)
|
||||
),
|
||||
openfang_user: None,
|
||||
},
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct AgentRouter {
|
||||
default_agent: Option<AgentId>,
|
||||
/// Per-channel-type default agent (e.g., Telegram -> agent_a, Discord -> agent_b).
|
||||
channel_defaults: DashMap<String, AgentId>,
|
||||
/// Per-channel-type default agent *name* (for re-resolution when UUID becomes stale).
|
||||
channel_default_names: DashMap<String, String>,
|
||||
/// Sorted bindings (most specific first). Uses Mutex for runtime updates via Arc.
|
||||
bindings: Mutex<Vec<(AgentBinding, String)>>,
|
||||
/// Broadcast configuration. Uses Mutex for runtime updates via Arc.
|
||||
@@ -50,6 +52,7 @@ impl AgentRouter {
|
||||
direct_routes: DashMap::new(),
|
||||
default_agent: None,
|
||||
channel_defaults: DashMap::new(),
|
||||
channel_default_names: DashMap::new(),
|
||||
bindings: Mutex::new(Vec::new()),
|
||||
broadcast: Mutex::new(BroadcastConfig::default()),
|
||||
agent_name_cache: DashMap::new(),
|
||||
@@ -66,6 +69,31 @@ impl AgentRouter {
|
||||
self.channel_defaults.insert(channel_key, agent_id);
|
||||
}
|
||||
|
||||
/// Set a per-channel-type default agent AND remember the agent name for
|
||||
/// re-resolution when the cached UUID becomes stale (e.g. after agent restart).
|
||||
pub fn set_channel_default_with_name(
|
||||
&self,
|
||||
channel_key: String,
|
||||
agent_id: AgentId,
|
||||
agent_name: String,
|
||||
) {
|
||||
self.channel_defaults.insert(channel_key.clone(), agent_id);
|
||||
self.channel_default_names.insert(channel_key, agent_name);
|
||||
}
|
||||
|
||||
/// Retrieve the stored agent name for a channel default (if any).
|
||||
pub fn channel_default_name(&self, channel_key: &str) -> Option<String> {
|
||||
self.channel_default_names
|
||||
.get(channel_key)
|
||||
.map(|r| r.clone())
|
||||
}
|
||||
|
||||
/// Update the cached agent ID for a channel default (after re-resolution).
|
||||
pub fn update_channel_default(&self, channel_key: &str, new_agent_id: AgentId) {
|
||||
self.channel_defaults
|
||||
.insert(channel_key.to_string(), new_agent_id);
|
||||
}
|
||||
|
||||
/// Set a user's default agent.
|
||||
pub fn set_user_default(&self, user_key: String, agent_id: AgentId) {
|
||||
self.user_defaults.insert(user_key, agent_id);
|
||||
@@ -327,6 +355,7 @@ fn channel_type_to_str(ct: &ChannelType) -> &str {
|
||||
ChannelType::WebChat => "webchat",
|
||||
ChannelType::CLI => "cli",
|
||||
ChannelType::Custom(s) => s.as_str(),
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use futures::{SinkExt, Stream, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
@@ -32,10 +33,25 @@ pub struct SlackAdapter {
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Bot's own user ID (populated after auth.test).
|
||||
bot_user_id: Arc<RwLock<Option<String>>>,
|
||||
/// Threads where the bot was @-mentioned. Maps thread_ts -> last interaction time.
|
||||
active_threads: Arc<DashMap<String, Instant>>,
|
||||
/// How long to track a thread after last interaction.
|
||||
thread_ttl: Duration,
|
||||
/// Whether auto-thread-reply is enabled.
|
||||
auto_thread_reply: bool,
|
||||
/// Whether to unfurl (expand previews for) links in posted messages.
|
||||
unfurl_links: bool,
|
||||
}
|
||||
|
||||
impl SlackAdapter {
|
||||
pub fn new(app_token: String, bot_token: String, allowed_channels: Vec<String>) -> Self {
|
||||
pub fn new(
|
||||
app_token: String,
|
||||
bot_token: String,
|
||||
allowed_channels: Vec<String>,
|
||||
auto_thread_reply: bool,
|
||||
thread_ttl_hours: u64,
|
||||
unfurl_links: bool,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
app_token: Zeroizing::new(app_token),
|
||||
@@ -45,6 +61,10 @@ impl SlackAdapter {
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
bot_user_id: Arc::new(RwLock::new(None)),
|
||||
active_threads: Arc::new(DashMap::new()),
|
||||
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
|
||||
auto_thread_reply,
|
||||
unfurl_links,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,14 +96,20 @@ impl SlackAdapter {
|
||||
&self,
|
||||
channel_id: &str,
|
||||
text: &str,
|
||||
thread_ts: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let chunks = split_message(text, SLACK_MSG_LIMIT);
|
||||
|
||||
for chunk in chunks {
|
||||
let body = serde_json::json!({
|
||||
let mut body = serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": chunk,
|
||||
"unfurl_links": self.unfurl_links,
|
||||
"unfurl_media": self.unfurl_links,
|
||||
});
|
||||
if let Some(ts) = thread_ts {
|
||||
body["thread_ts"] = serde_json::json!(ts);
|
||||
}
|
||||
|
||||
let resp: serde_json::Value = self
|
||||
.client
|
||||
@@ -133,6 +159,30 @@ impl ChannelAdapter for SlackAdapter {
|
||||
let allowed_channels = self.allowed_channels.clone();
|
||||
let client = self.client.clone();
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
let active_threads = self.active_threads.clone();
|
||||
let auto_thread_reply = self.auto_thread_reply;
|
||||
|
||||
// Spawn periodic cleanup of expired thread entries.
|
||||
{
|
||||
let active_threads = self.active_threads.clone();
|
||||
let thread_ttl = self.thread_ttl;
|
||||
let mut cleanup_shutdown = self.shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
active_threads.retain(|_, last| last.elapsed() < thread_ttl);
|
||||
}
|
||||
_ = cleanup_shutdown.changed() => {
|
||||
if *cleanup_shutdown.borrow() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = INITIAL_BACKOFF;
|
||||
@@ -240,8 +290,14 @@ impl ChannelAdapter for SlackAdapter {
|
||||
|
||||
// Extract the event
|
||||
let event = &payload["payload"]["event"];
|
||||
if let Some(msg) =
|
||||
parse_slack_event(event, &bot_user_id, &allowed_channels).await
|
||||
if let Some(msg) = parse_slack_event(
|
||||
event,
|
||||
&bot_user_id,
|
||||
&allowed_channels,
|
||||
&active_threads,
|
||||
auto_thread_reply,
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
"Slack message from {}: {:?}",
|
||||
@@ -289,10 +345,30 @@ impl ChannelAdapter for SlackAdapter {
|
||||
let channel_id = &user.platform_id;
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(channel_id, &text).await?;
|
||||
self.api_send_message(channel_id, &text, None).await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(channel_id, "(Unsupported content type)")
|
||||
self.api_send_message(channel_id, "(Unsupported content type)", None)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_in_thread(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
thread_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let channel_id = &user.platform_id;
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(channel_id, &text, Some(thread_id))
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(channel_id, "(Unsupported content type)", Some(thread_id))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
@@ -335,9 +411,11 @@ async fn parse_slack_event(
|
||||
event: &serde_json::Value,
|
||||
bot_user_id: &Arc<RwLock<Option<String>>>,
|
||||
allowed_channels: &[String],
|
||||
active_threads: &Arc<DashMap<String, Instant>>,
|
||||
auto_thread_reply: bool,
|
||||
) -> Option<ChannelMessage> {
|
||||
let event_type = event["type"].as_str()?;
|
||||
if event_type != "message" {
|
||||
if event_type != "message" && event_type != "app_mention" {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -413,6 +491,50 @@ async fn parse_slack_event(
|
||||
ChannelContent::Text(text.to_string())
|
||||
};
|
||||
|
||||
// Extract thread_id: threaded replies have `thread_ts`, top-level messages
|
||||
// use their own `ts` so the reply will start a thread under the original.
|
||||
let thread_id = msg_data["thread_ts"]
|
||||
.as_str()
|
||||
.or_else(|| event["thread_ts"].as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| Some(ts.to_string()));
|
||||
|
||||
// Check if the bot was @-mentioned (for group_policy = "mention_only")
|
||||
let mut metadata = HashMap::new();
|
||||
if event_type == "app_mention" {
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true));
|
||||
}
|
||||
|
||||
// Determine the real thread_ts from the event (None for top-level messages).
|
||||
let real_thread_ts = msg_data["thread_ts"]
|
||||
.as_str()
|
||||
.or_else(|| event["thread_ts"].as_str());
|
||||
|
||||
let mut explicitly_mentioned = false;
|
||||
if let Some(ref bid) = *bot_user_id.read().await {
|
||||
let mention_tag = format!("<@{bid}>");
|
||||
if text.contains(&mention_tag) {
|
||||
explicitly_mentioned = true;
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
|
||||
|
||||
// Track thread for auto-reply on subsequent messages.
|
||||
if let Some(tts) = real_thread_ts {
|
||||
active_threads.insert(tts.to_string(), Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-reply to follow-up messages in tracked threads.
|
||||
if !explicitly_mentioned && auto_thread_reply {
|
||||
if let Some(tts) = real_thread_ts {
|
||||
if let Some(mut entry) = active_threads.get_mut(tts) {
|
||||
// Refresh TTL and mark as mentioned so dispatch proceeds.
|
||||
*entry = Instant::now();
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Slack,
|
||||
platform_message_id: ts.to_string(),
|
||||
@@ -425,8 +547,8 @@ async fn parse_slack_event(
|
||||
target_agent: None,
|
||||
timestamp,
|
||||
is_group: true,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
thread_id,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -445,7 +567,9 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Slack);
|
||||
assert_eq!(msg.sender.platform_id, "C789");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello agent!"));
|
||||
@@ -463,7 +587,7 @@ mod tests {
|
||||
"bot_id": "B999"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -478,7 +602,7 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -494,12 +618,25 @@ mod tests {
|
||||
});
|
||||
|
||||
// Not in allowed channels
|
||||
let msg =
|
||||
parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()]).await;
|
||||
let msg = parse_slack_event(
|
||||
&event,
|
||||
&bot_id,
|
||||
&["C111".to_string(), "C222".to_string()],
|
||||
&Arc::new(DashMap::new()),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed channels
|
||||
let msg = parse_slack_event(&event, &bot_id, &["C789".to_string()]).await;
|
||||
let msg = parse_slack_event(
|
||||
&event,
|
||||
&bot_id,
|
||||
&["C789".to_string()],
|
||||
&Arc::new(DashMap::new()),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -516,7 +653,7 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -531,7 +668,9 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
|
||||
.await
|
||||
.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -556,7 +695,9 @@ mod tests {
|
||||
"ts": "1700000001.000200"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Slack);
|
||||
assert_eq!(msg.sender.platform_id, "C789");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message text"));
|
||||
@@ -568,8 +709,37 @@ mod tests {
|
||||
"xapp-test".to_string(),
|
||||
"xoxb-test".to_string(),
|
||||
vec!["C123".to_string()],
|
||||
true,
|
||||
24,
|
||||
true,
|
||||
);
|
||||
assert_eq!(adapter.name(), "slack");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Slack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slack_adapter_unfurl_links_enabled() {
|
||||
let adapter = SlackAdapter::new(
|
||||
"xapp-test".to_string(),
|
||||
"xoxb-test".to_string(),
|
||||
vec![],
|
||||
true,
|
||||
24,
|
||||
true,
|
||||
);
|
||||
assert!(adapter.unfurl_links);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slack_adapter_unfurl_links_disabled() {
|
||||
let adapter = SlackAdapter::new(
|
||||
"xapp-test".to_string(),
|
||||
"xoxb-test".to_string(),
|
||||
vec![],
|
||||
true,
|
||||
24,
|
||||
false,
|
||||
);
|
||||
assert!(!adapter.unfurl_links);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ pub enum ChannelType {
|
||||
Mattermost,
|
||||
WebChat,
|
||||
CLI,
|
||||
/// MQTT pub/sub messaging.
|
||||
Mqtt,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
@@ -49,6 +51,13 @@ pub enum ChannelContent {
|
||||
url: String,
|
||||
filename: String,
|
||||
},
|
||||
/// 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,
|
||||
duration_seconds: u32,
|
||||
@@ -261,6 +270,15 @@ pub trait ChannelAdapter: Send + Sync {
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.send(user, content).await
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -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,12 +258,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::File { url, filename } => {
|
||||
let body = serde_json::json!({
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
+407
-68
@@ -113,7 +113,11 @@ enum Commands {
|
||||
quick: bool,
|
||||
},
|
||||
/// Start the OpenFang kernel daemon (API server + kernel).
|
||||
Start,
|
||||
Start {
|
||||
/// Auto-approve all tool calls (no confirmation prompts).
|
||||
#[arg(long)]
|
||||
yolo: bool,
|
||||
},
|
||||
/// Stop the running daemon.
|
||||
Stop,
|
||||
/// Manage agents (new, list, chat, kill, spawn) [*].
|
||||
@@ -520,6 +524,23 @@ enum WorkflowCommands {
|
||||
/// Path to a JSON file describing the workflow.
|
||||
file: PathBuf,
|
||||
},
|
||||
/// Get a workflow by ID.
|
||||
Get {
|
||||
/// Workflow ID (UUID).
|
||||
workflow_id: String,
|
||||
},
|
||||
/// Update a workflow from a JSON file.
|
||||
Update {
|
||||
/// Workflow ID (UUID).
|
||||
workflow_id: String,
|
||||
/// Path to a JSON file with the updated workflow definition.
|
||||
file: PathBuf,
|
||||
},
|
||||
/// Delete a workflow by ID.
|
||||
Delete {
|
||||
/// Workflow ID (UUID).
|
||||
workflow_id: String,
|
||||
},
|
||||
/// Run a workflow by ID.
|
||||
Run {
|
||||
/// Workflow ID (UUID).
|
||||
@@ -777,12 +798,38 @@ enum SystemCommands {
|
||||
},
|
||||
}
|
||||
|
||||
fn config_log_level() -> String {
|
||||
let config_path = if let Ok(home) = std::env::var("OPENFANG_HOME") {
|
||||
std::path::PathBuf::from(home).join("config.toml")
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join(".openfang")
|
||||
.join("config.toml")
|
||||
};
|
||||
if let Ok(content) = std::fs::read_to_string(config_path) {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("log_level") {
|
||||
if let Some(val) = trimmed.split('=').nth(1) {
|
||||
let level = val.trim().trim_matches('"').trim_matches('\'');
|
||||
if !level.is_empty() {
|
||||
return level.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"info".to_string()
|
||||
}
|
||||
|
||||
fn init_tracing_stderr() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
|
||||
)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
}
|
||||
|
||||
@@ -807,7 +854,7 @@ fn init_tracing_file() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
|
||||
)
|
||||
.with_writer(std::sync::Mutex::new(file))
|
||||
.with_ansi(false)
|
||||
@@ -823,6 +870,21 @@ fn init_tracing_file() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `msg` to stdout, silently exiting with code 0 on BrokenPipe.
|
||||
/// Use this instead of `println!` for machine-readable (JSON) output that is
|
||||
/// commonly piped into other tools.
|
||||
fn write_stdout_safe(msg: &str) {
|
||||
let out = std::io::stdout();
|
||||
let mut lock = out.lock();
|
||||
if let Err(e) = writeln!(lock, "{}", msg) {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(0);
|
||||
}
|
||||
eprintln!("error: failed writing to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Load ~/.openfang/.env into process environment (system env takes priority).
|
||||
dotenv::load_dotenv();
|
||||
@@ -876,7 +938,7 @@ fn main() {
|
||||
}
|
||||
Some(Commands::Tui) => tui::run(cli.config),
|
||||
Some(Commands::Init { quick }) => cmd_init(quick),
|
||||
Some(Commands::Start) => cmd_start(cli.config),
|
||||
Some(Commands::Start { yolo }) => cmd_start(cli.config, yolo),
|
||||
Some(Commands::Stop) => cmd_stop(),
|
||||
Some(Commands::Agent(sub)) => match sub {
|
||||
AgentCommands::New { template } => cmd_agent_new(cli.config, template),
|
||||
@@ -893,6 +955,11 @@ fn main() {
|
||||
Some(Commands::Workflow(sub)) => match sub {
|
||||
WorkflowCommands::List => cmd_workflow_list(),
|
||||
WorkflowCommands::Create { file } => cmd_workflow_create(file),
|
||||
WorkflowCommands::Get { workflow_id } => cmd_workflow_get(&workflow_id),
|
||||
WorkflowCommands::Update { workflow_id, file } => {
|
||||
cmd_workflow_update(&workflow_id, file)
|
||||
}
|
||||
WorkflowCommands::Delete { workflow_id } => cmd_workflow_delete(&workflow_id),
|
||||
WorkflowCommands::Run { workflow_id, input } => cmd_workflow_run(&workflow_id, &input),
|
||||
},
|
||||
Some(Commands::Trigger(sub)) => match sub {
|
||||
@@ -966,7 +1033,7 @@ fn main() {
|
||||
ModelsCommands::Set { model } => cmd_models_set(model),
|
||||
},
|
||||
Some(Commands::Gateway(sub)) => match sub {
|
||||
GatewayCommands::Start => cmd_start(cli.config),
|
||||
GatewayCommands::Start => cmd_start(cli.config, false),
|
||||
GatewayCommands::Stop => cmd_stop(),
|
||||
GatewayCommands::Status { json } => cmd_status(cli.config, json),
|
||||
},
|
||||
@@ -1021,7 +1088,10 @@ fn main() {
|
||||
SystemCommands::Version { json } => cmd_system_version(json),
|
||||
},
|
||||
Some(Commands::Reset { confirm }) => cmd_reset(confirm),
|
||||
Some(Commands::Uninstall { confirm, keep_config }) => cmd_uninstall(confirm, keep_config),
|
||||
Some(Commands::Uninstall {
|
||||
confirm,
|
||||
keep_config,
|
||||
}) => cmd_uninstall(confirm, keep_config),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1078,8 +1148,8 @@ pub(crate) fn find_daemon() -> Option<String> {
|
||||
/// includes a `Authorization: Bearer <key>` header on every request.
|
||||
/// When api_key is empty or missing, no auth header is sent.
|
||||
pub(crate) fn daemon_client() -> reqwest::blocking::Client {
|
||||
let mut builder = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120));
|
||||
let mut builder =
|
||||
reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(120));
|
||||
|
||||
if let Some(key) = read_api_key() {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
@@ -1411,7 +1481,7 @@ decay_rate = 0.05
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_start(config: Option<PathBuf>) {
|
||||
fn cmd_start(config: Option<PathBuf>, yolo: bool) {
|
||||
if let Some(base) = find_daemon() {
|
||||
ui::error_with_fix(
|
||||
&format!("Daemon already running at {base}"),
|
||||
@@ -1427,7 +1497,12 @@ fn cmd_start(config: Option<PathBuf>) {
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let kernel = match OpenFangKernel::boot(config.as_deref()) {
|
||||
let mut kernel_config = openfang_kernel::config::load_config(config.as_deref());
|
||||
if yolo {
|
||||
kernel_config.approval.auto_approve = true;
|
||||
kernel_config.approval.apply_shorthands();
|
||||
}
|
||||
let kernel = match OpenFangKernel::boot_with_config(kernel_config) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
boot_kernel_error(&e);
|
||||
@@ -1480,15 +1555,26 @@ fn cmd_start(config: Option<PathBuf>) {
|
||||
/// Returns `None` when the key is missing, empty, or whitespace-only —
|
||||
/// meaning the daemon is running in public (unauthenticated) mode.
|
||||
fn read_api_key() -> Option<String> {
|
||||
// 1. Config file takes precedence
|
||||
let config_path = cli_openfang_home().join("config.toml");
|
||||
let text = std::fs::read_to_string(config_path).ok()?;
|
||||
let table: toml::Value = text.parse().ok()?;
|
||||
let key = table.get("api_key")?.as_str()?.trim();
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(key.to_string())
|
||||
if let Ok(text) = std::fs::read_to_string(config_path) {
|
||||
if let Ok(table) = text.parse::<toml::Value>() {
|
||||
if let Some(key) = table.get("api_key").and_then(|v| v.as_str()) {
|
||||
let key = key.trim();
|
||||
if !key.is_empty() {
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Fall back to OPENFANG_API_KEY env var
|
||||
if let Ok(key) = std::env::var("OPENFANG_API_KEY") {
|
||||
let key = key.trim().to_string();
|
||||
if !key.is_empty() {
|
||||
return Some(key);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn cmd_stop() {
|
||||
@@ -2045,18 +2131,14 @@ fn cmd_doctor(json: bool, repair: bool) {
|
||||
ui::check_ok(".env file (permissions fixed to 0600)");
|
||||
}
|
||||
repaired = true;
|
||||
} else {
|
||||
if !json {
|
||||
ui::check_warn(&format!(
|
||||
".env file has loose permissions ({:o}), should be 0600",
|
||||
mode
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !json {
|
||||
ui::check_ok(".env file");
|
||||
} else if !json {
|
||||
ui::check_warn(&format!(
|
||||
".env file has loose permissions ({:o}), should be 0600",
|
||||
mode
|
||||
));
|
||||
}
|
||||
} else if !json {
|
||||
ui::check_ok(".env file");
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
@@ -2183,7 +2265,9 @@ decay_rate = 0.05
|
||||
if !json {
|
||||
ui::check_ok(&format!("Port {api_listen} is available"));
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "port", "status": "ok", "address": api_listen}));
|
||||
checks.push(
|
||||
serde_json::json!({"check": "port", "status": "ok", "address": api_listen}),
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
if !json {
|
||||
@@ -2342,11 +2426,14 @@ decay_rate = 0.05
|
||||
if !json {
|
||||
ui::provider_status(name, env_var, true);
|
||||
}
|
||||
} else if !json {
|
||||
ui::check_warn(&format!("{name} ({env_var}) - key rejected (401/403)"));
|
||||
} else {
|
||||
if !json {
|
||||
ui::check_fail(&format!("{name} ({env_var}) - key rejected (401/403)"));
|
||||
}
|
||||
all_ok = false;
|
||||
}
|
||||
any_key_set = true;
|
||||
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "warn" }, "live_test": !valid}));
|
||||
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "fail" }, "live_test": !valid}));
|
||||
} else {
|
||||
if !json {
|
||||
ui::provider_status(name, env_var, false);
|
||||
@@ -2508,7 +2595,8 @@ decay_rate = 0.05
|
||||
checks.push(serde_json::json!({"check": "mcp_server_config", "status": "warn", "name": server.name}));
|
||||
}
|
||||
}
|
||||
openfang_types::config::McpTransportEntry::Sse { url } => {
|
||||
openfang_types::config::McpTransportEntry::Sse { url }
|
||||
| openfang_types::config::McpTransportEntry::Http { url } => {
|
||||
if url.is_empty() {
|
||||
if !json {
|
||||
ui::check_warn(&format!(
|
||||
@@ -2554,9 +2642,7 @@ decay_rate = 0.05
|
||||
// Check workspace skills if home dir available
|
||||
if skills_dir.exists() {
|
||||
match skill_reg.load_workspace_skills(&skills_dir) {
|
||||
Ok(_) => {
|
||||
let total = skill_reg.count();
|
||||
let ws_count = total.saturating_sub(bundled_count);
|
||||
Ok(ws_count) => {
|
||||
if ws_count > 0 {
|
||||
if !json {
|
||||
ui::check_ok(&format!("Workspace skills loaded: {ws_count}"));
|
||||
@@ -2598,8 +2684,15 @@ decay_rate = 0.05
|
||||
}
|
||||
}
|
||||
}
|
||||
if injection_warnings > 0 {
|
||||
checks.push(serde_json::json!({"check": "skill_injection_scan", "status": "warn", "warnings": injection_warnings}));
|
||||
let blocked = skill_reg.blocked_count();
|
||||
if injection_warnings > 0 || blocked > 0 {
|
||||
let total_warnings = injection_warnings + blocked;
|
||||
if blocked > 0 && !json {
|
||||
ui::check_warn(&format!(
|
||||
"{blocked} workspace skill(s) were blocked for critical prompt injection"
|
||||
));
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "skill_injection_scan", "status": "warn", "warnings": total_warnings, "blocked": blocked}));
|
||||
} else {
|
||||
if !json {
|
||||
ui::check_ok("All skills pass prompt injection scan");
|
||||
@@ -2837,19 +2930,20 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
write_stdout_safe(
|
||||
&serde_json::to_string_pretty(&serde_json::json!({
|
||||
"all_ok": all_ok,
|
||||
"checks": checks,
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
} else {
|
||||
println!();
|
||||
if all_ok {
|
||||
ui::success("All checks passed! OpenFang is ready.");
|
||||
ui::hint("Start the daemon: openfang start");
|
||||
if find_daemon().is_none() {
|
||||
ui::hint("Start the daemon: openfang start");
|
||||
}
|
||||
} else if repaired {
|
||||
ui::success("Repairs applied. Re-run `openfang doctor` to verify.");
|
||||
} else {
|
||||
@@ -3111,6 +3205,104 @@ fn cmd_workflow_run(workflow_id: &str, input: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_workflow_get(workflow_id: &str) {
|
||||
let base = require_daemon("workflow get");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.get(format!("{base}/api/workflows/{workflow_id}"))
|
||||
.send(),
|
||||
);
|
||||
|
||||
if body.get("error").is_some() {
|
||||
eprintln!(
|
||||
"Workflow not found: {}",
|
||||
body["error"].as_str().unwrap_or("Unknown error")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!("Workflow: {}", body["name"].as_str().unwrap_or("?"));
|
||||
println!(" ID: {}", body["id"].as_str().unwrap_or("?"));
|
||||
println!(
|
||||
" Description: {}",
|
||||
body["description"].as_str().unwrap_or("")
|
||||
);
|
||||
println!(
|
||||
" Created: {}",
|
||||
body["created_at"].as_str().unwrap_or("?")
|
||||
);
|
||||
|
||||
if let Some(steps) = body["steps"].as_array() {
|
||||
println!(" Steps ({}):", steps.len());
|
||||
for (i, s) in steps.iter().enumerate() {
|
||||
let name = s["name"].as_str().unwrap_or("step");
|
||||
let agent = s["agent"]
|
||||
.get("name")
|
||||
.or_else(|| s["agent"].get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?");
|
||||
println!(" #{}: {} -> {}", i + 1, name, agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_workflow_update(workflow_id: &str, file: PathBuf) {
|
||||
let base = require_daemon("workflow update");
|
||||
if !file.exists() {
|
||||
eprintln!("Workflow file not found: {}", file.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
let contents = std::fs::read_to_string(&file).unwrap_or_else(|e| {
|
||||
eprintln!("Error reading workflow file: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let json_body: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|e| {
|
||||
eprintln!("Invalid JSON: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.put(format!("{base}/api/workflows/{workflow_id}"))
|
||||
.json(&json_body)
|
||||
.send(),
|
||||
);
|
||||
|
||||
if body["status"].as_str() == Some("updated") {
|
||||
println!("Workflow updated successfully!");
|
||||
println!(" ID: {}", body["workflow_id"].as_str().unwrap_or("?"));
|
||||
} else {
|
||||
eprintln!(
|
||||
"Failed to update workflow: {}",
|
||||
body["error"].as_str().unwrap_or("Unknown error")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_workflow_delete(workflow_id: &str) {
|
||||
let base = require_daemon("workflow delete");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.delete(format!("{base}/api/workflows/{workflow_id}"))
|
||||
.send(),
|
||||
);
|
||||
|
||||
if body["status"].as_str() == Some("removed") {
|
||||
println!("Workflow deleted successfully!");
|
||||
println!(" ID: {}", body["workflow_id"].as_str().unwrap_or("?"));
|
||||
} else {
|
||||
eprintln!(
|
||||
"Failed to delete workflow: {}",
|
||||
body["error"].as_str().unwrap_or("Unknown error")
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trigger commands
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3318,6 +3510,7 @@ fn cmd_skill_install(source: &str) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("Installed OpenClaw skill: {}", manifest.skill.name);
|
||||
notify_daemon_skill_reload();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to convert OpenClaw skill: {e}");
|
||||
@@ -3347,6 +3540,86 @@ fn cmd_skill_install(source: &str) {
|
||||
"Installed skill: {} v{}",
|
||||
manifest.skill.name, manifest.skill.version
|
||||
);
|
||||
notify_daemon_skill_reload();
|
||||
} else if source.starts_with("https://")
|
||||
|| source.starts_with("http://")
|
||||
|| source.starts_with("git@")
|
||||
{
|
||||
// Git URL install — clone to temp dir then install from there
|
||||
ui::step(&format!("Cloning skill from {source}..."));
|
||||
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| {
|
||||
eprintln!("Failed to create temp directory: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let clone_path = tmp_dir.path().join("skill");
|
||||
let status = std::process::Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
source,
|
||||
clone_path.to_str().unwrap(),
|
||||
])
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => {}
|
||||
Ok(_) => {
|
||||
eprintln!("Failed to clone repository: {source}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to run git: {e}");
|
||||
ui::hint("Make sure git is installed and available on your PATH.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse the local directory install logic on the cloned repo
|
||||
let manifest_path = clone_path.join("skill.toml");
|
||||
if !manifest_path.exists() {
|
||||
if openfang_skills::openclaw_compat::detect_openclaw_skill(&clone_path) {
|
||||
println!("Detected OpenClaw skill format. Converting...");
|
||||
match openfang_skills::openclaw_compat::convert_openclaw_skill(&clone_path) {
|
||||
Ok(manifest) => {
|
||||
let dest = skills_dir.join(&manifest.skill.name);
|
||||
copy_dir_recursive(&clone_path, &dest);
|
||||
if let Err(e) = openfang_skills::openclaw_compat::write_openfang_manifest(
|
||||
&dest, &manifest,
|
||||
) {
|
||||
eprintln!("Failed to write manifest: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("Installed OpenClaw skill: {}", manifest.skill.name);
|
||||
notify_daemon_skill_reload();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to convert OpenClaw skill: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
eprintln!("No skill.toml found in cloned repository: {source}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let toml_str = std::fs::read_to_string(&manifest_path).unwrap_or_else(|e| {
|
||||
eprintln!("Error reading skill.toml: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let manifest: openfang_skills::SkillManifest =
|
||||
toml::from_str(&toml_str).unwrap_or_else(|e| {
|
||||
eprintln!("Error parsing skill.toml: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let dest = skills_dir.join(&manifest.skill.name);
|
||||
copy_dir_recursive(&clone_path, &dest);
|
||||
println!(
|
||||
"Installed skill: {} v{}",
|
||||
manifest.skill.name, manifest.skill.version
|
||||
);
|
||||
notify_daemon_skill_reload();
|
||||
} else {
|
||||
// Remote install from FangHub
|
||||
println!("Installing {source} from FangHub...");
|
||||
@@ -3355,7 +3628,10 @@ fn cmd_skill_install(source: &str) {
|
||||
openfang_skills::marketplace::MarketplaceConfig::default(),
|
||||
);
|
||||
match rt.block_on(client.install(source, &skills_dir)) {
|
||||
Ok(version) => println!("Installed {source} {version}"),
|
||||
Ok(version) => {
|
||||
println!("Installed {source} {version}");
|
||||
notify_daemon_skill_reload();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to install skill: {e}");
|
||||
std::process::exit(1);
|
||||
@@ -3364,6 +3640,25 @@ fn cmd_skill_install(source: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Notify the running daemon to hot-reload its skill registry after a CLI install.
|
||||
///
|
||||
/// If the daemon is not running, this is a no-op with a hint to the user.
|
||||
fn notify_daemon_skill_reload() {
|
||||
if let Some(base) = find_daemon() {
|
||||
let client = daemon_client();
|
||||
match client.post(format!("{base}/api/skills/reload")).send() {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
ui::step("Daemon notified — skill registry reloaded.");
|
||||
}
|
||||
_ => {
|
||||
ui::check_warn("Could not notify daemon. Restart with: openfang restart");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui::hint("Start the daemon to make this skill available to agents: openfang start");
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_skill_list() {
|
||||
let home = openfang_home();
|
||||
let skills_dir = home.join("skills");
|
||||
@@ -3985,7 +4280,10 @@ fn cmd_hand_install(path: &str) {
|
||||
body["name"].as_str().unwrap_or("?"),
|
||||
body["id"].as_str().unwrap_or("?"),
|
||||
);
|
||||
println!("Use `openfang hand activate {}` to start it.", body["id"].as_str().unwrap_or("?"));
|
||||
println!(
|
||||
"Use `openfang hand activate {}` to start it.",
|
||||
body["id"].as_str().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
|
||||
fn cmd_hand_list() {
|
||||
@@ -4010,10 +4308,7 @@ fn cmd_hand_list() {
|
||||
println!("No hands available.");
|
||||
return;
|
||||
}
|
||||
println!(
|
||||
"{:<14} {:<20} {:<10} DESCRIPTION",
|
||||
"ID", "NAME", "CATEGORY"
|
||||
);
|
||||
println!("{:<14} {:<20} {:<10} DESCRIPTION", "ID", "NAME", "CATEGORY");
|
||||
println!("{}", "-".repeat(72));
|
||||
for h in arr {
|
||||
println!(
|
||||
@@ -4021,7 +4316,12 @@ fn cmd_hand_list() {
|
||||
h["id"].as_str().unwrap_or("?"),
|
||||
h["name"].as_str().unwrap_or("?"),
|
||||
h["category"].as_str().unwrap_or("?"),
|
||||
h["description"].as_str().unwrap_or("").chars().take(40).collect::<String>(),
|
||||
h["description"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(40)
|
||||
.collect::<String>(),
|
||||
);
|
||||
}
|
||||
println!("\nUse `openfang hand activate <id>` to activate a hand.");
|
||||
@@ -4043,10 +4343,7 @@ fn cmd_hand_active() {
|
||||
println!("No active hands.");
|
||||
return;
|
||||
}
|
||||
println!(
|
||||
"{:<38} {:<14} {:<10} AGENT",
|
||||
"INSTANCE", "HAND", "STATUS"
|
||||
);
|
||||
println!("{:<38} {:<14} {:<10} AGENT", "INSTANCE", "HAND", "STATUS");
|
||||
println!("{}", "-".repeat(72));
|
||||
for i in &arr {
|
||||
println!(
|
||||
@@ -4134,10 +4431,7 @@ fn cmd_hand_info(id: &str) {
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(client.get(format!("{base}/api/hands/{id}")).send());
|
||||
if body.get("error").is_some() {
|
||||
eprintln!(
|
||||
"Hand not found: {}",
|
||||
body["error"].as_str().unwrap_or(id)
|
||||
);
|
||||
eprintln!("Hand not found: {}", body["error"].as_str().unwrap_or(id));
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!(
|
||||
@@ -4566,6 +4860,8 @@ fn cmd_config_set(key: &str, value: &str) {
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak"));
|
||||
|
||||
std::fs::write(&config_path, &serialized).unwrap_or_else(|e| {
|
||||
ui::error(&format!("Failed to write config: {e}"));
|
||||
std::process::exit(1);
|
||||
@@ -4632,6 +4928,8 @@ fn cmd_config_unset(key: &str) {
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak"));
|
||||
|
||||
std::fs::write(&config_path, &serialized).unwrap_or_else(|e| {
|
||||
ui::error(&format!("Failed to write config: {e}"));
|
||||
std::process::exit(1);
|
||||
@@ -4650,6 +4948,10 @@ fn cmd_config_set_key(provider: &str) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try vault first (best-effort)
|
||||
save_credential_prefer_vault(&env_var, &key);
|
||||
|
||||
// Always save to dotenv as fallback
|
||||
match dotenv::save_env_key(&env_var, &key) {
|
||||
Ok(()) => {
|
||||
ui::success(&format!("Saved {env_var} to ~/.openfang/.env"));
|
||||
@@ -4672,6 +4974,18 @@ fn cmd_config_set_key(provider: &str) {
|
||||
fn cmd_config_delete_key(provider: &str) {
|
||||
let env_var = provider_to_env_var(provider);
|
||||
|
||||
// Remove from vault (best-effort)
|
||||
{
|
||||
let home = openfang_home();
|
||||
let vault_path = home.join("vault.enc");
|
||||
if vault_path.exists() {
|
||||
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
|
||||
if vault.unlock().is_ok() {
|
||||
let _ = vault.remove(&env_var);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match dotenv::remove_env_key(&env_var) {
|
||||
Ok(()) => ui::success(&format!("Removed {env_var} from ~/.openfang/.env")),
|
||||
Err(e) => {
|
||||
@@ -4701,6 +5015,26 @@ fn cmd_config_test_key(provider: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to store a credential in the vault first; silently falls through if vault
|
||||
/// is not initialized or cannot be unlocked. The caller should always also
|
||||
/// write to dotenv as a fallback.
|
||||
fn save_credential_prefer_vault(env_var: &str, value: &str) {
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
let home = openfang_home();
|
||||
let vault_path = home.join("vault.enc");
|
||||
if !vault_path.exists() {
|
||||
return;
|
||||
}
|
||||
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
|
||||
if vault.unlock().is_err() {
|
||||
return;
|
||||
}
|
||||
if let Ok(()) = vault.set(env_var.to_string(), Zeroizing::new(value.to_string())) {
|
||||
println!(" {}", "Also stored in encrypted vault".dimmed());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick chat (OpenClaw alias)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -5456,7 +5790,15 @@ fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<
|
||||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||
.take(64)
|
||||
.collect();
|
||||
format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt })
|
||||
format!(
|
||||
"{}-{}",
|
||||
agent,
|
||||
if short_prompt.is_empty() {
|
||||
"job"
|
||||
} else {
|
||||
&short_prompt
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
let body = daemon_json(
|
||||
@@ -6210,10 +6552,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
|
||||
} else {
|
||||
match std::fs::remove_dir_all(&openfang_dir) {
|
||||
Ok(()) => ui::success(&format!("Removed {}", openfang_dir.display())),
|
||||
Err(e) => ui::error(&format!(
|
||||
"Failed to remove {}: {e}",
|
||||
openfang_dir.display()
|
||||
)),
|
||||
Err(e) => ui::error(&format!("Failed to remove {}: {e}", openfang_dir.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6222,10 +6561,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
|
||||
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
|
||||
match std::fs::remove_file(&cargo_bin) {
|
||||
Ok(()) => ui::success(&format!("Removed {}", cargo_bin.display())),
|
||||
Err(e) => ui::error(&format!(
|
||||
"Failed to remove {}: {e}",
|
||||
cargo_bin.display()
|
||||
)),
|
||||
Err(e) => ui::error(&format!("Failed to remove {}: {e}", cargo_bin.display())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6465,7 +6801,10 @@ fn remove_self_binary(exe_path: &std::path::Path) {
|
||||
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.spawn();
|
||||
|
||||
ui::success(&format!("Removed {} (deferred cleanup)", exe_path.display()));
|
||||
ui::success(&format!(
|
||||
"Removed {} (deferred cleanup)",
|
||||
exe_path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -329,7 +329,11 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
|
||||
_ => {
|
||||
// Unknown method — always respond with error
|
||||
Some(jsonrpc_error(rid, -32601, &format!("Method not found: {method}")))
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +303,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,14 +1417,24 @@ 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("")
|
||||
|
||||
@@ -516,8 +516,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 +1183,7 @@ impl App {
|
||||
name,
|
||||
result_preview,
|
||||
is_error,
|
||||
..
|
||||
} => {
|
||||
self.chat.tool_result(&name, &result_preview, is_error);
|
||||
}
|
||||
@@ -1869,14 +1869,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 +1929,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 +1981,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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1524,6 +1524,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +341,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,18 @@ const CHANNEL_DEFS: &[ChannelDef] = &[
|
||||
display_name: "DingTalk",
|
||||
category: "Enterprise",
|
||||
env_vars: &["DINGTALK_ACCESS_TOKEN", "DINGTALK_SECRET"],
|
||||
description: "DingTalk Robot API adapter",
|
||||
description: "DingTalk Robot API adapter (webhook mode)",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "dingtalk_stream",
|
||||
display_name: "DingTalk Stream",
|
||||
category: "Enterprise",
|
||||
env_vars: &[
|
||||
"DINGTALK_APP_KEY",
|
||||
"DINGTALK_APP_SECRET",
|
||||
"DINGTALK_ROBOT_CODE",
|
||||
],
|
||||
description: "DingTalk Stream Mode (WebSocket long-connection)",
|
||||
},
|
||||
ChannelDef {
|
||||
name: "pumble",
|
||||
|
||||
@@ -483,8 +483,7 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
return; // Too small to show picker
|
||||
}
|
||||
let popup_w = area.width.clamp(30, 54);
|
||||
let popup_h = (filtered.len() as u16 + 4)
|
||||
.clamp(5, area.height.saturating_sub(2));
|
||||
let popup_h = (filtered.len() as u16 + 4).clamp(5, area.height.saturating_sub(2));
|
||||
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
|
||||
let popup_area = Rect::new(x, y, popup_w, popup_h);
|
||||
@@ -548,7 +547,12 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let max_name = (chunks[1].width as usize).saturating_sub(14);
|
||||
for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) {
|
||||
for (i, entry) in filtered
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(scroll_start)
|
||||
.take(visible_h)
|
||||
{
|
||||
let selected = i == state.model_picker_idx;
|
||||
let indicator = if selected { "\u{25b6} " } else { " " };
|
||||
|
||||
@@ -882,6 +886,9 @@ fn truncate_line(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max_len.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max_len.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,11 +158,7 @@ impl CommsState {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 {
|
||||
self.events.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
};
|
||||
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
@@ -339,12 +335,12 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) {
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Percentage(35), // topology
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(4), // event list
|
||||
Constraint::Length(1), // hints
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(4), // event list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
@@ -441,10 +437,7 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
|
||||
if state.nodes.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No agents running.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
Paragraph::new(Span::styled(" No agents running.", theme::dim_style())),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
@@ -491,7 +484,10 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(branch, theme::dim_style()),
|
||||
Span::styled(format!("[{}]", child.state), state_color(&child.state)),
|
||||
Span::styled(format!(" {} ", child.name), Style::default().fg(theme::TEXT)),
|
||||
Span::styled(
|
||||
format!(" {} ", child.name),
|
||||
Style::default().fg(theme::TEXT),
|
||||
),
|
||||
Span::styled(format!("({})", child.model), theme::dim_style()),
|
||||
]));
|
||||
}
|
||||
@@ -679,7 +675,10 @@ fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
rows[3],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))),
|
||||
Paragraph::new(Span::styled(
|
||||
"Assign to (agent ID, optional):",
|
||||
field_style(2),
|
||||
)),
|
||||
rows[4],
|
||||
);
|
||||
f.render_widget(
|
||||
|
||||
@@ -273,6 +273,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,14 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "nvidia",
|
||||
display: "NVIDIA NIM",
|
||||
env_var: "NVIDIA_API_KEY",
|
||||
default_model: "nvidia/llama-3.1-nemotron-70b-instruct",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
display: "Claude Code",
|
||||
@@ -950,7 +958,9 @@ fn handle_migration_key(
|
||||
let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
PathBuf::from(h)
|
||||
} else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang")
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".openfang")
|
||||
};
|
||||
let tx = migrate_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
|
||||
@@ -405,6 +405,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,10 +317,7 @@ fn draw_agent_select(f: &mut Frame, area: Rect, state: &mut MemoryState) {
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let id_short = if a.id.len() > 12 {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&a.id, 12)
|
||||
)
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(&a.id, 12))
|
||||
} else {
|
||||
a.id.clone()
|
||||
};
|
||||
@@ -408,10 +405,7 @@ fn draw_kv_browser(f: &mut Frame, area: Rect, state: &mut MemoryState) {
|
||||
.iter()
|
||||
.map(|kv| {
|
||||
let val_display = if kv.value.len() > 40 {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&kv.value, 39)
|
||||
)
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(&kv.value, 39))
|
||||
} else {
|
||||
kv.value.clone()
|
||||
};
|
||||
@@ -555,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
pub mod agents;
|
||||
pub mod audit;
|
||||
pub mod channels;
|
||||
pub mod comms;
|
||||
pub mod chat;
|
||||
pub mod comms;
|
||||
pub mod dashboard;
|
||||
pub mod extensions;
|
||||
pub mod hands;
|
||||
|
||||
@@ -149,10 +149,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut PeersState) {
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let id_short = if p.node_id.len() > 12 {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&p.node_id, 12)
|
||||
)
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(&p.node_id, 12))
|
||||
} else {
|
||||
p.node_id.clone()
|
||||
};
|
||||
@@ -211,6 +208,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,10 +251,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut SessionsState) {
|
||||
.map(|&idx| {
|
||||
let s = &state.sessions[idx];
|
||||
let id_short = if s.id.len() > 12 {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&s.id, 12)
|
||||
)
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(&s.id, 12))
|
||||
} else {
|
||||
s.id.clone()
|
||||
};
|
||||
@@ -311,6 +308,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,7 +604,10 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -612,7 +612,10 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -399,6 +399,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +439,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,12 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
default_model: "codegeex-4",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "nvidia",
|
||||
env_var: "NVIDIA_API_KEY",
|
||||
default_model: "nvidia/llama-3.1-nemotron-70b-instruct",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
env_var: "",
|
||||
|
||||
@@ -697,6 +697,9 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
||||
use openfang_api::server::build_router;
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::watch;
|
||||
use tracing::{error, info};
|
||||
@@ -20,24 +21,40 @@ pub struct ServerHandle {
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
/// Join handle for the background server thread.
|
||||
server_thread: Option<std::thread::JoinHandle<()>>,
|
||||
/// Track whether shutdown has already been initiated to prevent double shutdown.
|
||||
shutdown_initiated: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ServerHandle {
|
||||
/// Signal the server to shut down and wait for the background thread.
|
||||
pub fn shutdown(mut self) {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
if let Some(handle) = self.server_thread.take() {
|
||||
let _ = handle.join();
|
||||
// Only proceed if shutdown hasn't been initiated yet
|
||||
if self
|
||||
.shutdown_initiated
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
if let Some(handle) = self.server_thread.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
self.kernel.shutdown();
|
||||
info!("OpenFang embedded server stopped");
|
||||
}
|
||||
self.kernel.shutdown();
|
||||
info!("OpenFang embedded server stopped");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ServerHandle {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
// Best-effort: don't block in drop, the thread will exit on its own.
|
||||
// Only send shutdown signal if it hasn't been initiated yet
|
||||
if self
|
||||
.shutdown_initiated
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
// Best-effort: don't block in drop, the thread will exit on its own.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +64,11 @@ impl Drop for ServerHandle {
|
||||
/// any Tauri window is created. The actual axum server runs on a dedicated
|
||||
/// thread with its own tokio runtime.
|
||||
pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
|
||||
// Load .env and secrets.env into process environment (same as CLI).
|
||||
// Without this, API keys stored in ~/.openfang/.env are invisible to
|
||||
// the kernel's provider detection and credential resolver.
|
||||
load_dotenv_files();
|
||||
|
||||
// Boot kernel (sync — no tokio needed)
|
||||
let kernel = OpenFangKernel::boot(None)?;
|
||||
let kernel = Arc::new(kernel);
|
||||
@@ -61,6 +83,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let kernel_clone = kernel.clone();
|
||||
let shutdown_initiated = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let server_thread = std::thread::Builder::new()
|
||||
.name("openfang-server".into())
|
||||
@@ -83,6 +106,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
|
||||
kernel,
|
||||
shutdown_tx,
|
||||
server_thread: Some(server_thread),
|
||||
shutdown_initiated,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,3 +150,44 @@ async fn run_embedded_server(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load ~/.openfang/.env and ~/.openfang/secrets.env into the process environment.
|
||||
/// System env vars take priority — existing vars are NOT overridden.
|
||||
fn load_dotenv_files() {
|
||||
let home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
std::path::PathBuf::from(h)
|
||||
} else {
|
||||
let user_home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_default();
|
||||
if user_home.is_empty() {
|
||||
return;
|
||||
}
|
||||
std::path::PathBuf::from(user_home).join(".openfang")
|
||||
};
|
||||
|
||||
for filename in &[".env", "secrets.env"] {
|
||||
let path = home.join(filename);
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = trimmed.split_once('=') {
|
||||
let key = key.trim();
|
||||
let mut value = value.trim().to_string();
|
||||
if ((value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\'')))
|
||||
&& value.len() >= 2
|
||||
{
|
||||
value = value[1..value.len() - 1].to_string();
|
||||
}
|
||||
if !key.is_empty() && std::env::var(key).is_err() {
|
||||
std::env::set_var(key, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenFang",
|
||||
"version": "0.1.0",
|
||||
"version": "0.5.5",
|
||||
"identifier": "ai.openfang.desktop",
|
||||
"build": {},
|
||||
"app": {
|
||||
|
||||
@@ -11,7 +11,7 @@ command = "npx"
|
||||
args = ["-y", "@notionhq/notion-mcp-server"]
|
||||
|
||||
[[required_env]]
|
||||
name = "NOTION_API_KEY"
|
||||
name = "NOTION_TOKEN"
|
||||
label = "Notion Integration Token"
|
||||
help = "An internal integration token created in your Notion workspace settings"
|
||||
is_secret = true
|
||||
@@ -24,5 +24,5 @@ unhealthy_threshold = 3
|
||||
setup_instructions = """
|
||||
1. Go to https://www.notion.so/my-integrations and click 'New integration'.
|
||||
2. Give it a name, select your workspace, and grant the required capabilities (Read/Update/Insert content).
|
||||
3. Copy the Internal Integration Token and paste it into the NOTION_API_KEY field above. Then share relevant pages with the integration in Notion.
|
||||
3. Copy the Internal Integration Token and paste it into the NOTION_TOKEN field above. Then share relevant pages with the integration in Notion.
|
||||
"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user