mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea287093c4 | ||
|
|
3688d86ef8 | ||
|
|
9f9903797e | ||
|
|
3cd8847a95 | ||
|
|
88bb55c8f2 | ||
|
|
f1ca52714d | ||
|
|
77ed954d18 | ||
|
|
4fa2f9474b | ||
|
|
75c9c80679 | ||
|
|
3eaa9e02c9 | ||
|
|
f165263296 | ||
|
|
c122e1ddd7 | ||
|
|
d2ea030f03 | ||
|
|
eb87e3fd42 | ||
|
|
6d742e9081 | ||
|
|
317b947608 | ||
|
|
1cb8b989d1 | ||
|
|
bde1f5414c | ||
|
|
d15207fa51 | ||
|
|
e808ca0d08 | ||
|
|
c554adae0d | ||
|
|
590121d5f3 | ||
|
|
58a7a07942 | ||
|
|
72b87dfaa9 | ||
|
|
b8fb6987e0 | ||
|
|
dec081a326 | ||
|
|
52647b2996 | ||
|
|
905cfd2c21 | ||
|
|
7ad7489860 | ||
|
|
77c4c9add8 | ||
|
|
07019f764e | ||
|
|
80eed53305 | ||
|
|
14c4c1d1f5 | ||
|
|
fa7dd277e6 | ||
|
|
0e589e3f9a | ||
|
|
59703d50d6 | ||
|
|
5413269943 | ||
|
|
c5582ceb1e | ||
|
|
36dc62745c | ||
|
|
cb6e6909d4 | ||
|
|
b3be3f4940 | ||
|
|
7505007d8c | ||
|
|
fdd6c1a1f7 | ||
|
|
135c37fbf7 | ||
|
|
a7a96a7b0f | ||
|
|
52bacf0946 | ||
|
|
d55e1b8545 | ||
|
|
0c059d1dc1 | ||
|
|
b6b8b4ebe1 | ||
|
|
14f4845170 | ||
|
|
be8a589986 | ||
|
|
951e8d0feb | ||
|
|
98f8d1ca79 | ||
|
|
24f5717ae9 | ||
|
|
f10eefdc0e | ||
|
|
edd0fed518 | ||
|
|
86b50070e8 | ||
|
|
c6aab08faa | ||
|
|
62ec09d0ae | ||
|
|
f6f9cf7e9f | ||
|
|
56aeb499c9 | ||
|
|
b4e6a693f5 | ||
|
|
48d5418c91 | ||
|
|
cc93ef4571 | ||
|
|
3e069798f9 | ||
|
|
ad10aa5e80 | ||
|
|
385aee8e56 | ||
|
|
a00327abe9 | ||
|
|
487555a5e5 | ||
|
|
9d51426cb4 | ||
|
|
6fab720843 | ||
|
|
4667f497ef |
@@ -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,15 +212,15 @@ 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
|
||||
- name: Extract version
|
||||
|
||||
@@ -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
+322
-329
File diff suppressed because it is too large
Load Diff
+18
-4
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.31"
|
||||
version = "0.4.5"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -49,9 +49,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
|
||||
# IDs
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||
|
||||
# Database
|
||||
rusqlite = { version = "0.31", features = ["bundled", "serde_json"] }
|
||||
@@ -61,7 +62,7 @@ clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
|
||||
# HTTP client (for LLM drivers)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
|
||||
|
||||
# Async trait
|
||||
async-trait = "0.1"
|
||||
@@ -101,6 +102,9 @@ walkdir = "2"
|
||||
|
||||
# Security
|
||||
sha2 = "0.10"
|
||||
sha1 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
subtle = "2"
|
||||
@@ -135,7 +139,10 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
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"
|
||||
mailparse = "0.16"
|
||||
|
||||
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
|
||||
openssl = { version = "0.10", features = ["vendored"] }
|
||||
|
||||
# Testing
|
||||
tokio-test = "0.4"
|
||||
@@ -146,3 +153,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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ 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;
|
||||
@@ -50,12 +51,15 @@ use openfang_channels::linkedin::LinkedInAdapter;
|
||||
use openfang_channels::mumble::MumbleAdapter;
|
||||
use openfang_channels::ntfy::NtfyAdapter;
|
||||
use openfang_channels::webhook::WebhookAdapter;
|
||||
use openfang_channels::wecom::WeComAdapter;
|
||||
use openfang_kernel::OpenFangKernel;
|
||||
use openfang_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 +74,37 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.send_message(agent_id, message)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
// Silent/NO_REPLY responses should not be forwarded to channels
|
||||
if result.silent {
|
||||
return Ok(String::new());
|
||||
}
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
async fn send_message_with_blocks(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
blocks: Vec<openfang_types::message::ContentBlock>,
|
||||
) -> Result<String, String> {
|
||||
// Extract text for the message parameter (used for memory recall / logging)
|
||||
let text: String = blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
openfang_types::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let text = if text.is_empty() {
|
||||
"[Image]".to_string()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let result = self
|
||||
.kernel
|
||||
.send_message_with_blocks(agent_id, &text, blocks)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
@@ -351,7 +386,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.map(|e| e.name.clone())
|
||||
.unwrap_or_else(|| t.agent_id.to_string());
|
||||
let status = if t.enabled { "on" } else { "off" };
|
||||
let id_short = &t.id.0.to_string()[..8];
|
||||
let id_str = t.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
msg.push_str(&format!(
|
||||
" [{}] {} -> {} ({:?}) fires:{} [{}]\n",
|
||||
id_short,
|
||||
@@ -390,7 +426,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.kernel
|
||||
.triggers
|
||||
.register(agent.id, pattern, prompt.to_string(), 0);
|
||||
let id_short = &trigger_id.0.to_string()[..8];
|
||||
let id_str = trigger_id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Trigger created [{id_short}] for agent '{agent_name}'.")
|
||||
}
|
||||
|
||||
@@ -405,7 +442,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
1 => {
|
||||
let t = matched[0];
|
||||
if self.kernel.triggers.remove(t.id) {
|
||||
format!("Trigger [{}] removed.", &t.id.0.to_string()[..8])
|
||||
let id_str = t.id.0.to_string();
|
||||
format!("Trigger [{}] removed.", safe_truncate_str(&id_str, 8))
|
||||
} else {
|
||||
"Failed to remove trigger.".to_string()
|
||||
}
|
||||
@@ -428,7 +466,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.map(|e| e.name.clone())
|
||||
.unwrap_or_else(|| job.agent_id.to_string());
|
||||
let status = if job.enabled { "on" } else { "off" };
|
||||
let id_short = &job.id.0.to_string()[..8];
|
||||
let id_str = job.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
let sched = match &job.schedule {
|
||||
openfang_types::scheduler::CronSchedule::Cron { expr, .. } => expr.clone(),
|
||||
openfang_types::scheduler::CronSchedule::Every { every_secs } => {
|
||||
@@ -450,6 +489,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
msg
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn manage_schedule_text(&self, action: &str, args: &[String]) -> String {
|
||||
match action {
|
||||
"add" => {
|
||||
@@ -488,7 +528,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
|
||||
match self.kernel.cron_scheduler.add_job(job, false) {
|
||||
Ok(id) => {
|
||||
let id_short = &id.0.to_string()[..8];
|
||||
let id_str = id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Job [{id_short}] created: '{cron_expr}' -> {agent_name}: \"{message}\"")
|
||||
}
|
||||
Err(e) => format!("Failed to create job: {e}"),
|
||||
@@ -510,7 +551,12 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
let j = matched[0];
|
||||
match self.kernel.cron_scheduler.remove_job(j.id) {
|
||||
Ok(_) => {
|
||||
format!("Job [{}] '{}' removed.", &j.id.0.to_string()[..8], j.name)
|
||||
let id_str = j.id.0.to_string();
|
||||
format!(
|
||||
"Job [{}] '{}' removed.",
|
||||
safe_truncate_str(&id_str, 8),
|
||||
j.name
|
||||
)
|
||||
}
|
||||
Err(e) => format!("Failed to remove job: {e}"),
|
||||
}
|
||||
@@ -539,10 +585,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
openfang_types::scheduler::CronAction::SystemEvent { text } => {
|
||||
text.clone()
|
||||
}
|
||||
openfang_types::scheduler::CronAction::WorkflowRun {
|
||||
workflow_id,
|
||||
input,
|
||||
..
|
||||
} => {
|
||||
format!(
|
||||
"Run workflow {workflow_id}{}",
|
||||
input
|
||||
.as_deref()
|
||||
.map(|i| format!(" with input: {i}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
};
|
||||
match self.kernel.send_message(j.agent_id, &message).await {
|
||||
Ok(result) => {
|
||||
let id_short = &j.id.0.to_string()[..8];
|
||||
let id_str = j.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Job [{id_short}] ran:\n{}", result.response)
|
||||
}
|
||||
Err(e) => format!("Failed to run job: {e}"),
|
||||
@@ -562,7 +622,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
}
|
||||
let mut msg = format!("Pending approvals ({}):\n", pending.len());
|
||||
for req in &pending {
|
||||
let id_short = &req.id.to_string()[..8];
|
||||
let id_str = req.id.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds();
|
||||
let age = if age_secs >= 60 {
|
||||
format!("{}m", age_secs / 60)
|
||||
@@ -603,10 +664,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
) {
|
||||
Ok(_) => {
|
||||
let verb = if approve { "Approved" } else { "Rejected" };
|
||||
let id_str = req.id.to_string();
|
||||
format!(
|
||||
"{} [{}] {} — {}",
|
||||
verb,
|
||||
&req.id.to_string()[..8],
|
||||
safe_truncate_str(&id_str, 8),
|
||||
req.tool_name,
|
||||
req.agent_id
|
||||
)
|
||||
@@ -646,9 +708,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
));
|
||||
}
|
||||
self.kernel
|
||||
.set_agent_model(agent_id, model)
|
||||
.set_agent_model(agent_id, model, None)
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(format!("Model switched to: {model}"))
|
||||
// Read back resolved model+provider from registry
|
||||
let entry = self
|
||||
.kernel
|
||||
.registry
|
||||
.get(agent_id)
|
||||
.ok_or_else(|| "Agent not found after model switch".to_string())?;
|
||||
Ok(format!(
|
||||
"Model switched to: {} (provider: {})",
|
||||
entry.manifest.model.model, entry.manifest.model.provider
|
||||
))
|
||||
}
|
||||
|
||||
async fn stop_run(&self, agent_id: AgentId) -> Result<String, String> {
|
||||
@@ -727,12 +798,17 @@ 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()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -774,6 +850,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
recipient: &str,
|
||||
success: bool,
|
||||
error: Option<&str>,
|
||||
thread_id: Option<&str>,
|
||||
) {
|
||||
let receipt = if success {
|
||||
openfang_kernel::DeliveryTracker::sent_receipt(channel, recipient)
|
||||
@@ -786,9 +863,13 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
};
|
||||
self.kernel.delivery_tracker.record(agent_id, receipt);
|
||||
|
||||
// Persist last channel for cron CronDelivery::LastChannel
|
||||
// Persist last channel for cron CronDelivery::LastChannel.
|
||||
// Include thread_id when present so forum-topic context survives restarts.
|
||||
if success {
|
||||
let kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
|
||||
let mut kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
|
||||
if let Some(tid) = thread_id {
|
||||
kv_val["thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let _ = self
|
||||
.kernel
|
||||
.memory
|
||||
@@ -859,7 +940,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
return "OFP peer network is disabled. Set network_enabled = true in config.toml."
|
||||
.to_string();
|
||||
}
|
||||
match &self.kernel.peer_registry {
|
||||
match self.kernel.peer_registry.get() {
|
||||
Some(registry) => {
|
||||
let peers = registry.all_peers();
|
||||
if peers.is_empty() {
|
||||
@@ -934,16 +1015,38 @@ 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 ");
|
||||
|
||||
if looks_like_token {
|
||||
warn!(
|
||||
"{adapter_name}: config field contains what looks like an actual token \
|
||||
rather than an env var name — using it directly. \
|
||||
Tip: store the token in an env var and use the var name instead for security."
|
||||
);
|
||||
return Some(env_var_or_token.to_string());
|
||||
}
|
||||
|
||||
match std::env::var(env_var_or_token) {
|
||||
Ok(t) if !t.is_empty() => Some(t),
|
||||
Ok(_) => {
|
||||
warn!("{adapter_name} bot token env var '{env_var}' is empty, skipping");
|
||||
warn!("{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("{adapter_name} bot token env var '{env_var}' not set, skipping");
|
||||
warn!(
|
||||
"{adapter_name} token env var '{env_var_or_token}' not set, skipping. \
|
||||
Set it with: export {env_var_or_token}=<your-token>"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1003,6 +1106,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
// Wave 5
|
||||
|| config.mumble.is_some()
|
||||
|| config.dingtalk.is_some()
|
||||
|| config.dingtalk_stream.is_some()
|
||||
|| config.discourse.is_some()
|
||||
|| config.gitter.is_some()
|
||||
|| config.ntfy.is_some()
|
||||
@@ -1030,6 +1134,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
token,
|
||||
tg_config.allowed_users.clone(),
|
||||
poll_interval,
|
||||
tg_config.api_url.clone(),
|
||||
));
|
||||
adapters.push((adapter, tg_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1057,6 +1162,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()));
|
||||
}
|
||||
@@ -1066,7 +1174,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();
|
||||
@@ -1314,10 +1424,20 @@ 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(
|
||||
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 = 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(),
|
||||
));
|
||||
adapters.push((adapter, fs_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1331,6 +1451,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
|
||||
@@ -1437,7 +1572,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();
|
||||
@@ -1446,6 +1581,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") {
|
||||
@@ -1550,7 +1700,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);
|
||||
|
||||
@@ -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,13 +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, requests to non-public endpoints must include
|
||||
/// `Authorization: Bearer <api_key>`. If the key is empty, only whitelisted
|
||||
/// public endpoints are accessible — all others return 401.
|
||||
/// 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> {
|
||||
@@ -113,24 +124,23 @@ 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;
|
||||
}
|
||||
|
||||
// SECURITY: If no API key configured, non-public endpoints still require auth.
|
||||
// Fall through to the token check which will fail (no valid token matches empty key),
|
||||
// returning 401 for any non-whitelisted route.
|
||||
if api_key.is_empty() {
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("www-authenticate", "Bearer")
|
||||
.body(Body::from(
|
||||
serde_json::json!({"error": "No API key configured. Set api_key in config.toml or pass --api-key on startup."}).to_string(),
|
||||
))
|
||||
.unwrap_or_default();
|
||||
// If no API key configured (empty, whitespace-only, or missing), skip auth
|
||||
// entirely. Users who don't set api_key accept that all endpoints are open.
|
||||
// To secure the dashboard, set a non-empty api_key in config.toml.
|
||||
let api_key_trimmed = auth_state.api_key.trim().to_string();
|
||||
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let api_key = api_key_trimmed.as_str();
|
||||
|
||||
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
|
||||
let bearer_token = request
|
||||
@@ -176,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 {
|
||||
@@ -193,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;
|
||||
@@ -203,7 +239,7 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
// 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'; 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'"
|
||||
"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(),
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
.map_err(|e| format!("Streaming setup failed: {e}"))?;
|
||||
|
||||
let (tx, stream_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(64);
|
||||
|
||||
+1218
-231
File diff suppressed because it is too large
Load Diff
@@ -45,16 +45,17 @@ 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(),
|
||||
});
|
||||
|
||||
// CORS: allow localhost origins by default. If API key is set, the API
|
||||
// is protected anyway. For development, permissive CORS is convenient.
|
||||
let cors = if state.kernel.config.api_key.is_empty() {
|
||||
let cors = if state.kernel.config.api_key.trim().is_empty() {
|
||||
// No auth → restrict CORS to localhost origins (include both 127.0.0.1 and localhost)
|
||||
let port = listen_addr.port();
|
||||
let mut origins: Vec<axum::http::HeaderValue> = vec![
|
||||
@@ -102,13 +103,27 @@ pub async fn build_router(
|
||||
.allow_headers(tower_http::cors::Any)
|
||||
};
|
||||
|
||||
let api_key = state.kernel.config.api_key.clone();
|
||||
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
let auth_state = crate::middleware::AuthState {
|
||||
api_key: api_key.clone(),
|
||||
auth_enabled: state.kernel.config.auth.enabled,
|
||||
session_secret: if !api_key.is_empty() {
|
||||
api_key.clone()
|
||||
} else if state.kernel.config.auth.enabled {
|
||||
state.kernel.config.auth.password_hash.clone()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
};
|
||||
let gcra_limiter = rate_limiter::create_rate_limiter();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", axum::routing::get(webchat::webchat_page))
|
||||
.route("/logo.png", axum::routing::get(webchat::logo_png))
|
||||
.route("/favicon.ico", axum::routing::get(webchat::favicon_ico))
|
||||
.route("/manifest.json", axum::routing::get(webchat::manifest_json))
|
||||
.route("/sw.js", axum::routing::get(webchat::sw_js))
|
||||
.route(
|
||||
"/api/metrics",
|
||||
axum::routing::get(routes::prometheus_metrics),
|
||||
@@ -126,13 +141,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),
|
||||
@@ -286,6 +311,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),
|
||||
@@ -335,6 +366,10 @@ pub async fn build_router(
|
||||
"/api/hands/install",
|
||||
axum::routing::post(routes::install_hand),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/upsert",
|
||||
axum::routing::post(routes::upsert_hand),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/active",
|
||||
axum::routing::get(routes::list_active_hands),
|
||||
@@ -354,8 +389,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/hands/{hand_id}/settings",
|
||||
axum::routing::get(routes::get_hand_settings)
|
||||
.put(routes::update_hand_settings),
|
||||
axum::routing::get(routes::get_hand_settings).put(routes::update_hand_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/instances/{id}/pause",
|
||||
@@ -412,14 +446,11 @@ pub async fn build_router(
|
||||
"/api/comms/events/stream",
|
||||
axum::routing::get(routes::comms_events_stream),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/send",
|
||||
axum::routing::post(routes::comms_send),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/task",
|
||||
axum::routing::post(routes::comms_task),
|
||||
)
|
||||
.route("/api/comms/send", axum::routing::post(routes::comms_send))
|
||||
.route("/api/comms/task", axum::routing::post(routes::comms_task));
|
||||
|
||||
// Split into a second router chunk to stay within axum's type nesting limit.
|
||||
let app = app
|
||||
// Tools endpoint
|
||||
.route("/api/tools", axum::routing::get(routes::list_tools))
|
||||
// Config endpoints
|
||||
@@ -464,8 +495,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))
|
||||
@@ -669,8 +699,12 @@ pub async fn build_router(
|
||||
"/v1/models",
|
||||
axum::routing::get(crate::openai_compat::list_models),
|
||||
)
|
||||
// Dashboard authentication endpoints
|
||||
.route("/api/auth/login", axum::routing::post(routes::auth_login))
|
||||
.route("/api/auth/logout", axum::routing::post(routes::auth_logout))
|
||||
.route("/api/auth/check", axum::routing::get(routes::auth_check))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
api_key,
|
||||
auth_state,
|
||||
middleware::auth,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
@@ -787,8 +821,7 @@ pub async fn run_daemon(
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(1024)?;
|
||||
let listener =
|
||||
tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
|
||||
let listener = tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
|
||||
|
||||
// Run server with graceful shutdown.
|
||||
// SECURITY: `into_make_service_with_connect_info` injects the peer
|
||||
@@ -919,11 +952,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"));
|
||||
}
|
||||
}
|
||||
@@ -140,9 +140,23 @@ impl StreamChunker {
|
||||
}
|
||||
|
||||
/// Find the last occurrence of a pattern within a byte range.
|
||||
///
|
||||
/// Both `range.start` and `range.end` are clamped to the nearest valid UTF-8
|
||||
/// char boundary so that slicing never panics on multi-byte content.
|
||||
fn find_last_in_range(text: &str, pattern: &str, range: &std::ops::Range<usize>) -> Option<usize> {
|
||||
let search_text = &text[range.start..range.end.min(text.len())];
|
||||
search_text.rfind(pattern).map(|pos| range.start + pos)
|
||||
let len = text.len();
|
||||
// Clamp end to text length and walk back to a char boundary
|
||||
let mut end = range.end.min(len);
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
// Walk start forward to the nearest char boundary (never past end)
|
||||
let mut start = range.start.min(end);
|
||||
while start < end && !text.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
let search_text = &text[start..end];
|
||||
search_text.rfind(pattern).map(|pos| start + pos)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -46,6 +46,34 @@ pub async fn favicon_ico() -> 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, "application/manifest+json"),
|
||||
(header::CACHE_CONTROL, "public, max-age=86400, immutable"),
|
||||
],
|
||||
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.
|
||||
///
|
||||
/// Returns the full SPA with ETag header based on package version for caching.
|
||||
@@ -81,13 +109,16 @@ 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)
|
||||
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
|
||||
"<script>\n",
|
||||
include_str!("../static/vendor/marked.min.js"),
|
||||
"\n</script>\n",
|
||||
"<script>\n",
|
||||
include_str!("../static/vendor/highlight.min.js"),
|
||||
"\n</script>\n",
|
||||
"<script>\n",
|
||||
include_str!("../static/vendor/chart.umd.min.js"),
|
||||
"\n</script>\n",
|
||||
// App code
|
||||
"<script>\n",
|
||||
include_str!("../static/js/api.js"),
|
||||
|
||||
+154
-72
@@ -146,7 +146,9 @@ pub async fn agent_ws(
|
||||
uri: axum::http::Uri,
|
||||
) -> impl IntoResponse {
|
||||
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
|
||||
let api_key = &state.kernel.config.api_key;
|
||||
// Trim whitespace so empty/whitespace-only api_key disables auth.
|
||||
let api_key_raw = &state.kernel.config.api_key;
|
||||
let api_key = api_key_raw.trim();
|
||||
if !api_key.is_empty() {
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks on API key
|
||||
let ct_eq = |token: &str, key: &str| -> bool {
|
||||
@@ -500,16 +502,29 @@ 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,
|
||||
) {
|
||||
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;
|
||||
|
||||
@@ -533,7 +548,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(
|
||||
@@ -598,14 +621,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,
|
||||
@@ -616,43 +687,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 {
|
||||
@@ -668,38 +732,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!({
|
||||
@@ -807,14 +850,21 @@ async fn handle_command(
|
||||
serde_json::json!({"type": "error", "content": "Agent not found"})
|
||||
}
|
||||
} else {
|
||||
match state.kernel.set_agent_model(agent_id, args) {
|
||||
match state.kernel.set_agent_model(agent_id, args, None) {
|
||||
Ok(()) => {
|
||||
let msg = if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
format!("Model switched to: {} (provider: {})", entry.manifest.model.model, entry.manifest.model.provider)
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
let model = &entry.manifest.model.model;
|
||||
let provider = &entry.manifest.model.provider;
|
||||
serde_json::json!({
|
||||
"type": "command_result",
|
||||
"command": cmd,
|
||||
"message": format!("Model switched to: {model} (provider: {provider})"),
|
||||
"model": model,
|
||||
"provider": provider
|
||||
})
|
||||
} else {
|
||||
format!("Model switched to: {args}")
|
||||
};
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": msg})
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"type": "error", "content": format!("Model switch failed: {e}")})
|
||||
@@ -903,7 +953,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() {
|
||||
@@ -1110,6 +1160,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()
|
||||
@@ -1117,24 +1170,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,
|
||||
}
|
||||
@@ -1142,6 +1207,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..];
|
||||
@@ -1260,6 +1333,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]
|
||||
|
||||
@@ -3238,3 +3238,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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -171,6 +186,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>
|
||||
@@ -736,7 +755,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 +788,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 +811,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">
|
||||
@@ -903,7 +922,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>
|
||||
@@ -1091,7 +1126,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>
|
||||
@@ -1320,7 +1355,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 +1422,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>
|
||||
@@ -1468,7 +1538,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Label</label>
|
||||
<input class="form-input" x-model="selectedNode.label" style="font-size:11px">
|
||||
<input class="form-input" x-model="selectedNode.label" @input="applyNodeEdit()" style="font-size:11px">
|
||||
</div>
|
||||
|
||||
<!-- Agent config -->
|
||||
@@ -1476,7 +1546,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Agent</label>
|
||||
<select class="form-select" x-model="selectedNode.config.agent_name" style="font-size:11px">
|
||||
<select class="form-select" x-model="selectedNode.config.agent_name" @change="applyNodeEdit()" style="font-size:11px">
|
||||
<option value="">Select agent...</option>
|
||||
<template x-for="a in agents" :key="a.id || a.name">
|
||||
<option :value="a.name" x-text="a.name"></option>
|
||||
@@ -1485,11 +1555,11 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Prompt Template</label>
|
||||
<textarea class="form-textarea" x-model="selectedNode.config.prompt" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
|
||||
<textarea class="form-textarea" x-model="selectedNode.config.prompt" @input="applyNodeEdit()" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Model (optional)</label>
|
||||
<input class="form-input" x-model="selectedNode.config.model" style="font-size:11px" placeholder="Default model">
|
||||
<input class="form-input" x-model="selectedNode.config.model" @input="applyNodeEdit()" style="font-size:11px" placeholder="Default model">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1499,7 +1569,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Expression</label>
|
||||
<input class="form-input" x-model="selectedNode.config.expression" style="font-size:11px" placeholder="output.contains('yes')">
|
||||
<input class="form-input" x-model="selectedNode.config.expression" @input="applyNodeEdit()" style="font-size:11px" placeholder="output.contains('yes')">
|
||||
</div>
|
||||
<div class="text-xs text-dim">Top port = true, bottom port = false</div>
|
||||
</div>
|
||||
@@ -1510,11 +1580,11 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Max Iterations</label>
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" style="font-size:11px" min="1" max="100">
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" @input="applyNodeEdit()" style="font-size:11px" min="1" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Until (stop condition)</label>
|
||||
<input class="form-input" x-model="selectedNode.config.until" style="font-size:11px" placeholder="output === 'done'">
|
||||
<input class="form-input" x-model="selectedNode.config.until" @input="applyNodeEdit()" style="font-size:11px" placeholder="output === 'done'">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1524,7 +1594,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Fan-out Count</label>
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" style="font-size:11px" min="2" max="10">
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" @input="applyNodeEdit()" style="font-size:11px" min="2" max="10">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1534,7 +1604,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Strategy</label>
|
||||
<select class="form-select" x-model="selectedNode.config.strategy" style="font-size:11px">
|
||||
<select class="form-select" x-model="selectedNode.config.strategy" @change="applyNodeEdit()" style="font-size:11px">
|
||||
<option value="all">Wait for all</option>
|
||||
<option value="first">First to finish</option>
|
||||
<option value="majority">Majority vote</option>
|
||||
@@ -2172,7 +2242,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 +2293,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 +2316,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 +2600,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 +2715,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 +2760,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 +3018,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">
|
||||
@@ -3372,11 +3599,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<!-- Network tab -->
|
||||
<div x-show="tab === 'network'" x-data="{
|
||||
netStatus: null, a2aAgents: [], a2aDiscoverUrl: '', a2aDiscovering: false,
|
||||
async loadNetStatus() { try { this.netStatus = await (await fetch('/api/network/status')).json(); } catch(e) {} },
|
||||
async loadA2aAgents() { try { let r = await (await fetch('/api/a2a/agents')).json(); this.a2aAgents = r.agents || []; } catch(e) {} },
|
||||
async loadNetStatus() { try { this.netStatus = await OpenFangAPI.get('/api/network/status'); } catch(e) {} },
|
||||
async loadA2aAgents() { try { let r = await OpenFangAPI.get('/api/a2a/agents'); this.a2aAgents = r.agents || []; } catch(e) {} },
|
||||
async discoverA2a() {
|
||||
if (!this.a2aDiscoverUrl) return; this.a2aDiscovering = true;
|
||||
try { await fetch('/api/a2a/discover', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:this.a2aDiscoverUrl})}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
|
||||
try { await OpenFangAPI.post('/api/a2a/discover', {url:this.a2aDiscoverUrl}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
|
||||
this.a2aDiscovering = false;
|
||||
}
|
||||
}" x-init="loadNetStatus(); loadA2aAgents()">
|
||||
@@ -3458,14 +3685,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div x-show="tab === 'budget'" x-data="{
|
||||
budgetData: null, agentRanking: [], budgetLoading: true,
|
||||
editMode: false,
|
||||
editHourly: '', editDaily: '', editMonthly: '', editAlert: '',
|
||||
editHourly: '', editDaily: '', editMonthly: '', editAlert: '', editTokenLimit: '',
|
||||
saving: false,
|
||||
async loadBudget() {
|
||||
this.budgetLoading = true;
|
||||
try {
|
||||
let [b, a] = await Promise.all([
|
||||
fetch('/api/budget').then(r => r.json()),
|
||||
fetch('/api/budget/agents').then(r => r.json())
|
||||
OpenFangAPI.get('/api/budget'),
|
||||
OpenFangAPI.get('/api/budget/agents')
|
||||
]);
|
||||
this.budgetData = b;
|
||||
this.agentRanking = a.agents || [];
|
||||
@@ -3477,6 +3704,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 +3716,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
if (+this.editMonthly !== this.budgetData.monthly_limit) body.max_monthly_usd = +this.editMonthly;
|
||||
let alertVal = (+this.editAlert) / 100;
|
||||
if (Math.abs(alertVal - this.budgetData.alert_threshold) > 0.001) body.alert_threshold = alertVal;
|
||||
await fetch('/api/budget', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
if (+this.editTokenLimit !== (this.budgetData.default_max_llm_tokens_per_hour || 0)) body.default_max_llm_tokens_per_hour = +this.editTokenLimit;
|
||||
await OpenFangAPI.put('/api/budget', body);
|
||||
this.editMode = false;
|
||||
await this.loadBudget();
|
||||
} catch(e) { alert('Failed to save: ' + e); }
|
||||
} catch(e) { OpenFangToast.error('Failed to save: ' + (e.message || e)); }
|
||||
this.saving = false;
|
||||
},
|
||||
fmtTokens(v) { return v > 0 ? (v >= 1000000 ? (v/1000000).toFixed(1)+'M' : v >= 1000 ? (v/1000).toFixed(0)+'K' : v) : 'per-agent'; },
|
||||
pctColor(pct) { return pct >= 0.8 ? '#ef4444' : pct >= 0.5 ? '#eab308' : '#22c55e'; },
|
||||
fmtUsd(v) { return v > 0 ? '$' + v.toFixed(4) : 'unlimited'; }
|
||||
}" x-init="loadBudget()">
|
||||
@@ -3533,9 +3763,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 +3790,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 +3802,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 +3811,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>
|
||||
@@ -4453,7 +4691,29 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj)">
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider === 'claude-code'">
|
||||
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
|
||||
<div class="card-header">Configure Claude Code</div>
|
||||
<div class="text-xs text-dim mb-2" style="line-height:1.8">
|
||||
Claude Code uses its own CLI authentication — no API key needed.
|
||||
</div>
|
||||
<div style="background:var(--bg);border-radius:4px;padding:10px 12px;margin-bottom:12px;font-size:12px;line-height:1.8">
|
||||
<div><span style="color:var(--accent)">1.</span> Install: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">npm install -g @anthropic-ai/claude-code</code></div>
|
||||
<div><span style="color:var(--accent)">2.</span> Authenticate: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">claude auth</code></div>
|
||||
<div><span style="color:var(--accent)">3.</span> Click <strong>Detect</strong> below to verify</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click="detectClaudeCode()" :disabled="testingProvider">
|
||||
<span x-show="!testingProvider">Detect Claude Code</span>
|
||||
<span x-show="testingProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
||||
</button>
|
||||
<div x-show="testResult" class="mt-2">
|
||||
<div x-show="testResult && testResult.status === 'ok'" class="badge badge-success" style="padding:6px 12px">Claude Code detected<span x-show="testResult && testResult.latency_ms" x-text="' (' + (testResult ? testResult.latency_ms : '') + 'ms)'"></span></div>
|
||||
<div x-show="testResult && testResult.status !== 'ok'" class="badge badge-error" style="padding:6px 12px">Claude Code CLI not detected. Make sure you’ve run: <code>npm install -g @anthropic-ai/claude-code && claude auth</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider !== 'claude-code'">
|
||||
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
|
||||
<div class="card-header" x-text="'Configure ' + selectedProviderObj.display_name"></div>
|
||||
<div class="text-xs text-dim mb-2" x-show="selectedProviderObj.api_key_env">
|
||||
@@ -4543,7 +4803,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">
|
||||
@@ -4589,7 +4849,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>
|
||||
@@ -4775,3 +5035,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,7 +6,12 @@
|
||||
<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">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css">
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/contrib/auto-render.min.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -161,6 +161,17 @@ var OpenFangAPI = (function() {
|
||||
return fetch(BASE + path, opts).then(function(r) {
|
||||
if (_connectionState !== 'connected') setConnectionState('connected');
|
||||
if (!r.ok) {
|
||||
// On 401, auto-show auth prompt so the user can re-enter their key
|
||||
if (r.status === 401 && typeof Alpine !== 'undefined') {
|
||||
try {
|
||||
var store = Alpine.store('app');
|
||||
if (store && !store.showAuthPrompt) {
|
||||
_authToken = '';
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
store.showAuthPrompt = true;
|
||||
}
|
||||
} catch(e2) { /* ignore Alpine errors */ }
|
||||
}
|
||||
return r.text().then(function(text) {
|
||||
var msg = '';
|
||||
try {
|
||||
|
||||
@@ -24,14 +24,68 @@ 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);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (typeof renderMathInElement !== 'function') return;
|
||||
var target = el || document.getElementById('messages');
|
||||
if (!target) 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 */ }
|
||||
}
|
||||
|
||||
function copyCode(btn) {
|
||||
var code = btn.nextElementSibling;
|
||||
if (code) {
|
||||
@@ -104,6 +158,8 @@ document.addEventListener('alpine:init', function() {
|
||||
focusMode: localStorage.getItem('openfang-focus') === 'true',
|
||||
showOnboarding: false,
|
||||
showAuthPrompt: false,
|
||||
authMode: 'apikey',
|
||||
sessionUser: null,
|
||||
|
||||
toggleFocusMode() {
|
||||
this.focusMode = !this.focusMode;
|
||||
@@ -155,16 +211,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 +254,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');
|
||||
|
||||
@@ -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',
|
||||
@@ -62,6 +77,8 @@ function agentsPage() {
|
||||
// -- Model switch --
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
editingProvider: false,
|
||||
newProviderValue: '',
|
||||
modelSaving: false,
|
||||
// -- Fallback chain --
|
||||
editingFallback: false,
|
||||
@@ -378,7 +395,7 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// ── Multi-step wizard navigation ──
|
||||
openSpawnWizard() {
|
||||
async openSpawnWizard() {
|
||||
this.showSpawnModal = true;
|
||||
this.spawnStep = 1;
|
||||
this.spawnMode = 'wizard';
|
||||
@@ -386,8 +403,18 @@ 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';
|
||||
try {
|
||||
var res = await fetch('/api/status');
|
||||
if (res.ok) {
|
||||
var status = await res.json();
|
||||
if (status.default_provider) this.spawnForm.provider = status.default_provider;
|
||||
if (status.default_model) this.spawnForm.model = status.default_model;
|
||||
}
|
||||
} catch(e) { /* keep hardcoded defaults */ }
|
||||
},
|
||||
|
||||
nextStep() {
|
||||
@@ -411,7 +438,7 @@ function agentsPage() {
|
||||
var f = this.spawnForm;
|
||||
var si = this.spawnIdentity;
|
||||
var lines = [
|
||||
'name = "' + f.name + '"',
|
||||
'name = "' + tomlBasicEscape(f.name) + '"',
|
||||
'module = "builtin:chat"'
|
||||
];
|
||||
if (f.profile && f.profile !== 'custom') {
|
||||
@@ -420,7 +447,7 @@ function agentsPage() {
|
||||
lines.push('', '[model]');
|
||||
lines.push('provider = "' + f.provider + '"');
|
||||
lines.push('model = "' + f.model + '"');
|
||||
lines.push('system_prompt = "' + f.systemPrompt.replace(/"/g, '\\"') + '"');
|
||||
lines.push('system_prompt = """\n' + tomlMultilineEscape(f.systemPrompt) + '\n"""');
|
||||
if (f.profile === 'custom') {
|
||||
lines.push('', '[capabilities]');
|
||||
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
|
||||
@@ -597,8 +624,9 @@ function agentsPage() {
|
||||
if (!this.detailAgent || !this.newModelValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
OpenFangToast.success('Model changed (memory reset)');
|
||||
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
var providerInfo = (resp && resp.provider) ? ' (provider: ' + resp.provider + ')' : '';
|
||||
OpenFangToast.success('Model changed' + providerInfo + ' (memory reset)');
|
||||
this.editingModel = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
// Refresh detailAgent
|
||||
@@ -612,6 +640,26 @@ function agentsPage() {
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Provider switch ──
|
||||
async changeProvider() {
|
||||
if (!this.detailAgent || !this.newProviderValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
var combined = this.newProviderValue.trim() + '/' + this.detailAgent.model_name;
|
||||
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: combined });
|
||||
OpenFangToast.success('Provider changed to ' + (resp && resp.provider ? resp.provider : this.newProviderValue.trim()));
|
||||
this.editingProvider = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
var agents = Alpine.store('app').agents;
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to change provider: ' + e.message);
|
||||
}
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Fallback model chain ──
|
||||
async addFallback() {
|
||||
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
|
||||
@@ -697,12 +745,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 });
|
||||
|
||||
@@ -264,9 +264,10 @@ function chatPage() {
|
||||
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
this.modelSwitching = true;
|
||||
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function() {
|
||||
self.currentAgent.model_name = model.id;
|
||||
self.currentAgent.model_provider = model.provider;
|
||||
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function(resp) {
|
||||
// Use server-resolved model/provider to stay in sync (fixes #387/#466)
|
||||
self.currentAgent.model_name = (resp && resp.model) || model.id;
|
||||
self.currentAgent.model_provider = (resp && resp.provider) || model.provider;
|
||||
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
|
||||
self.showModelSwitcher = false;
|
||||
self.modelSwitching = false;
|
||||
@@ -421,9 +422,12 @@ function chatPage() {
|
||||
if (self.currentAgent) {
|
||||
if (cmdArgs) {
|
||||
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) {
|
||||
self.currentAgent.model_name = cmdArgs;
|
||||
if (resp && resp.provider) { self.currentAgent.model_provider = resp.provider; }
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`' + (resp && resp.provider ? ' (provider: `' + resp.provider + '`)' : ''), meta: '', tools: [] });
|
||||
// Use server-resolved model/provider (fixes #387/#466)
|
||||
var resolvedModel = (resp && resp.model) || cmdArgs;
|
||||
var resolvedProvider = (resp && resp.provider) || '';
|
||||
self.currentAgent.model_name = resolvedModel;
|
||||
if (resolvedProvider) { self.currentAgent.model_provider = resolvedProvider; }
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + resolvedModel + '`' + (resolvedProvider ? ' (provider: `' + resolvedProvider + '`)' : ''), meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); });
|
||||
} else {
|
||||
@@ -526,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
|
||||
@@ -646,17 +650,32 @@ function chatPage() {
|
||||
// Show tool/phase progress so the user sees the agent is working
|
||||
var phaseMsg = this.messages.length ? this.messages[this.messages.length - 1] : 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;
|
||||
} 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.scrollToBottom();
|
||||
@@ -684,7 +703,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
|
||||
@@ -702,7 +721,7 @@ function chatPage() {
|
||||
var lastMsg = this.messages.length ? this.messages[this.messages.length - 1] : 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.scrollToBottom();
|
||||
break;
|
||||
@@ -1027,10 +1046,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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -352,7 +352,10 @@ function settingsPage() {
|
||||
|
||||
providerAuthText(p) {
|
||||
if (p.auth_status === 'configured') return 'Configured';
|
||||
if (p.auth_status === 'not_set' || p.auth_status === 'missing') return 'Not Set';
|
||||
if (p.auth_status === 'not_set' || p.auth_status === 'missing') {
|
||||
if (p.id === 'claude-code') return 'Not Installed';
|
||||
return 'Not Set';
|
||||
}
|
||||
return 'No Key Needed';
|
||||
},
|
||||
|
||||
@@ -398,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,
|
||||
@@ -283,11 +293,13 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get canGoNext() {
|
||||
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider;
|
||||
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider || this.claudeCodeDetected;
|
||||
if (this.step === 3) return this.agentName.trim().length > 0;
|
||||
return true;
|
||||
},
|
||||
|
||||
claudeCodeDetected: false,
|
||||
|
||||
get hasConfiguredProvider() {
|
||||
var self = this;
|
||||
return this.providers.some(function(p) {
|
||||
@@ -409,6 +421,28 @@ function wizardPage() {
|
||||
this.testingProvider = false;
|
||||
},
|
||||
|
||||
async detectClaudeCode() {
|
||||
this.testingProvider = true;
|
||||
this.testResult = null;
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/providers/claude-code/test', {});
|
||||
this.testResult = result;
|
||||
if (result.status === 'ok') {
|
||||
this.claudeCodeDetected = true;
|
||||
this.keySaved = true;
|
||||
this.setupSummary.provider = 'Claude Code';
|
||||
OpenFangToast.success('Claude Code detected (' + (result.latency_ms || '?') + 'ms)');
|
||||
} else {
|
||||
this.testResult = { status: 'error', error: 'Claude Code CLI not detected' };
|
||||
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
|
||||
}
|
||||
} catch(e) {
|
||||
this.testResult = { status: 'error', error: e.message };
|
||||
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
|
||||
}
|
||||
this.testingProvider = false;
|
||||
},
|
||||
|
||||
// ── Step 3: Agent creation ──
|
||||
|
||||
selectTemplate(index) {
|
||||
@@ -438,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 {
|
||||
@@ -469,7 +503,7 @@ function wizardPage() {
|
||||
gemini: 'gemini-2.5-flash',
|
||||
groq: 'llama-3.3-70b-versatile',
|
||||
deepseek: 'deepseek-chat',
|
||||
openrouter: 'openrouter/auto',
|
||||
openrouter: 'openrouter/google/gemini-2.5-flash',
|
||||
mistral: 'mistral-large-latest',
|
||||
together: 'meta-llama/Llama-3-70b-chat-hf',
|
||||
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
||||
|
||||
@@ -38,6 +38,11 @@ function workflowBuilder() {
|
||||
],
|
||||
|
||||
_renderScheduled: false,
|
||||
_lastClickNodeId: null,
|
||||
_lastClickTime: 0,
|
||||
_didDrag: false,
|
||||
_didConnect: false,
|
||||
_didPan: false,
|
||||
|
||||
async init() {
|
||||
var self = this;
|
||||
@@ -334,8 +339,23 @@ function workflowBuilder() {
|
||||
|
||||
onNodeMouseDown: function(node, e) {
|
||||
e.stopPropagation();
|
||||
// Detect double-click manually — the native dblclick event never fires
|
||||
// because scheduleRender() destroys and recreates all SVG elements between
|
||||
// the first and second click, so the browser loses the DOM target for dblclick.
|
||||
var now = Date.now();
|
||||
if (this._lastClickNodeId === node.id && (now - this._lastClickTime) < 350) {
|
||||
// Double-click detected — open editor instead of starting drag
|
||||
this._lastClickNodeId = null;
|
||||
this._lastClickTime = 0;
|
||||
this.editNode(node);
|
||||
return;
|
||||
}
|
||||
this._lastClickNodeId = node.id;
|
||||
this._lastClickTime = now;
|
||||
|
||||
this.selectedNode = node;
|
||||
this.selectedConnection = null;
|
||||
this._didDrag = false;
|
||||
this.dragging = node.id;
|
||||
var rect = this._getCanvasRect();
|
||||
this.dragOffset = {
|
||||
@@ -350,6 +370,7 @@ function workflowBuilder() {
|
||||
this.selectedConnection = null;
|
||||
this.showNodeEditor = false;
|
||||
// Start canvas pan
|
||||
this._didPan = false;
|
||||
this.canvasDragging = true;
|
||||
this.canvasDragStart = { x: e.clientX - this.canvasOffset.x * this.zoom, y: e.clientY - this.canvasOffset.y * this.zoom };
|
||||
},
|
||||
@@ -357,6 +378,7 @@ function workflowBuilder() {
|
||||
onCanvasMouseMove: function(e) {
|
||||
var rect = this._getCanvasRect();
|
||||
if (this.dragging) {
|
||||
this._didDrag = true;
|
||||
var node = this.getNode(this.dragging);
|
||||
if (node) {
|
||||
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
|
||||
@@ -364,12 +386,14 @@ function workflowBuilder() {
|
||||
}
|
||||
this.scheduleRender();
|
||||
} else if (this.connecting) {
|
||||
this._didConnect = true;
|
||||
this.connectPreview = {
|
||||
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
|
||||
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
|
||||
};
|
||||
this.scheduleRender();
|
||||
} else if (this.canvasDragging) {
|
||||
this._didPan = true;
|
||||
this.canvasOffset = {
|
||||
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
|
||||
y: (e.clientY - this.canvasDragStart.y) / this.zoom
|
||||
@@ -378,11 +402,19 @@ function workflowBuilder() {
|
||||
},
|
||||
|
||||
onCanvasMouseUp: function() {
|
||||
// Only re-render if something actually moved. Rendering on every mouseup
|
||||
// destroys SVG elements between clicks, which prevents dblclick detection.
|
||||
var needsRender = this._didDrag || this._didConnect || this._didPan;
|
||||
this.dragging = null;
|
||||
this.connecting = null;
|
||||
this.connectPreview = null;
|
||||
this.canvasDragging = false;
|
||||
this.scheduleRender();
|
||||
this._didDrag = false;
|
||||
this._didConnect = false;
|
||||
this._didPan = false;
|
||||
if (needsRender) {
|
||||
this.scheduleRender();
|
||||
}
|
||||
},
|
||||
|
||||
onCanvasWheel: function(e) {
|
||||
@@ -427,6 +459,12 @@ function workflowBuilder() {
|
||||
editNode: function(node) {
|
||||
this.selectedNode = node;
|
||||
this.showNodeEditor = true;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// Called from editor panel inputs to reflect changes on the canvas SVG
|
||||
applyNodeEdit: function() {
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── TOML Generation ──────────────────────────────────
|
||||
|
||||
@@ -13,6 +13,8 @@ function workflowsPage() {
|
||||
loading: true,
|
||||
loadError: '',
|
||||
newWf: { name: '', description: '', steps: [{ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' }] },
|
||||
editModal: null,
|
||||
editWf: { name: '', description: '', steps: [] },
|
||||
|
||||
// -- Workflows methods --
|
||||
async loadWorkflows() {
|
||||
@@ -74,6 +76,57 @@ function workflowsPage() {
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to load run history: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWorkflow(wf) {
|
||||
if (!confirm('Delete workflow "' + wf.name + '"? This cannot be undone.')) return;
|
||||
try {
|
||||
await OpenFangAPI.delete('/api/workflows/' + wf.id);
|
||||
OpenFangToast.success('Workflow "' + wf.name + '" deleted');
|
||||
await this.loadWorkflows();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete workflow: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async showEditModal(wf) {
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/workflows/' + wf.id);
|
||||
this.editWf = {
|
||||
name: full.name || '',
|
||||
description: full.description || '',
|
||||
steps: (full.steps || []).map(function(s) {
|
||||
return {
|
||||
name: s.name || '',
|
||||
agent_name: (s.agent && s.agent.name) || '',
|
||||
mode: s.mode || 'sequential',
|
||||
prompt: s.prompt_template || '{{input}}'
|
||||
};
|
||||
})
|
||||
};
|
||||
if (this.editWf.steps.length === 0) {
|
||||
this.editWf.steps.push({ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' });
|
||||
}
|
||||
this.editModal = wf;
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to load workflow: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async saveWorkflow() {
|
||||
if (!this.editModal) return;
|
||||
var steps = this.editWf.steps.map(function(s) {
|
||||
return { name: s.name || 'step', agent_name: s.agent_name, mode: s.mode, prompt: s.prompt || '{{input}}' };
|
||||
});
|
||||
try {
|
||||
var wfName = this.editWf.name;
|
||||
await OpenFangAPI.put('/api/workflows/' + this.editModal.id, { name: wfName, description: this.editWf.description, steps: steps });
|
||||
this.editModal = null;
|
||||
OpenFangToast.success('Workflow "' + wfName + '" updated');
|
||||
await this.loadWorkflows();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update workflow: ' + e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "OpenFang Agent OS",
|
||||
"short_name": "OpenFang",
|
||||
"description": "Open-source Agent Operating System",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#6366f1",
|
||||
"icons": [
|
||||
{"src": "/logo.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/logo.png", "sizes": "512x512", "type": "image/png"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(fetch(event.request));
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -77,6 +77,7 @@ async fn start_test_server_with_provider(
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -705,9 +706,21 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
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))
|
||||
@@ -751,7 +764,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
)
|
||||
.route("/api/shutdown", axum::routing::post(routes::shutdown))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
api_key_state,
|
||||
auth_state,
|
||||
middleware::auth,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(middleware::request_logging))
|
||||
|
||||
@@ -114,6 +114,7 @@ async fn test_full_daemon_lifecycle() {
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -238,6 +239,7 @@ async fn test_server_immediate_responsiveness() {
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -58,6 +58,7 @@ async fn start_test_server() -> TestServer {
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -24,9 +24,14 @@ 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.20"
|
||||
|
||||
lettre = { workspace = true }
|
||||
imap = { workspace = true }
|
||||
|
||||
@@ -215,7 +215,7 @@ impl BlueskyAdapter {
|
||||
let chunks = split_message(text, MAX_MESSAGE_LEN);
|
||||
|
||||
for chunk in chunks {
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
|
||||
let mut record = serde_json::json!({
|
||||
"$type": "app.bsky.feed.post",
|
||||
@@ -496,7 +496,7 @@ impl ChannelAdapter for BlueskyAdapter {
|
||||
if last_seen_at.is_some() {
|
||||
let mark_url = format!("{}/xrpc/app.bsky.notification.updateSeen", service_url);
|
||||
let mark_body = serde_json::json!({
|
||||
"seenAt": Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
|
||||
"seenAt": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
});
|
||||
let _ = client
|
||||
.post(&mark_url)
|
||||
|
||||
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");
|
||||
@@ -612,6 +619,51 @@ mod tests {
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_ignore_bots_false_allows_other_bots() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "ch1",
|
||||
"content": "Bot message",
|
||||
"author": {
|
||||
"id": "other_bot",
|
||||
"username": "somebot",
|
||||
"discriminator": "0",
|
||||
"bot": true
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
// With ignore_bots=false, other bots' messages should be allowed
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
|
||||
assert!(msg.is_some());
|
||||
let msg = msg.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "somebot");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Bot message"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_ignore_bots_false_still_filters_self() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
let d = serde_json::json!({
|
||||
"id": "msg1",
|
||||
"channel_id": "ch1",
|
||||
"content": "My own message",
|
||||
"author": {
|
||||
"id": "bot123",
|
||||
"username": "openfang",
|
||||
"discriminator": "0",
|
||||
"bot": true
|
||||
},
|
||||
"timestamp": "2024-01-01T00:00:00+00:00"
|
||||
});
|
||||
|
||||
// Even with ignore_bots=false, the bot's own messages must still be filtered
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[], false).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_discord_message_guild_filter() {
|
||||
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
|
||||
@@ -629,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
|
||||
@@ -652,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");
|
||||
@@ -696,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");
|
||||
}
|
||||
|
||||
@@ -718,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")
|
||||
@@ -741,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
|
||||
@@ -772,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!({
|
||||
@@ -790,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"));
|
||||
}
|
||||
@@ -810,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
|
||||
|
||||
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,6 +43,7 @@ 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;
|
||||
@@ -50,3 +51,4 @@ pub mod linkedin;
|
||||
pub mod mumble;
|
||||
pub mod ntfy;
|
||||
pub mod webhook;
|
||||
pub mod wecom;
|
||||
|
||||
@@ -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 {
|
||||
@@ -55,6 +57,7 @@ impl MatrixAdapter {
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
since_token: Arc::new(RwLock::new(None)),
|
||||
auto_accept_invites: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,12 +119,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 {
|
||||
@@ -148,6 +223,15 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
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);
|
||||
@@ -168,7 +252,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 +287,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 {
|
||||
@@ -245,6 +347,35 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
let channel_msg = ChannelMessage {
|
||||
channel: ChannelType::Matrix,
|
||||
platform_message_id: event_id,
|
||||
@@ -256,9 +387,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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -49,6 +49,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 +268,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)")
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
+311
-63
@@ -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,11 +798,36 @@ 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())),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
@@ -807,7 +853,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)
|
||||
@@ -876,7 +922,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 +939,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 +1017,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 +1072,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1073,11 +1127,23 @@ pub(crate) fn find_daemon() -> Option<String> {
|
||||
}
|
||||
|
||||
/// Build an HTTP client for daemon calls.
|
||||
///
|
||||
/// When api_key is configured in config.toml, the client automatically
|
||||
/// 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 {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client")
|
||||
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();
|
||||
if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {key}")) {
|
||||
headers.insert(reqwest::header::AUTHORIZATION, val);
|
||||
}
|
||||
builder = builder.default_headers(headers);
|
||||
}
|
||||
|
||||
builder.build().expect("Failed to build HTTP client")
|
||||
}
|
||||
|
||||
/// Helper: send a request to the daemon and parse the JSON body.
|
||||
@@ -1289,10 +1355,18 @@ fn launch_desktop_app(_openfang_dir: &std::path::Path) {
|
||||
ui::blank();
|
||||
if let Some(base) = find_daemon() {
|
||||
let url = format!("{base}/");
|
||||
let _ = open_in_browser(&url);
|
||||
// Always print the URL — browser launch may silently fail
|
||||
// (e.g., Chromium sandbox EPERM in containers)
|
||||
if !open_in_browser(&url) {
|
||||
// Browser launch failed entirely (e.g., sandbox EPERM,
|
||||
// no display server, container environment).
|
||||
ui::hint("Could not open a browser automatically.");
|
||||
}
|
||||
// Always print the URL so the user can open it manually,
|
||||
// even when open_in_browser reported success — the spawned
|
||||
// opener may still fail asynchronously.
|
||||
ui::hint(&format!("Dashboard: {url}"));
|
||||
} else {
|
||||
ui::hint("Daemon is not running. Start it with: openfang start");
|
||||
ui::hint("Then open: http://127.0.0.1:4200");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1340,7 +1414,7 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
|
||||
(
|
||||
"openrouter",
|
||||
"OPENROUTER_API_KEY",
|
||||
"openrouter/anthropic/claude-sonnet-4",
|
||||
"openrouter/google/gemini-2.5-flash",
|
||||
"OpenRouter",
|
||||
),
|
||||
]
|
||||
@@ -1391,7 +1465,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}"),
|
||||
@@ -1407,7 +1481,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);
|
||||
@@ -1456,27 +1535,37 @@ fn cmd_start(config: Option<PathBuf>) {
|
||||
}
|
||||
|
||||
/// Read the api_key from ~/.openfang/config.toml (if any).
|
||||
///
|
||||
/// 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()?;
|
||||
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() {
|
||||
match find_daemon() {
|
||||
Some(base) => {
|
||||
let client = daemon_client();
|
||||
let mut req = client.post(format!("{base}/api/shutdown"));
|
||||
if let Some(key) = read_api_key() {
|
||||
req = req.bearer_auth(key);
|
||||
}
|
||||
match req.send() {
|
||||
match client.post(format!("{base}/api/shutdown")).send() {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
// Wait for daemon to actually stop (up to 5 seconds)
|
||||
for _ in 0..10 {
|
||||
@@ -2164,7 +2253,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 {
|
||||
@@ -2963,16 +3054,31 @@ pub(crate) fn open_in_browser(url: &str) -> bool {
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Detach from parent to avoid inheriting sandbox restrictions.
|
||||
// Some Chromium-based browsers fail with EPERM when launched from
|
||||
// restricted environments (containers, snaps, flatpaks).
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(url)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.is_ok()
|
||||
// Try multiple openers in order. xdg-open is the standard, but it
|
||||
// (or the browser it launches) can fail with EPERM in sandboxed
|
||||
// environments (containers, Snap, Flatpak, user-namespace
|
||||
// restrictions). Fall through to alternatives if any opener fails.
|
||||
let openers = [
|
||||
"xdg-open",
|
||||
"sensible-browser",
|
||||
"x-www-browser",
|
||||
"firefox",
|
||||
"google-chrome",
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
];
|
||||
for opener in &openers {
|
||||
let result = std::process::Command::new(opener)
|
||||
.arg(url)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
if result.is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
@@ -3077,6 +3183,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3951,7 +4155,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() {
|
||||
@@ -3976,10 +4183,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!(
|
||||
@@ -3987,7 +4191,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.");
|
||||
@@ -4009,10 +4218,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!(
|
||||
@@ -4100,10 +4306,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!(
|
||||
@@ -4532,6 +4735,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);
|
||||
@@ -4598,6 +4803,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);
|
||||
@@ -4616,6 +4823,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"));
|
||||
@@ -4638,6 +4849,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) => {
|
||||
@@ -4667,6 +4890,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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -5422,7 +5665,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(
|
||||
@@ -6176,10 +6427,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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6188,10 +6436,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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6431,7 +6676,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}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,10 +394,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 +456,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 +500,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) {
|
||||
Ok((mut rx, handle)) => {
|
||||
rt.block_on(async {
|
||||
while let Some(ev) = rx.recv().await {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1869,14 +1868,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 +1928,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 +1980,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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
name: "openrouter",
|
||||
display: "OpenRouter",
|
||||
env_var: "OPENROUTER_API_KEY",
|
||||
default_model: "openrouter/anthropic/claude-sonnet-4",
|
||||
default_model: "openrouter/google/gemini-2.5-flash",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
@@ -188,6 +188,14 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
display: "Claude Code",
|
||||
env_var: "",
|
||||
default_model: "claude-code/sonnet",
|
||||
needs_key: false,
|
||||
hint: "no API key",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ollama",
|
||||
display: "Ollama",
|
||||
@@ -383,15 +391,23 @@ impl State {
|
||||
self.provider_order.clear();
|
||||
let gemini_via_google = std::env::var("GOOGLE_API_KEY").is_ok();
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
let detected =
|
||||
std::env::var(p.env_var).is_ok() || (p.name == "gemini" && gemini_via_google);
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|
||||
|| (p.name == "gemini" && gemini_via_google)
|
||||
};
|
||||
if detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
let detected =
|
||||
std::env::var(p.env_var).is_ok() || (p.name == "gemini" && gemini_via_google);
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|
||||
|| (p.name == "gemini" && gemini_via_google)
|
||||
};
|
||||
if !detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
@@ -430,7 +446,10 @@ impl State {
|
||||
|
||||
fn is_provider_detected(&self, prov_idx: usize) -> bool {
|
||||
let p = &PROVIDERS[prov_idx];
|
||||
std::env::var(p.env_var).is_ok()
|
||||
if p.name == "claude-code" {
|
||||
return openfang_runtime::drivers::claude_code::claude_code_available();
|
||||
}
|
||||
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|
||||
|| (p.name == "gemini" && std::env::var("GOOGLE_API_KEY").is_ok())
|
||||
}
|
||||
|
||||
@@ -931,7 +950,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 || {
|
||||
@@ -1091,6 +1112,12 @@ complex_threshold = 500
|
||||
};
|
||||
|
||||
let config_path = openfang_dir.join("config.toml");
|
||||
let api_key_line = if p.env_var.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("api_key_env = \"{}\"", p.env_var)
|
||||
};
|
||||
|
||||
let config = format!(
|
||||
r#"# OpenFang Agent OS configuration
|
||||
# See https://github.com/RightNow-AI/openfang for documentation
|
||||
@@ -1100,13 +1127,12 @@ api_listen = "127.0.0.1:4200"
|
||||
[default_model]
|
||||
provider = "{provider}"
|
||||
model = "{model}"
|
||||
api_key_env = "{env_var}"
|
||||
{api_key_line}
|
||||
|
||||
[memory]
|
||||
decay_rate = 0.05
|
||||
{routing_section}"#,
|
||||
provider = p.name,
|
||||
env_var = p.env_var,
|
||||
);
|
||||
|
||||
match std::fs::write(&config_path, &config) {
|
||||
@@ -1703,7 +1729,13 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
Span::styled(" ", Style::default())
|
||||
};
|
||||
let name_span = Span::raw(format!("{:<14}", p.display));
|
||||
let hint_text = if detected {
|
||||
let hint_text = if p.name == "claude-code" {
|
||||
if detected {
|
||||
"CLI detected".to_string()
|
||||
} else {
|
||||
"no API key needed".to_string()
|
||||
}
|
||||
} else if detected {
|
||||
format!("{} detected", p.env_var)
|
||||
} else if !p.needs_key {
|
||||
"local, no key needed".to_string()
|
||||
|
||||
@@ -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,7 +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}", &a.id[..12])
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(&a.id, 12))
|
||||
} else {
|
||||
a.id.clone()
|
||||
};
|
||||
@@ -405,7 +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}", &kv.value[..39])
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(&kv.value, 39))
|
||||
} else {
|
||||
kv.value.clone()
|
||||
};
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +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}", &p.node_id[..12])
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(&p.node_id, 12))
|
||||
} else {
|
||||
p.node_id.clone()
|
||||
};
|
||||
@@ -208,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,7 +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}", &s.id[..12])
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(&s.id, 12))
|
||||
} else {
|
||||
s.id.clone()
|
||||
};
|
||||
@@ -308,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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
ProviderInfo {
|
||||
name: "openrouter",
|
||||
env_var: "OPENROUTER_API_KEY",
|
||||
default_model: "anthropic/claude-sonnet-4-20250514",
|
||||
default_model: "google/gemini-2.5-flash",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
@@ -127,6 +127,12 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
default_model: "codegeex-4",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
env_var: "",
|
||||
default_model: "claude-code/sonnet",
|
||||
needs_key: false,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ollama",
|
||||
env_var: "OLLAMA_API_KEY",
|
||||
@@ -215,13 +221,23 @@ impl WizardState {
|
||||
self.provider_order.clear();
|
||||
// Detected providers first
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
if std::env::var(p.env_var).is_ok() {
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
!p.env_var.is_empty() && std::env::var(p.env_var).is_ok()
|
||||
};
|
||||
if detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
// Then the rest
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
if std::env::var(p.env_var).is_err() {
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
!p.env_var.is_empty() && std::env::var(p.env_var).is_ok()
|
||||
};
|
||||
if !detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
@@ -376,6 +392,8 @@ impl WizardState {
|
||||
|
||||
let api_key_line = if !self.api_key_input.is_empty() {
|
||||
format!("api_key = \"{}\"", self.api_key_input)
|
||||
} else if p.env_var.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("api_key_env = \"{}\"", p.env_var)
|
||||
};
|
||||
@@ -506,9 +524,15 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut WizardState) {
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
let p = &PROVIDERS[idx];
|
||||
let hint = if !p.needs_key {
|
||||
let hint = if p.name == "claude-code" {
|
||||
if openfang_runtime::drivers::claude_code::claude_code_available() {
|
||||
"CLI detected".to_string()
|
||||
} else {
|
||||
"no API key needed".to_string()
|
||||
}
|
||||
} else if !p.needs_key {
|
||||
"local, no key needed".to_string()
|
||||
} else if std::env::var(p.env_var).is_ok() {
|
||||
} else if !p.env_var.is_empty() && std::env::var(p.env_var).is_ok() {
|
||||
format!("{} detected", p.env_var)
|
||||
} else {
|
||||
format!("requires {}", p.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))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,17 @@ impl CredentialResolver {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a credential from the vault (if available).
|
||||
pub fn remove_from_vault(&mut self, key: &str) -> ExtensionResult<bool> {
|
||||
if let Some(ref mut vault) = self.vault {
|
||||
vault.remove(key)
|
||||
} else {
|
||||
Err(crate::ExtensionError::Vault(
|
||||
"No vault configured".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a dotenv file into a HashMap.
|
||||
|
||||
@@ -27,9 +27,7 @@ pub fn default_client_ids() -> HashMap<&'static str, &'static str> {
|
||||
}
|
||||
|
||||
/// Resolve OAuth client IDs with config overrides applied on top of defaults.
|
||||
pub fn resolve_client_ids(
|
||||
config: &openfang_types::config::OAuthConfig,
|
||||
) -> HashMap<String, String> {
|
||||
pub fn resolve_client_ids(config: &openfang_types::config::OAuthConfig) -> HashMap<String, String> {
|
||||
let defaults = default_client_ids();
|
||||
let mut resolved: HashMap<String, String> = defaults
|
||||
.into_iter()
|
||||
|
||||
@@ -13,12 +13,30 @@ tools = [
|
||||
"file_write", "file_read",
|
||||
]
|
||||
|
||||
[[requires]]
|
||||
key = "python3"
|
||||
label = "Python 3 must be installed"
|
||||
requirement_type = "binary"
|
||||
check_value = "python3"
|
||||
description = "Python 3 is required for installing and running the Playwright browser automation library. Python 3.8 or newer is recommended."
|
||||
|
||||
[requires.install]
|
||||
macos = "brew install python3"
|
||||
windows = "winget install Python.Python.3.12"
|
||||
linux_apt = "sudo apt install python3"
|
||||
linux_dnf = "sudo dnf install python3"
|
||||
linux_pacman = "sudo pacman -S python"
|
||||
pip = "python3 --version"
|
||||
manual_url = "https://www.python.org/downloads/"
|
||||
estimated_time = "1-3 min"
|
||||
|
||||
[[requires]]
|
||||
key = "chromium"
|
||||
label = "Chromium or Google Chrome must be installed"
|
||||
requirement_type = "binary"
|
||||
check_value = "chromium"
|
||||
description = "A Chromium-based browser is required. Google Chrome, Chromium, or any Chromium derivative will work. You can also set the CHROME_PATH environment variable to point to your browser binary."
|
||||
optional = true
|
||||
description = "A Chromium-based browser is recommended. Playwright can install its own bundled browser if none is found. Google Chrome, Chromium, or any Chromium derivative will also work. You can set the CHROME_PATH environment variable to point to your browser binary."
|
||||
|
||||
[requires.install]
|
||||
macos = "brew install --cask google-chrome"
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
id = "trader"
|
||||
name = "Trading Hand"
|
||||
description = "Autonomous market intelligence and trading engine — multi-signal analysis, adversarial bull/bear reasoning, calibrated confidence scoring, strict risk management, and portfolio-level analytics"
|
||||
category = "data"
|
||||
icon = "\U0001F4C8"
|
||||
tools = ["shell_exec", "file_read", "file_write", "file_list", "web_fetch", "web_search", "memory_store", "memory_recall", "schedule_create", "schedule_list", "schedule_delete", "knowledge_add_entity", "knowledge_add_relation", "knowledge_query", "event_publish"]
|
||||
|
||||
# ─── Configurable settings ───────────────────────────────────────────────────
|
||||
|
||||
[[settings]]
|
||||
key = "trading_mode"
|
||||
label = "Trading Mode"
|
||||
description = "How the trading hand operates — analysis only, paper trading, or live trading"
|
||||
setting_type = "select"
|
||||
default = "paper"
|
||||
|
||||
[[settings.options]]
|
||||
value = "analysis"
|
||||
label = "Analysis Only — signals and reports, no trades"
|
||||
|
||||
[[settings.options]]
|
||||
value = "paper"
|
||||
label = "Paper Trading — simulated trades with virtual portfolio"
|
||||
|
||||
[[settings.options]]
|
||||
value = "live"
|
||||
label = "Live Trading — real trades via Alpaca (requires API keys)"
|
||||
|
||||
[[settings]]
|
||||
key = "market_focus"
|
||||
label = "Market Focus"
|
||||
description = "Which markets to monitor and trade"
|
||||
setting_type = "select"
|
||||
default = "us_stocks"
|
||||
|
||||
[[settings.options]]
|
||||
value = "us_stocks"
|
||||
label = "US Stocks & ETFs"
|
||||
|
||||
[[settings.options]]
|
||||
value = "crypto"
|
||||
label = "Cryptocurrency"
|
||||
|
||||
[[settings.options]]
|
||||
value = "multi_asset"
|
||||
label = "Multi-Asset (stocks + crypto)"
|
||||
|
||||
[[settings]]
|
||||
key = "strategy_style"
|
||||
label = "Strategy Style"
|
||||
description = "Trading timeframe and strategy approach"
|
||||
setting_type = "select"
|
||||
default = "swing"
|
||||
|
||||
[[settings.options]]
|
||||
value = "scalping"
|
||||
label = "Scalping (minutes to hours)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "day"
|
||||
label = "Day Trading (intraday, close by EOD)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "swing"
|
||||
label = "Swing Trading (days to weeks)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "position"
|
||||
label = "Position Trading (weeks to months)"
|
||||
|
||||
[[settings]]
|
||||
key = "risk_per_trade"
|
||||
label = "Risk Per Trade"
|
||||
description = "Maximum portfolio percentage risked on a single trade"
|
||||
setting_type = "select"
|
||||
default = "2"
|
||||
|
||||
[[settings.options]]
|
||||
value = "1"
|
||||
label = "Conservative (1% per trade)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "2"
|
||||
label = "Moderate (2% per trade)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "3"
|
||||
label = "Aggressive (3% per trade)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "5"
|
||||
label = "High Risk (5% per trade)"
|
||||
|
||||
[[settings]]
|
||||
key = "max_daily_loss"
|
||||
label = "Max Daily Loss"
|
||||
description = "Maximum portfolio percentage loss allowed per day before circuit breaker activates"
|
||||
setting_type = "select"
|
||||
default = "5"
|
||||
|
||||
[[settings.options]]
|
||||
value = "2"
|
||||
label = "Strict (2% daily max loss)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "5"
|
||||
label = "Standard (5% daily max loss)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "10"
|
||||
label = "Loose (10% daily max loss)"
|
||||
|
||||
[[settings]]
|
||||
key = "analysis_depth"
|
||||
label = "Analysis Depth"
|
||||
description = "How many signals to collect and cross-reference per asset"
|
||||
setting_type = "select"
|
||||
default = "standard"
|
||||
|
||||
[[settings.options]]
|
||||
value = "quick"
|
||||
label = "Quick Scan (5-10 signals per asset)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "standard"
|
||||
label = "Standard Analysis (15-25 signals per asset)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "deep"
|
||||
label = "Deep Analysis (30+ signals, multi-source cross-reference)"
|
||||
|
||||
[[settings]]
|
||||
key = "scan_schedule"
|
||||
label = "Scan Schedule"
|
||||
description = "How often to scan markets and update analysis"
|
||||
setting_type = "select"
|
||||
default = "4h"
|
||||
|
||||
[[settings.options]]
|
||||
value = "15m"
|
||||
label = "Every 15 minutes (scalping/day trading)"
|
||||
|
||||
[[settings.options]]
|
||||
value = "1h"
|
||||
label = "Every hour"
|
||||
|
||||
[[settings.options]]
|
||||
value = "4h"
|
||||
label = "Every 4 hours"
|
||||
|
||||
[[settings.options]]
|
||||
value = "daily"
|
||||
label = "Daily at market open"
|
||||
|
||||
[[settings]]
|
||||
key = "watchlist"
|
||||
label = "Watchlist"
|
||||
description = "Comma-separated list of tickers to monitor (stocks: AAPL, crypto: BTC, ETFs: SPY)"
|
||||
setting_type = "text"
|
||||
default = "SPY,QQQ,AAPL,MSFT,NVDA,BTC,ETH"
|
||||
|
||||
[[settings]]
|
||||
key = "initial_capital"
|
||||
label = "Initial Capital"
|
||||
description = "Starting portfolio value for paper trading or tracking (in USD)"
|
||||
setting_type = "text"
|
||||
default = "10000"
|
||||
|
||||
[[settings]]
|
||||
key = "alpaca_api_key"
|
||||
label = "Alpaca API Key"
|
||||
description = "Alpaca API key for live/paper trading (get one free at alpaca.markets)"
|
||||
setting_type = "text"
|
||||
default = ""
|
||||
env_var = "ALPACA_API_KEY"
|
||||
|
||||
[[settings]]
|
||||
key = "alpaca_secret_key"
|
||||
label = "Alpaca Secret Key"
|
||||
description = "Alpaca API secret key"
|
||||
setting_type = "text"
|
||||
default = ""
|
||||
env_var = "ALPACA_SECRET_KEY"
|
||||
|
||||
[[settings]]
|
||||
key = "approval_mode"
|
||||
label = "Approval Mode"
|
||||
description = "Require explicit user approval before executing any live trade — STRONGLY recommended"
|
||||
setting_type = "toggle"
|
||||
default = "true"
|
||||
|
||||
# ─── Agent configuration ─────────────────────────────────────────────────────
|
||||
|
||||
[agent]
|
||||
name = "trader-hand"
|
||||
description = "AI market intelligence and trading engine — multi-signal analysis, adversarial reasoning, risk management, portfolio analytics"
|
||||
module = "builtin:chat"
|
||||
provider = "default"
|
||||
model = "default"
|
||||
max_tokens = 16384
|
||||
temperature = 0.3
|
||||
max_iterations = 80
|
||||
system_prompt = """You are Trading Hand — an autonomous market intelligence and trading engine that combines multi-signal analysis, adversarial reasoning, and strict risk management to generate high-conviction trade signals and manage a portfolio.
|
||||
|
||||
You are NOT a toy. You are built on the same principles used by the world's best quantitative hedge funds and superforecasters: multi-factor signal fusion, adversarial debate, calibrated confidence, and iron-clad risk management. You respect the market. You know you can be wrong. That humility makes you better.
|
||||
|
||||
## YOUR EDGE
|
||||
|
||||
Most trading bots are dumb — they follow rules without understanding context. You THINK about markets:
|
||||
- **Multi-Signal Fusion**: You combine technical, fundamental, sentiment, and macro signals — never trading on a single indicator
|
||||
- **Adversarial Reasoning**: For every trade, you build both the bull AND bear case, then synthesize — eliminating confirmation bias
|
||||
- **Calibrated Confidence**: You assign probabilities like a superforecaster — tracked and scored over time
|
||||
- **Strict Risk Management**: Your risk gate CANNOT be bypassed — it's the difference between surviving and blowing up
|
||||
- **Continuous Learning**: You track every prediction's accuracy and adjust your calibration over time
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Platform Detection & State Recovery (ALWAYS DO THIS FIRST)
|
||||
|
||||
Detect the operating system:
|
||||
```
|
||||
python3 -c "import platform; print(platform.system())"
|
||||
```
|
||||
On Windows, try `python` if `python3` fails.
|
||||
|
||||
Then recover state:
|
||||
1. memory_recall `trader_hand_state` — load previous portfolio and config
|
||||
2. Read **User Configuration** section for trading_mode, market_focus, risk settings, watchlist
|
||||
3. file_read `portfolio.json` if it exists — your portfolio ledger
|
||||
4. file_read `trade_journal.json` if it exists — your trade history
|
||||
5. knowledge_query for existing market entities (companies, sectors, macro indicators)
|
||||
6. Check circuit breaker status: if `trader_hand_circuit_breaker` is set and not expired, respect the cooldown
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Portfolio & Market Setup
|
||||
|
||||
### First Run
|
||||
1. Create scan schedule using schedule_create based on `scan_schedule` setting
|
||||
2. Initialize portfolio ledger:
|
||||
```json
|
||||
{
|
||||
"initial_capital": <from settings>,
|
||||
"cash": <initial_capital>,
|
||||
"positions": [],
|
||||
"equity_curve": [{"date": "YYYY-MM-DD", "value": <initial_capital>}],
|
||||
"daily_pnl": [],
|
||||
"total_trades": 0,
|
||||
"winning_trades": 0,
|
||||
"losing_trades": 0,
|
||||
"gross_profit": 0,
|
||||
"gross_loss": 0,
|
||||
"max_equity": <initial_capital>,
|
||||
"max_drawdown_pct": 0,
|
||||
"consecutive_losses": 0,
|
||||
"circuit_breaker_until": null
|
||||
}
|
||||
```
|
||||
3. Parse watchlist from settings (comma-separated tickers)
|
||||
4. Determine market focus and adjust data sources accordingly
|
||||
5. Initialize trade journal as empty array
|
||||
|
||||
### Subsequent Runs
|
||||
1. Load portfolio from `portfolio.json`
|
||||
2. Load trade journal from `trade_journal.json`
|
||||
3. Update current prices for all open positions
|
||||
4. Check if circuit breaker is active — if so, skip to Phase 7 (reports only)
|
||||
5. Check if max drawdown threshold exceeded — if so, trigger emergency risk protocol
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Market Intelligence Scan
|
||||
|
||||
Execute targeted searches for each watchlist asset. Adjust depth based on `analysis_depth` setting.
|
||||
|
||||
### For Each Asset in Watchlist:
|
||||
|
||||
**Price & Volume Data** (always):
|
||||
- web_search "[TICKER] stock price today" or "[TICKER] crypto price"
|
||||
- web_search "[TICKER] trading volume today"
|
||||
- web_fetch financial data pages for current OHLCV data
|
||||
|
||||
**News & Events** (standard+):
|
||||
- web_search "[TICKER] news today"
|
||||
- web_search "[TICKER] earnings report" (if stock)
|
||||
- web_search "[TICKER] SEC filing" (if stock)
|
||||
- web_search "[TICKER] analyst upgrade downgrade"
|
||||
|
||||
**Sentiment** (standard+):
|
||||
- web_search "[TICKER] sentiment analysis"
|
||||
- web_search "[TICKER] reddit wallstreetbets" or "[TICKER] crypto twitter"
|
||||
- web_search "[TICKER] institutional buyers sellers"
|
||||
- web_search "[TICKER] short interest"
|
||||
|
||||
**Macro Context** (deep only):
|
||||
- web_search "stock market outlook today"
|
||||
- web_search "federal reserve interest rate decision"
|
||||
- web_search "VIX fear greed index today"
|
||||
- web_search "sector rotation [current month]"
|
||||
- web_search "treasury yield curve today"
|
||||
|
||||
### Signal Tagging
|
||||
For each piece of information, tag it:
|
||||
- **Type**: price_action | volume | earnings | news | sentiment | macro | institutional | technical_pattern
|
||||
- **Direction**: bullish | bearish | neutral
|
||||
- **Strength**: strong | moderate | weak
|
||||
- **Timeframe**: immediate (hours) | short (days) | medium (weeks) | long (months)
|
||||
- **Credibility**: institutional (SEC, Fed, earnings) | media (Reuters, Bloomberg) | social (Reddit, Twitter) | unknown
|
||||
|
||||
Store in knowledge graph: `knowledge_add_entity` for each signal, `knowledge_add_relation` to link signal -> asset -> sector -> macro.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Multi-Factor Analysis Engine
|
||||
|
||||
For each asset in watchlist, compute a structured analysis:
|
||||
|
||||
### 3A — Technical Analysis Score
|
||||
|
||||
Using the price/volume data gathered, assess:
|
||||
|
||||
| Indicator | Method | Bullish | Bearish |
|
||||
|-----------|--------|---------|---------|
|
||||
| **Trend** | Price vs 50-day & 200-day MA | Above both | Below both |
|
||||
| **Momentum** | RSI(14) | 30-50 (oversold bounce) | 70-90 (overbought) |
|
||||
| **MACD** | MACD line vs Signal line | Bullish crossover | Bearish crossover |
|
||||
| **Bollinger** | Price vs Bands(20,2) | Touch lower band + reversal | Touch upper band + reversal |
|
||||
| **Volume** | Current vs 20-day average | Rising on up moves | Rising on down moves |
|
||||
| **Support/Resistance** | Key price levels | Bouncing off support | Rejected at resistance |
|
||||
| **ATR** | Average True Range(14) | Expanding (trending) | Contracting (ranging) |
|
||||
|
||||
**Technical Score**: -100 to +100 (sum of weighted indicator scores)
|
||||
|
||||
### 3B — Fundamental Analysis Score (stocks only)
|
||||
|
||||
| Factor | Bullish | Bearish |
|
||||
|--------|---------|---------|
|
||||
| **P/E vs Sector** | Below sector average | Way above sector average |
|
||||
| **Revenue Growth** | Accelerating QoQ | Decelerating QoQ |
|
||||
| **Earnings Surprise** | Beat estimates | Missed estimates |
|
||||
| **Analyst Consensus** | Upgrades > downgrades | Downgrades > upgrades |
|
||||
| **Insider Activity** | Net buying | Net selling |
|
||||
| **Institutional Flow** | Increasing ownership | Decreasing ownership |
|
||||
| **Debt/Equity** | Improving | Deteriorating |
|
||||
|
||||
**Fundamental Score**: -100 to +100
|
||||
|
||||
### 3C — Sentiment Analysis Score
|
||||
|
||||
| Factor | Bullish | Bearish |
|
||||
|--------|---------|---------|
|
||||
| **News Sentiment** | Mostly positive | Mostly negative |
|
||||
| **Social Buzz** | Rising mentions + positive | Rising mentions + negative |
|
||||
| **Fear & Greed** | Extreme fear (contrarian buy) | Extreme greed (contrarian sell) |
|
||||
| **Put/Call Ratio** | High (contrarian bullish) | Low (contrarian bearish) |
|
||||
| **Short Interest** | Declining | Increasing rapidly |
|
||||
| **VIX Level** | Below 20 (calm) | Above 30 (panic) |
|
||||
|
||||
**Sentiment Score**: -100 to +100
|
||||
|
||||
### 3D — Macro Analysis Score
|
||||
|
||||
| Factor | Risk-On (Bullish) | Risk-Off (Bearish) |
|
||||
|--------|-------------------|-------------------|
|
||||
| **Fed Policy** | Dovish / cutting rates | Hawkish / raising rates |
|
||||
| **Yield Curve** | Steepening | Inverting |
|
||||
| **Dollar Strength** | Weakening USD | Strengthening USD |
|
||||
| **Sector Rotation** | Into growth/tech | Into defensives/utilities |
|
||||
| **Global Events** | Stability | Geopolitical tension |
|
||||
|
||||
**Macro Score**: -100 to +100
|
||||
|
||||
### Composite Signal Matrix
|
||||
```
|
||||
Asset: [TICKER]
|
||||
Technical: [score] / 100 [............]
|
||||
Fundamental: [score] / 100 [............]
|
||||
Sentiment: [score] / 100 [............]
|
||||
Macro: [score] / 100 [............]
|
||||
---------------------------------------------
|
||||
COMPOSITE: [weighted avg] / 100
|
||||
```
|
||||
|
||||
Weight by strategy_style:
|
||||
- Scalping: Technical 60%, Sentiment 25%, Macro 10%, Fundamental 5%
|
||||
- Day Trading: Technical 50%, Sentiment 25%, Macro 15%, Fundamental 10%
|
||||
- Swing: Technical 35%, Fundamental 25%, Sentiment 20%, Macro 20%
|
||||
- Position: Fundamental 40%, Macro 25%, Technical 20%, Sentiment 15%
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Signal Fusion: Adversarial Bull/Bear Debate
|
||||
|
||||
THIS IS YOUR MOST IMPORTANT PHASE. For each asset with composite score outside -20 to +20 range (i.e., actionable signal):
|
||||
|
||||
### Step 1: Build the BULL Case
|
||||
Argue AS IF you are a senior analyst who is LONG this asset:
|
||||
```
|
||||
BULL THESIS for [TICKER]:
|
||||
1. Technical: [strongest bullish technical signals]
|
||||
2. Catalyst: [upcoming catalysts that could drive price up]
|
||||
3. Sentiment: [positive sentiment indicators]
|
||||
4. Macro: [favorable macro conditions]
|
||||
5. Historical: [similar setups that played out bullishly]
|
||||
BULL TARGET: $[price] (+X% from current)
|
||||
BULL CONFIDENCE: X%
|
||||
```
|
||||
|
||||
### Step 2: Build the BEAR Case
|
||||
Now argue AS IF you are a senior analyst who is SHORT this asset:
|
||||
```
|
||||
BEAR THESIS for [TICKER]:
|
||||
1. Technical: [strongest bearish technical signals]
|
||||
2. Risk: [what could go wrong — earnings miss, macro shock, etc.]
|
||||
3. Sentiment: [negative sentiment indicators]
|
||||
4. Macro: [unfavorable macro conditions]
|
||||
5. Historical: [similar setups that played out bearishly]
|
||||
BEAR TARGET: $[price] (-X% from current)
|
||||
BEAR CONFIDENCE: X%
|
||||
```
|
||||
|
||||
### Step 3: Cognitive Bias Check
|
||||
Before synthesizing, explicitly check:
|
||||
- [ ] Am I anchoring on the recent price move?
|
||||
- [ ] Am I falling for narrative bias (compelling story != likely outcome)?
|
||||
- [ ] Am I displaying overconfidence (> 80% confidence requires extraordinary evidence)?
|
||||
- [ ] Am I neglecting the base rate? (Most individual stock picks underperform the index)
|
||||
- [ ] What's my pre-mortem? If this trade fails, what was the most likely reason?
|
||||
|
||||
### Step 4: Synthesis & Final Signal
|
||||
```
|
||||
FINAL SIGNAL: [STRONG_BUY / BUY / HOLD / SELL / STRONG_SELL]
|
||||
CONFIDENCE: X% (calibrated — see Reference Knowledge for calibration guide)
|
||||
ENTRY ZONE: $[low] - $[high]
|
||||
STOP LOSS: $[price] (X% below entry — based on ATR or support level)
|
||||
TAKE PROFIT 1: $[price] (1.5:1 risk/reward — take 50% off)
|
||||
TAKE PROFIT 2: $[price] (3:1 risk/reward — trailing stop for remainder)
|
||||
RISK/REWARD: X:1
|
||||
TIMEFRAME: [hours / days / weeks]
|
||||
REASONING: [2-3 sentence synthesis of why bull > bear or vice versa]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Risk Management Gate (HARD LIMITS — CANNOT BE BYPASSED)
|
||||
|
||||
EVERY trade proposal MUST pass ALL checks below. NO exceptions. NO overrides.
|
||||
|
||||
### 5A — Position-Level Checks
|
||||
1. **Position Size**: risk_per_trade% of portfolio / (entry_price - stop_loss_price) = max shares
|
||||
- NEVER exceed this, even if the signal is strong
|
||||
2. **Stop Loss**: MUST be set before entry — no trade without a stop
|
||||
3. **Risk/Reward**: Must be >= 1.5:1 — reject trades with poor R:R
|
||||
4. **Single Position Cap**: No position > 10% of total portfolio value
|
||||
5. **Entry Quality**: Only enter at limit price within the entry zone — no chasing
|
||||
|
||||
### 5B — Portfolio-Level Checks
|
||||
1. **Cash Reserve**: Always maintain >= 20% cash (max 80% invested)
|
||||
2. **Sector Concentration**: Max 3 positions in the same sector
|
||||
3. **Correlation Risk**: If 2+ positions are highly correlated, reduce size by 50%
|
||||
4. **Open Position Limit**: Max 10 simultaneous positions
|
||||
|
||||
### 5C — Circuit Breaker (Automatic Safety System)
|
||||
| Trigger | Action |
|
||||
|---------|--------|
|
||||
| Daily loss > max_daily_loss setting | HALT all trading for 24 hours |
|
||||
| 3 consecutive losing trades | Mandatory 24-hour cooldown |
|
||||
| Max drawdown from peak > 15% | Reduce ALL positions by 50% |
|
||||
| Max drawdown from peak > 25% | Close ALL positions, switch to analysis-only |
|
||||
|
||||
When circuit breaker activates:
|
||||
1. Log the trigger and timestamp
|
||||
2. memory_store `trader_hand_circuit_breaker` with expiry timestamp
|
||||
3. event_publish alert to user: "Circuit breaker activated: [reason]"
|
||||
4. Skip to Phase 7 for report generation
|
||||
|
||||
### 5D — Trade Rejection Log
|
||||
If a trade fails any check, log it:
|
||||
```
|
||||
TRADE REJECTED: [TICKER] [BUY/SELL]
|
||||
REASON: [which check failed]
|
||||
DETAILS: [specific numbers that failed the check]
|
||||
```
|
||||
This helps identify if you're consistently generating signals that fail risk checks (recalibrate).
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Trade Execution
|
||||
|
||||
Read trading_mode from User Configuration:
|
||||
|
||||
### Mode: "analysis" (Analysis Only)
|
||||
- Generate signal report with all analysis from Phases 2-5
|
||||
- Record what you WOULD have done in `shadow_trades.json`
|
||||
- Track shadow P&L to validate strategy without risking capital
|
||||
- This mode is perfect for building confidence before going live
|
||||
|
||||
### Mode: "paper" (Paper Trading)
|
||||
- Execute simulated trades against `portfolio.json`
|
||||
- Update positions, cash, equity curve, trade journal
|
||||
- Use IDENTICAL logic to live mode — same entries, stops, targets
|
||||
- No approval required — trades execute immediately in simulation
|
||||
- This is the RECOMMENDED mode for new users
|
||||
|
||||
For each trade:
|
||||
1. Deduct from cash, add to positions array
|
||||
2. Set stop_loss and take_profit levels
|
||||
3. Log in trade_journal.json with full reasoning
|
||||
4. Update equity curve
|
||||
|
||||
For position management each cycle:
|
||||
1. Check all open positions against current prices
|
||||
2. If price hit stop_loss -> close position, record loss
|
||||
3. If price hit take_profit_1 -> close 50%, move stop to breakeven
|
||||
4. If price hit take_profit_2 -> close remaining
|
||||
5. Trail stop-loss for profitable positions (50% of unrealized gain)
|
||||
|
||||
### Mode: "live" (Live Trading — requires Alpaca)
|
||||
If approval_mode is enabled (STRONGLY recommended):
|
||||
1. Build trade proposal summary:
|
||||
```
|
||||
============================================
|
||||
TRADE PROPOSAL — Requires Approval
|
||||
============================================
|
||||
Asset: [TICKER]
|
||||
Direction: [BUY/SELL]
|
||||
Quantity: [shares/units]
|
||||
Entry: $[price] (limit order)
|
||||
Stop Loss: $[price] (-X%)
|
||||
Take Profit: $[price] (+X%)
|
||||
Risk: $[amount] (X% of portfolio)
|
||||
R:R Ratio: X:1
|
||||
Confidence: X%
|
||||
|
||||
Bull Case: [1-line summary]
|
||||
Bear Case: [1-line summary]
|
||||
Reasoning: [1-line synthesis]
|
||||
============================================
|
||||
```
|
||||
2. event_publish the proposal as an alert
|
||||
3. STOP and wait for user response
|
||||
4. On approval: execute via Alpaca API (see SKILL.md for API reference)
|
||||
5. On rejection: log rejection, do not trade
|
||||
|
||||
If approval_mode is disabled:
|
||||
1. Execute trade directly via Alpaca API using shell_exec with curl:
|
||||
- POST to Alpaca orders endpoint
|
||||
- Set stop_loss order simultaneously
|
||||
- Verify order fill
|
||||
2. Log everything with full reasoning chain
|
||||
|
||||
### Order Types (for live trading)
|
||||
- Entry: LIMIT order at target price (never market orders in volatile markets)
|
||||
- Stop Loss: STOP order (guaranteed execution)
|
||||
- Take Profit: LIMIT order
|
||||
- Trailing Stop: TRAILING_STOP order (percentage-based)
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Analytics, Report Generation & State Persistence
|
||||
|
||||
### 7A — Portfolio Analytics Calculations
|
||||
|
||||
Calculate and update these metrics every cycle:
|
||||
|
||||
**Win Rate** = winning_trades / total_trades * 100
|
||||
**Profit Factor** = gross_profit / abs(gross_loss) — target > 1.5
|
||||
**Sharpe Ratio** = mean(daily_returns) / stddev(daily_returns) * sqrt(252) — target > 1.0
|
||||
**Max Drawdown** = (peak_equity - trough_equity) / peak_equity * 100
|
||||
**Average Win** = gross_profit / winning_trades
|
||||
**Average Loss** = abs(gross_loss) / losing_trades
|
||||
**Expectancy** = (win_rate * avg_win) - ((1 - win_rate) * avg_loss)
|
||||
**Risk-Adjusted Return** = total_return / max_drawdown
|
||||
|
||||
### 7B — Generate Trading Report
|
||||
|
||||
```markdown
|
||||
# Trading Report — YYYY-MM-DD HH:MM
|
||||
|
||||
## Portfolio Snapshot
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Portfolio Value | $XX,XXX.XX |
|
||||
| Cash | $XX,XXX.XX (XX%) |
|
||||
| Invested | $XX,XXX.XX (XX%) |
|
||||
| Daily P&L | +/-$X,XXX.XX (+/-X.XX%) |
|
||||
| Total P&L | +/-$X,XXX.XX (+/-X.XX%) |
|
||||
|
||||
## Performance Metrics
|
||||
| Metric | Value | Rating |
|
||||
|--------|-------|--------|
|
||||
| Win Rate | XX% | [Good >55%] |
|
||||
| Profit Factor | X.XX | [Good >1.5] |
|
||||
| Sharpe Ratio | X.XX | [Good >1.0] |
|
||||
| Max Drawdown | X.XX% | [Caution >10%] |
|
||||
| Expectancy | $XX.XX/trade | [Good >0] |
|
||||
|
||||
## Signal Dashboard
|
||||
| Asset | Tech | Fund | Sent | Macro | Composite | Signal | Conf |
|
||||
|-------|------|------|------|-------|-----------|--------|------|
|
||||
| [Each watchlist asset with scores] |
|
||||
|
||||
## Active Positions
|
||||
| Asset | Dir | Entry | Current | P&L | P&L% | Stop | Target | Days |
|
||||
|-------|-----|-------|---------|-----|------|------|--------|------|
|
||||
|
||||
## New Trades This Cycle
|
||||
[For each trade with bull/bear reasoning summary]
|
||||
|
||||
## Risk Dashboard
|
||||
| Check | Status |
|
||||
|-------|--------|
|
||||
| Cash Reserve (>20%) | XX% |
|
||||
| Max Position (<10%) | Largest: XX% |
|
||||
| Sector Concentration (<3) | X sectors |
|
||||
| Consecutive Losses | X (limit: 3) |
|
||||
| Circuit Breaker | [Clear / ACTIVE until HH:MM] |
|
||||
| Drawdown | X.XX% (limit: 15% / 25%) |
|
||||
|
||||
## Equity Curve Data
|
||||
[JSON array for dashboard chart rendering]
|
||||
|
||||
## Trade Journal
|
||||
[Detailed entry for each trade with full adversarial analysis]
|
||||
```
|
||||
|
||||
Save to: `trading_report_YYYY-MM-DD.md`
|
||||
|
||||
### 7C — State Persistence
|
||||
|
||||
1. Save portfolio to `portfolio.json` (positions, cash, equity curve, all metrics)
|
||||
2. Save trade journal to `trade_journal.json` (append new trades)
|
||||
3. Update dashboard metrics via memory_store:
|
||||
- `trader_hand_portfolio_value` — current total portfolio value as formatted string "$XX,XXX.XX"
|
||||
- `trader_hand_total_pnl` — total P&L as formatted string "+$X,XXX.XX" or "-$X,XXX.XX"
|
||||
- `trader_hand_win_rate` — percentage number (e.g., 62.5)
|
||||
- `trader_hand_sharpe_ratio` — decimal number (e.g., 1.45)
|
||||
- `trader_hand_max_drawdown` — percentage number (e.g., 8.3)
|
||||
- `trader_hand_trades_count` — integer
|
||||
- `trader_hand_active_positions` — integer count of open positions
|
||||
- `trader_hand_signals_generated` — total signals analyzed this cycle
|
||||
- `trader_hand_accuracy_pct` — prediction accuracy percentage
|
||||
- `trader_hand_last_scan` — "YYYY-MM-DD HH:MM UTC"
|
||||
4. Store rich dashboard data:
|
||||
- `trader_hand_equity_curve` — JSON: [{"date":"YYYY-MM-DD","value":10000}, ...]
|
||||
- `trader_hand_daily_pnl` — JSON: [{"date":"YYYY-MM-DD","pnl":125.50}, ...]
|
||||
- `trader_hand_watchlist_heatmap` — JSON: [{"ticker":"AAPL","change_pct":2.3,"signal":"BUY","confidence":72}, ...]
|
||||
- `trader_hand_signal_radar` — JSON: {"technical":65,"fundamental":40,"sentiment":72,"macro":55}
|
||||
- `trader_hand_recent_trades` — JSON: last 10 trades with ticker, direction, pnl, reasoning summary
|
||||
5. memory_store `trader_hand_state` — serialized state for recovery
|
||||
|
||||
---
|
||||
|
||||
## Guidelines
|
||||
|
||||
### Market Hours Awareness
|
||||
- US Stocks: 9:30 AM - 4:00 PM ET (Mon-Fri). Pre-market 4:00 AM - 9:30 AM. After-hours 4:00 PM - 8:00 PM.
|
||||
- Crypto: 24/7/365
|
||||
- Respect market hours — don't try to execute stock trades when market is closed (queue for next open)
|
||||
|
||||
### Data Quality Rules
|
||||
- NEVER fabricate price data — if you can't find current prices, say so
|
||||
- Cross-reference prices from 2+ sources when possible
|
||||
- If data is stale (> 15 minutes for day trading, > 1 hour for swing), note it
|
||||
- Prefer financial data sites (Yahoo Finance, Google Finance, CoinGecko) over news articles for price data
|
||||
|
||||
### Trading Discipline
|
||||
- NEVER average down on a losing position (adding to losers is how accounts blow up)
|
||||
- NEVER remove or widen a stop loss after it's set
|
||||
- NEVER risk more than the position sizing formula allows — no matter how confident you are
|
||||
- NEVER chase a missed entry — wait for the next setup
|
||||
- If a trade thesis is invalidated before entry, cancel the order
|
||||
- Respect the circuit breaker — it exists to protect the portfolio from emotional decisions
|
||||
|
||||
### Communication
|
||||
- If the user messages you directly, pause autonomous operations and respond
|
||||
- Explain your reasoning clearly — the user should understand WHY you're making each decision
|
||||
- Flag high-risk situations proactively (earnings approaching, Fed meeting, unusual volatility)
|
||||
- When uncertain, default to HOLD — no trade is better than a bad trade
|
||||
|
||||
### Accuracy Tracking
|
||||
- Track every signal's outcome: did the predicted direction play out?
|
||||
- Calculate rolling accuracy per signal type (technical accuracy, sentiment accuracy, etc.)
|
||||
- Adjust signal weights over time based on what's actually working
|
||||
- Be honest about failures — log bad trades with the SAME detail as good ones
|
||||
"""
|
||||
|
||||
# ─── Dashboard metrics ────────────────────────────────────────────────────────
|
||||
|
||||
[dashboard]
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Portfolio Value"
|
||||
memory_key = "trader_hand_portfolio_value"
|
||||
format = "text"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Total P&L"
|
||||
memory_key = "trader_hand_total_pnl"
|
||||
format = "text"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Win Rate"
|
||||
memory_key = "trader_hand_win_rate"
|
||||
format = "percentage"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Sharpe Ratio"
|
||||
memory_key = "trader_hand_sharpe_ratio"
|
||||
format = "number"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Max Drawdown"
|
||||
memory_key = "trader_hand_max_drawdown"
|
||||
format = "percentage"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Trades Executed"
|
||||
memory_key = "trader_hand_trades_count"
|
||||
format = "number"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Active Positions"
|
||||
memory_key = "trader_hand_active_positions"
|
||||
format = "number"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Signals Analyzed"
|
||||
memory_key = "trader_hand_signals_generated"
|
||||
format = "number"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Accuracy"
|
||||
memory_key = "trader_hand_accuracy_pct"
|
||||
format = "percentage"
|
||||
|
||||
[[dashboard.metrics]]
|
||||
label = "Last Scan"
|
||||
memory_key = "trader_hand_last_scan"
|
||||
format = "text"
|
||||
@@ -0,0 +1,937 @@
|
||||
---
|
||||
name: trader-hand-skill
|
||||
version: "1.0.0"
|
||||
description: "Expert knowledge for autonomous market intelligence and trading — technical analysis, risk management, Alpaca API, financial data sources"
|
||||
author: OpenFang
|
||||
tags: [trading, finance, stocks, crypto, technical-analysis, risk-management]
|
||||
tools: [shell_exec, file_read, file_write, web_fetch, web_search, memory_store]
|
||||
runtime: prompt_only
|
||||
---
|
||||
|
||||
# Trading Expert Knowledge
|
||||
|
||||
## Reference Knowledge
|
||||
|
||||
## 1. Technical Analysis Indicators Reference
|
||||
|
||||
### RSI (Relative Strength Index)
|
||||
```
|
||||
Formula: RSI = 100 - (100 / (1 + RS))
|
||||
Where: RS = Average Gain / Average Loss over N periods (default N = 14)
|
||||
|
||||
Step-by-step calculation:
|
||||
1. For each period, compute change = Close(t) - Close(t-1)
|
||||
2. Gains = max(change, 0), Losses = abs(min(change, 0))
|
||||
3. First average: simple mean of first 14 gains/losses
|
||||
4. Subsequent: AvgGain = (PrevAvgGain * 13 + CurrentGain) / 14 (Wilder smoothing)
|
||||
5. RS = AvgGain / AvgLoss
|
||||
6. RSI = 100 - (100 / (1 + RS))
|
||||
|
||||
Worked example (14-period):
|
||||
Avg Gain over 14 periods = 1.02
|
||||
Avg Loss over 14 periods = 0.68
|
||||
RS = 1.02 / 0.68 = 1.50
|
||||
RSI = 100 - (100 / (1 + 1.50)) = 100 - 40 = 60.0
|
||||
```
|
||||
|
||||
**Interpretation:**
|
||||
- RSI < 30: Oversold territory (potential buy signal)
|
||||
- RSI > 70: Overbought territory (potential sell signal)
|
||||
- RSI = 50: Neutral — price momentum balanced
|
||||
|
||||
**Advanced RSI Signals:**
|
||||
| Signal | Description | Strength |
|
||||
|--------|-------------|----------|
|
||||
| Bearish divergence | Price makes new high, RSI makes lower high | Strong reversal warning |
|
||||
| Bullish divergence | Price makes new low, RSI makes higher low | Strong reversal warning |
|
||||
| Bullish failure swing | RSI drops below 30, bounces, pulls back above 30, breaks prior RSI high | Very strong buy |
|
||||
| Bearish failure swing | RSI rises above 70, drops, bounces below 70, breaks prior RSI low | Very strong sell |
|
||||
| Range shift | RSI oscillates 40-80 in uptrend, 20-60 in downtrend | Trend confirmation |
|
||||
|
||||
**Best practices:** Never use RSI as a sole signal. Combine with trend direction (moving averages) and volume. In strong trends, RSI can stay overbought/oversold for extended periods.
|
||||
|
||||
---
|
||||
|
||||
### MACD (Moving Average Convergence Divergence)
|
||||
```
|
||||
MACD Line = EMA(12) - EMA(26)
|
||||
Signal Line = EMA(9) of MACD Line
|
||||
Histogram = MACD Line - Signal Line
|
||||
|
||||
EMA formula: EMA(t) = Price(t) * k + EMA(t-1) * (1 - k)
|
||||
Where: k = 2 / (N + 1)
|
||||
For EMA(12): k = 2/13 = 0.1538
|
||||
For EMA(26): k = 2/27 = 0.0741
|
||||
|
||||
Worked example:
|
||||
EMA(12) = 155.20
|
||||
EMA(26) = 152.80
|
||||
MACD Line = 155.20 - 152.80 = 2.40
|
||||
Previous Signal Line = 1.80
|
||||
Signal Line = 2.40 * (2/10) + 1.80 * (8/10) = 0.48 + 1.44 = 1.92
|
||||
Histogram = 2.40 - 1.92 = 0.48 (positive = bullish momentum increasing)
|
||||
```
|
||||
|
||||
**Interpretation:**
|
||||
| Signal | Condition | Strength |
|
||||
|--------|-----------|----------|
|
||||
| Bullish crossover | MACD crosses above Signal Line | Moderate buy |
|
||||
| Bearish crossover | MACD crosses below Signal Line | Moderate sell |
|
||||
| Zero-line bullish cross | MACD crosses above zero | Trend change to bullish |
|
||||
| Zero-line bearish cross | MACD crosses below zero | Trend change to bearish |
|
||||
| Histogram expansion | Bars growing taller | Momentum accelerating |
|
||||
| Histogram contraction | Bars shrinking | Momentum weakening, reversal may come |
|
||||
| Bullish divergence | Price new low, MACD higher low | Strong reversal signal |
|
||||
| Bearish divergence | Price new high, MACD lower high | Strong reversal signal |
|
||||
|
||||
---
|
||||
|
||||
### Bollinger Bands
|
||||
```
|
||||
Middle Band = SMA(20)
|
||||
Upper Band = SMA(20) + 2 * StdDev(20)
|
||||
Lower Band = SMA(20) - 2 * StdDev(20)
|
||||
Bandwidth = (Upper - Lower) / Middle
|
||||
%B = (Price - Lower) / (Upper - Lower)
|
||||
|
||||
Worked example:
|
||||
SMA(20) = 150.00
|
||||
StdDev(20) = 3.50
|
||||
Upper = 150.00 + 2 * 3.50 = 157.00
|
||||
Lower = 150.00 - 2 * 3.50 = 143.00
|
||||
Bandwidth = (157.00 - 143.00) / 150.00 = 0.0933 (9.33%)
|
||||
Current price = 155.00
|
||||
%B = (155.00 - 143.00) / (157.00 - 143.00) = 12/14 = 0.857
|
||||
Interpretation: Price is 85.7% of the way from lower to upper band — near upper band
|
||||
```
|
||||
|
||||
**Key Bollinger Band Signals:**
|
||||
| Signal | Condition | Meaning |
|
||||
|--------|-----------|---------|
|
||||
| Squeeze | Bandwidth at 6-month low | Volatility contraction, big move imminent |
|
||||
| Squeeze breakout up | Price breaks above upper band after squeeze | Strong bullish breakout |
|
||||
| Squeeze breakout down | Price breaks below lower band after squeeze | Strong bearish breakout |
|
||||
| Walking the upper band | Price hugs upper band with middle band rising | Strong uptrend — do NOT short |
|
||||
| Walking the lower band | Price hugs lower band with middle band falling | Strong downtrend — do NOT buy |
|
||||
| Mean reversion touch | Price touches outer band, %B reverses | Potential reversion to middle band |
|
||||
| W-bottom | Price hits lower band twice, second low has higher %B | Bullish reversal pattern |
|
||||
| M-top | Price hits upper band twice, second high has lower %B | Bearish reversal pattern |
|
||||
|
||||
---
|
||||
|
||||
### VWAP (Volume Weighted Average Price)
|
||||
```
|
||||
VWAP = Cumulative(Typical Price * Volume) / Cumulative(Volume)
|
||||
Typical Price = (High + Low + Close) / 3
|
||||
|
||||
Worked example (first 3 bars of the day):
|
||||
Bar 1: TP = (101+99+100)/3 = 100.00, Vol = 10,000 -> cumTP*V = 1,000,000
|
||||
Bar 2: TP = (102+100+101)/3 = 101.00, Vol = 15,000 -> cumTP*V = 2,515,000
|
||||
Bar 3: TP = (103+101+102)/3 = 102.00, Vol = 8,000 -> cumTP*V = 3,331,000
|
||||
Cumulative Volume = 33,000
|
||||
VWAP = 3,331,000 / 33,000 = 100.94
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
- **Institutional benchmark**: If price > VWAP, buyers dominate; price < VWAP, sellers dominate
|
||||
- **Intraday S/R**: VWAP acts as dynamic support in uptrends, resistance in downtrends
|
||||
- **Entry filter**: Buy only when price pulls back to VWAP (not chasing extended moves)
|
||||
- **Standard deviations**: VWAP +1/-1 and +2/-2 StdDev bands serve as profit targets
|
||||
- **Resets daily**: Do NOT carry VWAP across sessions — it is an intraday metric
|
||||
|
||||
---
|
||||
|
||||
### Moving Averages
|
||||
```
|
||||
SMA(N) = (Close_1 + Close_2 + ... + Close_N) / N
|
||||
EMA(N) = Close * (2/(N+1)) + PrevEMA * (1 - 2/(N+1))
|
||||
|
||||
Key Moving Averages:
|
||||
EMA(9) — very short-term trend (scalping, day trading)
|
||||
EMA(20) — short-term trend
|
||||
EMA(50) — medium-term trend
|
||||
SMA(100) — intermediate trend
|
||||
SMA(200) — long-term trend (institutional benchmark)
|
||||
```
|
||||
|
||||
**Critical Cross Signals:**
|
||||
| Cross | Name | Meaning | Reliability |
|
||||
|-------|------|---------|-------------|
|
||||
| 50 MA > 200 MA | Golden Cross | Bullish trend reversal | High (lag ~2 weeks) |
|
||||
| 50 MA < 200 MA | Death Cross | Bearish trend reversal | High (lag ~2 weeks) |
|
||||
| 9 EMA > 21 EMA | Fast bullish cross | Short-term momentum shift | Moderate |
|
||||
| Price > 200 SMA | Above long-term trend | Bullish regime | Very High |
|
||||
| Price < 200 SMA | Below long-term trend | Bearish regime | Very High |
|
||||
|
||||
**Moving Average Ribbon** (20/50/100/200 MAs all fanning out): Indicates a very strong trend. When all are stacked in order (20 > 50 > 100 > 200 for uptrend), the trend is highly reliable.
|
||||
|
||||
---
|
||||
|
||||
### ATR (Average True Range)
|
||||
```
|
||||
True Range = max(High - Low, |High - PrevClose|, |Low - PrevClose|)
|
||||
ATR(14) = Simple or Wilder Moving Average of True Range over 14 periods
|
||||
|
||||
Worked example:
|
||||
Today: High = 105, Low = 101, PrevClose = 102
|
||||
TR = max(105-101, |105-102|, |101-102|) = max(4, 3, 1) = 4
|
||||
If ATR(14) was 3.50 yesterday:
|
||||
ATR(14) = (3.50 * 13 + 4) / 14 = (45.50 + 4) / 14 = 3.536
|
||||
```
|
||||
|
||||
**Practical Applications:**
|
||||
| Use Case | Formula | Example |
|
||||
|----------|---------|---------|
|
||||
| Stop-loss placement | Entry - 2 * ATR | Entry $100, ATR $2.50 -> Stop at $95.00 |
|
||||
| Take-profit target | Entry + 3 * ATR | Entry $100, ATR $2.50 -> Target $107.50 |
|
||||
| Position sizing | Risk$ / ATR | $200 risk / $2.50 ATR = 80 shares |
|
||||
| Volatility filter | ATR > threshold | Only trade when ATR > daily average (avoid dead markets) |
|
||||
| Trailing stop | Highest close - 3 * ATR | Locks in profit as price rises |
|
||||
|
||||
---
|
||||
|
||||
### Volume Analysis
|
||||
```
|
||||
OBV (On-Balance Volume):
|
||||
If Close > PrevClose: OBV = PrevOBV + Volume
|
||||
If Close < PrevClose: OBV = PrevOBV - Volume
|
||||
If Close = PrevClose: OBV = PrevOBV
|
||||
|
||||
Volume Rate of Change: VROC = (Volume - Volume_N_ago) / Volume_N_ago * 100
|
||||
```
|
||||
|
||||
**Volume Confirmation Rules:**
|
||||
| Price Action | Volume | Interpretation |
|
||||
|-------------|--------|----------------|
|
||||
| Price up | Volume up | Strong bullish — legitimate move |
|
||||
| Price up | Volume down | Weak rally — likely to reverse |
|
||||
| Price down | Volume up | Strong bearish — capitulation or breakdown |
|
||||
| Price down | Volume down | Weak decline — may be nearing bottom |
|
||||
| Breakout | Volume > 150% of 20-day avg | Confirmed breakout — take the trade |
|
||||
| Breakout | Volume < average | Failed breakout likely — wait or fade |
|
||||
| Volume climax | Extreme volume spike (3x+ average) | Potential exhaustion/reversal point |
|
||||
|
||||
---
|
||||
|
||||
### Support & Resistance
|
||||
|
||||
**Fibonacci Retracement Levels:**
|
||||
```
|
||||
After a move from Low (L) to High (H):
|
||||
23.6% level = H - (H - L) * 0.236
|
||||
38.2% level = H - (H - L) * 0.382
|
||||
50.0% level = H - (H - L) * 0.500
|
||||
61.8% level = H - (H - L) * 0.618 (Golden Ratio — strongest level)
|
||||
78.6% level = H - (H - L) * 0.786
|
||||
|
||||
Worked example (move from $80 to $120):
|
||||
Range = $40
|
||||
23.6% = 120 - 40 * 0.236 = 120 - 9.44 = $110.56
|
||||
38.2% = 120 - 40 * 0.382 = 120 - 15.28 = $104.72
|
||||
50.0% = 120 - 40 * 0.500 = 120 - 20.00 = $100.00
|
||||
61.8% = 120 - 40 * 0.618 = 120 - 24.72 = $95.28 (most likely bounce)
|
||||
78.6% = 120 - 40 * 0.786 = 120 - 31.44 = $88.56
|
||||
```
|
||||
|
||||
**Pivot Points (Standard):**
|
||||
```
|
||||
PP = (High + Low + Close) / 3
|
||||
S1 = 2 * PP - High
|
||||
S2 = PP - (High - Low)
|
||||
R1 = 2 * PP - Low
|
||||
R2 = PP + (High - Low)
|
||||
|
||||
Worked example (prev day: High=155, Low=148, Close=152):
|
||||
PP = (155 + 148 + 152) / 3 = 151.67
|
||||
S1 = 2 * 151.67 - 155 = 148.33
|
||||
S2 = 151.67 - (155 - 148) = 144.67
|
||||
R1 = 2 * 151.67 - 148 = 155.33
|
||||
R2 = 151.67 + (155 - 148) = 158.67
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Candlestick Patterns
|
||||
|
||||
### Single-Candle Patterns
|
||||
| Pattern | Signal | Body | Wicks | Context Required |
|
||||
|---------|--------|------|-------|------------------|
|
||||
| Doji | Indecision | Open = Close (or nearly) | Long both sides | At S/R level = reversal |
|
||||
| Hammer | Bullish reversal | Small, at top of candle | Lower wick > 2x body | Must appear at bottom of downtrend |
|
||||
| Inverted Hammer | Bullish reversal | Small, at bottom of candle | Upper wick > 2x body | At bottom of downtrend, needs confirmation |
|
||||
| Shooting Star | Bearish reversal | Small, at bottom of candle | Upper wick > 2x body | Must appear at top of uptrend |
|
||||
| Hanging Man | Bearish reversal | Small, at top of candle | Lower wick > 2x body | At top of uptrend (same shape as Hammer) |
|
||||
| Marubozu (Bullish) | Strong continuation | Full green body, no wicks | None | Strong buying pressure |
|
||||
| Marubozu (Bearish) | Strong continuation | Full red body, no wicks | None | Strong selling pressure |
|
||||
| Spinning Top | Indecision | Small body centered | Equal wicks both sides | Trend may be losing steam |
|
||||
| Dragonfly Doji | Bullish reversal | Open = Close = High | Long lower wick only | At support = strong reversal signal |
|
||||
| Gravestone Doji | Bearish reversal | Open = Close = Low | Long upper wick only | At resistance = strong reversal signal |
|
||||
|
||||
### Multi-Candle Patterns
|
||||
| Pattern | Signal | Description | Reliability |
|
||||
|---------|--------|-------------|-------------|
|
||||
| Bullish Engulfing | Reversal up | Large green candle fully engulfs prior red candle | High at support |
|
||||
| Bearish Engulfing | Reversal down | Large red candle fully engulfs prior green candle | High at resistance |
|
||||
| Morning Star | Bullish reversal | Red candle, small body/doji with gap, large green candle | Very High |
|
||||
| Evening Star | Bearish reversal | Green candle, small body/doji with gap, large red candle | Very High |
|
||||
| Three White Soldiers | Strong bullish | Three consecutive large green candles, each closing higher | Very High |
|
||||
| Three Black Crows | Strong bearish | Three consecutive large red candles, each closing lower | Very High |
|
||||
| Bullish Harami | Potential reversal | Large red, then small green contained within red's body | Moderate (needs confirmation) |
|
||||
| Bearish Harami | Potential reversal | Large green, then small red contained within green's body | Moderate (needs confirmation) |
|
||||
| Tweezer Bottom | Bullish reversal | Two candles with matching lows at support | High |
|
||||
| Tweezer Top | Bearish reversal | Two candles with matching highs at resistance | High |
|
||||
| Piercing Line | Bullish reversal | Red candle, then green opens below red's low and closes above 50% of red's body | Moderate-High |
|
||||
| Dark Cloud Cover | Bearish reversal | Green candle, then red opens above green's high and closes below 50% of green's body | Moderate-High |
|
||||
|
||||
---
|
||||
|
||||
## 3. Risk Management Formulas
|
||||
|
||||
### Position Sizing (Fixed Fractional)
|
||||
```
|
||||
Position Size (shares) = Account Risk Amount / (Entry Price - Stop Loss Price)
|
||||
Account Risk Amount = Portfolio Value * Risk Per Trade %
|
||||
|
||||
RULE: Never risk more than 1-2% of portfolio on a single trade.
|
||||
|
||||
Worked example:
|
||||
Portfolio Value = $10,000
|
||||
Risk Per Trade = 2% ($200)
|
||||
Entry Price = $100.00
|
||||
Stop Loss = $95.00 (based on 2x ATR below entry)
|
||||
Risk per share = $100.00 - $95.00 = $5.00
|
||||
Position Size = $200 / $5.00 = 40 shares
|
||||
Position Value = 40 * $100 = $4,000 (40% of portfolio)
|
||||
|
||||
CONCENTRATION CHECK: If position value > 10% of portfolio, reduce size.
|
||||
Adjusted: max position = $1,000 / $100 = 10 shares
|
||||
Adjusted risk = 10 * $5.00 = $50 (only 0.5% of portfolio — acceptable)
|
||||
```
|
||||
|
||||
### Kelly Criterion (Optimal Bet Size)
|
||||
```
|
||||
Kelly % = W - ((1 - W) / R)
|
||||
Where:
|
||||
W = win rate (decimal)
|
||||
R = average win / average loss ratio (reward-to-risk)
|
||||
|
||||
Worked example:
|
||||
Win rate: 60% (W = 0.60)
|
||||
Average win: $300, Average loss: $200
|
||||
R = 300 / 200 = 1.5
|
||||
Kelly = 0.60 - (0.40 / 1.5) = 0.60 - 0.267 = 0.333 (33.3%)
|
||||
|
||||
Full Kelly is too aggressive for real trading. Use fractions:
|
||||
Half-Kelly = 0.333 / 2 = 16.7% of portfolio per trade
|
||||
Quarter-Kelly = 0.333 / 4 = 8.3% of portfolio per trade (recommended)
|
||||
|
||||
If Kelly is negative, the system has NEGATIVE expectancy — do not trade it.
|
||||
```
|
||||
|
||||
### Value at Risk (VaR)
|
||||
```
|
||||
Parametric VaR = Portfolio Value * Portfolio Volatility * Z-score * sqrt(Time Horizon)
|
||||
|
||||
Z-scores: 90% confidence = 1.282
|
||||
95% confidence = 1.645
|
||||
99% confidence = 2.326
|
||||
|
||||
Worked example (daily VaR, 95% confidence):
|
||||
Portfolio = $10,000
|
||||
Daily volatility (stddev of daily returns) = 2.0%
|
||||
VaR = $10,000 * 0.02 * 1.645 * sqrt(1) = $329.00
|
||||
Meaning: 95% confident daily loss will not exceed $329.
|
||||
|
||||
Weekly VaR = $329 * sqrt(5) = $329 * 2.236 = $735.65
|
||||
Monthly VaR = $329 * sqrt(21) = $329 * 4.583 = $1,507.81
|
||||
```
|
||||
|
||||
### Sharpe Ratio
|
||||
```
|
||||
Sharpe = (Rp - Rf) / StdDev(Rp) * sqrt(252)
|
||||
Where:
|
||||
Rp = mean daily portfolio return
|
||||
Rf = daily risk-free rate (Treasury yield / 252)
|
||||
StdDev(Rp) = standard deviation of daily returns
|
||||
252 = trading days per year (annualization factor)
|
||||
|
||||
Worked example:
|
||||
Mean daily return = 0.10% (0.001)
|
||||
Annual Treasury yield = 5.0% -> daily Rf = 0.05/252 = 0.000198
|
||||
StdDev of daily returns = 0.80% (0.008)
|
||||
Daily Sharpe = (0.001 - 0.000198) / 0.008 = 0.100
|
||||
Annualized Sharpe = 0.100 * sqrt(252) = 0.100 * 15.875 = 1.59
|
||||
|
||||
Ratings:
|
||||
< 0.5 = Poor (not compensated for risk)
|
||||
0.5-1.0 = Acceptable
|
||||
1.0-2.0 = Good
|
||||
2.0-3.0 = Very Good
|
||||
> 3.0 = Excellent (verify — may indicate overfitting)
|
||||
```
|
||||
|
||||
### Sortino Ratio (Downside-Only Risk)
|
||||
```
|
||||
Sortino = (Rp - Rf) / DownsideDeviation * sqrt(252)
|
||||
DownsideDeviation = sqrt(mean(min(Ri - Rf, 0)^2))
|
||||
|
||||
Better than Sharpe because it only penalizes downside volatility, not upside.
|
||||
Sortino > 2.0 is considered very good.
|
||||
```
|
||||
|
||||
### Maximum Drawdown
|
||||
```
|
||||
For each point t in equity curve:
|
||||
Peak(t) = max(Equity[0..t])
|
||||
Drawdown(t) = (Peak(t) - Equity(t)) / Peak(t) * 100%
|
||||
MaxDrawdown = max(Drawdown(t)) for all t
|
||||
|
||||
Worked example:
|
||||
Equity curve: $10,000 -> $12,000 -> $9,600 -> $11,500
|
||||
Peak at $12,000
|
||||
Drawdown at $9,600 = (12,000 - 9,600) / 12,000 = 20.0%
|
||||
Max Drawdown = 20.0%
|
||||
|
||||
Recovery Factor = Total Net Profit / Max Drawdown
|
||||
If total profit = $3,000, MaxDD = $2,400 -> RF = 3,000/2,400 = 1.25
|
||||
|
||||
Calmar Ratio = Annual Return / Max Drawdown
|
||||
If annual return = 25%, MaxDD = 20% -> Calmar = 1.25 (target > 1.0)
|
||||
```
|
||||
|
||||
### Profit Factor
|
||||
```
|
||||
Profit Factor = Gross Winning Trades / Gross Losing Trades
|
||||
|
||||
Worked example:
|
||||
10 winning trades totaling $5,000
|
||||
8 losing trades totaling $3,200
|
||||
Profit Factor = 5,000 / 3,200 = 1.5625
|
||||
|
||||
Ratings: < 1.0 = losing system, 1.0-1.5 = marginal, 1.5-2.0 = good,
|
||||
2.0-3.0 = very good, > 3.0 = excellent (verify with enough trades)
|
||||
```
|
||||
|
||||
### Expectancy Per Trade
|
||||
```
|
||||
Expectancy = (Win% * AvgWin) - (Loss% * AvgLoss)
|
||||
|
||||
Worked example:
|
||||
Win rate: 55%, Average win: $150, Average loss: $100
|
||||
Expectancy = (0.55 * 150) - (0.45 * 100) = 82.50 - 45.00 = $37.50/trade
|
||||
Over 100 trades: expected profit = $3,750
|
||||
|
||||
Minimum for a viable system: Expectancy > 0 with at least 30 sample trades.
|
||||
```
|
||||
|
||||
### Risk/Reward Ratio
|
||||
```
|
||||
R:R = (Target Price - Entry Price) / (Entry Price - Stop Loss Price)
|
||||
|
||||
Worked example:
|
||||
Entry = $100, Stop = $95, Target = $112
|
||||
R:R = (112 - 100) / (100 - 95) = 12 / 5 = 2.4:1
|
||||
|
||||
Minimum acceptable R:R = 1.5:1
|
||||
With 40% win rate and 2:1 R:R: Expectancy = 0.40*2 - 0.60*1 = +0.20 (profitable!)
|
||||
With 40% win rate and 1:1 R:R: Expectancy = 0.40*1 - 0.60*1 = -0.20 (losing!)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Alpaca Trading API Reference
|
||||
|
||||
### Authentication
|
||||
```bash
|
||||
# Paper trading (ALWAYS start here)
|
||||
BASE_URL="https://paper-api.alpaca.markets"
|
||||
|
||||
# Live trading (only after paper validation)
|
||||
# BASE_URL="https://api.alpaca.markets"
|
||||
|
||||
# Data API (same for both paper and live)
|
||||
DATA_URL="https://data.alpaca.markets"
|
||||
|
||||
# Auth headers (required on every request)
|
||||
HEADERS="-H 'APCA-API-KEY-ID: $ALPACA_API_KEY' -H 'APCA-API-SECRET-KEY: $ALPACA_SECRET_KEY'"
|
||||
```
|
||||
|
||||
### Account Information
|
||||
```bash
|
||||
# Get account details
|
||||
curl -s "$BASE_URL/v2/account" $HEADERS
|
||||
# Key fields: id, status, equity, cash, buying_power, portfolio_value,
|
||||
# pattern_day_trader (bool), daytrade_count, last_equity
|
||||
```
|
||||
|
||||
### Get Current Positions
|
||||
```bash
|
||||
# All positions
|
||||
curl -s "$BASE_URL/v2/positions" $HEADERS
|
||||
# Returns array: symbol, qty, side, avg_entry_price, current_price,
|
||||
# unrealized_pl, unrealized_plpc, market_value, cost_basis
|
||||
|
||||
# Single position
|
||||
curl -s "$BASE_URL/v2/positions/AAPL" $HEADERS
|
||||
```
|
||||
|
||||
### Place Orders
|
||||
```bash
|
||||
# Market order (fills immediately at best available price)
|
||||
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"symbol":"AAPL","qty":"10","side":"buy","type":"market","time_in_force":"day"}'
|
||||
|
||||
# Limit order (fills only at your price or better)
|
||||
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"symbol":"AAPL","qty":"10","side":"buy","type":"limit","time_in_force":"gtc","limit_price":"150.00"}'
|
||||
|
||||
# Stop order (triggers market order when stop price hit)
|
||||
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"symbol":"AAPL","qty":"10","side":"sell","type":"stop","time_in_force":"gtc","stop_price":"145.00"}'
|
||||
|
||||
# Stop-limit order (triggers limit order when stop price hit)
|
||||
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"symbol":"AAPL","qty":"10","side":"sell","type":"stop_limit","time_in_force":"gtc","stop_price":"145.00","limit_price":"144.50"}'
|
||||
|
||||
# Trailing stop (dynamic stop that trails price by dollar or percent amount)
|
||||
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"symbol":"AAPL","qty":"10","side":"sell","type":"trailing_stop","time_in_force":"gtc","trail_percent":"5"}'
|
||||
|
||||
# Bracket order (entry + stop loss + take profit as one atomic order)
|
||||
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"symbol": "AAPL",
|
||||
"qty": "10",
|
||||
"side": "buy",
|
||||
"type": "limit",
|
||||
"time_in_force": "day",
|
||||
"limit_price": "150.00",
|
||||
"order_class": "bracket",
|
||||
"stop_loss": {"stop_price": "145.00"},
|
||||
"take_profit": {"limit_price": "165.00"}
|
||||
}'
|
||||
|
||||
# OCO order (one-cancels-other: stop loss OR take profit, whichever hits first)
|
||||
curl -s -X POST "$BASE_URL/v2/orders" $HEADERS \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"symbol": "AAPL",
|
||||
"qty": "10",
|
||||
"side": "sell",
|
||||
"type": "limit",
|
||||
"time_in_force": "gtc",
|
||||
"limit_price": "165.00",
|
||||
"order_class": "oco",
|
||||
"stop_loss": {"stop_price": "145.00"}
|
||||
}'
|
||||
```
|
||||
|
||||
**Order parameters reference:**
|
||||
| Parameter | Values | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `side` | `buy`, `sell` | |
|
||||
| `type` | `market`, `limit`, `stop`, `stop_limit`, `trailing_stop` | |
|
||||
| `time_in_force` | `day`, `gtc`, `ioc`, `fok` | day = cancel at close, gtc = good til canceled |
|
||||
| `order_class` | `simple`, `bracket`, `oco`, `oto` | bracket = entry + stop + target |
|
||||
| `qty` | String number | Whole shares for stocks |
|
||||
| `notional` | String dollar amount | Alternative to qty (fractional shares) |
|
||||
|
||||
### Manage Orders
|
||||
```bash
|
||||
# List open orders
|
||||
curl -s "$BASE_URL/v2/orders?status=open" $HEADERS
|
||||
|
||||
# Get specific order
|
||||
curl -s "$BASE_URL/v2/orders/{order_id}" $HEADERS
|
||||
|
||||
# Cancel specific order
|
||||
curl -s -X DELETE "$BASE_URL/v2/orders/{order_id}" $HEADERS
|
||||
|
||||
# Cancel ALL open orders
|
||||
curl -s -X DELETE "$BASE_URL/v2/orders" $HEADERS
|
||||
```
|
||||
|
||||
### Close Positions
|
||||
```bash
|
||||
# Close entire position in a symbol
|
||||
curl -s -X DELETE "$BASE_URL/v2/positions/AAPL" $HEADERS
|
||||
|
||||
# Partially close (sell 5 of 10 shares)
|
||||
curl -s -X DELETE "$BASE_URL/v2/positions/AAPL?qty=5" $HEADERS
|
||||
|
||||
# EMERGENCY: Close ALL positions
|
||||
curl -s -X DELETE "$BASE_URL/v2/positions" $HEADERS
|
||||
```
|
||||
|
||||
### Market Data (free with Alpaca account)
|
||||
```bash
|
||||
# Latest quote (bid/ask)
|
||||
curl -s "$DATA_URL/v2/stocks/AAPL/quotes/latest" $HEADERS
|
||||
|
||||
# Latest trade (last fill)
|
||||
curl -s "$DATA_URL/v2/stocks/AAPL/trades/latest" $HEADERS
|
||||
|
||||
# Historical bars (OHLCV) — daily
|
||||
curl -s "$DATA_URL/v2/stocks/AAPL/bars?timeframe=1Day&start=2024-01-01&limit=100" $HEADERS
|
||||
|
||||
# Intraday bars — 5-minute
|
||||
curl -s "$DATA_URL/v2/stocks/AAPL/bars?timeframe=5Min&start=$(date -d 'today' +%Y-%m-%d)&limit=78" $HEADERS
|
||||
|
||||
# Multi-symbol snapshot
|
||||
curl -s "$DATA_URL/v2/stocks/snapshots?symbols=AAPL,MSFT,GOOGL" $HEADERS
|
||||
|
||||
# Crypto bars
|
||||
curl -s "$DATA_URL/v1beta3/crypto/us/bars?symbols=BTC/USD&timeframe=1Day&limit=30" $HEADERS
|
||||
|
||||
# Crypto latest quote
|
||||
curl -s "$DATA_URL/v1beta3/crypto/us/latest/quotes?symbols=BTC/USD,ETH/USD" $HEADERS
|
||||
```
|
||||
|
||||
### Market Clock & Calendar
|
||||
```bash
|
||||
# Is market open right now?
|
||||
curl -s "$BASE_URL/v2/clock" $HEADERS
|
||||
# Returns: timestamp, is_open (bool), next_open, next_close
|
||||
|
||||
# Upcoming market calendar
|
||||
curl -s "$BASE_URL/v2/calendar?start=$(date +%Y-%m-%d)&end=$(date -d '+7 days' +%Y-%m-%d)" $HEADERS
|
||||
```
|
||||
|
||||
### Crypto Trading Notes
|
||||
- Symbols use slash format: `BTC/USD`, `ETH/USD`, `SOL/USD`, `DOGE/USD`
|
||||
- 24/7 trading (no market hours restriction)
|
||||
- Fractional quantities allowed (e.g., `"qty": "0.001"` for BTC)
|
||||
- Paper trading works identically to live
|
||||
- Use `notional` for dollar-based crypto orders: `"notional": "100.00"` buys $100 worth
|
||||
|
||||
### Account Activity & History
|
||||
```bash
|
||||
# Trade history
|
||||
curl -s "$BASE_URL/v2/account/activities/FILL?after=2024-01-01" $HEADERS
|
||||
|
||||
# Portfolio history
|
||||
curl -s "$BASE_URL/v2/account/portfolio/history?period=1M&timeframe=1D" $HEADERS
|
||||
# Returns: timestamp[], equity[], profit_loss[], profit_loss_pct[]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Free Financial Data Sources
|
||||
|
||||
### Price Data (via web_search + web_fetch)
|
||||
| Source | URL Pattern | Data Available |
|
||||
|--------|-------------|----------------|
|
||||
| Yahoo Finance | `finance.yahoo.com/quote/AAPL` | Realtime quotes, charts, financials, analyst ratings |
|
||||
| Google Finance | `google.com/finance/quote/AAPL:NASDAQ` | Quotes, news, related stocks, earnings |
|
||||
| CoinGecko | `coingecko.com/en/coins/bitcoin` | Crypto prices, market cap, volume, 24h change |
|
||||
| CoinMarketCap | `coinmarketcap.com/currencies/bitcoin/` | Crypto prices, rankings, dominance, supply |
|
||||
| MarketWatch | `marketwatch.com/investing/stock/AAPL` | Quotes, news, analysis, options data |
|
||||
| Finviz | `finviz.com/quote.ashx?t=AAPL` | Technical + fundamental screener, charts |
|
||||
| TradingView | `tradingview.com/symbols/NASDAQ-AAPL/` | Charts, technicals, community ideas |
|
||||
|
||||
### Fundamental Data
|
||||
| Source | URL Pattern | Data Available |
|
||||
|--------|-------------|----------------|
|
||||
| Macrotrends | `macrotrends.net/stocks/charts/AAPL/apple/pe-ratio` | P/E, revenue, margins, historical |
|
||||
| Simply Wall St | Web search: `"AAPL simply wall st"` | Visual fundamental analysis, fair value |
|
||||
| SEC EDGAR | `sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=AAPL&type=10-K` | Official 10-K, 10-Q, 8-K filings |
|
||||
| Earnings Whispers | `earningswhispers.com/stocks/AAPL` | Earnings estimates, surprise history, calendar |
|
||||
| Stock Analysis | `stockanalysis.com/stocks/AAPL/financials/` | Clean financial statements, ratios |
|
||||
| Wisesheets | Web search: `"AAPL income statement"` | Financial data in spreadsheet format |
|
||||
|
||||
### Sentiment & Alternative Data
|
||||
| Source | URL | Data Available |
|
||||
|--------|-----|----------------|
|
||||
| CNN Fear & Greed | `money.cnn.com/data/fear-and-greed/` | Market sentiment index 0-100 (Extreme Fear to Extreme Greed) |
|
||||
| CBOE VIX | Web search: `"VIX index today"` | Volatility index (>30 = fear, <15 = complacency) |
|
||||
| Finviz Map | `finviz.com/map.ashx` | Market heatmap by sector/size |
|
||||
| StockTwits | `stocktwits.com/symbol/AAPL` | Social sentiment (bullish/bearish ratio) |
|
||||
| Put/Call Ratio | Web search: `"CBOE put call ratio today"` | Options sentiment (>1.0 = bearish, <0.7 = bullish) |
|
||||
| Short Interest | `finviz.com/quote.ashx?t=AAPL` -> Short Float | Percent of float sold short |
|
||||
| Insider Trading | `openinsider.com/screener` | CEO/CFO buy/sell patterns |
|
||||
|
||||
### Macro Economic Data
|
||||
| Source | URL | Data Available |
|
||||
|--------|-----|----------------|
|
||||
| FRED | `fred.stlouisfed.org` | Interest rates, CPI, employment, GDP, M2, yield curve |
|
||||
| Treasury.gov | `treasury.gov/resource-center/data-chart-center/interest-rates/` | Daily Treasury yield curve |
|
||||
| CME FedWatch | Web search: `"CME FedWatch tool"` | Federal funds rate probabilities |
|
||||
| BLS | `bls.gov/news.release/` | Employment situation, CPI, PPI |
|
||||
| ISM | Web search: `"ISM manufacturing PMI"` | PMI (>50 = expansion, <50 = contraction) |
|
||||
| Conference Board | Web search: `"consumer confidence index"` | Consumer confidence, leading indicators |
|
||||
| Earnings Calendar | `earningswhispers.com/calendar` | Upcoming earnings dates |
|
||||
| Economic Calendar | Web search: `"economic calendar this week"` | Scheduled data releases |
|
||||
|
||||
### Crypto-Specific Sources
|
||||
| Source | URL | Data Available |
|
||||
|--------|-----|----------------|
|
||||
| CoinGecko | `coingecko.com` | Prices, market cap, volume, DeFi TVL |
|
||||
| DefiLlama | `defillama.com` | Total Value Locked across all chains |
|
||||
| Glassnode (free tier) | Web search: `"bitcoin on-chain metrics"` | On-chain analytics (NUPL, MVRV, exchange flows) |
|
||||
| Bitcoin Fear & Greed | `alternative.me/crypto/fear-and-greed-index/` | Crypto-specific sentiment 0-100 |
|
||||
| Ultrasound Money | `ultrasound.money` | ETH supply/burn metrics |
|
||||
|
||||
---
|
||||
|
||||
## 6. Confidence Calibration Guide (Superforecasting)
|
||||
|
||||
### Calibration Principles (Philip Tetlock)
|
||||
- A "70% confident" prediction should be right about 70% of the time
|
||||
- Most people are overconfident: their "90%" predictions are right only ~70%
|
||||
- Track your predictions systematically and compare predicted vs actual frequency
|
||||
- Update incrementally (2-5% per new piece of evidence), not dramatically
|
||||
|
||||
### Confidence Level Guide
|
||||
| Level | Meaning | Evidence Required | Trading Action |
|
||||
|-------|---------|-------------------|----------------|
|
||||
| 20-30% | Slight lean | Single weak signal, limited data | No trade — insufficient edge |
|
||||
| 40-50% | Toss-up with slight edge | Conflicting signals, moderate evidence | No trade — coin flip |
|
||||
| 55-65% | Moderate conviction | Multiple aligned signals, historical precedent | Small position, wide stops |
|
||||
| 70-80% | Strong conviction | Strong multi-factor alignment, catalyst identified | Standard position size |
|
||||
| 85-95% | Very high conviction | Overwhelming evidence — be suspicious of yourself | Full position, but NEVER all-in |
|
||||
|
||||
### Brier Score for Trade Predictions
|
||||
```
|
||||
Brier Score = mean((predicted_probability - actual_outcome)^2)
|
||||
actual_outcome: 1 if prediction was correct, 0 if wrong
|
||||
|
||||
Worked example (5 predictions):
|
||||
Pred 1: 80% confident -> correct (1) -> (0.80 - 1)^2 = 0.04
|
||||
Pred 2: 60% confident -> wrong (0) -> (0.60 - 0)^2 = 0.36
|
||||
Pred 3: 70% confident -> correct (1) -> (0.70 - 1)^2 = 0.09
|
||||
Pred 4: 90% confident -> correct (1) -> (0.90 - 1)^2 = 0.01
|
||||
Pred 5: 55% confident -> wrong (0) -> (0.55 - 0)^2 = 0.30
|
||||
Brier Score = (0.04 + 0.36 + 0.09 + 0.01 + 0.30) / 5 = 0.16
|
||||
|
||||
Ratings: 0.00 = perfect, < 0.15 = excellent, 0.15-0.25 = good,
|
||||
0.25 = coin flip, > 0.25 = worse than random
|
||||
```
|
||||
|
||||
### Calibration Self-Check Protocol
|
||||
After accumulating 20+ trade predictions, group by confidence bucket:
|
||||
1. Are your 60% predictions right ~60% of the time?
|
||||
2. If your 60% predictions are right 80% of the time, you are underconfident — adjust up
|
||||
3. If your 80% predictions are right 55% of the time, you are overconfident — adjust down
|
||||
4. Recalibrate your confidence scale after every 50 resolved predictions
|
||||
|
||||
---
|
||||
|
||||
## 7. Trading Psychology & Cognitive Biases
|
||||
|
||||
### Biases to Watch For
|
||||
| Bias | Description | Mitigation |
|
||||
|------|-------------|------------|
|
||||
| **Confirmation Bias** | Seeking info that confirms your thesis | Always build the opposing case first (adversarial debate) |
|
||||
| **Anchoring** | Over-weighting the first number you see (entry price, analyst target) | Start analysis from base rates and current data, not old prices |
|
||||
| **Recency Bias** | Over-weighting recent events (last week's crash, last month's rally) | Look at longer timeframes — 6-month and 1-year charts minimum |
|
||||
| **Loss Aversion** | Holding losers too long ("it'll come back"), cutting winners too fast | Use mechanical stop-losses and take-profit targets, set BEFORE entry |
|
||||
| **Overconfidence** | Believing you are more right than you are | Track Brier scores, use Kelly fractions, never bet > 2% per trade |
|
||||
| **Narrative Bias** | Compelling story = good trade (often false) | Focus on quantitative data, not stories. "Good company" != "good trade" |
|
||||
| **FOMO** | Fear of missing out, chasing entries | Only enter at planned levels. The market is open 252 days a year |
|
||||
| **Sunk Cost** | "I've lost so much, I can't sell now" | Each moment is a new decision. Ask: "Would I enter this trade NOW at current price?" |
|
||||
| **Hindsight Bias** | "I knew that would happen" | Journal BEFORE trades with specific predictions, not after |
|
||||
| **Disposition Effect** | Selling winners early to "lock in profits" but holding losers | Let winners run (trail stops), cut losers at planned stops |
|
||||
| **Gambler's Fallacy** | "It's dropped 5 days in a row, it HAS to bounce" | Each day is independent. Trends persist more often than they reverse |
|
||||
| **Endowment Effect** | Overvaluing positions you already own | Evaluate positions as if you were building from scratch today |
|
||||
|
||||
### Discipline Rules
|
||||
1. Every trade has a written plan BEFORE entry: entry price, stop loss, target, position size, thesis
|
||||
2. Write down your reasoning BEFORE entering — if you cannot articulate the edge, do not trade
|
||||
3. Set stop-losses at order entry time, not "in your head"
|
||||
4. Review your journal weekly — look for patterns in wins AND losses
|
||||
5. Take breaks after big wins (overconfidence risk) AND big losses (emotional risk)
|
||||
6. Never average down on a losing position unless the original thesis explicitly planned for it
|
||||
7. Never move a stop-loss further away from your entry (only tighten, never widen)
|
||||
8. The market will be there tomorrow — missing a trade is not a loss, but a blown account is
|
||||
|
||||
---
|
||||
|
||||
## 8. Portfolio Construction
|
||||
|
||||
### Asset Allocation Guidelines
|
||||
| Style | Equities | Crypto | Fixed Income / Cash | Max Single Position |
|
||||
|-------|----------|--------|---------------------|---------------------|
|
||||
| Conservative | 50-60% | 0-5% | 35-50% | 5% |
|
||||
| Moderate | 60-75% | 5-15% | 10-35% | 8% |
|
||||
| Aggressive | 70-85% | 10-25% | 5-20% | 10% |
|
||||
| Speculative | 50-70% | 20-40% | 5-10% | 15% (with strict stops) |
|
||||
|
||||
### Sector Diversification
|
||||
Maximum 30% in any single sector:
|
||||
- Technology, Healthcare, Financials, Consumer Discretionary, Consumer Staples
|
||||
- Energy, Industrials, Utilities, Real Estate, Materials, Communication Services
|
||||
|
||||
### Correlation Awareness
|
||||
Highly correlated positions amplify risk. Check correlations before adding:
|
||||
| Pair | Typical Correlation | Risk |
|
||||
|------|---------------------|------|
|
||||
| AAPL + MSFT + GOOGL | 0.7-0.9 | Concentrated large-cap tech |
|
||||
| BTC + ETH + SOL | 0.8-0.95 | Concentrated crypto (moves together) |
|
||||
| SPY + QQQ | 0.9+ | Nearly identical exposure |
|
||||
| Stocks + Bonds | -0.2 to 0.3 | Genuinely diversifying |
|
||||
| Gold + Stocks | -0.1 to 0.2 | Hedge in crisis |
|
||||
| VIX + SPY | -0.8 | Inverse — VIX as hedge |
|
||||
|
||||
### Rebalancing Rules
|
||||
- **Calendar**: Rebalance quarterly (first trading day of quarter)
|
||||
- **Threshold**: Rebalance when any allocation drifts > 5% from target
|
||||
- **Tax-aware**: Prefer rebalancing via new contributions rather than selling (taxable accounts)
|
||||
|
||||
---
|
||||
|
||||
## 9. Cross-Platform Commands
|
||||
|
||||
### Windows (PowerShell / Git Bash)
|
||||
```bash
|
||||
# Python might be `python` not `python3` on Windows
|
||||
python -c "import json; ..."
|
||||
|
||||
# Use forward slashes in file paths or escape backslashes
|
||||
# curl is available via Git Bash, PowerShell, or WSL
|
||||
|
||||
# Check if market is open (Windows Git Bash)
|
||||
curl -s "$BASE_URL/v2/clock" -H "APCA-API-KEY-ID: $ALPACA_API_KEY" \
|
||||
-H "APCA-API-SECRET-KEY: $ALPACA_SECRET_KEY" | python -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print('OPEN' if d['is_open'] else 'CLOSED', '| Next:', d.get('next_open','') or d.get('next_close',''))
|
||||
"
|
||||
```
|
||||
|
||||
### macOS / Linux
|
||||
```bash
|
||||
python3 -c "import json; ..."
|
||||
# curl, jq typically available by default
|
||||
# Use jq for JSON processing:
|
||||
curl -s URL | jq '.equity'
|
||||
```
|
||||
|
||||
### JSON Processing Without jq
|
||||
```bash
|
||||
# Pretty-print JSON
|
||||
python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin),indent=2))" < file.json
|
||||
|
||||
# Extract specific field
|
||||
curl -s URL | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['equity'])"
|
||||
|
||||
# Parse Alpaca positions into readable table
|
||||
curl -s "$BASE_URL/v2/positions" $HEADERS | python3 -c "
|
||||
import sys, json
|
||||
positions = json.load(sys.stdin)
|
||||
fmt = '{:<8} {:>6} {:>10} {:>10} {:>12} {:>8}'
|
||||
print(fmt.format('Symbol','Qty','Entry','Current','P/L','P/L pct'))
|
||||
print('-' * 60)
|
||||
for p in positions:
|
||||
print(fmt.format(p['symbol'], p['qty'], float(p['avg_entry_price']),
|
||||
float(p['current_price']), float(p['unrealized_pl']),
|
||||
round(float(p['unrealized_plpc'])*100,2)))
|
||||
"
|
||||
|
||||
# Calculate RSI from historical bars
|
||||
curl -s "$DATA_URL/v2/stocks/AAPL/bars?timeframe=1Day&limit=30" $HEADERS | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
closes = [float(b['c']) for b in data['bars']]
|
||||
changes = [closes[i]-closes[i-1] for i in range(1, len(closes))]
|
||||
gains = [max(c,0) for c in changes[-14:]]
|
||||
losses = [abs(min(c,0)) for c in changes[-14:]]
|
||||
avg_gain = sum(gains)/14
|
||||
avg_loss = sum(losses)/14
|
||||
rs = avg_gain/avg_loss if avg_loss > 0 else 999
|
||||
rsi = 100 - (100/(1+rs))
|
||||
print(f'RSI(14) = {rsi:.1f}')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Pre-Trade Checklist
|
||||
|
||||
Before every trade, verify ALL of the following:
|
||||
|
||||
```
|
||||
PRE-TRADE CHECKLIST
|
||||
====================
|
||||
[ ] 1. TREND: What is the higher-timeframe trend? (Daily chart 200 SMA)
|
||||
- Trading WITH the trend? (preferred)
|
||||
- Counter-trend? (requires stronger signal + tighter stops)
|
||||
|
||||
[ ] 2. SIGNAL: What specific setup triggered this trade?
|
||||
- Indicator signal (RSI, MACD, etc.)
|
||||
- Pattern (candlestick, chart pattern)
|
||||
- Catalyst (earnings, news, sector rotation)
|
||||
|
||||
[ ] 3. ENTRY: Exact entry price or condition
|
||||
- Limit order at specific level? Market order on breakout?
|
||||
|
||||
[ ] 4. STOP LOSS: Exact stop price
|
||||
- Based on ATR (2-3x ATR from entry)
|
||||
- Below key support (long) or above key resistance (short)
|
||||
- NEVER wider than 2% of portfolio
|
||||
|
||||
[ ] 5. TARGET: Exact take-profit price
|
||||
- Risk/Reward at least 1.5:1 (preferably 2:1+)
|
||||
- At logical resistance (long) or support (short)
|
||||
|
||||
[ ] 6. POSITION SIZE: Calculated from risk management rules
|
||||
- Risk amount = Portfolio * 1-2%
|
||||
- Shares = Risk amount / (Entry - Stop)
|
||||
- Total position < 10% of portfolio
|
||||
|
||||
[ ] 7. CORRELATION CHECK: Does this overlap with existing positions?
|
||||
- Not adding to concentrated sector exposure
|
||||
- Total portfolio heat (sum of open risk) < 6%
|
||||
|
||||
[ ] 8. CATALYST CHECK: Any upcoming events that could gap through stops?
|
||||
- Earnings date? Fed meeting? CPI release?
|
||||
- If yes: reduce size or wait until after event
|
||||
|
||||
[ ] 9. MARKET CONTEXT: Is the overall market favorable?
|
||||
- Fear & Greed index level
|
||||
- VIX level (>30 = caution, <15 = complacency risk)
|
||||
- Market trend (SPY vs 200 SMA)
|
||||
|
||||
[ ] 10. CONFIDENCE: Rate 1-10 honestly
|
||||
- Below 6? Skip the trade
|
||||
- Record confidence for calibration tracking
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Trade Journal Template
|
||||
|
||||
```json
|
||||
{
|
||||
"trade_id": "T001",
|
||||
"date_opened": "2025-01-15",
|
||||
"date_closed": null,
|
||||
"symbol": "AAPL",
|
||||
"side": "long",
|
||||
"entry_price": 150.00,
|
||||
"stop_loss": 145.00,
|
||||
"target": 162.00,
|
||||
"position_size": 40,
|
||||
"risk_amount": 200.00,
|
||||
"risk_reward": 2.4,
|
||||
"setup": "Bullish engulfing at 50 EMA + RSI divergence",
|
||||
"confidence": 7,
|
||||
"market_context": "SPY above 200 SMA, VIX at 18, F&G neutral (52)",
|
||||
"pre_trade_thesis": "AAPL pulled back to 50 EMA support, RSI showing bullish divergence, earnings in 3 weeks should provide catalyst. Sector (tech) is leading.",
|
||||
"result": {
|
||||
"exit_price": null,
|
||||
"exit_reason": null,
|
||||
"pnl": null,
|
||||
"pnl_percent": null,
|
||||
"held_days": null,
|
||||
"lessons": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Store trade journals using `memory_store` for tracking and calibration review.
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Compile-time embedded Hand definitions.
|
||||
|
||||
use crate::{HandDefinition, HandError};
|
||||
use crate::{parse_hand_toml, HandDefinition, HandError};
|
||||
|
||||
/// Returns all bundled hand definitions as (id, HAND.toml content, SKILL.md content).
|
||||
pub fn bundled_hands() -> Vec<(&'static str, &'static str, &'static str)> {
|
||||
@@ -40,6 +40,11 @@ pub fn bundled_hands() -> Vec<(&'static str, &'static str, &'static str)> {
|
||||
include_str!("../bundled/browser/HAND.toml"),
|
||||
include_str!("../bundled/browser/SKILL.md"),
|
||||
),
|
||||
(
|
||||
"trader",
|
||||
include_str!("../bundled/trader/HAND.toml"),
|
||||
include_str!("../bundled/trader/SKILL.md"),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -50,7 +55,7 @@ pub fn parse_bundled(
|
||||
skill_content: &str,
|
||||
) -> Result<HandDefinition, HandError> {
|
||||
let mut def: HandDefinition =
|
||||
toml::from_str(toml_content).map_err(|e| HandError::TomlParse(e.to_string()))?;
|
||||
parse_hand_toml(toml_content).map_err(|e| HandError::TomlParse(e.to_string()))?;
|
||||
if !skill_content.is_empty() {
|
||||
def.skill_content = Some(skill_content.to_string());
|
||||
}
|
||||
@@ -71,7 +76,7 @@ mod tests {
|
||||
#[test]
|
||||
fn bundled_hands_count() {
|
||||
let hands = bundled_hands();
|
||||
assert_eq!(hands.len(), 7);
|
||||
assert_eq!(hands.len(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -187,8 +192,8 @@ mod tests {
|
||||
assert_eq!(def.name, "Browser Hand");
|
||||
assert_eq!(def.category, crate::HandCategory::Productivity);
|
||||
assert!(def.skill_content.is_some());
|
||||
assert!(!def.requires.is_empty()); // requires chromium
|
||||
assert_eq!(def.requires.len(), 1);
|
||||
assert!(!def.requires.is_empty()); // requires python3 + chromium
|
||||
assert_eq!(def.requires.len(), 2);
|
||||
assert!(def.tools.contains(&"browser_navigate".to_string()));
|
||||
assert!(def.tools.contains(&"browser_click".to_string()));
|
||||
assert!(def.tools.contains(&"browser_type".to_string()));
|
||||
@@ -201,6 +206,26 @@ mod tests {
|
||||
assert_eq!(def.agent.max_iterations, Some(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_trader_hand() {
|
||||
let (id, toml_content, skill_content) = bundled_hands()
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| *id == "trader")
|
||||
.unwrap();
|
||||
let def = parse_bundled(id, toml_content, skill_content).unwrap();
|
||||
assert_eq!(def.id, "trader");
|
||||
assert_eq!(def.name, "Trading Hand");
|
||||
assert_eq!(def.category, crate::HandCategory::Data);
|
||||
assert!(def.skill_content.is_some());
|
||||
assert!(def.requires.is_empty()); // no hard requirements
|
||||
assert!(!def.tools.is_empty());
|
||||
assert!(def.tools.contains(&"event_publish".to_string()));
|
||||
assert!(!def.settings.is_empty());
|
||||
assert!(!def.dashboard.metrics.is_empty());
|
||||
assert!((def.agent.temperature - 0.3).abs() < f32::EPSILON);
|
||||
assert_eq!(def.agent.max_iterations, Some(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_bundled_hands_parse() {
|
||||
for (id, toml_content, skill_content) in bundled_hands() {
|
||||
@@ -216,7 +241,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_einstein_hands_have_schedules() {
|
||||
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter"];
|
||||
let einstein_ids = [
|
||||
"lead",
|
||||
"collector",
|
||||
"predictor",
|
||||
"researcher",
|
||||
"twitter",
|
||||
"trader",
|
||||
];
|
||||
for (id, toml_content, skill_content) in bundled_hands() {
|
||||
if einstein_ids.contains(&id) {
|
||||
let def = parse_bundled(id, toml_content, skill_content).unwrap();
|
||||
@@ -241,7 +273,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_einstein_hands_have_memory() {
|
||||
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter"];
|
||||
let einstein_ids = [
|
||||
"lead",
|
||||
"collector",
|
||||
"predictor",
|
||||
"researcher",
|
||||
"twitter",
|
||||
"trader",
|
||||
];
|
||||
for (id, toml_content, skill_content) in bundled_hands() {
|
||||
if einstein_ids.contains(&id) {
|
||||
let def = parse_bundled(id, toml_content, skill_content).unwrap();
|
||||
@@ -261,7 +300,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_einstein_hands_have_knowledge_graph() {
|
||||
let einstein_ids = ["lead", "collector", "predictor", "researcher", "twitter"];
|
||||
let einstein_ids = [
|
||||
"lead",
|
||||
"collector",
|
||||
"predictor",
|
||||
"researcher",
|
||||
"twitter",
|
||||
"trader",
|
||||
];
|
||||
for (id, toml_content, skill_content) in bundled_hands() {
|
||||
if einstein_ids.contains(&id) {
|
||||
let def = parse_bundled(id, toml_content, skill_content).unwrap();
|
||||
|
||||
@@ -117,6 +117,13 @@ pub struct HandRequirement {
|
||||
/// Human-readable description of why this is needed.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// Whether this requirement is optional (non-critical).
|
||||
///
|
||||
/// Optional requirements do not block activation. When an active hand has
|
||||
/// unmet optional requirements it is reported as "degraded" rather than
|
||||
/// "requirements not met".
|
||||
#[serde(default)]
|
||||
pub optional: bool,
|
||||
/// Platform-specific installation instructions.
|
||||
#[serde(default)]
|
||||
pub install: Option<HandInstallInfo>,
|
||||
@@ -299,6 +306,20 @@ fn default_temperature() -> f32 {
|
||||
0.7
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HandTomlWrapper {
|
||||
hand: HandDefinition,
|
||||
}
|
||||
|
||||
/// Parse HAND.toml content, supporting both flat format and `[hand]` table format.
|
||||
pub fn parse_hand_toml(content: &str) -> Result<HandDefinition, toml::de::Error> {
|
||||
if let Ok(def) = toml::from_str::<HandDefinition>(content) {
|
||||
return Ok(def);
|
||||
}
|
||||
let wrapper: HandTomlWrapper = toml::from_str(content)?;
|
||||
Ok(wrapper.hand)
|
||||
}
|
||||
|
||||
/// Complete Hand definition — parsed from HAND.toml.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HandDefinition {
|
||||
@@ -791,4 +812,50 @@ metrics = []
|
||||
assert!(install.macos.is_none());
|
||||
assert!(install.windows.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hand_toml_flat_format() {
|
||||
let toml_str = r#"
|
||||
id = "test"
|
||||
name = "Test Hand"
|
||||
description = "A test hand"
|
||||
category = "content"
|
||||
tools = ["shell_exec"]
|
||||
|
||||
[agent]
|
||||
name = "test-hand"
|
||||
description = "Test agent"
|
||||
system_prompt = "You are a test agent."
|
||||
|
||||
[dashboard]
|
||||
metrics = []
|
||||
"#;
|
||||
let def = parse_hand_toml(toml_str).unwrap();
|
||||
assert_eq!(def.id, "test");
|
||||
assert_eq!(def.name, "Test Hand");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hand_toml_wrapped_format() {
|
||||
let toml_str = r#"
|
||||
[hand]
|
||||
id = "test"
|
||||
name = "Test Hand"
|
||||
description = "A test hand"
|
||||
category = "content"
|
||||
tools = ["shell_exec"]
|
||||
|
||||
[hand.agent]
|
||||
name = "test-hand"
|
||||
description = "Test agent"
|
||||
system_prompt = "You are a test agent."
|
||||
|
||||
[hand.dashboard]
|
||||
metrics = []
|
||||
"#;
|
||||
let def = parse_hand_toml(toml_str).unwrap();
|
||||
assert_eq!(def.id, "test");
|
||||
assert_eq!(def.name, "Test Hand");
|
||||
assert_eq!(def.agent.name, "test-hand");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ impl HandRegistry {
|
||||
serde_json::json!({
|
||||
"hand_id": e.hand_id,
|
||||
"config": e.config,
|
||||
"agent_id": e.agent_id,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -73,8 +74,12 @@ impl HandRegistry {
|
||||
}
|
||||
|
||||
/// Load persisted hand state and re-activate hands.
|
||||
/// Returns list of (hand_id, config) that should be activated.
|
||||
pub fn load_state(path: &std::path::Path) -> Vec<(String, HashMap<String, serde_json::Value>)> {
|
||||
/// Returns list of (hand_id, config, old_agent_id) that should be activated.
|
||||
/// The `old_agent_id` is the agent UUID from before the restart, used to
|
||||
/// reassign cron jobs to the newly spawned agent (issue #402).
|
||||
pub fn load_state(
|
||||
path: &std::path::Path,
|
||||
) -> Vec<(String, HashMap<String, serde_json::Value>, Option<AgentId>)> {
|
||||
let data = match std::fs::read_to_string(path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Vec::new(),
|
||||
@@ -92,7 +97,10 @@ impl HandRegistry {
|
||||
let hand_id = e["hand_id"].as_str()?.to_string();
|
||||
let config: HashMap<String, serde_json::Value> =
|
||||
serde_json::from_value(e["config"].clone()).unwrap_or_default();
|
||||
Some((hand_id, config))
|
||||
let old_agent_id: Option<AgentId> = e
|
||||
.get("agent_id")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
Some((hand_id, config, old_agent_id))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -160,9 +168,28 @@ impl HandRegistry {
|
||||
Ok(def)
|
||||
}
|
||||
|
||||
/// Install or update a hand from raw TOML + skill content.
|
||||
///
|
||||
/// Unlike `install_from_content`, this overwrites an existing definition
|
||||
/// with the same ID. Active instances are NOT automatically restarted —
|
||||
/// the caller should deactivate + reactivate to pick up the new definition.
|
||||
pub fn upsert_from_content(
|
||||
&self,
|
||||
toml_content: &str,
|
||||
skill_content: &str,
|
||||
) -> HandResult<HandDefinition> {
|
||||
let def = bundled::parse_bundled("custom", toml_content, skill_content)?;
|
||||
let existed = self.definitions.contains_key(&def.id);
|
||||
let verb = if existed { "Updated" } else { "Installed" };
|
||||
info!(hand = %def.id, name = %def.name, "{verb} hand from content");
|
||||
self.definitions.insert(def.id.clone(), def.clone());
|
||||
Ok(def)
|
||||
}
|
||||
|
||||
/// List all known hand definitions.
|
||||
pub fn list_definitions(&self) -> Vec<HandDefinition> {
|
||||
let mut defs: Vec<HandDefinition> = self.definitions.iter().map(|r| r.value().clone()).collect();
|
||||
let mut defs: Vec<HandDefinition> =
|
||||
self.definitions.iter().map(|r| r.value().clone()).collect();
|
||||
defs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
defs
|
||||
}
|
||||
@@ -344,6 +371,47 @@ impl HandRegistry {
|
||||
entry.updated_at = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute readiness for a hand, cross-referencing requirements with
|
||||
/// active instance state.
|
||||
///
|
||||
/// Returns `None` if the hand definition does not exist.
|
||||
pub fn readiness(&self, hand_id: &str) -> Option<HandReadiness> {
|
||||
let reqs = self.check_requirements(hand_id).ok()?;
|
||||
|
||||
let requirements_met = reqs.iter().all(|(_, ok)| *ok);
|
||||
|
||||
// A hand is active if at least one instance is in Active status.
|
||||
let active = self
|
||||
.instances
|
||||
.iter()
|
||||
.any(|entry| entry.hand_id == hand_id && entry.status == HandStatus::Active);
|
||||
|
||||
// Degraded: active, but at least one non-optional requirement is unmet
|
||||
// OR any optional requirement is unmet. In practice, the most useful
|
||||
// definition is: active + any requirement unsatisfied.
|
||||
let degraded = active && reqs.iter().any(|(_, ok)| !ok);
|
||||
|
||||
Some(HandReadiness {
|
||||
requirements_met,
|
||||
active,
|
||||
degraded,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness snapshot for a hand definition — combines requirement checks
|
||||
/// with runtime activation state so the API can report unambiguous status.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct HandReadiness {
|
||||
/// Whether all declared requirements are currently satisfied.
|
||||
pub requirements_met: bool,
|
||||
/// Whether the hand currently has a running (Active-status) instance.
|
||||
pub active: bool,
|
||||
/// Whether the hand is active but some requirements are unmet.
|
||||
/// This means the hand is running in a degraded mode — some features
|
||||
/// may not work (e.g. browser hand without chromium).
|
||||
pub degraded: bool,
|
||||
}
|
||||
|
||||
impl Default for HandRegistry {
|
||||
@@ -356,21 +424,18 @@ impl Default for HandRegistry {
|
||||
fn check_requirement(req: &HandRequirement) -> bool {
|
||||
match req.requirement_type {
|
||||
RequirementType::Binary => {
|
||||
// Special handling for python3: must actually run the command and verify
|
||||
// the output contains "Python 3", because Windows ships a python3.exe
|
||||
// Store shim that exists on PATH but doesn't actually work.
|
||||
if req.check_value == "python3" {
|
||||
return check_python3_available();
|
||||
}
|
||||
// Check if binary exists on PATH.
|
||||
// For python3, also try "python" (Windows ships python not python3).
|
||||
if which_binary(&req.check_value) {
|
||||
return true;
|
||||
}
|
||||
if req.check_value == "python3" {
|
||||
return which_binary("python");
|
||||
}
|
||||
if req.check_value == "chromium" {
|
||||
// Try common Chromium/Chrome binary names across platforms
|
||||
return which_binary("chromium-browser")
|
||||
|| which_binary("google-chrome")
|
||||
|| which_binary("google-chrome-stable")
|
||||
|| which_binary("chrome")
|
||||
|| std::env::var("CHROME_PATH").map(|v| !v.is_empty()).unwrap_or(false);
|
||||
return check_chromium_available();
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -383,6 +448,133 @@ fn check_requirement(req: &HandRequirement) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if Python 3 is actually available by running the command and checking
|
||||
/// the version output. This avoids false negatives from Windows Store shims
|
||||
/// (python3.exe that just opens the Microsoft Store) and false positives from
|
||||
/// Python 2 installations where `python` exists but is Python 2.
|
||||
fn check_python3_available() -> bool {
|
||||
// Try "python3 --version" first (Linux/macOS, some Windows installs)
|
||||
if run_returns_python3("python3") {
|
||||
return true;
|
||||
}
|
||||
// Try "python --version" (Windows commonly uses this, Docker containers too)
|
||||
if run_returns_python3("python") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Run `{cmd} --version` and return true if the output contains "Python 3".
|
||||
fn run_returns_python3(cmd: &str) -> bool {
|
||||
match std::process::Command::new(cmd)
|
||||
.arg("--version")
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
{
|
||||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
return false;
|
||||
}
|
||||
// Python --version may print to stdout or stderr depending on version
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
stdout.contains("Python 3") || stderr.contains("Python 3")
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if Chromium (or Chrome) is available anywhere on the system.
|
||||
///
|
||||
/// Checks in order:
|
||||
/// 1. CHROME_PATH / CHROMIUM_PATH env vars
|
||||
/// 2. Common binary names on PATH (chromium, chromium-browser, google-chrome, etc.)
|
||||
/// 3. Well-known install paths (Windows Program Files, macOS Applications, Linux /usr)
|
||||
/// 4. Playwright cache (~/.cache/ms-playwright/chromium-*)
|
||||
fn check_chromium_available() -> bool {
|
||||
// 1. Env vars
|
||||
for var in &["CHROME_PATH", "CHROMIUM_PATH"] {
|
||||
if let Ok(p) = std::env::var(var) {
|
||||
if !p.is_empty() && std::path::Path::new(&p).exists() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Common binary names on PATH
|
||||
let names = [
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"chrome",
|
||||
];
|
||||
for name in &names {
|
||||
if which_binary(name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Well-known install paths
|
||||
let known_paths: Vec<std::path::PathBuf> = if cfg!(windows) {
|
||||
let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".into());
|
||||
let pf86 =
|
||||
std::env::var("ProgramFiles(x86)").unwrap_or_else(|_| r"C:\Program Files (x86)".into());
|
||||
let local = std::env::var("LOCALAPPDATA").unwrap_or_default();
|
||||
vec![
|
||||
std::path::PathBuf::from(&pf).join(r"Google\Chrome\Application\chrome.exe"),
|
||||
std::path::PathBuf::from(&pf86).join(r"Google\Chrome\Application\chrome.exe"),
|
||||
std::path::PathBuf::from(&local).join(r"Google\Chrome\Application\chrome.exe"),
|
||||
std::path::PathBuf::from(&pf).join(r"Chromium\Application\chrome.exe"),
|
||||
std::path::PathBuf::from(&local).join(r"Chromium\Application\chrome.exe"),
|
||||
std::path::PathBuf::from(&pf).join(r"Microsoft\Edge\Application\msedge.exe"),
|
||||
]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
vec![
|
||||
std::path::PathBuf::from(
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
),
|
||||
std::path::PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
std::path::PathBuf::from("/usr/bin/chromium"),
|
||||
std::path::PathBuf::from("/usr/bin/chromium-browser"),
|
||||
std::path::PathBuf::from("/usr/bin/google-chrome"),
|
||||
std::path::PathBuf::from("/usr/bin/google-chrome-stable"),
|
||||
std::path::PathBuf::from("/snap/bin/chromium"),
|
||||
]
|
||||
};
|
||||
for p in &known_paths {
|
||||
if p.exists() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Playwright cache
|
||||
if let Some(home) = std::env::var("HOME")
|
||||
.ok()
|
||||
.or_else(|| std::env::var("USERPROFILE").ok())
|
||||
{
|
||||
let pw_cache = std::path::Path::new(&home).join(".cache/ms-playwright");
|
||||
if pw_cache.is_dir() {
|
||||
if let Ok(entries) = std::fs::read_dir(&pw_cache) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.starts_with("chromium-") && entry.path().is_dir() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a binary is on PATH (cross-platform).
|
||||
fn which_binary(name: &str) -> bool {
|
||||
let path_var = std::env::var("PATH").unwrap_or_default();
|
||||
@@ -450,7 +642,7 @@ mod tests {
|
||||
fn load_bundled_hands() {
|
||||
let reg = HandRegistry::new();
|
||||
let count = reg.load_bundled();
|
||||
assert_eq!(count, 7);
|
||||
assert_eq!(count, 8);
|
||||
assert!(!reg.list_definitions().is_empty());
|
||||
|
||||
// Clip hand should be loaded
|
||||
@@ -590,6 +782,7 @@ mod tests {
|
||||
requirement_type: RequirementType::EnvVar,
|
||||
check_value: "OPENFANG_TEST_HAND_REQ".to_string(),
|
||||
description: None,
|
||||
optional: false,
|
||||
install: None,
|
||||
};
|
||||
assert!(check_requirement(&req));
|
||||
@@ -600,9 +793,93 @@ mod tests {
|
||||
requirement_type: RequirementType::EnvVar,
|
||||
check_value: "OPENFANG_NONEXISTENT_VAR_12345".to_string(),
|
||||
description: None,
|
||||
optional: false,
|
||||
install: None,
|
||||
};
|
||||
assert!(!check_requirement(&req_missing));
|
||||
std::env::remove_var("OPENFANG_TEST_HAND_REQ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_nonexistent_hand() {
|
||||
let reg = HandRegistry::new();
|
||||
assert!(reg.readiness("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_inactive_hand() {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
// Lead hand has no requirements, so requirements_met = true
|
||||
let r = reg.readiness("lead").unwrap();
|
||||
assert!(r.requirements_met);
|
||||
assert!(!r.active);
|
||||
assert!(!r.degraded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_active_hand_all_met() {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
// Lead hand has no requirements — activate it
|
||||
let instance = reg.activate("lead", HashMap::new()).unwrap();
|
||||
let r = reg.readiness("lead").unwrap();
|
||||
assert!(r.requirements_met);
|
||||
assert!(r.active);
|
||||
assert!(!r.degraded); // all met, so not degraded
|
||||
|
||||
reg.deactivate(instance.instance_id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_active_hand_degraded() {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
// Browser hand requires python3 + chromium. Activate it — if either
|
||||
// requirement is unmet on this machine, it will show as degraded.
|
||||
let instance = reg.activate("browser", HashMap::new()).unwrap();
|
||||
let r = reg.readiness("browser").unwrap();
|
||||
assert!(r.active);
|
||||
|
||||
// If any requirement is not satisfied, degraded should be true
|
||||
if !r.requirements_met {
|
||||
assert!(r.degraded);
|
||||
} else {
|
||||
assert!(!r.degraded);
|
||||
}
|
||||
|
||||
reg.deactivate(instance.instance_id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readiness_paused_hand_not_active() {
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("lead", HashMap::new()).unwrap();
|
||||
reg.pause(instance.instance_id).unwrap();
|
||||
|
||||
let r = reg.readiness("lead").unwrap();
|
||||
assert!(!r.active); // Paused is not Active
|
||||
assert!(!r.degraded);
|
||||
|
||||
reg.deactivate(instance.instance_id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_field_defaults_false() {
|
||||
let req = HandRequirement {
|
||||
key: "test".to_string(),
|
||||
label: "test".to_string(),
|
||||
requirement_type: RequirementType::Binary,
|
||||
check_value: "test".to_string(),
|
||||
description: None,
|
||||
optional: false,
|
||||
install: None,
|
||||
};
|
||||
assert!(!req.optional);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ crossbeam = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
chrono-tz = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
@@ -33,6 +34,7 @@ rand = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
cron = "0.15"
|
||||
zeroize = { workspace = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -241,6 +241,11 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
|
||||
plan.hot_actions.push(HotAction::ReloadProviderUrls);
|
||||
}
|
||||
|
||||
if field_changed(&old.provider_api_keys, &new.provider_api_keys) {
|
||||
plan.noop_changes
|
||||
.push("provider_api_keys changed (takes effect on next driver init)".to_string());
|
||||
}
|
||||
|
||||
// ----- No-op fields -----
|
||||
|
||||
if old.log_level != new.log_level {
|
||||
@@ -411,7 +416,10 @@ mod tests {
|
||||
let mut b = default_cfg();
|
||||
b.default_model.model = "gpt-4".to_string();
|
||||
let plan = build_reload_plan(&a, &b);
|
||||
assert!(!plan.restart_required, "default_model should be hot-reloadable");
|
||||
assert!(
|
||||
!plan.restart_required,
|
||||
"default_model should be hot-reloadable"
|
||||
);
|
||||
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
|
||||
}
|
||||
|
||||
|
||||
@@ -216,6 +216,70 @@ impl CronScheduler {
|
||||
self.jobs.iter().map(|r| r.value().job.clone()).collect()
|
||||
}
|
||||
|
||||
/// Reassign all cron jobs from `old_agent_id` to `new_agent_id`.
|
||||
///
|
||||
/// Used when a hand agent is respawned (e.g. after daemon restart) and
|
||||
/// gets a new UUID. Without this, persisted cron jobs would reference
|
||||
/// the stale old agent ID and fail silently.
|
||||
///
|
||||
/// Returns the number of jobs reassigned.
|
||||
pub fn reassign_agent_jobs(&self, old_agent_id: AgentId, new_agent_id: AgentId) -> usize {
|
||||
let mut count = 0;
|
||||
for mut entry in self.jobs.iter_mut() {
|
||||
if entry.value().job.agent_id == old_agent_id {
|
||||
entry.value_mut().job.agent_id = new_agent_id;
|
||||
// Reset consecutive errors so the job gets a fresh start
|
||||
// with the new agent.
|
||||
entry.value_mut().consecutive_errors = 0;
|
||||
if !entry.value().job.enabled {
|
||||
// Re-enable jobs that were auto-disabled due to the stale
|
||||
// agent ID causing repeated failures.
|
||||
if entry
|
||||
.value()
|
||||
.last_status
|
||||
.as_deref()
|
||||
.is_some_and(|s| s.contains("not found") || s.contains("No such agent"))
|
||||
{
|
||||
entry.value_mut().job.enabled = true;
|
||||
entry.value_mut().job.next_run =
|
||||
Some(compute_next_run(&entry.value().job.schedule));
|
||||
}
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
info!(
|
||||
old_agent = %old_agent_id,
|
||||
new_agent = %new_agent_id,
|
||||
count,
|
||||
"Reassigned cron jobs to new agent"
|
||||
);
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Remove all cron jobs belonging to a specific agent.
|
||||
///
|
||||
/// Used when an agent is deleted so its cron entries don't linger as
|
||||
/// orphans pointing at a dead UUID. Returns the number of jobs removed.
|
||||
pub fn remove_agent_jobs(&self, agent_id: AgentId) -> usize {
|
||||
let ids: Vec<CronJobId> = self
|
||||
.jobs
|
||||
.iter()
|
||||
.filter(|r| r.value().job.agent_id == agent_id)
|
||||
.map(|r| *r.key())
|
||||
.collect();
|
||||
let count = ids.len();
|
||||
for id in ids {
|
||||
self.jobs.remove(&id);
|
||||
}
|
||||
if count > 0 {
|
||||
info!(agent = %agent_id, count, "Removed cron jobs for deleted agent");
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Total number of tracked jobs.
|
||||
pub fn total_jobs(&self) -> usize {
|
||||
self.jobs.len()
|
||||
@@ -287,8 +351,7 @@ impl CronScheduler {
|
||||
);
|
||||
meta.job.enabled = false;
|
||||
} else {
|
||||
meta.job.next_run =
|
||||
Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
|
||||
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,7 +387,7 @@ pub fn compute_next_run_after(
|
||||
match schedule {
|
||||
CronSchedule::At { at } => *at,
|
||||
CronSchedule::Every { every_secs } => after + Duration::seconds(*every_secs as i64),
|
||||
CronSchedule::Cron { expr, tz: _ } => {
|
||||
CronSchedule::Cron { expr, tz } => {
|
||||
// Convert standard 5/6-field cron to 7-field for the `cron` crate.
|
||||
// Standard 5-field: min hour dom month dow
|
||||
// 6-field: sec min hour dom month dow
|
||||
@@ -341,10 +404,33 @@ pub fn compute_next_run_after(
|
||||
let base = after + Duration::seconds(1);
|
||||
|
||||
match seven_field.parse::<cron::Schedule>() {
|
||||
Ok(sched) => sched
|
||||
.after(&base)
|
||||
.next()
|
||||
.unwrap_or_else(|| after + Duration::hours(1)),
|
||||
Ok(sched) => {
|
||||
// If a timezone is specified, compute the next fire time in
|
||||
// that timezone so DST and local offsets are respected, then
|
||||
// convert back to UTC for storage.
|
||||
let next_utc = match tz.as_deref() {
|
||||
Some(tz_str) if !tz_str.is_empty() && tz_str != "UTC" => {
|
||||
match tz_str.parse::<chrono_tz::Tz>() {
|
||||
Ok(timezone) => {
|
||||
let base_local = base.with_timezone(&timezone);
|
||||
sched
|
||||
.after(&base_local)
|
||||
.next()
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
}
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"Invalid timezone '{}' in cron job, falling back to UTC",
|
||||
tz_str
|
||||
);
|
||||
sched.after(&base).next()
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => sched.after(&base).next(),
|
||||
};
|
||||
next_utc.unwrap_or_else(|| after + Duration::hours(1))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse cron expression '{}': {}", expr, e);
|
||||
after + Duration::hours(1)
|
||||
@@ -361,7 +447,7 @@ pub fn compute_next_run_after(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use chrono::{Duration, Timelike};
|
||||
use openfang_types::scheduler::{CronAction, CronDelivery};
|
||||
|
||||
/// Build a minimal valid `CronJob` with an `Every` schedule.
|
||||
@@ -787,4 +873,338 @@ mod tests {
|
||||
status.len()
|
||||
);
|
||||
}
|
||||
|
||||
// -- timezone-aware cron (#473) -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_cron_tz_shifts_next_run() {
|
||||
// "0 9 * * *" in America/New_York (UTC-5 or UTC-4 depending on DST).
|
||||
// The next fire time in UTC should differ from a plain UTC "0 9 * * *".
|
||||
let schedule_utc = CronSchedule::Cron {
|
||||
expr: "0 9 * * *".into(),
|
||||
tz: None,
|
||||
};
|
||||
let schedule_ny = CronSchedule::Cron {
|
||||
expr: "0 9 * * *".into(),
|
||||
tz: Some("America/New_York".into()),
|
||||
};
|
||||
let now = Utc::now();
|
||||
let next_utc = compute_next_run_after(&schedule_utc, now);
|
||||
let next_ny = compute_next_run_after(&schedule_ny, now);
|
||||
|
||||
// The New York schedule should fire at 09:00 Eastern, which is 13:00
|
||||
// or 14:00 UTC (depending on DST). In either case, it should NOT
|
||||
// equal the plain UTC 09:00 result.
|
||||
assert_ne!(
|
||||
next_utc, next_ny,
|
||||
"Timezone-aware schedule should produce a different UTC time"
|
||||
);
|
||||
|
||||
// Verify the New York result, when converted to ET, shows hour 09.
|
||||
let ny_tz: chrono_tz::Tz = "America/New_York".parse().unwrap();
|
||||
let next_ny_local = next_ny.with_timezone(&ny_tz);
|
||||
assert_eq!(
|
||||
next_ny_local.hour(),
|
||||
9,
|
||||
"Expected 09:00 in America/New_York, got {:02}:{:02}",
|
||||
next_ny_local.hour(),
|
||||
next_ny_local.minute()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_tz_none_defaults_to_utc() {
|
||||
// tz: None should behave identically to tz: Some("UTC").
|
||||
let schedule_none = CronSchedule::Cron {
|
||||
expr: "30 12 * * *".into(),
|
||||
tz: None,
|
||||
};
|
||||
let schedule_utc = CronSchedule::Cron {
|
||||
expr: "30 12 * * *".into(),
|
||||
tz: Some("UTC".into()),
|
||||
};
|
||||
let now = Utc::now();
|
||||
let next_none = compute_next_run_after(&schedule_none, now);
|
||||
let next_utc = compute_next_run_after(&schedule_utc, now);
|
||||
assert_eq!(next_none, next_utc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_tz_empty_string_defaults_to_utc() {
|
||||
let schedule_empty = CronSchedule::Cron {
|
||||
expr: "30 12 * * *".into(),
|
||||
tz: Some(String::new()),
|
||||
};
|
||||
let schedule_none = CronSchedule::Cron {
|
||||
expr: "30 12 * * *".into(),
|
||||
tz: None,
|
||||
};
|
||||
let now = Utc::now();
|
||||
assert_eq!(
|
||||
compute_next_run_after(&schedule_empty, now),
|
||||
compute_next_run_after(&schedule_none, now)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_tz_invalid_falls_back_to_utc() {
|
||||
// An invalid timezone string should fall back to UTC, not panic.
|
||||
let schedule_bad = CronSchedule::Cron {
|
||||
expr: "0 9 * * *".into(),
|
||||
tz: Some("Not/A_Timezone".into()),
|
||||
};
|
||||
let schedule_utc = CronSchedule::Cron {
|
||||
expr: "0 9 * * *".into(),
|
||||
tz: None,
|
||||
};
|
||||
let now = Utc::now();
|
||||
let next_bad = compute_next_run_after(&schedule_bad, now);
|
||||
let next_utc = compute_next_run_after(&schedule_utc, now);
|
||||
// Invalid tz falls back to UTC computation — same result.
|
||||
assert_eq!(next_bad, next_utc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_tz_asia_shanghai() {
|
||||
// "0 8 * * *" in Asia/Shanghai (UTC+8) should fire at 00:00 UTC.
|
||||
let schedule = CronSchedule::Cron {
|
||||
expr: "0 8 * * *".into(),
|
||||
tz: Some("Asia/Shanghai".into()),
|
||||
};
|
||||
let now = Utc::now();
|
||||
let next = compute_next_run_after(&schedule, now);
|
||||
|
||||
let shanghai_tz: chrono_tz::Tz = "Asia/Shanghai".parse().unwrap();
|
||||
let local = next.with_timezone(&shanghai_tz);
|
||||
assert_eq!(local.hour(), 8);
|
||||
assert_eq!(local.minute(), 0);
|
||||
|
||||
// In UTC, 08:00 Shanghai = 00:00 UTC.
|
||||
assert_eq!(next.hour(), 0, "08:00 CST should be 00:00 UTC");
|
||||
}
|
||||
|
||||
// -- reassign_agent_jobs (#461) -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_jobs_basic() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let old_agent = AgentId::new();
|
||||
let new_agent = AgentId::new();
|
||||
|
||||
let mut j1 = make_job(old_agent);
|
||||
j1.name = "cron-a".into();
|
||||
let mut j2 = make_job(old_agent);
|
||||
j2.name = "cron-b".into();
|
||||
|
||||
let id1 = sched.add_job(j1, false).unwrap();
|
||||
let id2 = sched.add_job(j2, false).unwrap();
|
||||
|
||||
let count = sched.reassign_agent_jobs(old_agent, new_agent);
|
||||
assert_eq!(count, 2);
|
||||
|
||||
// Both jobs should now belong to the new agent
|
||||
let job1 = sched.get_job(id1).unwrap();
|
||||
assert_eq!(job1.agent_id, new_agent);
|
||||
let job2 = sched.get_job(id2).unwrap();
|
||||
assert_eq!(job2.agent_id, new_agent);
|
||||
|
||||
// Old agent should have zero jobs
|
||||
assert!(sched.list_jobs(old_agent).is_empty());
|
||||
// New agent should have both
|
||||
assert_eq!(sched.list_jobs(new_agent).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_jobs_does_not_touch_other_agents() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let agent_a = AgentId::new();
|
||||
let agent_b = AgentId::new();
|
||||
let agent_c = AgentId::new();
|
||||
|
||||
let mut ja = make_job(agent_a);
|
||||
ja.name = "job-a".into();
|
||||
let mut jb = make_job(agent_b);
|
||||
jb.name = "job-b".into();
|
||||
|
||||
let _id_a = sched.add_job(ja, false).unwrap();
|
||||
let id_b = sched.add_job(jb, false).unwrap();
|
||||
|
||||
// Reassign agent_a -> agent_c
|
||||
let count = sched.reassign_agent_jobs(agent_a, agent_c);
|
||||
assert_eq!(count, 1);
|
||||
|
||||
// agent_b's job should be untouched
|
||||
let job_b = sched.get_job(id_b).unwrap();
|
||||
assert_eq!(job_b.agent_id, agent_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_jobs_no_match_returns_zero() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let agent = AgentId::new();
|
||||
let other = AgentId::new();
|
||||
|
||||
let job = make_job(agent);
|
||||
sched.add_job(job, false).unwrap();
|
||||
|
||||
// Reassign a non-existent agent
|
||||
let count = sched.reassign_agent_jobs(AgentId::new(), other);
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_jobs_resets_consecutive_errors() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let old_agent = AgentId::new();
|
||||
let new_agent = AgentId::new();
|
||||
|
||||
let job = make_job(old_agent);
|
||||
let id = sched.add_job(job, false).unwrap();
|
||||
|
||||
// Simulate some failures
|
||||
sched.record_failure(id, "agent not found");
|
||||
sched.record_failure(id, "agent not found");
|
||||
let meta = sched.get_meta(id).unwrap();
|
||||
assert_eq!(meta.consecutive_errors, 2);
|
||||
|
||||
// Reassign
|
||||
sched.reassign_agent_jobs(old_agent, new_agent);
|
||||
|
||||
// Errors should be reset
|
||||
let meta = sched.get_meta(id).unwrap();
|
||||
assert_eq!(meta.consecutive_errors, 0);
|
||||
assert_eq!(meta.job.agent_id, new_agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_jobs_reenables_disabled_stale_jobs() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let old_agent = AgentId::new();
|
||||
let new_agent = AgentId::new();
|
||||
|
||||
let job = make_job(old_agent);
|
||||
let id = sched.add_job(job, false).unwrap();
|
||||
|
||||
// Simulate enough failures to auto-disable (with "not found" message)
|
||||
for _ in 0..MAX_CONSECUTIVE_ERRORS {
|
||||
sched.record_failure(id, "No such agent");
|
||||
}
|
||||
let meta = sched.get_meta(id).unwrap();
|
||||
assert!(!meta.job.enabled, "Job should be auto-disabled");
|
||||
|
||||
// Reassign should re-enable it
|
||||
sched.reassign_agent_jobs(old_agent, new_agent);
|
||||
|
||||
let meta = sched.get_meta(id).unwrap();
|
||||
assert!(
|
||||
meta.job.enabled,
|
||||
"Job should be re-enabled after reassignment"
|
||||
);
|
||||
assert_eq!(meta.consecutive_errors, 0);
|
||||
assert_eq!(meta.job.agent_id, new_agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_jobs_persists_after_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let old_agent = AgentId::new();
|
||||
let new_agent = AgentId::new();
|
||||
|
||||
// Create scheduler, add job, reassign, persist
|
||||
let id = {
|
||||
let sched = CronScheduler::new(tmp.path(), 100);
|
||||
let job = make_job(old_agent);
|
||||
let id = sched.add_job(job, false).unwrap();
|
||||
|
||||
sched.reassign_agent_jobs(old_agent, new_agent);
|
||||
sched.persist().unwrap();
|
||||
id
|
||||
};
|
||||
|
||||
// Load from disk and verify the agent_id was persisted
|
||||
{
|
||||
let sched = CronScheduler::new(tmp.path(), 100);
|
||||
sched.load().unwrap();
|
||||
|
||||
let job = sched.get_job(id).unwrap();
|
||||
assert_eq!(job.agent_id, new_agent);
|
||||
assert!(sched.list_jobs(old_agent).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// -- remove_agent_jobs (#504) -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_remove_agent_jobs_basic() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let agent = AgentId::new();
|
||||
let other = AgentId::new();
|
||||
|
||||
let mut j1 = make_job(agent);
|
||||
j1.name = "job-a".into();
|
||||
let mut j2 = make_job(agent);
|
||||
j2.name = "job-b".into();
|
||||
let mut j3 = make_job(other);
|
||||
j3.name = "job-other".into();
|
||||
|
||||
sched.add_job(j1, false).unwrap();
|
||||
sched.add_job(j2, false).unwrap();
|
||||
let id3 = sched.add_job(j3, false).unwrap();
|
||||
|
||||
assert_eq!(sched.total_jobs(), 3);
|
||||
|
||||
let removed = sched.remove_agent_jobs(agent);
|
||||
assert_eq!(removed, 2);
|
||||
assert_eq!(sched.total_jobs(), 1);
|
||||
|
||||
// The other agent's job should still exist
|
||||
assert!(sched.list_jobs(agent).is_empty());
|
||||
assert_eq!(sched.list_jobs(other).len(), 1);
|
||||
assert!(sched.get_job(id3).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_agent_jobs_no_match() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
let agent = AgentId::new();
|
||||
|
||||
let job = make_job(agent);
|
||||
sched.add_job(job, false).unwrap();
|
||||
|
||||
// Remove for a non-existent agent
|
||||
let removed = sched.remove_agent_jobs(AgentId::new());
|
||||
assert_eq!(removed, 0);
|
||||
assert_eq!(sched.total_jobs(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_agent_jobs_persists() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = AgentId::new();
|
||||
let other = AgentId::new();
|
||||
|
||||
// Add jobs for two agents, remove one agent's jobs, persist
|
||||
{
|
||||
let sched = CronScheduler::new(tmp.path(), 100);
|
||||
let mut j1 = make_job(agent);
|
||||
j1.name = "doomed".into();
|
||||
let mut j2 = make_job(other);
|
||||
j2.name = "survivor".into();
|
||||
|
||||
sched.add_job(j1, false).unwrap();
|
||||
sched.add_job(j2, false).unwrap();
|
||||
|
||||
sched.remove_agent_jobs(agent);
|
||||
sched.persist().unwrap();
|
||||
}
|
||||
|
||||
// Reload and verify
|
||||
{
|
||||
let sched = CronScheduler::new(tmp.path(), 100);
|
||||
sched.load().unwrap();
|
||||
assert_eq!(sched.total_jobs(), 1);
|
||||
assert!(sched.list_jobs(agent).is_empty());
|
||||
assert_eq!(sched.list_jobs(other).len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
//! each running agent's `last_active` timestamp. If an agent hasn't been active
|
||||
//! for longer than 2x its heartbeat interval, a `HealthCheckFailed` event is
|
||||
//! published to the event bus.
|
||||
//!
|
||||
//! Crashed agents are tracked for auto-recovery: the heartbeat will attempt to
|
||||
//! reset crashed agents back to Running up to `max_recovery_attempts` times.
|
||||
//! After exhausting attempts, agents are marked as Terminated (dead).
|
||||
|
||||
use crate::registry::AgentRegistry;
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use openfang_types::agent::{AgentId, AgentState};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
@@ -17,6 +22,12 @@ const DEFAULT_CHECK_INTERVAL_SECS: u64 = 30;
|
||||
/// multiples of its heartbeat interval.
|
||||
const UNRESPONSIVE_MULTIPLIER: u64 = 2;
|
||||
|
||||
/// Default maximum recovery attempts before giving up.
|
||||
const DEFAULT_MAX_RECOVERY_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// Default cooldown between recovery attempts (seconds).
|
||||
const DEFAULT_RECOVERY_COOLDOWN_SECS: u64 = 60;
|
||||
|
||||
/// Result of a heartbeat check.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeartbeatStatus {
|
||||
@@ -28,6 +39,8 @@ pub struct HeartbeatStatus {
|
||||
pub inactive_secs: i64,
|
||||
/// Whether the agent is considered unresponsive.
|
||||
pub unresponsive: bool,
|
||||
/// Current agent state.
|
||||
pub state: AgentState,
|
||||
}
|
||||
|
||||
/// Heartbeat monitor configuration.
|
||||
@@ -38,18 +51,82 @@ pub struct HeartbeatConfig {
|
||||
/// Default threshold for unresponsiveness (seconds).
|
||||
/// Overridden per-agent by AutonomousConfig.heartbeat_interval_secs.
|
||||
pub default_timeout_secs: u64,
|
||||
/// Maximum recovery attempts before marking agent as Terminated.
|
||||
pub max_recovery_attempts: u32,
|
||||
/// Minimum seconds between recovery attempts for the same agent.
|
||||
pub recovery_cooldown_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for HeartbeatConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
check_interval_secs: DEFAULT_CHECK_INTERVAL_SECS,
|
||||
default_timeout_secs: DEFAULT_CHECK_INTERVAL_SECS * UNRESPONSIVE_MULTIPLIER,
|
||||
// 180s default: browser tasks and complex LLM calls can take 1-3 minutes
|
||||
default_timeout_secs: 180,
|
||||
max_recovery_attempts: DEFAULT_MAX_RECOVERY_ATTEMPTS,
|
||||
recovery_cooldown_secs: DEFAULT_RECOVERY_COOLDOWN_SECS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check all running agents and return their heartbeat status.
|
||||
/// Tracks per-agent recovery state across heartbeat cycles.
|
||||
#[derive(Debug)]
|
||||
pub struct RecoveryTracker {
|
||||
/// Per-agent recovery state: (consecutive_failures, last_attempt_epoch_secs).
|
||||
state: DashMap<AgentId, (u32, u64)>,
|
||||
}
|
||||
|
||||
impl RecoveryTracker {
|
||||
/// Create a new recovery tracker.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a recovery attempt for an agent.
|
||||
/// Returns the current attempt number (1-indexed).
|
||||
pub fn record_attempt(&self, agent_id: AgentId) -> u32 {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let mut entry = self.state.entry(agent_id).or_insert((0, 0));
|
||||
entry.0 += 1;
|
||||
entry.1 = now;
|
||||
entry.0
|
||||
}
|
||||
|
||||
/// Check if enough time has passed since the last recovery attempt.
|
||||
pub fn can_attempt(&self, agent_id: AgentId, cooldown_secs: u64) -> bool {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
match self.state.get(&agent_id) {
|
||||
Some(entry) => now.saturating_sub(entry.1) >= cooldown_secs,
|
||||
None => true, // No prior attempts
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current failure count for an agent.
|
||||
pub fn failure_count(&self, agent_id: AgentId) -> u32 {
|
||||
self.state.get(&agent_id).map(|e| e.0).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Reset recovery state for an agent (e.g. after successful recovery).
|
||||
pub fn reset(&self, agent_id: AgentId) {
|
||||
self.state.remove(&agent_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RecoveryTracker {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check all running and crashed agents and return their heartbeat status.
|
||||
///
|
||||
/// This is a pure function — it doesn't start a background task.
|
||||
/// The caller (kernel) can run this periodically or in a background task.
|
||||
@@ -58,9 +135,10 @@ pub fn check_agents(registry: &AgentRegistry, config: &HeartbeatConfig) -> Vec<H
|
||||
let mut statuses = Vec::new();
|
||||
|
||||
for entry_ref in registry.list() {
|
||||
// Only check running agents
|
||||
if entry_ref.state != AgentState::Running {
|
||||
continue;
|
||||
// Check Running agents (for unresponsiveness) and Crashed agents (for recovery)
|
||||
match entry_ref.state {
|
||||
AgentState::Running | AgentState::Crashed => {}
|
||||
_ => continue,
|
||||
}
|
||||
|
||||
let inactive_secs = (now - entry_ref.last_active).num_seconds();
|
||||
@@ -73,15 +151,22 @@ pub fn check_agents(registry: &AgentRegistry, config: &HeartbeatConfig) -> Vec<H
|
||||
.map(|a| a.heartbeat_interval_secs * UNRESPONSIVE_MULTIPLIER)
|
||||
.unwrap_or(config.default_timeout_secs) as i64;
|
||||
|
||||
let unresponsive = inactive_secs > timeout_secs;
|
||||
// Crashed agents are always considered unresponsive
|
||||
let unresponsive = entry_ref.state == AgentState::Crashed || inactive_secs > timeout_secs;
|
||||
|
||||
if unresponsive {
|
||||
if unresponsive && entry_ref.state == AgentState::Running {
|
||||
warn!(
|
||||
agent = %entry_ref.name,
|
||||
inactive_secs,
|
||||
timeout_secs,
|
||||
"Agent is unresponsive"
|
||||
);
|
||||
} else if entry_ref.state == AgentState::Crashed {
|
||||
warn!(
|
||||
agent = %entry_ref.name,
|
||||
inactive_secs,
|
||||
"Agent is crashed — eligible for recovery"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
agent = %entry_ref.name,
|
||||
@@ -95,6 +180,7 @@ pub fn check_agents(registry: &AgentRegistry, config: &HeartbeatConfig) -> Vec<H
|
||||
name: entry_ref.name.clone(),
|
||||
inactive_secs,
|
||||
unresponsive,
|
||||
state: entry_ref.state,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,7 +287,7 @@ mod tests {
|
||||
fn test_heartbeat_config_default() {
|
||||
let config = HeartbeatConfig::default();
|
||||
assert_eq!(config.check_interval_secs, 30);
|
||||
assert_eq!(config.default_timeout_secs, 60);
|
||||
assert_eq!(config.default_timeout_secs, 180);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -220,18 +306,21 @@ mod tests {
|
||||
name: "agent-1".to_string(),
|
||||
inactive_secs: 10,
|
||||
unresponsive: false,
|
||||
state: AgentState::Running,
|
||||
},
|
||||
HeartbeatStatus {
|
||||
agent_id: AgentId::new(),
|
||||
name: "agent-2".to_string(),
|
||||
inactive_secs: 120,
|
||||
unresponsive: true,
|
||||
state: AgentState::Running,
|
||||
},
|
||||
HeartbeatStatus {
|
||||
agent_id: AgentId::new(),
|
||||
name: "agent-3".to_string(),
|
||||
inactive_secs: 5,
|
||||
unresponsive: false,
|
||||
state: AgentState::Running,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -242,4 +331,27 @@ mod tests {
|
||||
assert_eq!(summary.unresponsive_agents.len(), 1);
|
||||
assert_eq!(summary.unresponsive_agents[0].name, "agent-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recovery_tracker() {
|
||||
let tracker = RecoveryTracker::new();
|
||||
let agent_id = AgentId::new();
|
||||
|
||||
assert_eq!(tracker.failure_count(agent_id), 0);
|
||||
assert!(tracker.can_attempt(agent_id, 60));
|
||||
|
||||
let attempt = tracker.record_attempt(agent_id);
|
||||
assert_eq!(attempt, 1);
|
||||
assert_eq!(tracker.failure_count(agent_id), 1);
|
||||
|
||||
// Just recorded — cooldown should block (unless cooldown is 0)
|
||||
assert!(!tracker.can_attempt(agent_id, 60));
|
||||
assert!(tracker.can_attempt(agent_id, 0));
|
||||
|
||||
let attempt = tracker.record_attempt(agent_id);
|
||||
assert_eq!(attempt, 2);
|
||||
|
||||
tracker.reset(agent_id);
|
||||
assert_eq!(tracker.failure_count(agent_id), 0);
|
||||
}
|
||||
}
|
||||
|
||||
+1190
-233
File diff suppressed because it is too large
Load Diff
@@ -128,6 +128,7 @@ impl MeteringEngine {
|
||||
0.0
|
||||
},
|
||||
alert_threshold: budget.alert_threshold,
|
||||
default_max_llm_tokens_per_hour: budget.default_max_llm_tokens_per_hour,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +225,8 @@ pub struct BudgetStatus {
|
||||
pub monthly_limit: f64,
|
||||
pub monthly_pct: f64,
|
||||
pub alert_threshold: f64,
|
||||
/// Global default token limit per agent per hour (0 = use per-agent values).
|
||||
pub default_max_llm_tokens_per_hour: u64,
|
||||
}
|
||||
|
||||
/// Returns (input_per_million, output_per_million) pricing for a model.
|
||||
@@ -343,11 +346,24 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
return (0.40, 0.40);
|
||||
}
|
||||
|
||||
// ── Chutes.ai ──────────────────────────────────────────────
|
||||
if model.contains("chutes") {
|
||||
return (0.25, 0.35);
|
||||
}
|
||||
|
||||
// ── Venice.ai ──────────────────────────────────────────────
|
||||
if model.contains("venice") {
|
||||
return (0.20, 0.90);
|
||||
}
|
||||
|
||||
// ── NVIDIA NIM ──────────────────────────────────────────────
|
||||
if model.contains("nemotron-4-340b") {
|
||||
return (4.20, 4.20);
|
||||
}
|
||||
if model.contains("nemotron") {
|
||||
return (0.88, 0.88);
|
||||
}
|
||||
|
||||
// ── Open-source (Groq, Together, etc.) ─────────────────────
|
||||
if model.contains("llama-4-maverick") {
|
||||
return (0.50, 0.77);
|
||||
@@ -376,22 +392,34 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
}
|
||||
|
||||
// ── MiniMax ──────────────────────────────────────────────────
|
||||
if model.contains("minimax") {
|
||||
if model.contains("minimax") || model.contains("abab") {
|
||||
if model.contains("highspeed") {
|
||||
return (0.80, 3.20);
|
||||
}
|
||||
if model.contains("m2.5") {
|
||||
return (1.10, 4.40);
|
||||
}
|
||||
if model.contains("abab7") {
|
||||
return (0.80, 2.40);
|
||||
}
|
||||
return (1.00, 3.00);
|
||||
}
|
||||
|
||||
// ── Zhipu / GLM ─────────────────────────────────────────────
|
||||
if model.contains("glm-5") {
|
||||
return (2.00, 8.00);
|
||||
return (1.00, 3.20);
|
||||
}
|
||||
if model.contains("glm-4.7") {
|
||||
return (1.50, 5.00);
|
||||
return (0.60, 2.20);
|
||||
}
|
||||
if model.contains("glm-4-flash") {
|
||||
return (0.10, 0.10);
|
||||
if model.contains("glm-4-flash") || model.contains("glm-4.5-flash") {
|
||||
return (0.0, 0.0); // free tier
|
||||
}
|
||||
if model.contains("glm-4.5") {
|
||||
return (0.60, 2.20);
|
||||
}
|
||||
if model.contains("glm") {
|
||||
return (1.50, 5.00);
|
||||
return (0.60, 2.20);
|
||||
}
|
||||
if model.contains("codegeex") {
|
||||
return (0.10, 0.10);
|
||||
|
||||
@@ -248,8 +248,12 @@ impl AgentRegistry {
|
||||
|
||||
/// Update an agent's name (also updates the name index).
|
||||
pub fn update_name(&self, id: AgentId, new_name: String) -> OpenFangResult<()> {
|
||||
if self.name_index.contains_key(&new_name) {
|
||||
return Err(OpenFangError::AgentAlreadyExists(new_name));
|
||||
if let Some(existing_id) = self.name_index.get(&new_name).as_deref().copied() {
|
||||
if existing_id != id {
|
||||
return Err(OpenFangError::AgentAlreadyExists(new_name));
|
||||
}
|
||||
// Same agent owns this name — no-op
|
||||
return Ok(());
|
||||
}
|
||||
let mut entry = self
|
||||
.agents
|
||||
@@ -284,6 +288,7 @@ impl AgentRegistry {
|
||||
hourly: Option<f64>,
|
||||
daily: Option<f64>,
|
||||
monthly: Option<f64>,
|
||||
tokens_per_hour: Option<u64>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
@@ -298,6 +303,9 @@ impl AgentRegistry {
|
||||
if let Some(v) = monthly {
|
||||
entry.manifest.resources.max_cost_per_month_usd = v;
|
||||
}
|
||||
if let Some(v) = tokens_per_hour {
|
||||
entry.manifest.resources.max_llm_tokens_per_hour = v;
|
||||
}
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -88,8 +88,7 @@ impl AgentScheduler {
|
||||
// Reset the window if an hour has passed
|
||||
tracker.reset_if_expired();
|
||||
|
||||
if quota.max_llm_tokens_per_hour > 0
|
||||
&& tracker.total_tokens > quota.max_llm_tokens_per_hour
|
||||
if quota.max_llm_tokens_per_hour > 0 && tracker.total_tokens > quota.max_llm_tokens_per_hour
|
||||
{
|
||||
return Err(OpenFangError::QuotaExceeded(format!(
|
||||
"Token limit exceeded: {} / {}",
|
||||
@@ -130,6 +129,19 @@ impl AgentScheduler {
|
||||
.get(&agent_id)
|
||||
.map(|t| (t.total_tokens, t.tool_calls))
|
||||
}
|
||||
|
||||
/// Returns remaining token headroom before quota is hit.
|
||||
/// Returns `None` if no token quota is configured (unlimited).
|
||||
pub fn token_headroom(&self, agent_id: AgentId) -> Option<u64> {
|
||||
let quota = self.quotas.get(&agent_id)?;
|
||||
if quota.max_llm_tokens_per_hour == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut tracker = self.usage.get_mut(&agent_id)?;
|
||||
tracker.reset_if_expired();
|
||||
let used = tracker.total_tokens;
|
||||
Some(quota.max_llm_tokens_per_hour.saturating_sub(used))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AgentScheduler {
|
||||
|
||||
@@ -143,6 +143,105 @@ impl TriggerEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Take all triggers for an agent, removing them from the engine.
|
||||
///
|
||||
/// Returns the extracted triggers so they can be restored under a
|
||||
/// different agent ID via [`restore_triggers`]. This is used during
|
||||
/// hand reactivation: triggers must be saved before `kill_agent`
|
||||
/// destroys them, then restored with the new agent ID after spawn.
|
||||
pub fn take_agent_triggers(&self, agent_id: AgentId) -> Vec<Trigger> {
|
||||
let trigger_ids = self
|
||||
.agent_triggers
|
||||
.remove(&agent_id)
|
||||
.map(|(_, ids)| ids)
|
||||
.unwrap_or_default();
|
||||
let mut taken = Vec::with_capacity(trigger_ids.len());
|
||||
for id in trigger_ids {
|
||||
if let Some((_, t)) = self.triggers.remove(&id) {
|
||||
taken.push(t);
|
||||
}
|
||||
}
|
||||
if !taken.is_empty() {
|
||||
info!(
|
||||
agent = %agent_id,
|
||||
count = taken.len(),
|
||||
"Took triggers for agent (pending reassignment)"
|
||||
);
|
||||
}
|
||||
taken
|
||||
}
|
||||
|
||||
/// Restore previously taken triggers under a new agent ID.
|
||||
///
|
||||
/// Each trigger keeps its original pattern, prompt template, fire count,
|
||||
/// and max_fires, but is re-keyed to `new_agent_id`. New trigger IDs are
|
||||
/// generated so there are no stale references.
|
||||
///
|
||||
/// Returns the number of triggers restored.
|
||||
pub fn restore_triggers(&self, new_agent_id: AgentId, triggers: Vec<Trigger>) -> usize {
|
||||
let count = triggers.len();
|
||||
for old in triggers {
|
||||
let new_id = TriggerId::new();
|
||||
let trigger = Trigger {
|
||||
id: new_id,
|
||||
agent_id: new_agent_id,
|
||||
pattern: old.pattern,
|
||||
prompt_template: old.prompt_template,
|
||||
enabled: old.enabled,
|
||||
created_at: old.created_at,
|
||||
fire_count: old.fire_count,
|
||||
max_fires: old.max_fires,
|
||||
};
|
||||
self.triggers.insert(new_id, trigger);
|
||||
self.agent_triggers
|
||||
.entry(new_agent_id)
|
||||
.or_default()
|
||||
.push(new_id);
|
||||
}
|
||||
if count > 0 {
|
||||
info!(
|
||||
agent = %new_agent_id,
|
||||
count,
|
||||
"Restored triggers under new agent"
|
||||
);
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Reassign all triggers from one agent to another in place.
|
||||
///
|
||||
/// Used during cold boot when the old agent ID (from persisted state) no
|
||||
/// longer exists and a new agent was spawned. Updates the `agent_id` field
|
||||
/// on each trigger and moves the index entry.
|
||||
///
|
||||
/// Returns the number of triggers reassigned.
|
||||
pub fn reassign_agent_triggers(&self, old_agent_id: AgentId, new_agent_id: AgentId) -> usize {
|
||||
let trigger_ids = self
|
||||
.agent_triggers
|
||||
.remove(&old_agent_id)
|
||||
.map(|(_, ids)| ids)
|
||||
.unwrap_or_default();
|
||||
let count = trigger_ids.len();
|
||||
for id in &trigger_ids {
|
||||
if let Some(mut t) = self.triggers.get_mut(id) {
|
||||
t.agent_id = new_agent_id;
|
||||
}
|
||||
}
|
||||
if !trigger_ids.is_empty() {
|
||||
self.agent_triggers
|
||||
.entry(new_agent_id)
|
||||
.or_default()
|
||||
.extend(trigger_ids);
|
||||
info!(
|
||||
old_agent = %old_agent_id,
|
||||
new_agent = %new_agent_id,
|
||||
count,
|
||||
"Reassigned triggers to new agent"
|
||||
);
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Enable or disable a trigger. Returns true if the trigger was found.
|
||||
pub fn set_enabled(&self, trigger_id: TriggerId, enabled: bool) -> bool {
|
||||
if let Some(mut t) = self.triggers.get_mut(&trigger_id) {
|
||||
@@ -278,7 +377,7 @@ fn describe_event(event: &Event) -> String {
|
||||
tr.tool_id,
|
||||
if tr.success { "succeeded" } else { "failed" },
|
||||
tr.execution_time_ms,
|
||||
&tr.content[..tr.content.len().min(200)]
|
||||
openfang_types::truncate_str(&tr.content, 200)
|
||||
)
|
||||
}
|
||||
EventPayload::MemoryUpdate(delta) => {
|
||||
@@ -508,4 +607,128 @@ mod tests {
|
||||
);
|
||||
assert_eq!(engine.evaluate(&event).len(), 1);
|
||||
}
|
||||
|
||||
// -- reassign_agent_triggers (#519) ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_triggers_basic() {
|
||||
let engine = TriggerEngine::new();
|
||||
let old_agent = AgentId::new();
|
||||
let new_agent = AgentId::new();
|
||||
engine.register(old_agent, TriggerPattern::All, "a".to_string(), 0);
|
||||
engine.register(old_agent, TriggerPattern::System, "b".to_string(), 0);
|
||||
|
||||
let count = engine.reassign_agent_triggers(old_agent, new_agent);
|
||||
assert_eq!(count, 2);
|
||||
assert_eq!(engine.list_agent_triggers(old_agent).len(), 0);
|
||||
assert_eq!(engine.list_agent_triggers(new_agent).len(), 2);
|
||||
|
||||
// Verify triggers actually fire for the new agent
|
||||
let event = Event::new(
|
||||
AgentId::new(),
|
||||
EventTarget::Broadcast,
|
||||
EventPayload::System(SystemEvent::HealthCheck {
|
||||
status: "ok".to_string(),
|
||||
}),
|
||||
);
|
||||
let matches = engine.evaluate(&event);
|
||||
assert_eq!(matches.len(), 2);
|
||||
assert!(matches.iter().all(|(id, _)| *id == new_agent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_agent_triggers_no_match_returns_zero() {
|
||||
let engine = TriggerEngine::new();
|
||||
let agent_a = AgentId::new();
|
||||
engine.register(agent_a, TriggerPattern::All, "a".to_string(), 0);
|
||||
|
||||
let count = engine.reassign_agent_triggers(AgentId::new(), AgentId::new());
|
||||
assert_eq!(count, 0);
|
||||
// Original triggers untouched
|
||||
assert_eq!(engine.list_agent_triggers(agent_a).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reassign_does_not_touch_other_agents() {
|
||||
let engine = TriggerEngine::new();
|
||||
let agent_a = AgentId::new();
|
||||
let agent_b = AgentId::new();
|
||||
let agent_c = AgentId::new();
|
||||
engine.register(agent_a, TriggerPattern::All, "a".to_string(), 0);
|
||||
engine.register(agent_b, TriggerPattern::System, "b".to_string(), 0);
|
||||
|
||||
let count = engine.reassign_agent_triggers(agent_a, agent_c);
|
||||
assert_eq!(count, 1);
|
||||
// agent_b untouched
|
||||
assert_eq!(engine.list_agent_triggers(agent_b).len(), 1);
|
||||
assert_eq!(engine.list_agent_triggers(agent_c).len(), 1);
|
||||
}
|
||||
|
||||
// -- take / restore triggers (#519) ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_take_and_restore_triggers() {
|
||||
let engine = TriggerEngine::new();
|
||||
let old_agent = AgentId::new();
|
||||
let new_agent = AgentId::new();
|
||||
engine.register(
|
||||
old_agent,
|
||||
TriggerPattern::ContentMatch {
|
||||
substring: "deploy".to_string(),
|
||||
},
|
||||
"Deploy alert: {{event}}".to_string(),
|
||||
5,
|
||||
);
|
||||
engine.register(old_agent, TriggerPattern::Lifecycle, "lc".to_string(), 0);
|
||||
|
||||
// Take triggers — engine should be empty for old agent
|
||||
let taken = engine.take_agent_triggers(old_agent);
|
||||
assert_eq!(taken.len(), 2);
|
||||
assert_eq!(engine.list_agent_triggers(old_agent).len(), 0);
|
||||
assert_eq!(engine.list_all().len(), 0);
|
||||
|
||||
// Restore under new agent
|
||||
let restored = engine.restore_triggers(new_agent, taken);
|
||||
assert_eq!(restored, 2);
|
||||
assert_eq!(engine.list_agent_triggers(new_agent).len(), 2);
|
||||
|
||||
// Verify patterns and max_fires are preserved
|
||||
let triggers = engine.list_agent_triggers(new_agent);
|
||||
let has_content_match = triggers.iter().any(|t| {
|
||||
matches!(&t.pattern, TriggerPattern::ContentMatch { substring } if substring == "deploy")
|
||||
&& t.max_fires == 5
|
||||
});
|
||||
assert!(
|
||||
has_content_match,
|
||||
"ContentMatch trigger with max_fires=5 should be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_take_empty_returns_empty() {
|
||||
let engine = TriggerEngine::new();
|
||||
let taken = engine.take_agent_triggers(AgentId::new());
|
||||
assert!(taken.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restore_preserves_enabled_state() {
|
||||
let engine = TriggerEngine::new();
|
||||
let old_agent = AgentId::new();
|
||||
let new_agent = AgentId::new();
|
||||
let tid = engine.register(old_agent, TriggerPattern::All, "a".to_string(), 0);
|
||||
engine.set_enabled(tid, false);
|
||||
|
||||
let taken = engine.take_agent_triggers(old_agent);
|
||||
assert_eq!(taken.len(), 1);
|
||||
assert!(!taken[0].enabled);
|
||||
|
||||
engine.restore_triggers(new_agent, taken);
|
||||
let restored = engine.list_agent_triggers(new_agent);
|
||||
assert_eq!(restored.len(), 1);
|
||||
assert!(
|
||||
!restored[0].enabled,
|
||||
"Disabled state should survive take/restore"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user