Compare commits

...
93 Commits
Author SHA1 Message Date
jaberjaber23 52bacf0946 community fixes 2026-03-14 22:49:43 +03:00
jaberjaber23 d55e1b8545 community batch v0.4.0 2026-03-12 23:33:19 +03:00
jaberjaber23 0c059d1dc1 bump v0.3.49 2026-03-12 18:34:16 +03:00
jaberjaber23 b6b8b4ebe1 fix community issues 2026-03-12 16:42:39 +03:00
jaberjaber23 14f4845170 trader dashboard 2026-03-12 06:20:46 +03:00
jaberjaber23 be8a589986 bump v0.3.47 2026-03-12 01:23:35 +03:00
jaberjaber23 951e8d0feb fix 11 issues 2026-03-12 01:22:45 +03:00
jaberjaber23 98f8d1ca79 fix community PRs (inspired by #438 @pandego, #433 @ozekimasaki, #417 @f-liva, #392 @cryptonahue, #410 @hobostay, #413 @castorinop, #275 @woodcoal, #464 @citadelgrad, #419 @shipdocs, #480 @skeltavik, #439 @modship) 2026-03-11 03:25:16 +03:00
jaberjaber23 24f5717ae9 fix streaming-think, cron-orphans 2026-03-10 18:24:33 +03:00
jaberjaber23 f10eefdc0e fix 6 issues 2026-03-10 17:28:06 +03:00
jaberjaber23 edd0fed518 fix 7 issues 2026-03-10 16:53:04 +03:00
jaberjaber23 86b50070e8 fix gemini-schema 2026-03-10 04:09:26 +03:00
jaberjaber23 c6aab08faa fix temperature, free-models 2026-03-10 04:06:24 +03:00
jaberjaber23 62ec09d0ae bump version 2026-03-10 02:16:05 +03:00
jaberjaber23 f6f9cf7e9f fix claude-code 2026-03-10 02:14:49 +03:00
jaberjaber23 56aeb499c9 fix tool-schema 2026-03-10 01:41:33 +03:00
jaberjaber23 b4e6a693f5 version bump 2026-03-10 01:27:39 +03:00
jaberjaber23 48d5418c91 claude code fix
Fix Claude Code provider setup flow: wizard now shows Detect button instead of API key input for keyless providers, TUI wizards include claude-code in provider list, credentials detection checks both .credentials.json paths, subprocess env_clear prevents API key leaks. Fixes #376 #303.
2026-03-10 01:19:02 +03:00
jaberjaber23 cc93ef4571 community fixes
Fix tool name mapping so LLM-hallucinated aliases (fs-write, fsRead, writeFile, etc.) normalize to canonical names before capability check and dispatch (#349). Fix provider keys not loading after dashboard save by creating fresh drivers that read current env vars instead of stale boot-time cache (#465, #458, #355). Fix Moonshot/kimi model IDs and provider inference (#428). Add Telegram message reactions for agent lifecycle feedback (#435). Add configurable api_url for Telegram proxy support (#477). Add Discord ignore_bots config option (#403). Fix openfang init EPERM crash with 7-browser fallback on Linux (#389). Add text-based tool call parsing for models without native function calling — [TOOL_CALL], <tool_call>, bare JSON patterns (#354, #332). Fix pre-existing Windows test failures with cross-platform paths. 1948 tests pass, 0 clippy warnings.
2026-03-10 00:40:45 +03:00
jaberjaber23 3e069798f9 community fixes
Fix 8 issues: empty LLM response after ~4 rounds by re-validating message pairs after history trim (#460), MCP tools permission denied by bypassing ToolInvoke capability filter for extension tools (#352), Telegram photos silently dropped now downloaded and passed as multimodal ContentBlock::Image (#362), workflow visual builder double-click editing and live property updates (#357), Claude Code provider card reflects actual install/auth status (#376), Python 3 detection runs actual command instead of path lookup (#405), hand agent_id persisted for cron job reassignment on restart (#402), CLI sends auth headers on all commands not just stop (#478). 1921 tests pass, 0 clippy warnings.
2026-03-09 23:07:08 +03:00
jaberjaber23 ad10aa5e80 community fixes
Fix 12 GitHub issues: SSE streaming token counts (#stream_options), UTF-8 boundary panics (#472), cron timezone scheduling (#473), TOML multiline system_prompt (#463), dashboard 401 auth interceptor (#468), custom provider env var convention (#471), cron stale agent_id reassignment (#461), concurrent provider probing with cache (#474), model switch provider sync (#466/#387), OpenRouter real models (#385), embedding URL normalization (#395), ZHIPU content format (#384), Fish shell PATH detection (#372). 1915 tests pass, 0 clippy warnings.
2026-03-09 21:19:50 +03:00
jaberjaber23 385aee8e56 fix streaming
- Add stream_options (include_usage) for accurate token counts in streaming mode
- Add fallback for providers that don't support stream_options
- Add SSE stream diagnostic logging
2026-03-09 04:57:04 +03:00
jaberjaber23 a00327abe9 fix auth 2026-03-09 03:19:14 +03:00
jaberjaber23 487555a5e5 bump version 2026-03-09 02:18:51 +03:00
jaberjaber23 9d51426cb4 fix bugs 2026-03-09 02:16:36 +03:00
jaberjaber23 6fab720843 bump version 2026-03-08 22:53:48 +03:00
jaberjaber23 4667f497ef fix csp 2026-03-08 22:51:21 +03:00
jaberjaber23 eba9198827 community fixes 2026-03-08 22:29:54 +03:00
jaberjaber23 f2413949bc shell hardening 2026-03-08 20:20:34 +03:00
jaberjaber23 9e230f423e security hardening 2026-03-08 16:59:48 +03:00
jaberjaber23 8138b7e0e8 version bump 2026-03-08 04:05:39 +03:00
jaberjaber23 cfae867908 batch fixes 2026-03-08 04:04:37 +03:00
jaberjaber23 6857e3cf06 issue fixes 2026-03-08 01:25:20 +03:00
jaberjaber23 772cbdbe38 community fixes 2026-03-07 23:31:38 +03:00
jaberjaber23 b2e2b1a038 version bump 2026-03-07 05:29:52 +03:00
jaberjaber23 d237ecf161 community batch 2026-03-07 04:22:16 +03:00
jaberjaber23 4a3d570155 community hardening 2026-03-07 00:22:25 +03:00
jaberjaber23 ebcdc17c13 default resilience 2026-03-05 22:57:08 +03:00
jaberjaber23 45e06b9bad channel resilience 2026-03-05 21:35:37 +03:00
jaberjaber23 c6b46ccbe1 catalog composite 2026-03-05 20:31:49 +03:00
jaberjaber23 9fc0fe71bf driver resilience 2026-03-05 20:21:03 +03:00
jaberjaber23 06df0795c8 think stripping 2026-03-05 15:41:08 +03:00
jaberjaber23 eafeb6a012 bugfix batch 2026-03-05 15:27:10 +03:00
jaberjaber23 05431509be bugfix batch 2026-03-05 14:53:22 +03:00
jaberjaber23 9d3136e512 bugfix batch 2026-03-05 14:39:43 +03:00
jaberjaber23 60566f22fb bugfix batch 2026-03-05 03:17:37 +03:00
jaberjaber23 50440e4047 bugfix batch 2026-03-05 02:13:48 +03:00
jaberjaber23 f45268aedc stress hardening 2026-03-05 01:21:32 +03:00
jaberjaber23 cc54e14114 bugfix batch 2026-03-05 00:25:25 +03:00
jaberjaber23 1037ef768d bugfix batch 2026-03-04 15:22:10 +03:00
jaberjaber23 c3dcf02e3c bugfix batch 2026-03-04 05:43:38 +03:00
jaberjaber23 b157e3c7e6 issue fixes 2026-03-04 04:11:07 +03:00
jaberjaber23 74ac992420 issue fixes 2026-03-04 03:34:12 +03:00
jaberjaber23 53e1b31777 version bump 2026-03-04 02:08:52 +03:00
jaberjaber23 603a94e560 issue fixes 2026-03-04 02:08:32 +03:00
jaberjaber23 fac4ad33e5 discord bugfixes 2026-03-04 01:17:37 +03:00
jaberjaber23 fe96cd1004 bugfixes batch 2026-03-03 21:26:06 +03:00
jaberjaber23 7c85308cf6 bugfixes batch 2026-03-03 20:28:46 +03:00
jaberjaber23 a4a83b1699 bugfixes batch 2026-03-03 16:54:30 +03:00
jaberjaber23 8942d8c2b6 bugfixes release 2026-03-03 05:20:05 +03:00
jaberjaber23 260dd7a125 version bump 2026-03-03 01:18:02 +03:00
jaberjaber23 294f0e7af8 batch fixes 2026-03-03 01:17:34 +03:00
jaberjaber23 444d82e4d6 batch fixes 2026-03-02 23:49:44 +03:00
jaberjaber23 62e6e0f088 batch fixes 2026-03-02 23:12:19 +03:00
jaberjaber23 d3385f2cdc batch fixes 2026-03-02 20:52:57 +03:00
jaberjaber23 516f163dfb batch fixes 2026-03-02 18:37:56 +03:00
jaberjaber23 a54bb1cd4f batch fixes 2026-03-02 15:14:58 +03:00
jaberjaber23 6f3c4e7778 batch fixes 2026-03-02 05:08:42 +03:00
jaberjaber23 73ad49a3a1 batch fixes 2026-03-02 03:31:59 +03:00
jaberjaber23 7ec0e024c7 v0.2.5 release 2026-03-01 22:27:42 +03:00
jaberjaber23 7c81c187c4 batch fixes 2026-03-01 20:38:12 +03:00
jaberjaber23 7ae80b1b9f batch fixes 2026-03-01 04:16:17 +03:00
jaberjaber23 e58ae3e304 fix providers 2026-03-01 00:26:36 +03:00
jaberjaber23 74f5a91fdd bug fixes 2026-02-28 21:20:48 +03:00
jaberjaber23 6de0447e8c coffee badge 2026-02-28 15:42:03 +03:00
jaberjaber23 45dbf617a7 bump v0.2.0 2026-02-28 15:39:20 +03:00
jaberjaber23 b416bf417f critical fixes 2026-02-28 15:36:46 +03:00
jaberjaber23 1cd47d3905 fix bugs 2026-02-28 04:52:49 +03:00
jaberjaber23 a9124c34f2 real email 2026-02-28 02:24:58 +03:00
jaberjaber23 7bd0185695 bump version 2026-02-27 21:06:43 +03:00
jaberjaber23 9fc7ed87be fix MCP 2026-02-27 20:58:34 +03:00
jaberjaber23 0bb08f4ae1 PR fixes 2026-02-27 20:47:14 +03:00
jaberjaber23 51c1e154d9 MCP guidance 2026-02-27 18:06:17 +03:00
jaberjaber23 036cc14ad5 fix installer 2026-02-27 17:29:18 +03:00
jaberjaber23 b866ae7055 fix installer 2026-02-27 17:27:25 +03:00
jaberjaber23 15a859b7f9 fix issues 2026-02-27 17:13:10 +03:00
jaberjaber23 eda88ba5a2 provider URLs 2026-02-27 15:56:57 +03:00
jaberjaber23 0bb4e6f17b community fixes 2026-02-27 01:58:57 +03:00
jaberjaber23 a1d6776989 fix issues 2026-02-27 00:57:46 +03:00
jaberjaber23 ff408248d1 provider switching 2026-02-26 19:42:52 +03:00
jaberjaber23 94f8c2d60d openssl fix 2026-02-26 17:32:01 +03:00
jaberjaber23 9728eb1118 issue fixes 2026-02-26 14:20:41 +03:00
jaberjaber23 a8fae5f711 RightNow branding 2026-02-26 13:46:59 +03:00
240 changed files with 30961 additions and 3137 deletions
+1
View File
@@ -0,0 +1 @@
github: RightNow-AI
+62
View File
@@ -0,0 +1,62 @@
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: Description
description: What happened?
placeholder: Describe the bug clearly and concisely.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: How can we reproduce this?
placeholder: |
1. Run `openfang start`
2. Open dashboard at http://localhost:4200
3. Click ...
validations:
required: true
- type: input
id: version
attributes:
label: OpenFang Version
description: Output of `openfang -V`
placeholder: "0.3.23"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- Linux (x86_64)
- Linux (aarch64/ARM64)
- macOS (Apple Silicon)
- macOS (Intel)
- Windows
- Android (Termux)
- Other
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / Screenshots
description: Paste relevant logs or attach screenshots.
@@ -0,0 +1,24 @@
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: Description
description: What feature would you like?
placeholder: Describe the feature and why it would be useful.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you tried any workarounds?
- type: textarea
id: context
attributes:
label: Additional Context
description: Any other context, screenshots, or references.
+17
View File
@@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
labels:
- "ci"
+19
View File
@@ -0,0 +1,19 @@
## Summary
<!-- What does this PR do? Link related issues with "Fixes #123". -->
## Changes
<!-- Brief list of what changed. -->
## Testing
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes
- [ ] `cargo test --workspace` passes
- [ ] Live integration tested (if applicable)
## Security
- [ ] No new unsafe code
- [ ] No secrets or API keys in diff
- [ ] User input validated at boundaries
+15 -2
View File
@@ -42,6 +42,11 @@ jobs:
args: "--target x86_64-pc-windows-msvc"
rust_target: x86_64-pc-windows-msvc
- name: Windows ARM64
os: windows-latest
args: "--target aarch64-pc-windows-msvc"
rust_target: aarch64-pc-windows-msvc
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
@@ -138,10 +143,10 @@ jobs:
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
os: ubuntu-22.04
archive: tar.gz
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
os: ubuntu-22.04
archive: tar.gz
- target: x86_64-apple-darwin
os: macos-latest
@@ -152,6 +157,9 @@ jobs:
- target: x86_64-pc-windows-msvc
os: windows-latest
archive: zip
- target: aarch64-pc-windows-msvc
os: windows-latest
archive: zip
steps:
- uses: actions/checkout@v4
@@ -173,6 +181,11 @@ jobs:
- name: Build CLI
if: matrix.target != 'aarch64-unknown-linux-gnu'
run: cargo build --release --target ${{ matrix.target }} --bin openfang
- name: Ad-hoc codesign CLI binary (macOS)
if: runner.os == 'macOS'
run: |
xattr -cr target/${{ matrix.target }}/release/openfang || true
codesign --force --sign - target/${{ matrix.target }}/release/openfang
- name: Package (Unix)
if: matrix.archive == 'tar.gz'
run: |
Generated
+579 -341
View File
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.1.0"
version = "0.4.1"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -49,6 +49,7 @@ 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"] }
@@ -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"
@@ -76,7 +77,7 @@ bytes = "1"
futures = "0.3"
# WebSocket client (for Discord/Slack gateway)
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
url = "2"
# WASM sandbox
@@ -119,9 +120,24 @@ colored = "3"
aes-gcm = "0.10"
argon2 = "0.5"
# HTML entity decoding
html-escape = "0.2"
# Lightweight regex
regex-lite = "0.1"
# Socket options (SO_REUSEADDR)
socket2 = "0.5"
# Zip archive extraction
zip = { version = "2", default-features = false, features = ["deflate"] }
# Email (SMTP + IMAP)
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
imap = "2"
native-tls = "0.2"
mailparse = "0.15"
# Testing
tokio-test = "0.4"
tempfile = "3"
+35 -8
View File
@@ -7,7 +7,7 @@
<p align="center">
Open-source Agent OS built in Rust. 137K LOC. 14 crates. 1,767+ tests. Zero clippy warnings.<br/>
<strong>One binary. Production-grade. Agents that actually work for you.</strong>
<strong>One binary. Battle-tested. Agents that actually work for you.</strong>
</p>
<p align="center">
@@ -19,22 +19,23 @@
<p align="center">
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
<img src="https://img.shields.io/badge/version-0.1.0-green?style=flat-square" alt="v0.1.0" />
<img src="https://img.shields.io/badge/version-0.3.30-green?style=flat-square" alt="v0.3.30" />
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
</p>
---
> **v0.1.0 — First Release (February 2026)**
> **v0.3.30 — Security Hardening Release (March 2026)**
>
> OpenFang is feature-complete but this is the first public release. You may encounter instability, rough edges, or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
> OpenFang is feature-complete but still pre-1.0. You may encounter rough edges or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
---
## What is OpenFang?
OpenFang is a **production-grade Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust.
OpenFang is an **open-source Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust.
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you** — on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
@@ -370,7 +371,7 @@ cargo fmt --all -- --check
## Stability Notice
OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is production-grade. That said:
OpenFang v0.3.30 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
- **Breaking changes** may occur between minor versions until v1.0
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
@@ -381,6 +382,12 @@ We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
---
## Security
To report a security vulnerability, email **jaber@rightnowai.co**. We take all reports seriously and will respond within 48 hours.
---
## License
MIT — use it however you want.
@@ -392,11 +399,31 @@ MIT — use it however you want.
- [Website & Documentation](https://openfang.sh)
- [Quick Start Guide](https://openfang.sh/docs/getting-started)
- [GitHub](https://github.com/RightNow-AI/openfang)
- [Discord](https://discord.gg/sSJqgNnq6X)
- [Twitter / X](https://x.com/openfangg)
---
## Built by RightNow
<p align="center">
<strong>Built with Rust. Secured with 16 layers. Agents that actually work for you.</strong><br/>
<sub>OpenFang is developed by <a href="https://github.com/RightNow-AI">RightNow AI</a></sub>
<a href="https://www.rightnowai.co/">
<img src="public/assets/rightnow-logo.webp" width="60" alt="RightNow Logo" />
</a>
</p>
<p align="center">
OpenFang is built and maintained by <a href="https://x.com/Akashi203"><strong>Jaber</strong></a>, Founder of <a href="https://www.rightnowai.co/"><strong>RightNow</strong></a>.
</p>
<p align="center">
<a href="https://www.rightnowai.co/">Website</a> &bull;
<a href="https://x.com/Akashi203">Twitter / X</a> &bull;
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
</p>
---
<p align="center">
<strong>Built with Rust. Secured with 16 layers. Agents that actually work for you.</strong>
</p>
+2 -2
View File
@@ -4,7 +4,7 @@
| Version | Supported |
|---------|--------------------|
| 0.1.x | :white_check_mark: |
| 0.3.x | :white_check_mark: |
## Reporting a Vulnerability
@@ -14,7 +14,7 @@ If you discover a security vulnerability in OpenFang, please report it responsib
### How to Report
1. Email: **security@openfang.ai**
1. Email: **jaber@rightnowai.co**
2. Include:
- Description of the vulnerability
- Steps to reproduce
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.4
@@ -34,8 +34,8 @@ OUTPUT FORMAT:
- Caveats and limitations"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["architecture", "design", "planning"]
[model]
provider = "deepseek"
model = "deepseek-chat"
provider = "default"
model = "default"
api_key_env = "DEEPSEEK_API_KEY"
max_tokens = 8192
temperature = 0.3
@@ -31,8 +31,8 @@ Output format: Use clear headings, diagrams (ASCII), and structured reasoning.
When asked to review, be honest about weaknesses."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["general", "assistant", "default", "multipurpose", "conversation", "productivity"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.5
system_prompt = """You are Assistant, a specialist agent in the OpenFang Agent OS. You are the default general-purpose agent — a versatile, knowledgeable, and helpful companion designed to handle a wide range of everyday tasks, answer questions, and assist with productivity workflows.
@@ -61,7 +61,7 @@ TOOLS AVAILABLE:
You are reliable, adaptable, and genuinely helpful. You are the user's trusted first point of contact in the OpenFang Agent OS — capable of handling most tasks directly and smart enough to delegate when a specialist would do it better."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["review", "code-quality", "best-practices"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.3
@@ -34,8 +34,8 @@ Rules:
- Focus on things that matter for production"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["coding", "implementation", "rust", "python"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 8192
temperature = 0.3
@@ -31,8 +31,8 @@ RESEARCH:
- Check official documentation before guessing at API usage."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["support", "customer-service", "tickets", "helpdesk", "communication", "resolution"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.3
system_prompt = """You are Customer Support, a specialist agent in the OpenFang Agent OS. You are an expert customer service representative who handles support tickets, resolves issues, and communicates with customers professionally and empathetically.
@@ -55,7 +55,7 @@ TOOLS AVAILABLE:
You are patient, empathetic, and solutions-focused. You turn frustrated customers into satisfied advocates."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.3
@@ -36,8 +36,8 @@ Output format:
- Caveats and limitations"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.2
@@ -37,8 +37,8 @@ OUTPUT FORMAT:
- Prevention: Test or pattern to prevent recurrence"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.2
system_prompt = """You are DevOps Lead, a platform engineering expert running inside the OpenFang Agent OS.
@@ -35,7 +35,7 @@ When designing pipelines:
5. Automated rollback on failure"""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.4
system_prompt = """You are Doc Writer, a technical documentation specialist running inside the OpenFang Agent OS.
@@ -33,7 +33,7 @@ Style guide:
- Consistent formatting and structure"""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["email", "communication", "triage", "drafting", "scheduling", "productivity"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.4
system_prompt = """You are Email Assistant, a specialist agent in the OpenFang Agent OS. Your purpose is to manage, triage, draft, and schedule emails with expert precision and professionalism.
@@ -47,7 +47,7 @@ TOOLS AVAILABLE:
You are thorough, discreet, and efficient. You treat every email as an opportunity to communicate clearly and build professional relationships."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["health", "wellness", "fitness", "medication", "habits", "tracking"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.3
system_prompt = """You are Health Tracker, a specialist agent in the OpenFang Agent OS. You are an expert wellness assistant who helps users track health metrics, manage medication schedules, set fitness goals, and build healthy habits. You are NOT a medical professional and you always make this clear.
+2 -2
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.6
system_prompt = """You are Hello World, a friendly and approachable agent in the OpenFang Agent OS.
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["smart-home", "iot", "automation", "devices", "monitoring", "home"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.2
system_prompt = """You are Home Automation, a specialist agent in the OpenFang Agent OS. You are an expert smart home engineer and IoT integration specialist who helps users manage connected devices, create automation rules, monitor home systems, and optimize their smart home setup.
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["legal", "contracts", "compliance", "research", "review", "documents"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 8192
temperature = 0.2
@@ -58,8 +58,8 @@ DISCLAIMER: You are an AI assistant providing legal information for educational
You are meticulous, cautious, and precise. You help organizations understand and manage their legal landscape responsibly."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["meetings", "notes", "action-items", "agenda", "follow-up", "productivity"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.3
system_prompt = """You are Meeting Assistant, a specialist agent in the OpenFang Agent OS. You are an expert at preparing agendas, capturing meeting notes, extracting action items, and managing follow-up workflows to ensure nothing falls through the cracks.
@@ -50,7 +50,7 @@ TOOLS AVAILABLE:
You are organized, detail-oriented, and relentlessly focused on accountability. You turn chaotic meetings into clear outcomes."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+2 -2
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.1-8b-instant"
provider = "default"
model = "default"
max_tokens = 2048
temperature = 0.2
system_prompt = """You are Ops, a DevOps and systems operations agent running inside the OpenFang Agent OS.
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "deepseek"
model = "deepseek-chat"
provider = "default"
model = "default"
api_key_env = "DEEPSEEK_API_KEY"
max_tokens = 8192
temperature = 0.3
@@ -45,8 +45,8 @@ Always explain your delegation strategy before executing it.
Be thorough but efficient — don't delegate trivially simple tasks."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[schedule]
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["finance", "budget", "expenses", "savings", "planning", "money"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.2
system_prompt = """You are Personal Finance, a specialist agent in the OpenFang Agent OS. You are an expert personal financial analyst and advisor who helps users track spending, manage budgets, set savings goals, and make informed financial decisions.
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.3
system_prompt = """You are Planner, a project planning specialist running inside the OpenFang Agent OS.
@@ -37,7 +37,7 @@ Output format:
### Open Questions"""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["recruiting", "hiring", "resume", "outreach", "talent", "hr"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.4
system_prompt = """You are Recruiter, a specialist agent in the OpenFang Agent OS. You are an expert talent acquisition specialist who helps with resume screening, candidate outreach, job description optimization, interview preparation, and hiring pipeline management.
@@ -55,7 +55,7 @@ TOOLS AVAILABLE:
You are thorough, fair, and people-oriented. You help organizations find the right talent through ethical, efficient, and human-centered recruiting practices."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["research", "analysis", "web"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.5
@@ -36,8 +36,8 @@ OUTPUT:
Always cite your sources. Never present uncertain information as fact."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["sales", "crm", "outreach", "pipeline", "prospecting", "deals"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.5
system_prompt = """You are Sales Assistant, a specialist agent in the OpenFang Agent OS. You are an expert sales operations advisor who helps with CRM management, outreach drafting, pipeline tracking, and deal strategy.
@@ -54,7 +54,7 @@ TOOLS AVAILABLE:
You are strategic, persuasive, and detail-oriented. You help sales teams work smarter and close more deals."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["security", "audit", "vulnerability"]
[model]
provider = "deepseek"
model = "deepseek-chat"
provider = "default"
model = "default"
api_key_env = "DEEPSEEK_API_KEY"
max_tokens = 4096
temperature = 0.2
@@ -37,8 +37,8 @@ Severity levels: CRITICAL / HIGH / MEDIUM / LOW / INFO
Report format: Finding → Impact → Evidence → Remediation"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[schedule]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["social-media", "content", "marketing", "engagement", "scheduling", "analytics"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.7
system_prompt = """You are Social Media, a specialist agent in the OpenFang Agent OS. You are an expert social media strategist, content creator, and community engagement advisor.
@@ -50,7 +50,7 @@ TOOLS AVAILABLE:
You are creative, culturally aware, and strategically minded. You balance creativity with data-driven decision-making."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["testing", "qa", "validation"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.3
@@ -39,8 +39,8 @@ When reviewing test coverage:
- Suggest mutation testing targets"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["translation", "languages", "localization", "multilingual", "communication", "i18n"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.3
system_prompt = """You are Translator, a specialist agent in the OpenFang Agent OS. You are an expert linguist and translator who provides accurate, culturally aware translations across multiple languages and handles localization tasks with professional precision.
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["travel", "planning", "itinerary", "booking", "logistics", "vacation"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.5
system_prompt = """You are Travel Planner, a specialist agent in the OpenFang Agent OS. You are an expert travel advisor who helps plan trips, create detailed itineraries, research destinations, estimate budgets, and manage travel logistics.
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["education", "teaching", "tutoring", "learning", "explanation", "knowledge"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.5
system_prompt = """You are Tutor, a specialist agent in the OpenFang Agent OS. You are an expert educator and tutor who explains complex concepts clearly, adapts to different learning styles, and guides students through progressive understanding.
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.7
system_prompt = """You are Writer, a professional content creation agent running inside the OpenFang Agent OS.
@@ -30,7 +30,7 @@ OUTPUT:
- Adapt formatting to the target platform when specified."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+5 -1
View File
@@ -33,9 +33,13 @@ governor = { workspace = true }
tokio-stream = { workspace = true }
subtle = { workspace = true }
base64 = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
hex = { workspace = true }
socket2 = { workspace = true }
reqwest = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
reqwest = { workspace = true }
tempfile = { workspace = true }
uuid = { workspace = true }
+187 -48
View File
@@ -56,6 +56,8 @@ 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>,
@@ -73,6 +75,33 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
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)
}
async fn find_agent_by_name(&self, name: &str) -> Result<Option<AgentId>, String> {
Ok(self.kernel.registry.find_by_name(name).map(|e| e.id))
}
@@ -351,7 +380,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 +420,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 +436,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 +460,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 +483,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 +522,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 +545,8 @@ 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 +575,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 +612,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 +654,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 +698,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> {
@@ -774,6 +835,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)
@@ -785,6 +847,19 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
)
};
self.kernel.delivery_tracker.record(agent_id, receipt);
// Persist last channel for cron CronDelivery::LastChannel.
// Include thread_id when present so forum-topic context survives restarts.
if success {
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
.structured_set(agent_id, "delivery.last_channel", kv_val);
}
}
async fn check_auto_reply(&self, agent_id: AgentId, message: &str) -> Option<String> {
@@ -884,11 +959,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
msg.push_str(&format!(" {}{}\n", card.name, url));
let desc = &card.description;
if !desc.is_empty() {
let short = if desc.len() > 60 {
&desc[..60]
} else {
desc.as_str()
};
let short = openfang_types::truncate_str(desc, 60);
msg.push_str(&format!(" {short}\n"));
}
}
@@ -929,40 +1000,40 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
}
}
/// Resolve a default agent by name — find running or spawn from manifest.
async fn resolve_default_agent(
handle: &KernelBridgeAdapter,
name: &str,
router: &mut AgentRouter,
adapter_name: &str,
) {
match handle.find_agent_by_name(name).await {
Ok(Some(agent_id)) => {
router.set_default(agent_id);
info!("{adapter_name} default agent: {name} ({agent_id})");
}
_ => match handle.spawn_agent_by_name(name).await {
Ok(agent_id) => {
router.set_default(agent_id);
info!("{adapter_name}: spawned default agent {name} ({agent_id})");
}
Err(e) => {
warn!("{adapter_name}: could not find or spawn default agent '{name}': {e}");
}
},
}
}
/// 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 ");
/// 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) {
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
}
}
@@ -1049,6 +1120,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()));
}
@@ -1060,6 +1132,8 @@ pub async fn start_channel_bridge_with_config(
let adapter = Arc::new(DiscordAdapter::new(
token,
dc_config.allowed_guilds.clone(),
dc_config.allowed_users.clone(),
dc_config.ignore_bots,
dc_config.intents,
));
adapters.push((adapter, dc_config.default_agent.clone()));
@@ -1074,6 +1148,8 @@ 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,
));
adapters.push((adapter, sl_config.default_agent.clone()));
}
@@ -1540,12 +1616,40 @@ pub async fn start_channel_bridge_with_config(
return (None, Vec::new());
}
// Resolve default agent from first adapter that has one configured
// Resolve per-channel default agents AND set the first one as system-wide fallback
let mut router = AgentRouter::new();
for (_, default_agent) in &adapters {
let mut system_default_set = false;
for (adapter, default_agent) in &adapters {
if let Some(ref name) = default_agent {
resolve_default_agent(&handle, name, &mut router, "Channel bridge").await;
break; // Only need one default
// Resolve agent name to ID
let agent_id = match handle.find_agent_by_name(name).await {
Ok(Some(id)) => Some(id),
_ => match handle.spawn_agent_by_name(name).await {
Ok(id) => Some(id),
Err(e) => {
warn!(
"{}: could not find or spawn default agent '{}': {e}",
adapter.name(),
name
);
None
}
},
};
if let Some(agent_id) = agent_id {
// Register per-channel default
let channel_key = format!("{:?}", adapter.channel_type());
info!(
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
adapter.name()
);
router.set_channel_default(channel_key, agent_id);
// First configured default also becomes system-wide fallback
if !system_default_set {
router.set_default(agent_id);
system_default_set = true;
}
}
}
}
@@ -1571,12 +1675,18 @@ pub async fn start_channel_bridge_with_config(
let mut started_names = Vec::new();
for (adapter, _) in adapters {
let name = adapter.name().to_string();
// Register adapter in kernel so agents can use `channel_send` tool
kernel
.channel_adapters
.insert(name.clone(), adapter.clone());
match manager.start_adapter(adapter).await {
Ok(()) => {
info!("{name} channel bridge started");
started_names.push(name);
}
Err(e) => {
// Remove from kernel map if start failed
kernel.channel_adapters.remove(&name);
error!("Failed to start {name} bridge: {e}");
}
}
@@ -1605,6 +1715,35 @@ pub async fn reload_channels_from_disk(
*guard = None;
}
// Re-read secrets.env so new API tokens are available in std::env
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
if secrets_path.exists() {
if let Ok(content) = std::fs::read_to_string(&secrets_path) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let key = trimmed[..eq_pos].trim();
let mut value = trimmed[eq_pos + 1..].trim().to_string();
if !key.is_empty() {
// Strip matching quotes
if ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
&& value.len() >= 2
{
value = value[1..value.len() - 1].to_string();
}
// Always overwrite — the file is the source of truth after dashboard edits
std::env::set_var(key, &value);
}
}
}
info!("Reloaded secrets.env for channel hot-reload");
}
}
// Re-read config from disk
let config_path = state.kernel.config.home_dir.join("config.toml");
let fresh_config = openfang_kernel::config::load_config(Some(&config_path));
+1
View File
@@ -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;
+116 -33
View File
@@ -43,66 +43,121 @@ 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, all requests must include
/// `Authorization: Bearer <api_key>`. If the key is empty, auth is bypassed.
/// When `api_key` is non-empty (after trimming), requests to non-public
/// endpoints must include `Authorization: Bearer <api_key>`.
/// 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> {
// If no API key configured, restrict to loopback addresses only.
if api_key.is_empty() {
// SECURITY: Capture method early for method-aware public endpoint checks.
let method = request.method().clone();
// Shutdown is loopback-only (CLI on same machine) — skip token auth
let path = request.uri().path();
if path == "/api/shutdown" {
let is_loopback = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(false);
if !is_loopback {
tracing::warn!(
"Rejected non-localhost request: no API key configured. \
Set api_key in config.toml for remote access."
);
return Response::builder()
.status(StatusCode::FORBIDDEN)
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"error": "No API key configured. Remote access denied. Configure api_key in ~/.openfang/config.toml"
})
.to_string(),
))
.unwrap_or_default();
.unwrap_or(false); // SECURITY: default-deny — unknown origin is NOT loopback
if is_loopback {
return next.run(request).await;
}
return next.run(request).await;
}
// Public endpoints that don't require auth (dashboard needs these)
let path = request.uri().path();
if path == "/"
// Public endpoints that don't require auth (dashboard needs these).
// SECURITY: /api/agents is GET-only (listing). POST (spawn) requires auth.
// SECURITY: Public endpoints are GET-only unless explicitly noted.
// POST/PUT/DELETE to any endpoint ALWAYS requires auth to prevent
// unauthenticated writes (cron job creation, skill install, etc.).
let is_get = method == axum::http::Method::GET;
let is_public = path == "/"
|| path == "/logo.png"
|| path == "/favicon.ico"
|| (path == "/.well-known/agent.json" && is_get)
|| (path.starts_with("/a2a/") && is_get)
|| path == "/api/health"
|| path == "/api/health/detail"
|| path == "/api/status"
|| path == "/api/version"
|| path == "/api/agents"
|| path == "/api/profiles"
|| path == "/api/config"
|| path.starts_with("/api/uploads/")
{
|| (path == "/api/agents" && is_get)
|| (path == "/api/profiles" && is_get)
|| (path == "/api/config" && is_get)
|| (path == "/api/config/schema" && is_get)
|| (path.starts_with("/api/uploads/") && is_get)
// Dashboard read endpoints — allow unauthenticated so the SPA can
// render before the user enters their API key.
|| (path == "/api/models" && is_get)
|| (path == "/api/models/aliases" && is_get)
|| (path == "/api/providers" && is_get)
|| (path == "/api/budget" && is_get)
|| (path == "/api/budget/agents" && is_get)
|| (path.starts_with("/api/budget/agents/") && is_get)
|| (path == "/api/network/status" && is_get)
|| (path == "/api/a2a/agents" && is_get)
|| (path == "/api/approvals" && is_get)
|| (path.starts_with("/api/approvals/") && is_get)
|| (path == "/api/channels" && is_get)
|| (path == "/api/hands" && is_get)
|| (path == "/api/hands/active" && is_get)
|| (path.starts_with("/api/hands/") && is_get)
|| (path == "/api/skills" && is_get)
|| (path == "/api/sessions" && is_get)
|| (path == "/api/integrations" && is_get)
|| (path == "/api/integrations/available" && is_get)
|| (path == "/api/integrations/health" && is_get)
|| (path == "/api/workflows" && is_get)
|| path == "/api/logs/stream" // SSE stream, read-only
|| (path.starts_with("/api/cron/") && is_get)
|| path.starts_with("/api/providers/github-copilot/oauth/")
|| path == "/api/auth/login"
|| path == "/api/auth/logout"
|| (path == "/api/auth/check" && is_get);
if is_public {
return next.run(request).await;
}
// Check Authorization: Bearer <token> header
// 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
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
let api_token = bearer_token.or_else(|| {
request
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok())
});
// SECURITY: Use constant-time comparison to prevent timing attacks.
let header_auth = bearer_token.map(|token| {
let header_auth = api_token.map(|token| {
use subtle::ConstantTimeEq;
if token.len() != api_key.len() {
return false;
@@ -131,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 {
@@ -148,6 +214,19 @@ 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;
@@ -170,6 +249,10 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
"cache-control",
"no-store, no-cache, must-revalidate".parse().unwrap(),
);
headers.insert(
"strict-transport-security",
"max-age=63072000; includeSubDomains".parse().unwrap(),
);
response
}
+4 -5
View File
@@ -179,9 +179,8 @@ fn resolve_agent(state: &AppState, model: &str) -> Option<(AgentId, String)> {
return Some((entry.id, entry.name.clone()));
}
// 4. Fallback → first registered agent
let agents = state.kernel.registry.list();
agents.first().map(|e| (e.id, e.name.clone()))
// No match — return None so the caller returns a proper 404
None
}
// ── Message conversion ──────────────────────────────────────────────────────
@@ -204,7 +203,7 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
.iter()
.filter_map(|part| match part {
OaiContentPart::Text { text } => {
Some(ContentBlock::Text { text: text.clone() })
Some(ContentBlock::Text { text: text.clone(), provider_metadata: None })
}
OaiContentPart::ImageUrl { image_url } => {
// Parse data URI: data:{media_type};base64,{data}
@@ -336,7 +335,7 @@ pub async fn chat_completions(
index: 0,
message: ChoiceMessage {
role: "assistant",
content: Some(result.response),
content: Some(crate::ws::strip_think_tags(&result.response)),
tool_calls: None,
},
finish_reason: "stop",
File diff suppressed because it is too large Load Diff
+165 -17
View File
@@ -48,11 +48,14 @@ pub async fn build_router(
peer_registry: kernel.peer_registry.as_ref().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![
@@ -100,7 +103,19 @@ 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()
@@ -124,7 +139,7 @@ pub async fn build_router(
)
.route(
"/api/agents/{id}",
axum::routing::get(routes::get_agent).delete(routes::kill_agent),
axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent),
)
.route(
"/api/agents/{id}/mode",
@@ -155,6 +170,10 @@ pub async fn build_router(
"/api/agents/{id}/session/reset",
axum::routing::post(routes::reset_session),
)
.route(
"/api/agents/{id}/history",
axum::routing::delete(routes::clear_agent_history),
)
.route(
"/api/agents/{id}/session/compact",
axum::routing::post(routes::compact_session),
@@ -167,6 +186,10 @@ pub async fn build_router(
"/api/agents/{id}/model",
axum::routing::put(routes::set_model),
)
.route(
"/api/agents/{id}/tools",
axum::routing::get(routes::get_agent_tools).put(routes::set_agent_tools),
)
.route(
"/api/agents/{id}/skills",
axum::routing::get(routes::get_agent_skills).put(routes::set_agent_skills),
@@ -276,6 +299,10 @@ 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),
@@ -311,12 +338,20 @@ pub async fn build_router(
"/api/clawhub/skill/{slug}",
axum::routing::get(routes::clawhub_skill_detail),
)
.route(
"/api/clawhub/skill/{slug}/code",
axum::routing::get(routes::clawhub_skill_code),
)
.route(
"/api/clawhub/install",
axum::routing::post(routes::clawhub_install),
)
// Hands endpoints
.route("/api/hands", axum::routing::get(routes::list_hands))
.route(
"/api/hands/install",
axum::routing::post(routes::install_hand),
)
.route(
"/api/hands/active",
axum::routing::get(routes::list_active_hands),
@@ -334,6 +369,11 @@ pub async fn build_router(
"/api/hands/{hand_id}/install-deps",
axum::routing::post(routes::install_hand_deps),
)
.route(
"/api/hands/{hand_id}/settings",
axum::routing::get(routes::get_hand_settings)
.put(routes::update_hand_settings),
)
.route(
"/api/hands/instances/{id}/pause",
axum::routing::post(routes::pause_hand),
@@ -376,6 +416,31 @@ pub async fn build_router(
"/api/network/status",
axum::routing::get(routes::network_status),
)
// Agent communication (Comms) endpoints
.route(
"/api/comms/topology",
axum::routing::get(routes::comms_topology),
)
.route(
"/api/comms/events",
axum::routing::get(routes::comms_events),
)
.route(
"/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),
)
;
// 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
@@ -420,7 +485,8 @@ pub async fn build_router(
)
.route(
"/api/budget/agents/{id}",
axum::routing::get(routes::agent_budget_status),
axum::routing::get(routes::agent_budget_status)
.put(routes::update_agent_budget),
)
// Session endpoints
.route("/api/sessions", axum::routing::get(routes::list_sessions))
@@ -449,8 +515,25 @@ pub async fn build_router(
"/api/models/aliases",
axum::routing::get(routes::list_aliases),
)
.route(
"/api/models/custom",
axum::routing::post(routes::add_custom_model),
)
.route(
"/api/models/custom/{*id}",
axum::routing::delete(routes::remove_custom_model),
)
.route("/api/models/{*id}", axum::routing::get(routes::get_model))
.route("/api/providers", axum::routing::get(routes::list_providers))
// Copilot OAuth (must be before parametric {name} routes)
.route(
"/api/providers/github-copilot/oauth/start",
axum::routing::post(routes::copilot_oauth_start),
)
.route(
"/api/providers/github-copilot/oauth/poll/{poll_id}",
axum::routing::get(routes::copilot_oauth_poll),
)
.route(
"/api/providers/{name}/key",
axum::routing::post(routes::set_provider_key).delete(routes::delete_provider_key),
@@ -459,6 +542,10 @@ pub async fn build_router(
"/api/providers/{name}/test",
axum::routing::post(routes::test_provider),
)
.route(
"/api/providers/{name}/url",
axum::routing::put(routes::set_provider_url),
)
.route(
"/api/skills/create",
axum::routing::post(routes::create_skill),
@@ -603,8 +690,21 @@ 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(
@@ -674,7 +774,8 @@ pub async fn run_daemon(
if info_path.exists() {
if let Ok(existing) = std::fs::read_to_string(info_path) {
if let Ok(info) = serde_json::from_str::<DaemonInfo>(&existing) {
if is_process_alive(info.pid) {
// PID alive AND the health endpoint responds → truly running
if is_process_alive(info.pid) && is_daemon_responding(&info.listen_addr) {
return Err(format!(
"Another daemon (PID {}) is already running at {}",
info.pid, info.listen_addr
@@ -683,7 +784,8 @@ pub async fn run_daemon(
}
}
}
// Stale PID file, remove it
// Stale PID file (process dead or different process reused PID), remove it
info!("Removing stale daemon info file");
let _ = std::fs::remove_file(info_path);
}
@@ -705,16 +807,32 @@ pub async fn run_daemon(
info!("WebChat UI available at http://{addr}/",);
info!("WebSocket endpoint: ws://{addr}/api/agents/{{id}}/ws",);
let listener = tokio::net::TcpListener::bind(addr).await?;
// Use SO_REUSEADDR to allow binding immediately after reboot (avoids TIME_WAIT).
let socket = socket2::Socket::new(
if addr.is_ipv4() {
socket2::Domain::IPV4
} else {
socket2::Domain::IPV6
},
socket2::Type::STREAM,
None,
)?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(1024)?;
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
// SocketAddr so the auth middleware can check for loopback connections.
let api_shutdown = state.shutdown_notify.clone();
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(shutdown_signal())
.with_graceful_shutdown(shutdown_signal(api_shutdown))
.await?;
// Clean up daemon info file
@@ -752,11 +870,11 @@ pub fn read_daemon_info(home_dir: &Path) -> Option<DaemonInfo> {
serde_json::from_str(&contents).ok()
}
/// Wait for an OS termination signal.
/// Wait for an OS termination signal OR an API shutdown request.
///
/// On Unix: listens for SIGINT and SIGTERM.
/// On Windows: listens for Ctrl+C.
async fn shutdown_signal() {
/// On Unix: listens for SIGINT, SIGTERM, and API notify.
/// On Windows: listens for Ctrl+C and API notify.
async fn shutdown_signal(api_shutdown: Arc<tokio::sync::Notify>) {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
@@ -770,15 +888,22 @@ async fn shutdown_signal() {
_ = sigterm.recv() => {
info!("Received SIGTERM, shutting down...");
}
_ = api_shutdown.notified() => {
info!("Shutdown requested via API, shutting down...");
}
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c()
.await
.expect("Failed to install Ctrl+C handler");
info!("Ctrl+C received, shutting down...");
tokio::select! {
_ = tokio::signal::ctrl_c() => {
info!("Ctrl+C received, shutting down...");
}
_ = api_shutdown.notified() => {
info!("Shutdown requested via API, shutting down...");
}
}
}
}
@@ -817,3 +942,26 @@ fn is_process_alive(pid: u32) -> bool {
false
}
}
/// Check if an OpenFang daemon is actually responding at the given address.
/// This avoids false positives where a different process reused the same PID
/// after a system reboot.
fn is_daemon_responding(addr: &str) -> bool {
// Quick TCP connect check — don't make a full HTTP request to avoid delays
let addr_only = addr
.strip_prefix("http://")
.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()
} else {
// Fallback: try connecting to hostname
std::net::TcpStream::connect(addr_only)
.map(|_| true)
.unwrap_or(false)
}
}
+109
View File
@@ -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"));
}
}
+24 -4
View File
@@ -102,9 +102,15 @@ impl StreamChunker {
}
}
// Priority 4: Forced break at max_chunk_chars
// Priority 4: Forced break at max_chunk_chars (char-boundary safe)
if self.buffer.len() >= self.max_chunk_chars {
let break_at = self.max_chunk_chars;
let mut break_at = self.max_chunk_chars;
while break_at > 0 && !self.buffer.is_char_boundary(break_at) {
break_at -= 1;
}
if break_at == 0 {
break_at = self.buffer.len();
}
let chunk = self.buffer[..break_at].to_string();
self.buffer = self.buffer[break_at..].to_string();
return Some(chunk);
@@ -134,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)]
+9 -2
View File
@@ -2,11 +2,16 @@
use serde::{Deserialize, Serialize};
/// Request to spawn an agent from a TOML manifest string.
/// Request to spawn an agent from a TOML manifest string or a template name.
#[derive(Debug, Deserialize)]
pub struct SpawnRequest {
/// Agent manifest as TOML string.
/// Agent manifest as TOML string (optional if `template` is provided).
#[serde(default)]
pub manifest_toml: String,
/// Template name from `~/.openfang/agents/{template}/agent.toml`.
/// When provided and `manifest_toml` is empty, the template is loaded automatically.
#[serde(default)]
pub template: Option<String>,
/// Optional Ed25519 signed manifest envelope (JSON).
/// When present, the signature is verified before spawning.
#[serde(default)]
@@ -46,6 +51,8 @@ pub struct MessageResponse {
pub input_tokens: u64,
pub output_tokens: u64,
pub iterations: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost_usd: Option<f64>,
}
/// Request to install a skill from the marketplace.
+8 -1
View File
@@ -81,13 +81,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"),
@@ -123,6 +126,10 @@ const WEBCHAT_HTML: &str = concat!(
include_str!("../static/js/pages/wizard.js"),
"\n",
include_str!("../static/js/pages/approvals.js"),
"\n",
include_str!("../static/js/pages/comms.js"),
"\n",
include_str!("../static/js/pages/runtime.js"),
"\n</script>\n",
// Alpine.js MUST be last — it processes x-data and fires alpine:init
"<script>\n",
+110 -12
View File
@@ -146,19 +146,30 @@ 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 {
use subtle::ConstantTimeEq;
if token.len() != key.len() {
return false;
}
token.as_bytes().ct_eq(key.as_bytes()).into()
};
let header_auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|token| token == api_key)
.map(|token| ct_eq(token, api_key))
.unwrap_or(false);
let query_auth = uri
.query()
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
.map(|token| token == api_key)
.map(|token| ct_eq(token, api_key))
.unwrap_or(false);
if !header_auth && !query_auth {
@@ -621,8 +632,12 @@ async fn handle_text_message(
return;
}
// Strip <think>...</think> blocks from model output
// (e.g. MiniMax, DeepSeek reasoning tokens)
let cleaned_response = strip_think_tags(&result.response);
// Guard: ensure we never send an empty response
let content = if result.response.trim().is_empty() {
let content = if cleaned_response.trim().is_empty() {
format!(
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
result.total_usage.input_tokens,
@@ -630,7 +645,7 @@ async fn handle_text_message(
result.iterations,
)
} else {
result.response
cleaned_response
};
// Estimate context pressure from last call
@@ -794,9 +809,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(()) => {
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
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 {
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}")})
@@ -1092,6 +1119,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()
@@ -1099,17 +1129,33 @@ 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 => {
"Model unavailable. Use /model to see options.".to_string()
if inner.contains("localhost:11434") || inner.contains("ollama") {
"Model not found on Ollama. Run `ollama pull <model>` first. Use /model to see options.".to_string()
} else {
format!("{}. Use /model to see options.", classified.sanitized_message)
}
}
llm_errors::LlmErrorCategory::Format => {
// 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,
}
@@ -1117,6 +1163,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..];
@@ -1144,6 +1198,27 @@ fn extract_status_code(s: &str) -> Option<u16> {
None
}
/// Strip `<think>...</think>` blocks from model output.
///
/// Some models (MiniMax, DeepSeek, etc.) wrap their reasoning in `<think>` tags.
/// These are internal chain-of-thought and shouldn't be shown to the user.
pub fn strip_think_tags(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut remaining = text;
while let Some(start) = remaining.find("<think>") {
result.push_str(&remaining[..start]);
if let Some(end) = remaining[start..].find("</think>") {
remaining = &remaining[(start + end + 8)..]; // 8 = "</think>".len()
} else {
// Unclosed <think> tag — strip to end
remaining = "";
break;
}
}
result.push_str(remaining);
result
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -1214,10 +1289,33 @@ 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]
fn test_sanitize_trims_whitespace() {
assert_eq!(sanitize_user_input(" hello "), "hello");
}
#[test]
fn test_strip_think_tags() {
assert_eq!(
strip_think_tags("<think>reasoning here</think>The answer is 42."),
"The answer is 42."
);
assert_eq!(
strip_think_tags("Hello <think>\nsome thinking\n</think> world"),
"Hello world"
);
assert_eq!(strip_think_tags("No thinking here"), "No thinking here");
assert_eq!(strip_think_tags("<think>all thinking</think>"), "");
}
}
+388 -14
View File
@@ -69,6 +69,32 @@
gap: 16px;
}
/* Card-based flex containers for agent chips and similar inline layouts */
.card-flex {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
/* Nested list indentation inside cards, detail panels, and modals */
.card ul, .card ol,
.detail-grid ul, .detail-grid ol,
.modal ul, .modal ol,
.info-card ul, .info-card ol {
padding-left: 18px;
margin: 4px 0;
}
.card ul ul, .card ol ol,
.modal ul ul, .modal ol ol {
padding-left: 16px;
margin: 2px 0;
}
.card li, .modal li, .info-card li {
margin-bottom: 2px;
font-size: 12px;
line-height: 1.5;
}
/* Glow effect on card hover */
.card-glow {
overflow: hidden;
@@ -90,13 +116,17 @@
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
padding: 3px 8px;
border-radius: 20px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
white-space: nowrap;
line-height: 1.2;
vertical-align: middle;
}
.badge + .badge { margin-left: 4px; }
.badge-running { background: rgba(74,222,128,0.12); color: var(--success); }
.badge-suspended { background: rgba(245,158,11,0.12); color: var(--warning); }
@@ -110,7 +140,7 @@
.badge-error { background: rgba(239,68,68,0.12); color: var(--error); }
.badge-muted { background: rgba(148,163,184,0.12); color: var(--text-dim); }
.badge-info { background: rgba(59,130,246,0.12); color: var(--info); }
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; }
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; padding: 2px 6px; }
.text-danger { color: var(--error); }
/* Tables */
@@ -898,6 +928,129 @@ mark.search-highlight {
.slash-menu-item:last-child { border-bottom: none; }
.slash-menu-item:hover, .slash-menu-item.slash-active { background: var(--surface2); }
/* Model switcher dropdown */
.model-switcher-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 20px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
max-width: 200px;
transition: all 0.15s;
white-space: nowrap;
}
.model-switcher-btn:hover { border-color: var(--accent); color: var(--text); }
.model-switcher-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.model-switcher-btn:disabled:hover { border-color: var(--border); color: var(--text-dim); }
.model-switcher-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 150px;
}
.model-switcher-chevron {
transition: transform 0.2s;
flex-shrink: 0;
opacity: 0.5;
}
.model-switcher-chevron.open { transform: rotate(180deg); }
.model-switcher-dropdown {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
width: 340px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
overflow: hidden;
}
.model-switcher-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.model-switcher-search select {
max-width: 100px;
flex-shrink: 0;
}
.model-switcher-search select:focus {
outline: none;
border-color: var(--accent);
}
.model-switcher-search input {
flex: 1;
background: none;
border: none;
color: var(--text);
font-family: var(--font-mono);
font-size: 12px;
outline: none;
}
.model-switcher-list {
max-height: 320px;
overflow-y: auto;
overscroll-behavior: contain;
}
.model-switcher-group-header {
position: sticky;
top: 0;
z-index: 1;
padding: 6px 12px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
background: var(--surface2);
border-bottom: 1px solid var(--border);
}
.model-switcher-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.1s;
}
.model-switcher-item:hover { background: var(--surface2); }
.model-switcher-item.active {
background: var(--accent-subtle, rgba(255,92,0,0.06));
cursor: default;
}
.model-switcher-item-name {
font-size: 12px;
font-weight: 500;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-switcher-tier {
display: inline-block;
padding: 1px 5px;
border-radius: 8px;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
flex-shrink: 0;
}
.model-switcher-tier.tier-frontier { background: rgba(168,85,247,0.15); color: #a855f7; }
.model-switcher-tier.tier-smart { background: rgba(59,130,246,0.15); color: #3b82f6; }
.model-switcher-tier.tier-balanced { background: rgba(34,197,94,0.15); color: #22c55e; }
.model-switcher-tier.tier-fast { background: rgba(245,158,11,0.15); color: #f59e0b; }
.model-switcher-tier.tier-local { background: rgba(148,163,184,0.12); color: var(--text-dim); }
/* Sidebar footer */
.sidebar-footer {
padding: 8px 0;
@@ -1098,19 +1251,25 @@ mark.search-highlight {
font-weight: 500;
}
/* Theme toggle */
.theme-toggle {
cursor: pointer;
padding: 6px 8px;
/* Theme switcher — 3-mode pill (Light / System / Dark) */
.theme-switcher {
display: inline-flex;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 16px;
background: none;
border: 1px solid transparent;
transition: all 0.2s;
border: 1px solid var(--border);
overflow: hidden;
}
.theme-toggle:hover { color: var(--accent); border-color: var(--border); }
.theme-opt {
cursor: pointer;
padding: 4px 8px;
font-size: 14px;
background: none;
border: none;
color: var(--text-muted);
transition: all 0.2s;
line-height: 1;
}
.theme-opt:hover { color: var(--text-primary); background: var(--bg-hover); }
.theme-opt.active { color: var(--accent); background: var(--accent-glow); }
/* Utility */
.flex { display: flex; }
@@ -3066,4 +3225,219 @@ mark.search-highlight {
max-height: 400px;
overflow-y: auto;
}
.flex-col { flex-direction: column; }
/* Comms page */
.comms-topo-tree { padding: 4px 0 4px 8px; }
.comms-topo-child { padding: 0 0 0 20px; display: flex; align-items: center; gap: 4px; }
.comms-topo-branch { color: var(--text-dim); font-family: var(--font-mono); white-space: pre; }
.comms-topo-node { display: flex; align-items: center; gap: 4px; padding: 2px 0; }
.comms-event-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; border-bottom: 1px solid var(--border);
font-size: 12px; transition: background var(--transition-fast);
}
.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); }
@@ -55,6 +55,11 @@
transform: scale(1.05);
}
[data-theme="light"] .sidebar-logo img,
[data-theme="light"] .message-avatar img {
filter: invert(1);
}
.sidebar-header h1 {
font-size: 14px;
font-weight: 700;
File diff suppressed because it is too large Load Diff
+11
View File
@@ -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 {
+108 -4
View File
@@ -87,6 +87,10 @@ function toolIcon(toolName) {
// Alpine.js global store
document.addEventListener('alpine:init', function() {
// Restore saved API key on load
var savedKey = localStorage.getItem('openfang-api-key');
if (savedKey) OpenFangAPI.setAuthToken(savedKey);
Alpine.store('app', {
agents: [],
connected: false,
@@ -99,6 +103,9 @@ document.addEventListener('alpine:init', function() {
pendingAgent: null,
focusMode: localStorage.getItem('openfang-focus') === 'true',
showOnboarding: false,
showAuthPrompt: false,
authMode: 'apikey',
sessionUser: null,
toggleFocusMode() {
this.focusMode = !this.focusMode;
@@ -146,6 +153,79 @@ document.addEventListener('alpine:init', function() {
dismissOnboarding() {
this.showOnboarding = false;
localStorage.setItem('openfang-onboarded', 'true');
},
async checkAuth() {
try {
// 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)) {
var saved = localStorage.getItem('openfang-api-key');
if (saved) {
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
}
this.showAuthPrompt = true;
}
}
},
submitApiKey(key) {
if (!key || !key.trim()) return;
OpenFangAPI.setAuthToken(key.trim());
localStorage.setItem('openfang-api-key', key.trim());
this.showAuthPrompt = false;
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');
}
});
});
@@ -154,7 +234,12 @@ document.addEventListener('alpine:init', function() {
function app() {
return {
page: 'agents',
theme: localStorage.getItem('openfang-theme') || 'light',
themeMode: localStorage.getItem('openfang-theme-mode') || 'system',
theme: (() => {
var mode = localStorage.getItem('openfang-theme-mode') || 'system';
if (mode === 'system') return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
return mode;
})(),
sidebarCollapsed: localStorage.getItem('openfang-sidebar') === 'collapsed',
mobileMenuOpen: false,
connected: false,
@@ -167,8 +252,15 @@ function app() {
init() {
var self = this;
// Listen for OS theme changes (only matters when mode is 'system')
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
if (self.themeMode === 'system') {
self.theme = e.matches ? 'dark' : 'light';
}
});
// Hash routing
var validPages = ['overview','agents','sessions','approvals','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','runtime','settings','wizard'];
var pageRedirects = {
'chat': 'agents',
'templates': 'agents',
@@ -225,6 +317,7 @@ function app() {
// Initial data load
this.pollStatus();
Alpine.store('app').checkOnboarding();
Alpine.store('app').checkAuth();
setInterval(function() { self.pollStatus(); }, 5000);
},
@@ -234,9 +327,20 @@ function app() {
this.mobileMenuOpen = false;
},
setTheme(mode) {
this.themeMode = mode;
localStorage.setItem('openfang-theme-mode', mode);
if (mode === 'system') {
this.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} else {
this.theme = mode;
}
},
toggleTheme() {
this.theme = this.theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('openfang-theme', this.theme);
var modes = ['light', 'system', 'dark'];
var next = modes[(modes.indexOf(this.themeMode) + 1) % modes.length];
this.setTheme(next);
},
toggleSidebar() {
+170 -7
View File
@@ -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',
@@ -54,6 +69,18 @@ function agentsPage() {
filesLoading: false,
configForm: {},
configSaving: false,
// -- Tool filters --
toolFilters: { tool_allowlist: [], tool_blocklist: [] },
toolFiltersLoading: false,
newAllowTool: '',
newBlockTool: '',
// -- Model switch --
editingModel: false,
newModelValue: '',
modelSaving: false,
// -- Fallback chain --
editingFallback: false,
newFallbackValue: '',
// -- Templates state --
tplTemplates: [],
@@ -307,12 +334,15 @@ function agentsPage() {
OpenFangAPI.wsDisconnect();
},
showDetail(agent) {
async showDetail(agent) {
this.detailAgent = agent;
this.detailAgent._fallbacks = [];
this.detailTab = 'info';
this.agentFiles = [];
this.editingFile = null;
this.fileContent = '';
this.editingFallback = false;
this.newFallbackValue = '';
this.configForm = {
name: agent.name || '',
system_prompt: agent.system_prompt || '',
@@ -322,6 +352,11 @@ function agentsPage() {
vibe: (agent.identity && agent.identity.vibe) || ''
};
this.showDetailModal = true;
// Fetch full agent detail to get fallback_models
try {
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
this.detailAgent._fallbacks = full.fallback_models || [];
} catch(e) { /* ignore */ }
},
killAgent(agent) {
@@ -358,7 +393,7 @@ function agentsPage() {
},
// ── Multi-step wizard navigation ──
openSpawnWizard() {
async openSpawnWizard() {
this.showSpawnModal = true;
this.spawnStep = 1;
this.spawnMode = 'wizard';
@@ -366,8 +401,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() {
@@ -391,7 +436,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') {
@@ -400,7 +445,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 = ["*"]');
@@ -559,13 +604,131 @@ function agentsPage() {
}
},
// ── Clear agent history ──
async clearHistory(agent) {
var self = this;
OpenFangToast.confirm('Clear History', 'Clear all conversation history for "' + agent.name + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.del('/api/agents/' + agent.id + '/history');
OpenFangToast.success('History cleared for "' + agent.name + '"');
} catch(e) {
OpenFangToast.error('Failed to clear history: ' + e.message);
}
});
},
// ── Model switch ──
async changeModel() {
if (!this.detailAgent || !this.newModelValue.trim()) return;
this.modelSaving = true;
try {
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
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 model: ' + e.message);
}
this.modelSaving = false;
},
// ── Fallback model chain ──
async addFallback() {
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
var parts = this.newFallbackValue.trim().split('/');
var provider = parts.length > 1 ? parts[0] : this.detailAgent.model_provider;
var model = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
if (!this.detailAgent._fallbacks) this.detailAgent._fallbacks = [];
this.detailAgent._fallbacks.push({ provider: provider, model: model });
try {
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
fallback_models: this.detailAgent._fallbacks
});
OpenFangToast.success('Fallback added: ' + provider + '/' + model);
} catch(e) {
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
this.detailAgent._fallbacks.pop();
}
this.editingFallback = false;
this.newFallbackValue = '';
},
async removeFallback(idx) {
if (!this.detailAgent || !this.detailAgent._fallbacks) return;
var removed = this.detailAgent._fallbacks.splice(idx, 1);
try {
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
fallback_models: this.detailAgent._fallbacks
});
OpenFangToast.success('Fallback removed');
} catch(e) {
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
this.detailAgent._fallbacks.splice(idx, 0, removed[0]);
}
},
// ── Tool filters ──
async loadToolFilters() {
if (!this.detailAgent) return;
this.toolFiltersLoading = true;
try {
this.toolFilters = await OpenFangAPI.get('/api/agents/' + this.detailAgent.id + '/tools');
} catch(e) {
this.toolFilters = { tool_allowlist: [], tool_blocklist: [] };
}
this.toolFiltersLoading = false;
},
addAllowTool() {
var t = this.newAllowTool.trim();
if (t && this.toolFilters.tool_allowlist.indexOf(t) === -1) {
this.toolFilters.tool_allowlist.push(t);
this.newAllowTool = '';
this.saveToolFilters();
}
},
removeAllowTool(tool) {
this.toolFilters.tool_allowlist = this.toolFilters.tool_allowlist.filter(function(t) { return t !== tool; });
this.saveToolFilters();
},
addBlockTool() {
var t = this.newBlockTool.trim();
if (t && this.toolFilters.tool_blocklist.indexOf(t) === -1) {
this.toolFilters.tool_blocklist.push(t);
this.newBlockTool = '';
this.saveToolFilters();
}
},
removeBlockTool(tool) {
this.toolFilters.tool_blocklist = this.toolFilters.tool_blocklist.filter(function(t) { return t !== tool; });
this.saveToolFilters();
},
async saveToolFilters() {
if (!this.detailAgent) return;
try {
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/tools', this.toolFilters);
} catch(e) {
OpenFangToast.error('Failed to update tool filters: ' + e.message);
}
},
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 });
@@ -141,7 +141,16 @@ function channelsPage() {
openSetup(ch) {
this.setupModal = ch;
this.formValues = {};
// Pre-populate form values from saved config (non-secret fields).
var vals = {};
if (ch.fields) {
ch.fields.forEach(function(f) {
if (f.value !== undefined && f.value !== null && f.type !== 'secret') {
vals[f.key] = String(f.value);
}
});
}
this.formValues = vals;
this.showAdvanced = false;
this.showBusinessApi = false;
this.setupStep = ch.configured ? 3 : 1;
+173 -12
View File
@@ -29,6 +29,19 @@ function chatPage() {
_audioChunks: [],
recordingTime: 0,
_recordingTimer: null,
// Model autocomplete state
showModelPicker: false,
modelPickerList: [],
modelPickerFilter: '',
modelPickerIdx: 0,
// Model switcher dropdown
showModelSwitcher: false,
modelSwitcherFilter: '',
modelSwitcherProviderFilter: '',
modelSwitcherIdx: 0,
modelSwitching: false,
_modelCache: null,
_modelCacheTime: 0,
slashCommands: [
{ cmd: '/help', desc: 'Show available commands' },
{ cmd: '/agents', desc: 'Switch to Agents page' },
@@ -80,6 +93,47 @@ function chatPage() {
}
},
get modelDisplayName() {
if (!this.currentAgent) return '';
var name = this.currentAgent.model_name || '';
var short = name.replace(/-\d{8}$/, '');
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
},
get switcherProviders() {
var seen = {};
(this._modelCache || []).forEach(function(m) { seen[m.provider] = true; });
return Object.keys(seen).sort();
},
get filteredSwitcherModels() {
var models = this._modelCache || [];
var provFilter = this.modelSwitcherProviderFilter;
var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : '';
if (!provFilter && !textFilter) return models;
return models.filter(function(m) {
if (provFilter && m.provider !== provFilter) return false;
if (textFilter) {
return m.id.toLowerCase().indexOf(textFilter) !== -1 ||
(m.display_name || '').toLowerCase().indexOf(textFilter) !== -1 ||
m.provider.toLowerCase().indexOf(textFilter) !== -1;
}
return true;
});
},
get groupedSwitcherModels() {
var filtered = this.filteredSwitcherModels;
var groups = {}, order = [];
filtered.forEach(function(m) {
if (!groups[m.provider]) { groups[m.provider] = []; order.push(m.provider); }
groups[m.provider].push(m);
});
return order.map(function(p) {
return { provider: p.charAt(0).toUpperCase() + p.slice(1), models: groups[p] };
});
},
init() {
var self = this;
@@ -96,6 +150,11 @@ function chatPage() {
var input = document.getElementById('msg-input');
if (input) { input.focus(); self.inputText = '/'; }
}
// Ctrl+M for model switcher
if ((e.ctrlKey || e.metaKey) && e.key === 'm' && self.currentAgent) {
e.preventDefault();
self.toggleModelSwitcher();
}
// Ctrl+F for chat search
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && self.currentAgent) {
e.preventDefault();
@@ -126,18 +185,98 @@ function chatPage() {
}
});
// Watch for slash commands
// Watch for slash commands + model autocomplete
this.$watch('inputText', function(val) {
if (val.startsWith('/')) {
var modelMatch = val.match(/^\/model\s+(.*)$/i);
if (modelMatch) {
self.showSlashMenu = false;
self.modelPickerFilter = modelMatch[1].toLowerCase();
if (!self.modelPickerList.length) {
OpenFangAPI.get('/api/models').then(function(data) {
self.modelPickerList = (data.models || []).filter(function(m) { return m.available; });
self.showModelPicker = true;
self.modelPickerIdx = 0;
}).catch(function() {});
} else {
self.showModelPicker = true;
}
} else if (val.startsWith('/')) {
self.showModelPicker = false;
self.slashFilter = val.slice(1).toLowerCase();
self.showSlashMenu = true;
self.slashIdx = 0;
} else {
self.showSlashMenu = false;
self.showModelPicker = false;
}
});
},
get filteredModelPicker() {
if (!this.modelPickerFilter) return this.modelPickerList.slice(0, 15);
var f = this.modelPickerFilter;
return this.modelPickerList.filter(function(m) {
return m.id.toLowerCase().indexOf(f) !== -1 || (m.display_name || '').toLowerCase().indexOf(f) !== -1 || m.provider.toLowerCase().indexOf(f) !== -1;
}).slice(0, 15);
},
pickModel(modelId) {
this.showModelPicker = false;
this.inputText = '/model ' + modelId;
this.sendMessage();
},
toggleModelSwitcher() {
if (this.showModelSwitcher) { this.showModelSwitcher = false; return; }
var self = this;
var now = Date.now();
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
this.modelSwitcherFilter = '';
this.modelSwitcherProviderFilter = '';
this.modelSwitcherIdx = 0;
this.showModelSwitcher = true;
this.$nextTick(function() {
var el = document.getElementById('model-switcher-search');
if (el) el.focus();
});
return;
}
OpenFangAPI.get('/api/models').then(function(data) {
var models = (data.models || []).filter(function(m) { return m.available; });
self._modelCache = models;
self._modelCacheTime = Date.now();
self.modelPickerList = models;
self.modelSwitcherFilter = '';
self.modelSwitcherProviderFilter = '';
self.modelSwitcherIdx = 0;
self.showModelSwitcher = true;
self.$nextTick(function() {
var el = document.getElementById('model-switcher-search');
if (el) el.focus();
});
}).catch(function(e) {
OpenFangToast.error('Failed to load models: ' + e.message);
});
},
switchModel(model) {
if (!this.currentAgent) return;
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(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;
}).catch(function(e) {
OpenFangToast.error('Switch failed: ' + e.message);
self.modelSwitching = false;
});
},
// Fetch dynamic slash commands from server
fetchCommands: function() {
var self = this;
@@ -282,9 +421,13 @@ function chatPage() {
case '/model':
if (self.currentAgent) {
if (cmdArgs) {
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function() {
self.currentAgent.model_name = cmdArgs;
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`', meta: '', tools: [] });
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) {
// Use server-resolved model/provider (fixes #387/#466)
var resolvedModel = (resp && resp.model) || cmdArgs;
var resolvedProvider = (resp && resp.provider) || '';
self.currentAgent.model_name = resolvedModel;
if (resolvedProvider) { self.currentAgent.model_provider = resolvedProvider; }
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + resolvedModel + '`' + (resolvedProvider ? ' (provider: `' + resolvedProvider + '`)' : ''), meta: '', tools: [] });
self.scrollToBottom();
}).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); });
} else {
@@ -393,7 +536,10 @@ function chatPage() {
is_error: !!t.is_error
};
});
return { id: ++msgId, role: role, text: text, meta: '', tools: tools };
var images = (m.images || []).map(function(img) {
return { file_id: img.file_id, filename: img.filename || 'image' };
});
return { id: ++msgId, role: role, text: text, meta: '', tools: tools, images: images };
});
self.$nextTick(function() { self.scrollToBottom(); });
}
@@ -504,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();
@@ -0,0 +1,201 @@
// OpenFang Comms Page — Agent topology & inter-agent communication feed
'use strict';
function commsPage() {
return {
topology: { nodes: [], edges: [] },
events: [],
loading: true,
loadError: '',
sseSource: null,
showSendModal: false,
showTaskModal: false,
sendFrom: '',
sendTo: '',
sendMsg: '',
sendLoading: false,
taskTitle: '',
taskDesc: '',
taskAssign: '',
taskLoading: false,
async loadData() {
this.loading = true;
this.loadError = '';
try {
var results = await Promise.all([
OpenFangAPI.get('/api/comms/topology'),
OpenFangAPI.get('/api/comms/events?limit=200')
]);
this.topology = results[0] || { nodes: [], edges: [] };
this.events = results[1] || [];
this.startSSE();
} catch(e) {
this.loadError = e.message || 'Could not load comms data.';
}
this.loading = false;
},
startSSE() {
if (this.sseSource) this.sseSource.close();
var self = this;
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
this.sseSource = new EventSource(url);
this.sseSource.onmessage = function(ev) {
if (ev.data === 'ping') return;
try {
var event = JSON.parse(ev.data);
self.events.unshift(event);
if (self.events.length > 200) self.events.length = 200;
// Refresh topology on spawn/terminate events
if (event.kind === 'agent_spawned' || event.kind === 'agent_terminated') {
self.refreshTopology();
}
} catch(e) { /* ignore parse errors */ }
};
},
stopSSE() {
if (this.sseSource) {
this.sseSource.close();
this.sseSource = null;
}
},
async refreshTopology() {
try {
this.topology = await OpenFangAPI.get('/api/comms/topology');
} catch(e) { /* silent */ }
},
rootNodes() {
var childIds = {};
var self = this;
this.topology.edges.forEach(function(e) {
if (e.kind === 'parent_child') childIds[e.to] = true;
});
return this.topology.nodes.filter(function(n) { return !childIds[n.id]; });
},
childrenOf(id) {
var childIds = {};
this.topology.edges.forEach(function(e) {
if (e.kind === 'parent_child' && e.from === id) childIds[e.to] = true;
});
return this.topology.nodes.filter(function(n) { return childIds[n.id]; });
},
peersOf(id) {
var peerIds = {};
this.topology.edges.forEach(function(e) {
if (e.kind === 'peer') {
if (e.from === id) peerIds[e.to] = true;
if (e.to === id) peerIds[e.from] = true;
}
});
return this.topology.nodes.filter(function(n) { return peerIds[n.id]; });
},
stateBadgeClass(state) {
switch(state) {
case 'Running': return 'badge badge-success';
case 'Suspended': return 'badge badge-warning';
case 'Terminated': case 'Crashed': return 'badge badge-danger';
default: return 'badge badge-dim';
}
},
eventBadgeClass(kind) {
switch(kind) {
case 'agent_message': return 'badge badge-info';
case 'agent_spawned': return 'badge badge-success';
case 'agent_terminated': return 'badge badge-danger';
case 'task_posted': return 'badge badge-warning';
case 'task_claimed': return 'badge badge-info';
case 'task_completed': return 'badge badge-success';
default: return 'badge badge-dim';
}
},
eventIcon(kind) {
switch(kind) {
case 'agent_message': return '\u2709';
case 'agent_spawned': return '+';
case 'agent_terminated': return '\u2715';
case 'task_posted': return '\u2691';
case 'task_claimed': return '\u2690';
case 'task_completed': return '\u2713';
default: return '\u2022';
}
},
eventLabel(kind) {
switch(kind) {
case 'agent_message': return 'Message';
case 'agent_spawned': return 'Spawned';
case 'agent_terminated': return 'Terminated';
case 'task_posted': return 'Task Posted';
case 'task_claimed': return 'Task Claimed';
case 'task_completed': return 'Task Done';
default: return kind;
}
},
timeAgo(dateStr) {
if (!dateStr) return '';
var d = new Date(dateStr);
var secs = Math.floor((Date.now() - d.getTime()) / 1000);
if (secs < 60) return secs + 's ago';
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
return Math.floor(secs / 86400) + 'd ago';
},
openSendModal() {
this.sendFrom = '';
this.sendTo = '';
this.sendMsg = '';
this.showSendModal = true;
},
async submitSend() {
if (!this.sendFrom || !this.sendTo || !this.sendMsg.trim()) return;
this.sendLoading = true;
try {
await OpenFangAPI.post('/api/comms/send', {
from_agent_id: this.sendFrom,
to_agent_id: this.sendTo,
message: this.sendMsg
});
OpenFangToast.success('Message sent');
this.showSendModal = false;
} catch(e) {
OpenFangToast.error(e.message || 'Send failed');
}
this.sendLoading = false;
},
openTaskModal() {
this.taskTitle = '';
this.taskDesc = '';
this.taskAssign = '';
this.showTaskModal = true;
},
async submitTask() {
if (!this.taskTitle.trim()) return;
this.taskLoading = true;
try {
var body = { title: this.taskTitle, description: this.taskDesc };
if (this.taskAssign) body.assigned_to = this.taskAssign;
await OpenFangAPI.post('/api/comms/task', body);
OpenFangToast.success('Task posted');
this.showTaskModal = false;
} catch(e) {
OpenFangToast.error(e.message || 'Task failed');
}
this.taskLoading = false;
}
};
}
+453 -3
View File
@@ -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 }
}
}
}
}
});
}
}
}
};
}
@@ -0,0 +1,59 @@
// Runtime page — system overview and provider status
document.addEventListener('alpine:init', function() {
Alpine.data('runtimePage', function() {
return {
loading: true,
uptime: '-',
agentCount: 0,
version: '-',
defaultModel: '-',
platform: '-',
arch: '-',
apiListen: '-',
homeDir: '-',
logLevel: '-',
networkEnabled: false,
providers: [],
async loadData() {
this.loading = true;
try {
var results = await Promise.all([
OpenFangAPI.get('/api/status'),
OpenFangAPI.get('/api/version'),
OpenFangAPI.get('/api/providers'),
OpenFangAPI.get('/api/agents')
]);
var status = results[0];
var ver = results[1];
var prov = results[2];
var agents = results[3];
this.version = ver.version || '-';
this.platform = ver.platform || '-';
this.arch = ver.arch || '-';
this.agentCount = Array.isArray(agents) ? agents.length : 0;
this.defaultModel = status.default_model || '-';
this.apiListen = status.api_listen || status.listen || '-';
this.homeDir = status.home_dir || '-';
this.logLevel = status.log_level || '-';
this.networkEnabled = !!status.network_enabled;
// Compute uptime from uptime_seconds
var diff = status.uptime_seconds || 0;
if (diff < 60) this.uptime = diff + 's';
else if (diff < 3600) this.uptime = Math.floor(diff / 60) + 'm ' + (diff % 60) + 's';
else if (diff < 86400) this.uptime = Math.floor(diff / 3600) + 'h ' + Math.floor((diff % 3600) / 60) + 'm';
else this.uptime = Math.floor(diff / 86400) + 'd ' + Math.floor((diff % 86400) / 3600) + 'h';
this.providers = (prov.providers || []).filter(function(p) {
return p.auth_status === 'Configured' || p.reachable || p.is_local;
});
} catch(e) {
console.error('Runtime load error:', e);
}
this.loading = false;
}
};
});
});
@@ -62,8 +62,29 @@ function schedulerPage() {
},
async loadJobs() {
var data = await OpenFangAPI.get('/api/schedules');
this.jobs = data.schedules || [];
var data = await OpenFangAPI.get('/api/cron/jobs');
var raw = data.jobs || [];
// Normalize cron API response to flat fields the UI expects
this.jobs = raw.map(function(j) {
var cron = '';
if (j.schedule) {
if (j.schedule.kind === 'cron') cron = j.schedule.expr || '';
else if (j.schedule.kind === 'every') cron = 'every ' + j.schedule.every_secs + 's';
else if (j.schedule.kind === 'at') cron = 'at ' + (j.schedule.at || '');
}
return {
id: j.id,
name: j.name,
cron: cron,
agent_id: j.agent_id,
message: j.action ? j.action.message || '' : '',
enabled: j.enabled,
last_run: j.last_run,
next_run: j.next_run,
delivery: j.delivery ? j.delivery.kind || '' : '',
created_at: j.created_at
};
});
},
async loadTriggers() {
@@ -82,25 +103,20 @@ function schedulerPage() {
async loadHistory() {
this.historyLoading = true;
try {
// Build history from jobs with run data + recent audit entries
var historyItems = [];
// Add job run info from schedule data
var jobs = this.jobs || [];
for (var i = 0; i < jobs.length; i++) {
var job = jobs[i];
if (job.last_run) {
historyItems.push({
timestamp: job.last_run,
name: job.name || job.description || '(unnamed)',
name: job.name || '(unnamed)',
type: 'schedule',
status: 'completed',
run_count: job.run_count || 0
run_count: 0
});
}
}
// Also load trigger fire counts
var triggers = this.triggers || [];
for (var j = 0; j < triggers.length; j++) {
var t = triggers[j];
@@ -114,12 +130,9 @@ function schedulerPage() {
});
}
}
// Sort by timestamp descending
historyItems.sort(function(a, b) {
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
});
this.history = historyItems;
} catch(e) {
this.history = [];
@@ -141,13 +154,15 @@ function schedulerPage() {
this.creating = true;
try {
var jobName = this.newJob.name;
await OpenFangAPI.post('/api/schedules', {
name: this.newJob.name,
cron: this.newJob.cron,
var body = {
agent_id: this.newJob.agent_id,
message: this.newJob.message,
name: this.newJob.name,
schedule: { kind: 'cron', expr: this.newJob.cron },
action: { kind: 'agent_turn', message: this.newJob.message || 'Scheduled task: ' + this.newJob.name },
delivery: { kind: 'last_channel' },
enabled: this.newJob.enabled
});
};
await OpenFangAPI.post('/api/cron/jobs', body);
this.showCreateForm = false;
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
OpenFangToast.success('Schedule "' + jobName + '" created');
@@ -161,7 +176,7 @@ function schedulerPage() {
async toggleJob(job) {
try {
var newState = !job.enabled;
await OpenFangAPI.put('/api/schedules/' + job.id, { enabled: newState });
await OpenFangAPI.put('/api/cron/jobs/' + job.id + '/enable', { enabled: newState });
job.enabled = newState;
OpenFangToast.success('Schedule ' + (newState ? 'enabled' : 'paused'));
} catch(e) {
@@ -174,7 +189,7 @@ function schedulerPage() {
var jobName = job.name || job.id;
OpenFangToast.confirm('Delete Schedule', 'Delete "' + jobName + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.del('/api/schedules/' + job.id);
await OpenFangAPI.del('/api/cron/jobs/' + job.id);
self.jobs = self.jobs.filter(function(j) { return j.id !== job.id; });
OpenFangToast.success('Schedule "' + jobName + '" deleted');
} catch(e) {
@@ -189,19 +204,17 @@ function schedulerPage() {
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
if (result.status === 'completed') {
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
// Update the job's last_run locally
job.last_run = new Date().toISOString();
job.run_count = (job.run_count || 0) + 1;
} else {
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
}
} catch(e) {
OpenFangToast.error('Failed to run schedule: ' + (e.message || e));
OpenFangToast.error('Run Now is not yet available for cron jobs');
}
this.runningJobId = '';
},
// ── Trigger helpers (reused from workflows page) ──
// ── Trigger helpers ──
triggerType(pattern) {
if (!pattern) return 'unknown';
@@ -259,13 +272,16 @@ function schedulerPage() {
for (var i = 0; i < agents.length; i++) {
if (agents[i].id === agentId) return agents[i].name;
}
// Truncate UUID
if (agentId.length > 12) return agentId.substring(0, 8) + '...';
return agentId;
},
describeCron(expr) {
if (!expr) return '';
// Handle non-cron schedule descriptions
if (expr.indexOf('every ') === 0) return expr;
if (expr.indexOf('at ') === 0) return 'One-time: ' + expr.substring(3);
var map = {
'* * * * *': 'Every minute',
'*/2 * * * *': 'Every 2 minutes',
@@ -291,7 +307,6 @@ function schedulerPage() {
};
if (map[expr]) return map[expr];
// Try to parse common patterns
var parts = expr.split(' ');
if (parts.length !== 5) return expr;
@@ -301,22 +316,26 @@ function schedulerPage() {
var mon = parts[3];
var dow = parts[4];
// "*/N * * * *" patterns
if (min.indexOf('*/') === 0 && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
return 'Every ' + min.substring(2) + ' minutes';
}
// "0 */N * * *" patterns
if (min === '0' && hour.indexOf('*/') === 0 && dom === '*' && mon === '*' && dow === '*') {
return 'Every ' + hour.substring(2) + ' hours';
}
// "M H * * *" — daily at specific time
if (dom === '*' && mon === '*' && dow === '*' && min.match(/^\d+$/) && hour.match(/^\d+$/)) {
var dowNames = { '0': 'Sun', '1': 'Mon', '2': 'Tue', '3': 'Wed', '4': 'Thu', '5': 'Fri', '6': 'Sat', '7': 'Sun',
'1-5': 'Weekdays', '0,6': 'Weekends', '6,0': 'Weekends' };
if (dom === '*' && mon === '*' && min.match(/^\d+$/) && hour.match(/^\d+$/)) {
var h = parseInt(hour, 10);
var m = parseInt(min, 10);
var ampm = h >= 12 ? 'PM' : 'AM';
var h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);
var mStr = m < 10 ? '0' + m : '' + m;
return 'Daily at ' + h12 + ':' + mStr + ' ' + ampm;
var timeStr = h12 + ':' + mStr + ' ' + ampm;
if (dow === '*') return 'Daily at ' + timeStr;
var dowLabel = dowNames[dow] || ('DoW ' + dow);
return dowLabel + ' at ' + timeStr;
}
return expr;
@@ -340,7 +359,14 @@ function schedulerPage() {
try {
var diff = Date.now() - new Date(ts).getTime();
if (isNaN(diff)) return 'never';
if (diff < 0) return 'just now';
if (diff < 0) {
// Future time
var absDiff = Math.abs(diff);
if (absDiff < 60000) return 'in <1m';
if (absDiff < 3600000) return 'in ' + Math.floor(absDiff / 60000) + 'm';
if (absDiff < 86400000) return 'in ' + Math.floor(absDiff / 3600000) + 'h';
return 'in ' + Math.floor(absDiff / 86400000) + 'd';
}
if (diff < 60000) return 'just now';
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
+163 -3
View File
@@ -14,9 +14,23 @@ function settingsPage() {
modelSearch: '',
modelProviderFilter: '',
modelTierFilter: '',
showCustomModelForm: false,
customModelId: '',
customModelProvider: 'openrouter',
customModelContext: 128000,
customModelMaxOutput: 8192,
customModelStatus: '',
providerKeyInputs: {},
providerUrlInputs: {},
providerUrlSaving: {},
providerTesting: {},
providerTestResults: {},
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
customProviderName: '',
customProviderUrl: '',
customProviderKey: '',
customProviderStatus: '',
addingCustomProvider: false,
loading: true,
loadError: '',
@@ -208,6 +222,17 @@ function settingsPage() {
try {
var data = await OpenFangAPI.get('/api/providers');
this.providers = data.providers || [];
for (var i = 0; i < this.providers.length; i++) {
var p = this.providers[i];
if (p.is_local) {
if (!this.providerUrlInputs[p.id]) {
this.providerUrlInputs[p.id] = p.base_url || '';
}
if (this.providerUrlSaving[p.id] === undefined) {
this.providerUrlSaving[p.id] = false;
}
}
}
} catch(e) { this.providers = []; }
},
@@ -218,6 +243,37 @@ function settingsPage() {
} catch(e) { this.models = []; }
},
async addCustomModel() {
var id = this.customModelId.trim();
if (!id) return;
this.customModelStatus = 'Adding...';
try {
await OpenFangAPI.post('/api/models/custom', {
id: id,
provider: this.customModelProvider || 'openrouter',
context_window: this.customModelContext || 128000,
max_output_tokens: this.customModelMaxOutput || 8192,
});
this.customModelStatus = 'Added!';
this.customModelId = '';
this.showCustomModelForm = false;
await this.loadModels();
} catch(e) {
this.customModelStatus = 'Error: ' + (e.message || 'Failed');
}
},
async deleteCustomModel(modelId) {
if (!confirm('Delete custom model "' + modelId + '"?')) return;
try {
await OpenFangAPI.del('/api/models/custom/' + encodeURIComponent(modelId));
OpenFangToast.success('Model deleted');
await this.loadModels();
} catch(e) {
OpenFangToast.error('Failed to delete: ' + (e.message || 'Unknown error'));
}
},
async loadConfigSchema() {
try {
var results = await Promise.all([
@@ -239,11 +295,14 @@ function settingsPage() {
async saveConfigField(section, field, value) {
var key = section + '.' + field;
// Root-level fields (api_key, api_listen, log_level) use just the field name
var sectionMeta = this.configSchema && this.configSchema[section];
var path = (sectionMeta && sectionMeta.root_level) ? field : key;
this.configSaving[key] = true;
try {
await OpenFangAPI.post('/api/config/set', { path: key, value: value });
await OpenFangAPI.post('/api/config/set', { path: path, value: value });
this.configDirty[key] = false;
OpenFangToast.success('Saved ' + key);
OpenFangToast.success('Saved ' + field);
} catch(e) {
OpenFangToast.error('Failed to save: ' + e.message);
}
@@ -293,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';
},
@@ -360,6 +422,54 @@ function settingsPage() {
}
},
async startCopilotOAuth() {
this.copilotOAuth.polling = true;
this.copilotOAuth.userCode = '';
try {
var resp = await OpenFangAPI.post('/api/providers/github-copilot/oauth/start', {});
this.copilotOAuth.userCode = resp.user_code;
this.copilotOAuth.verificationUri = resp.verification_uri;
this.copilotOAuth.pollId = resp.poll_id;
this.copilotOAuth.interval = resp.interval || 5;
window.open(resp.verification_uri, '_blank');
this.pollCopilotOAuth();
} catch(e) {
OpenFangToast.error('Failed to start Copilot login: ' + e.message);
this.copilotOAuth.polling = false;
}
},
pollCopilotOAuth() {
var self = this;
setTimeout(async function() {
if (!self.copilotOAuth.pollId) return;
try {
var resp = await OpenFangAPI.get('/api/providers/github-copilot/oauth/poll/' + self.copilotOAuth.pollId);
if (resp.status === 'complete') {
OpenFangToast.success('GitHub Copilot authenticated successfully!');
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
await self.loadProviders();
await self.loadModels();
} else if (resp.status === 'pending') {
if (resp.interval) self.copilotOAuth.interval = resp.interval;
self.pollCopilotOAuth();
} else if (resp.status === 'expired') {
OpenFangToast.error('Device code expired. Please try again.');
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
} else if (resp.status === 'denied') {
OpenFangToast.error('Access denied by user.');
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
} else {
OpenFangToast.error('OAuth error: ' + (resp.error || resp.status));
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
}
} catch(e) {
OpenFangToast.error('Poll error: ' + e.message);
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
}
}, self.copilotOAuth.interval * 1000);
},
async testProvider(provider) {
this.providerTesting[provider.id] = true;
this.providerTestResults[provider.id] = null;
@@ -378,6 +488,56 @@ function settingsPage() {
this.providerTesting[provider.id] = false;
},
async saveProviderUrl(provider) {
var url = this.providerUrlInputs[provider.id];
if (!url || !url.trim()) { OpenFangToast.error('Please enter a base URL'); return; }
url = url.trim();
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
OpenFangToast.error('URL must start with http:// or https://'); return;
}
this.providerUrlSaving[provider.id] = true;
try {
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(provider.id) + '/url', { base_url: url });
if (result.reachable) {
OpenFangToast.success(provider.display_name + ' URL saved &mdash; reachable (' + (result.latency_ms || '?') + 'ms)');
} else {
OpenFangToast.warning(provider.display_name + ' URL saved but not reachable');
}
await this.loadProviders();
} catch(e) {
OpenFangToast.error('Failed to save URL: ' + e.message);
}
this.providerUrlSaving[provider.id] = false;
},
async addCustomProvider() {
var name = this.customProviderName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
if (!name) { OpenFangToast.error('Please enter a provider name'); return; }
var url = this.customProviderUrl.trim();
if (!url) { OpenFangToast.error('Please enter a base URL'); return; }
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
OpenFangToast.error('URL must start with http:// or https://'); return;
}
this.addingCustomProvider = true;
this.customProviderStatus = '';
try {
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(name) + '/url', { base_url: url });
if (this.customProviderKey.trim()) {
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(name) + '/key', { key: this.customProviderKey.trim() });
}
this.customProviderName = '';
this.customProviderUrl = '';
this.customProviderKey = '';
this.customProviderStatus = '';
OpenFangToast.success('Provider "' + name + '" added' + (result.reachable ? ' (reachable)' : ' (not reachable yet)'));
await this.loadProviders();
} catch(e) {
this.customProviderStatus = 'Error: ' + (e.message || 'Failed');
OpenFangToast.error('Failed to add provider: ' + e.message);
}
this.addingCustomProvider = false;
},
// -- Security methods --
async loadSecurity() {
this.secLoading = true;
+35 -1
View File
@@ -19,10 +19,16 @@ function skillsPage() {
installingSlug: null,
installResult: null,
_searchTimer: null,
_browseCache: {}, // { key: { ts, data } } client-side 60s cache
_searchCache: {},
// Skill detail modal
skillDetail: null,
detailLoading: false,
showSkillCode: false,
skillCode: '',
skillCodeFilename: '',
skillCodeLoading: false,
// MCP servers
mcpServers: [],
@@ -146,9 +152,16 @@ function skillsPage() {
if (this._searchTimer) clearTimeout(this._searchTimer);
},
// ClawHub browse by sort
// ClawHub browse by sort (with 60s client-side cache)
async browseClawHub(sort) {
this.clawhubSort = sort || 'trending';
var ckey = 'browse:' + this.clawhubSort;
var cached = this._browseCache[ckey];
if (cached && (Date.now() - cached.ts) < 60000) {
this.clawhubBrowseResults = cached.data.items || [];
this.clawhubNextCursor = cached.data.next_cursor || null;
return;
}
this.clawhubLoading = true;
this.clawhubError = '';
this.clawhubNextCursor = null;
@@ -157,6 +170,7 @@ function skillsPage() {
this.clawhubBrowseResults = data.items || [];
this.clawhubNextCursor = data.next_cursor || null;
if (data.error) this.clawhubError = data.error;
this._browseCache[ckey] = { ts: Date.now(), data: data };
} catch(e) {
this.clawhubBrowseResults = [];
this.clawhubError = e.message || 'Browse failed';
@@ -195,6 +209,26 @@ function skillsPage() {
closeDetail() {
this.skillDetail = null;
this.installResult = null;
this.showSkillCode = false;
this.skillCode = '';
this.skillCodeFilename = '';
},
async viewSkillCode(slug) {
if (this.showSkillCode) {
this.showSkillCode = false;
return;
}
this.skillCodeLoading = true;
try {
var data = await OpenFangAPI.get('/api/clawhub/skill/' + encodeURIComponent(slug) + '/code');
this.skillCode = data.code || '';
this.skillCodeFilename = data.filename || 'source';
this.showSkillCode = true;
} catch(e) {
OpenFangToast.error('Could not load skill source code');
}
this.skillCodeLoading = false;
},
// Install from ClawHub
+46 -10
View File
@@ -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) {
@@ -320,7 +332,7 @@ function wizardPage() {
},
get popularProviders() {
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
return this.providers.filter(function(p) {
return popular.indexOf(p.id) >= 0;
}).sort(function(a, b) {
@@ -329,7 +341,7 @@ function wizardPage() {
},
get otherProviders() {
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
return this.providers.filter(function(p) {
return popular.indexOf(p.id) < 0;
});
@@ -355,7 +367,8 @@ function wizardPage() {
fireworks: { url: 'https://fireworks.ai/account/api-keys', text: 'Get your key from Fireworks AI' },
perplexity: { url: 'https://www.perplexity.ai/settings/api', text: 'Get your key from Perplexity Settings' },
cohere: { url: 'https://dashboard.cohere.com/api-keys', text: 'Get your key from the Cohere Dashboard' },
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' }
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' },
'claude-code': { url: 'https://docs.anthropic.com/en/docs/claude-code', text: 'Install: npm install -g @anthropic-ai/claude-code && claude auth (no API key needed)' }
};
return help[id] || null;
},
@@ -408,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) {
@@ -437,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 += 'name = "' + model + '"\n\n';
toml += '[prompt]\nsystem = """\n' + tpl.system_prompt + '\n"""\n';
toml += 'model = "' + model + '"\n';
toml += 'system_prompt = """\n' + wizardTomlMultilineEscape(tpl.system_prompt) + '\n"""\n';
this.creatingAgent = true;
try {
@@ -468,13 +503,14 @@ function wizardPage() {
gemini: 'gemini-2.5-flash',
groq: 'llama-3.3-70b-versatile',
deepseek: 'deepseek-chat',
openrouter: 'openrouter/auto',
openrouter: 'openrouter/google/gemini-2.5-flash',
mistral: 'mistral-large-latest',
together: 'meta-llama/Llama-3-70b-chat-hf',
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
perplexity: 'llama-3.1-sonar-large-128k-online',
cohere: 'command-r-plus',
xai: 'grok-2'
xai: 'grok-2',
'claude-code': 'claude-code/sonnet'
};
return defaults[providerId] || '';
},
@@ -37,6 +37,13 @@ function workflowBuilder() {
{ type: 'end', label: 'End', color: '#ef4444', icon: 'E', ports: { in: 1, out: 0 } }
],
_renderScheduled: false,
_lastClickNodeId: null,
_lastClickTime: 0,
_didDrag: false,
_didConnect: false,
_didPan: false,
async init() {
var self = this;
// Load agents for the agent step dropdown
@@ -50,6 +57,157 @@ function workflowBuilder() {
self.addNode('start', 60, 200);
},
// ── SVG Manual Rendering ────────────────────────────
// Alpine.js x-for inside <svg> breaks because document.importNode
// doesn't handle SVG namespace correctly. We render nodes/connections
// manually via createElementNS and schedule re-renders reactively.
scheduleRender: function() {
if (this._renderScheduled) return;
this._renderScheduled = true;
var self = this;
requestAnimationFrame(function() {
self._renderScheduled = false;
self.renderCanvas();
});
},
renderCanvas: function() {
var container = document.getElementById('wf-render-group');
if (!container) return;
var SVG_NS = 'http://www.w3.org/2000/svg';
var self = this;
// Clear previous rendered content
while (container.firstChild) container.removeChild(container.firstChild);
// ── Connections ──
for (var ci = 0; ci < this.connections.length; ci++) {
var conn = this.connections[ci];
var d = this.getConnectionPath(conn);
if (!d) continue;
var path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', (this.selectedConnection && this.selectedConnection.id === conn.id) ? 'var(--accent)' : 'var(--text-dim)');
path.setAttribute('stroke-width', (this.selectedConnection && this.selectedConnection.id === conn.id) ? '3' : '2');
path.style.cursor = 'pointer';
(function(c) {
path.addEventListener('click', function(e) { e.stopPropagation(); self.selectedConnection = c; self.scheduleRender(); });
})(conn);
container.appendChild(path);
}
// ── Connection preview ──
if (this.connecting && this.connectPreview) {
var pd = this.getPreviewPath();
if (pd) {
var preview = document.createElementNS(SVG_NS, 'path');
preview.setAttribute('d', pd);
preview.setAttribute('fill', 'none');
preview.setAttribute('stroke', 'var(--accent)');
preview.setAttribute('stroke-width', '2');
preview.setAttribute('stroke-dasharray', '6,3');
container.appendChild(preview);
}
}
// ── Nodes ──
for (var ni = 0; ni < this.nodes.length; ni++) {
var node = this.nodes[ni];
var g = document.createElementNS(SVG_NS, 'g');
g.classList.add('wf-node');
g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')');
(function(n) {
g.addEventListener('mousedown', function(e) { self.onNodeMouseDown(n, e); });
g.addEventListener('dblclick', function() { self.editNode(n); });
})(node);
// Node body rect
var rect = document.createElementNS(SVG_NS, 'rect');
rect.setAttribute('x', '0'); rect.setAttribute('y', '0');
rect.setAttribute('width', node.width); rect.setAttribute('height', node.height);
rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8');
rect.setAttribute('fill', (self.selectedNode && self.selectedNode.id === node.id) ? 'var(--card-bg)' : 'var(--bg-secondary)');
rect.setAttribute('stroke', (self.selectedNode && self.selectedNode.id === node.id) ? node.color : 'var(--border)');
rect.setAttribute('stroke-width', '2');
rect.style.cursor = 'grab';
g.appendChild(rect);
// Color accent bar
var bar = document.createElementNS(SVG_NS, 'rect');
bar.setAttribute('x', '0'); bar.setAttribute('y', '0');
bar.setAttribute('width', '6'); bar.setAttribute('height', node.height);
bar.setAttribute('rx', '3'); bar.setAttribute('ry', '0');
bar.setAttribute('fill', node.color);
g.appendChild(bar);
// Icon circle + text
var circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('cx', '28'); circle.setAttribute('cy', node.height / 2);
circle.setAttribute('r', '14'); circle.setAttribute('fill', node.color);
circle.setAttribute('opacity', '0.15');
g.appendChild(circle);
var iconText = document.createElementNS(SVG_NS, 'text');
iconText.setAttribute('x', '28'); iconText.setAttribute('y', node.height / 2 + 4);
iconText.setAttribute('text-anchor', 'middle'); iconText.setAttribute('fill', node.color);
iconText.setAttribute('style', 'font-size:12px;font-weight:700;pointer-events:none');
iconText.textContent = node.icon;
g.appendChild(iconText);
// Label
var label = document.createElementNS(SVG_NS, 'text');
label.setAttribute('x', '50'); label.setAttribute('y', node.height / 2 - 4);
label.setAttribute('fill', 'var(--text)');
label.setAttribute('style', 'font-size:12px;font-weight:600;pointer-events:none');
label.textContent = node.label;
g.appendChild(label);
// Sub-label
var subLabel = document.createElementNS(SVG_NS, 'text');
subLabel.setAttribute('x', '50'); subLabel.setAttribute('y', node.height / 2 + 12);
subLabel.setAttribute('fill', 'var(--text-dim)');
subLabel.setAttribute('style', 'font-size:10px;pointer-events:none');
if (node.type === 'agent') subLabel.textContent = node.config.agent_name || 'No agent';
else if (node.type === 'condition') subLabel.textContent = node.config.expression || 'No condition';
else if (node.type === 'loop') subLabel.textContent = 'max ' + (node.config.max_iterations || 5) + ' iters';
else if (node.type === 'parallel') subLabel.textContent = (node.config.fan_count || 3) + ' branches';
else if (node.type === 'collect') subLabel.textContent = node.config.strategy || 'all';
g.appendChild(subLabel);
// Input ports
for (var pi = 0; pi < node.ports.in; pi++) {
var inp = document.createElementNS(SVG_NS, 'circle');
inp.classList.add('wf-port', 'wf-port-in');
inp.setAttribute('cx', node.width / (node.ports.in + 1) * (pi + 1));
inp.setAttribute('cy', '0'); inp.setAttribute('r', '6');
inp.setAttribute('fill', 'var(--bg-secondary)');
inp.setAttribute('stroke', 'var(--text-dim)'); inp.setAttribute('stroke-width', '2');
(function(nid, idx) {
inp.addEventListener('mouseup', function(e) { e.stopPropagation(); self.endConnect(nid, idx, e); });
})(node.id, pi);
g.appendChild(inp);
}
// Output ports
for (var po = 0; po < node.ports.out; po++) {
var outp = document.createElementNS(SVG_NS, 'circle');
outp.classList.add('wf-port', 'wf-port-out');
outp.setAttribute('cx', node.width / (node.ports.out + 1) * (po + 1));
outp.setAttribute('cy', node.height); outp.setAttribute('r', '6');
outp.setAttribute('fill', 'var(--bg-secondary)');
outp.setAttribute('stroke', node.color); outp.setAttribute('stroke-width', '2');
(function(nid, idx) {
outp.addEventListener('mousedown', function(e) { e.stopPropagation(); self.startConnect(nid, idx, e); });
})(node.id, po);
g.appendChild(outp);
}
container.appendChild(g);
}
},
// ── Node Management ──────────────────────────────────
addNode: function(type, x, y) {
@@ -83,6 +241,7 @@ function workflowBuilder() {
node.config = { strategy: 'all' };
}
this.nodes.push(node);
this.scheduleRender();
return node;
},
@@ -95,6 +254,7 @@ function workflowBuilder() {
this.selectedNode = null;
this.showNodeEditor = false;
}
this.scheduleRender();
},
duplicateNode: function(node) {
@@ -166,19 +326,36 @@ function workflowBuilder() {
}
this.connecting = null;
this.connectPreview = null;
this.scheduleRender();
},
deleteConnection: function(connId) {
this.connections = this.connections.filter(function(c) { return c.id !== connId; });
this.selectedConnection = null;
this.scheduleRender();
},
// ── Drag Handling ────────────────────────────────────
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 = {
@@ -193,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 };
},
@@ -200,17 +378,22 @@ 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);
node.y = Math.max(0, (e.clientY - rect.top) / this.zoom - this.canvasOffset.y - this.dragOffset.y);
}
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
@@ -219,10 +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._didDrag = false;
this._didConnect = false;
this._didPan = false;
if (needsRender) {
this.scheduleRender();
}
},
onCanvasWheel: function(e) {
@@ -267,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 ──────────────────────────────────
@@ -386,7 +584,7 @@ function workflowBuilder() {
var rect = this._getCanvasRect();
var x = (e.clientX - rect.left) / this.zoom - this.canvasOffset.x;
var y = (e.clientY - rect.top) / this.zoom - this.canvasOffset.y;
this.addNode(type, x - 90, y - 35);
this.addNode(type, x - 90, y - 35); // addNode already calls scheduleRender
},
onCanvasDragOver: function(e) {
@@ -405,6 +603,7 @@ function workflowBuilder() {
this.nodes[i].y = y;
y += 120;
}
this.scheduleRender();
},
// ── Clear ────────────────────────────────────────────
@@ -414,7 +613,7 @@ function workflowBuilder() {
this.connections = [];
this.selectedNode = null;
this.nextId = 1;
this.addNode('start', 60, 200);
this.addNode('start', 60, 200); // addNode already calls scheduleRender
},
// ── Zoom controls ────────────────────────────────────
@@ -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);
}
}
};
}
File diff suppressed because one or more lines are too long
@@ -75,6 +75,9 @@ async fn start_test_server_with_provider(
peer_registry: None,
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
let app = Router::new()
@@ -220,10 +223,10 @@ async fn test_status_endpoint() {
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "running");
assert_eq!(body["agent_count"], 0);
assert_eq!(body["agent_count"], 1); // default assistant auto-spawned
assert!(body["uptime_seconds"].is_number());
assert_eq!(body["default_provider"], "ollama");
assert_eq!(body["agents"].as_array().unwrap().len(), 0);
assert_eq!(body["agents"].as_array().unwrap().len(), 1);
}
#[tokio::test]
@@ -245,7 +248,7 @@ async fn test_spawn_list_kill_agent() {
let agent_id = body["agent_id"].as_str().unwrap().to_string();
assert!(!agent_id.is_empty());
// --- List (1 agent) ---
// --- List (2 agents: default assistant + test-agent) ---
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
@@ -253,10 +256,10 @@ async fn test_spawn_list_kill_agent() {
.unwrap();
assert_eq!(resp.status(), 200);
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0]["name"], "test-agent");
assert_eq!(agents[0]["id"], agent_id);
assert_eq!(agents[0]["model_provider"], "ollama");
assert_eq!(agents.len(), 2);
let test_agent = agents.iter().find(|a| a["name"] == "test-agent").unwrap();
assert_eq!(test_agent["id"], agent_id);
assert_eq!(test_agent["model_provider"], "ollama");
// --- Kill ---
let resp = client
@@ -268,7 +271,7 @@ async fn test_spawn_list_kill_agent() {
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "killed");
// --- List (empty) ---
// --- List (only default assistant remains) ---
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
@@ -276,7 +279,8 @@ async fn test_spawn_list_kill_agent() {
.unwrap();
assert_eq!(resp.status(), 200);
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 0);
assert_eq!(agents.len(), 1);
assert_eq!(agents[0]["name"], "assistant");
}
#[tokio::test]
@@ -615,14 +619,14 @@ memory_write = ["self.*"]
ids.push(body["agent_id"].as_str().unwrap().to_string());
}
// List should show 3
// List should show 4 (3 spawned + default assistant)
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 3);
assert_eq!(agents.len(), 4);
// Status should agree
let resp = client
@@ -631,7 +635,7 @@ memory_write = ["self.*"]
.await
.unwrap();
let status: serde_json::Value = resp.json().await.unwrap();
assert_eq!(status["agent_count"], 3);
assert_eq!(status["agent_count"], 4);
// Kill one
let resp = client
@@ -641,14 +645,14 @@ memory_write = ["self.*"]
.unwrap();
assert_eq!(resp.status(), 200);
// List should show 2
// List should show 3 (2 spawned + default assistant)
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 2);
assert_eq!(agents.len(), 3);
// Kill the rest
for id in [&ids[0], &ids[2]] {
@@ -659,14 +663,14 @@ memory_write = ["self.*"]
.unwrap();
}
// List should be empty
// List should have only default assistant
let resp = client
.get(format!("{}/api/agents", server.base_url))
.send()
.await
.unwrap();
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
assert_eq!(agents.len(), 0);
assert_eq!(agents.len(), 1);
}
// ---------------------------------------------------------------------------
@@ -700,9 +704,23 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
peer_registry: None,
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
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))
@@ -746,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))
@@ -112,6 +112,9 @@ async fn test_full_daemon_lifecycle() {
peer_registry: None,
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
let app = Router::new()
@@ -234,6 +237,9 @@ async fn test_server_immediate_responsiveness() {
peer_registry: None,
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
let app = Router::new()
+4 -1
View File
@@ -56,6 +56,9 @@ async fn start_test_server() -> TestServer {
peer_registry: None,
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
});
let app = Router::new()
@@ -539,7 +542,7 @@ async fn load_spawn_kill_cycle() {
.await
.unwrap();
let remaining = agents.as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(remaining, 0, "All agents should be killed");
assert_eq!(remaining, 1, "Only default assistant should remain");
}
/// Test: Prometheus metrics endpoint under sustained load.
+6
View File
@@ -26,6 +26,12 @@ hmac = { workspace = true }
sha2 = { workspace = true }
base64 = { workspace = true }
hex = { workspace = true }
html-escape = { workspace = true }
lettre = { workspace = true }
imap = { workspace = true }
native-tls = { workspace = true }
mailparse = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
+7 -3
View File
@@ -215,7 +215,7 @@ impl BlueskyAdapter {
let chunks = split_message(text, MAX_MESSAGE_LEN);
for chunk in chunks {
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let mut record = serde_json::json!({
"$type": "app.bsky.feed.post",
@@ -435,7 +435,11 @@ impl ChannelAdapter for BlueskyAdapter {
service_url
);
if let Some(ref seen) = last_seen_at {
url.push_str(&format!("&seenAt={}", seen));
let encoded: String = url::form_urlencoded::Serializer::new(String::new())
.append_pair("seenAt", seen)
.finish();
url.push('&');
url.push_str(&encoded);
}
let resp = match client.get(&url).bearer_auth(&token).send().await {
@@ -492,7 +496,7 @@ impl ChannelAdapter for BlueskyAdapter {
if last_seen_at.is_some() {
let mark_url = format!("{}/xrpc/app.bsky.notification.updateSeen", service_url);
let mark_body = serde_json::json!({
"seenAt": Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
"seenAt": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
});
let _ = client
.post(&mark_url)
+587 -33
View File
@@ -5,9 +5,13 @@
use crate::formatter;
use crate::router::AgentRouter;
use crate::types::{ChannelAdapter, ChannelContent, ChannelMessage, ChannelUser};
use crate::types::{
default_phase_emoji, AgentPhase, ChannelAdapter, ChannelContent, ChannelMessage, ChannelUser,
LifecycleReaction,
};
use async_trait::async_trait;
use dashmap::DashMap;
use openfang_types::message::ContentBlock;
use futures::StreamExt;
use openfang_types::agent::AgentId;
use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat};
@@ -25,6 +29,26 @@ pub trait ChannelBridgeHandle: Send + Sync {
/// Send a message to an agent and get the text response.
async fn send_message(&self, agent_id: AgentId, message: &str) -> Result<String, String>;
/// Send a message with structured content blocks (text + images) to an agent.
///
/// Default implementation extracts text from blocks and falls back to `send_message()`.
async fn send_message_with_blocks(
&self,
agent_id: AgentId,
blocks: Vec<ContentBlock>,
) -> Result<String, String> {
// Default: extract text from blocks and send as plain text
let text: String = blocks
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
self.send_message(agent_id, &text).await
}
/// Find an agent by name, returning its ID.
async fn find_agent_by_name(&self, name: &str) -> Result<Option<AgentId>, String>;
@@ -111,6 +135,9 @@ pub trait ChannelBridgeHandle: Send + Sync {
}
/// Record a delivery result for tracking (optional — default no-op).
///
/// `thread_id` preserves Telegram forum-topic context so cron/workflow
/// delivery can target the same topic later.
async fn record_delivery(
&self,
_agent_id: AgentId,
@@ -118,6 +145,7 @@ pub trait ChannelBridgeHandle: Send + Sync {
_recipient: &str,
_success: bool,
_error: Option<&str>,
_thread_id: Option<&str>,
) {
// Default: no tracking
}
@@ -264,6 +292,15 @@ impl BridgeManager {
}
/// Start an adapter: subscribe to its message stream and spawn a dispatch task.
///
/// Each incoming message is dispatched as a concurrent task so that slow LLM
/// calls (10-30s) don't block subsequent messages. This prevents voice/media
/// messages sent in quick succession from appearing "lost" — all messages
/// begin processing immediately. Per-agent serialization (to prevent session
/// corruption) is handled by the kernel's `agent_msg_locks`.
///
/// A semaphore limits concurrent dispatch tasks to prevent unbounded memory
/// growth under burst traffic.
pub async fn start_adapter(
&mut self,
adapter: Arc<dyn ChannelAdapter>,
@@ -275,6 +312,10 @@ impl BridgeManager {
let adapter_clone = adapter.clone();
let mut shutdown = self.shutdown_rx.clone();
// Limit concurrent dispatch tasks to prevent unbounded growth.
// 32 is generous — most setups have 1-5 concurrent users.
let semaphore = Arc::new(tokio::sync::Semaphore::new(32));
let task = tokio::spawn(async move {
let mut stream = std::pin::pin!(stream);
loop {
@@ -282,13 +323,28 @@ impl BridgeManager {
msg = stream.next() => {
match msg {
Some(message) => {
dispatch_message(
&message,
&handle,
&router,
adapter_clone.as_ref(),
&rate_limiter,
).await;
// Spawn each dispatch as a concurrent task so the stream
// loop is never blocked by slow LLM calls. The kernel's
// per-agent lock ensures session integrity.
let handle = handle.clone();
let router = router.clone();
let adapter = adapter_clone.clone();
let rate_limiter = rate_limiter.clone();
let sem = semaphore.clone();
tokio::spawn(async move {
// Acquire semaphore permit (blocks if 32 tasks are in flight).
let _permit = match sem.acquire().await {
Ok(p) => p,
Err(_) => return, // semaphore closed — shutting down
};
dispatch_message(
&message,
&handle,
&router,
adapter.as_ref(),
&rate_limiter,
).await;
});
}
None => {
info!("Channel adapter {} stream ended", adapter_clone.name());
@@ -359,6 +415,25 @@ async fn send_response(
}
}
/// Send a lifecycle reaction (best-effort, non-blocking for supported adapters).
///
/// Silently ignores errors — reactions are non-critical UX polish.
/// For Telegram, the underlying HTTP call is already fire-and-forget (spawned internally),
/// so this await returns almost immediately.
async fn send_lifecycle_reaction(
adapter: &dyn ChannelAdapter,
user: &ChannelUser,
message_id: &str,
phase: AgentPhase,
) {
let reaction = LifecycleReaction {
emoji: default_phase_emoji(&phase).to_string(),
phase,
remove_previous: true,
};
let _ = adapter.send_reaction(user, message_id, &reaction).await;
}
/// Dispatch a single incoming message — handles bot commands or routes to an agent.
///
/// Applies per-channel policies (DM/group filtering, rate limiting, formatting, threading).
@@ -373,11 +448,17 @@ async fn dispatch_message(
// Fetch per-channel overrides (if configured)
let overrides = handle.channel_overrides(ct_str).await;
let channel_default_format = match ct_str {
"telegram" => OutputFormat::TelegramHtml,
"slack" => OutputFormat::SlackMrkdwn,
_ => OutputFormat::Markdown,
};
let output_format = overrides
.as_ref()
.and_then(|o| o.output_format)
.unwrap_or(OutputFormat::Markdown);
.unwrap_or(channel_default_format);
let threading_enabled = overrides.as_ref().map(|o| o.threading).unwrap_or(false);
let lifecycle_reactions = overrides.as_ref().map(|o| o.lifecycle_reactions).unwrap_or(true);
let thread_id = if threading_enabled {
message.thread_id.as_deref()
} else {
@@ -402,8 +483,15 @@ async fn dispatch_message(
}
}
GroupPolicy::MentionOnly => {
// Pass through — adapters should only forward mentioned messages.
// This is a hint for adapters, not enforced here.
// Only allow messages where the bot was @mentioned or commands.
let was_mentioned = message.metadata.get("was_mentioned")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let is_command = matches!(&message.content, ChannelContent::Command { .. });
if !was_mentioned && !is_command {
debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)");
return;
}
}
GroupPolicy::All => {}
}
@@ -434,24 +522,57 @@ async fn dispatch_message(
}
}
let text = match &message.content {
ChannelContent::Text(t) => t.clone(),
ChannelContent::Command { name, args } => {
let result = handle_command(name, args, handle, router, &message.sender).await;
send_response(adapter, &message.sender, result, thread_id, output_format).await;
return;
}
_ => {
send_response(
// Handle commands first (early return)
if let ChannelContent::Command { ref name, ref args } = message.content {
let result = handle_command(name, args, handle, router, &message.sender).await;
send_response(adapter, &message.sender, result, thread_id, output_format).await;
return;
}
// For images: download, base64 encode, and send as multimodal content blocks
if let ChannelContent::Image { ref url, ref caption } = message.content {
let blocks = download_image_to_blocks(url, caption.as_deref()).await;
if blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. })) {
// We have actual image data — send as structured blocks for vision
dispatch_with_blocks(
blocks,
message,
handle,
router,
adapter,
&message.sender,
"I can only handle text messages for now.".to_string(),
ct_str,
thread_id,
output_format,
lifecycle_reactions,
)
.await;
return;
}
// Image download failed — fall through to text description below
}
let text = match &message.content {
ChannelContent::Text(t) => t.clone(),
ChannelContent::Command { .. } => unreachable!(), // handled above
ChannelContent::Image { ref url, ref caption } => {
// Fallback when image download failed
match caption {
Some(c) => format!("[User sent a photo: {url}]\nCaption: {c}"),
None => format!("[User sent a photo: {url}]"),
}
}
ChannelContent::File { ref url, ref filename } => {
format!("[User sent a file ({filename}): {url}]")
}
ChannelContent::Voice { ref url, duration_seconds } => {
format!("[User sent a voice message ({duration_seconds}s): {url}]")
}
ChannelContent::Location { lat, lon } => {
format!("[User shared location: {lat}, {lon}]")
}
ChannelContent::FileData { ref filename, .. } => {
format!("[User sent a local file: {filename}]")
}
};
// Check if it's a slash command embedded in text (e.g. "/agents")
@@ -577,14 +698,33 @@ async fn dispatch_message(
let agent_id = match agent_id {
Some(id) => id,
None => {
send_response(
adapter,
&message.sender,
"No agent assigned. Use /agents to list available agents, then /agent <name> to select one.".to_string(),
thread_id,
output_format,
).await;
return;
// Fallback: try "assistant" agent, then first available agent
let fallback = handle.find_agent_by_name("assistant").await.ok().flatten();
let fallback = match fallback {
Some(id) => Some(id),
None => handle
.list_agents()
.await
.ok()
.and_then(|agents| agents.first().map(|(id, _)| *id)),
};
match fallback {
Some(id) => {
// Auto-set this as the user's default so future messages route directly
router.set_user_default(message.sender.platform_id.clone(), id);
id
}
None => {
send_response(
adapter,
&message.sender,
"No agents available. Start the dashboard at http://127.0.0.1:4200 to create one.".to_string(),
thread_id,
output_format,
).await;
return;
}
}
}
};
@@ -609,7 +749,7 @@ async fn dispatch_message(
if let Some(reply) = handle.check_auto_reply(agent_id, &text).await {
send_response(adapter, &message.sender, reply, thread_id, output_format).await;
handle
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None)
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
.await;
return;
}
@@ -617,17 +757,30 @@ async fn dispatch_message(
// Send typing indicator (best-effort)
let _ = adapter.send_typing(&message.sender).await;
// Lifecycle reaction: ⏳ Queued → 🤔 Thinking → ✅ Done / ❌ Error
let msg_id = &message.platform_message_id;
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Queued).await;
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Thinking).await;
}
// Send to agent and relay response
match handle.send_message(agent_id, &text).await {
Ok(response) => {
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
}
send_response(adapter, &message.sender, response, thread_id, output_format).await;
handle
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None)
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
.await;
}
Err(e) => {
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
}
warn!("Agent error for {agent_id}: {e}");
let err_msg = format!("Agent error: {e}");
let err_msg = sanitize_agent_error(&e.to_string());
send_response(
adapter,
&message.sender,
@@ -643,6 +796,309 @@ async fn dispatch_message(
&message.sender.platform_id,
false,
Some(&err_msg),
thread_id,
)
.await;
}
}
}
fn sanitize_agent_error(raw: &str) -> String {
let lower = raw.to_lowercase();
if lower.contains("rate limit")
|| lower.contains("rate_limit")
|| lower.contains("429")
|| lower.contains("too many requests")
|| lower.contains("resource_exhausted")
{
return "Rate limit reached, please try again later.".to_string();
}
if lower.contains("authentication")
|| lower.contains("unauthorized")
|| lower.contains("invalid api key")
|| lower.contains("invalid x-goog-api-key")
|| lower.contains("incorrect api key")
|| lower.contains("permission denied")
|| lower.contains("billing")
|| lower.contains("quota exceeded")
{
return "Service temporarily unavailable.".to_string();
}
if lower.contains("context length")
|| lower.contains("token limit")
|| lower.contains("too many tokens")
|| lower.contains("maximum context")
|| lower.contains("max_tokens")
|| lower.contains("context window")
{
return "Message too long, try a shorter request.".to_string();
}
if lower.contains("overloaded")
|| lower.contains("503")
|| lower.contains("502")
|| lower.contains("server error")
|| lower.contains("internal error")
{
return "The AI service is temporarily overloaded, please try again shortly.".to_string();
}
if lower.contains("timeout") || lower.contains("timed out") || lower.contains("deadline") {
return "Request timed out, please try again.".to_string();
}
if lower.contains("model not found") || lower.contains("model_not_found") {
return "The requested model is currently unavailable.".to_string();
}
let cleaned = raw
.strip_prefix("LLM driver error: ")
.or_else(|| raw.strip_prefix("Agent error: "))
.unwrap_or(raw);
if let Some(first_sentence_end) = cleaned.find(". ") {
let first = &cleaned[..=first_sentence_end];
if first.len() < cleaned.len() / 2 {
return format!("Agent error: {first}");
}
}
if cleaned.contains('{') || cleaned.len() > 200 {
return "Something went wrong processing your request. Please try again.".to_string();
}
format!("Agent error: {cleaned}")
}
/// Detect image format from the first few magic bytes.
///
/// Returns `Some("image/...")` for JPEG, PNG, GIF, and WebP.
fn detect_image_magic(bytes: &[u8]) -> Option<String> {
if bytes.len() >= 3 && bytes[..3] == [0xFF, 0xD8, 0xFF] {
return Some("image/jpeg".to_string());
}
if bytes.len() >= 4 && bytes[..4] == [0x89, 0x50, 0x4E, 0x47] {
return Some("image/png".to_string());
}
if bytes.len() >= 4 && bytes[..4] == [0x47, 0x49, 0x46, 0x38] {
return Some("image/gif".to_string());
}
if bytes.len() >= 12 && bytes[..4] == [0x52, 0x49, 0x46, 0x46] && bytes[8..12] == [0x57, 0x45, 0x42, 0x50]
{
return Some("image/webp".to_string());
}
None
}
/// Guess image media type from the URL file extension.
fn media_type_from_url(url: &str) -> String {
if url.contains(".png") {
"image/png".to_string()
} else if url.contains(".gif") {
"image/gif".to_string()
} else if url.contains(".webp") {
"image/webp".to_string()
} else {
// JPEG is the most common image format — safe default
"image/jpeg".to_string()
}
}
/// Download an image from a URL and build content blocks for multimodal LLM input.
///
/// Returns a `Vec<ContentBlock>` containing an image block (base64-encoded) and
/// optionally a text block for the caption. If the download fails, returns a
/// text-only block describing the failure.
async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec<ContentBlock> {
use base64::Engine;
// 5 MB limit to prevent memory abuse from oversized images
const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
let client = reqwest::Client::new();
let resp = match client.get(url).send().await {
Ok(r) => r,
Err(e) => {
warn!("Failed to download image from channel: {e}");
return vec![ContentBlock::Text {
text: format!("[Image download failed: {e}]"),
provider_metadata: None,
}];
}
};
// Detect media type from Content-Type header — but only trust it if it's
// actually an image/* type. Many APIs (Telegram, S3 pre-signed URLs) return
// `application/octet-stream` for all files, which breaks vision.
let header_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string())
.filter(|ct| ct.starts_with("image/"));
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => {
warn!("Failed to read image bytes: {e}");
return vec![ContentBlock::Text {
text: format!("[Image read failed: {e}]"),
provider_metadata: None,
}];
}
};
// Three-tier media type detection:
// 1. Trusted Content-Type header (only if image/*)
// 2. Magic byte sniffing (most reliable for binary data)
// 3. URL extension fallback
let media_type = header_type.unwrap_or_else(|| {
detect_image_magic(&bytes).unwrap_or_else(|| media_type_from_url(url))
});
if bytes.len() > MAX_IMAGE_BYTES {
warn!(
"Image too large ({} bytes), skipping vision — sending as text",
bytes.len()
);
let desc = match caption {
Some(c) => format!("[Image too large for vision ({} KB)]\nCaption: {c}", bytes.len() / 1024),
None => format!("[Image too large for vision ({} KB)]", bytes.len() / 1024),
};
return vec![ContentBlock::Text { text: desc, provider_metadata: None }];
}
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
let mut blocks = Vec::new();
// Caption as text block first (gives the LLM context about the image)
if let Some(cap) = caption {
if !cap.is_empty() {
blocks.push(ContentBlock::Text {
text: cap.to_string(),
provider_metadata: None,
});
}
}
blocks.push(ContentBlock::Image { media_type, data });
blocks
}
/// Dispatch a multimodal message (content blocks) to an agent, handling routing
/// and RBAC the same way as the text path.
#[allow(clippy::too_many_arguments)]
async fn dispatch_with_blocks(
blocks: Vec<ContentBlock>,
message: &ChannelMessage,
handle: &Arc<dyn ChannelBridgeHandle>,
router: &Arc<AgentRouter>,
adapter: &dyn ChannelAdapter,
ct_str: &str,
thread_id: Option<&str>,
output_format: OutputFormat,
lifecycle_reactions: bool,
) {
// Route to agent (same logic as text path)
let agent_id = router.resolve(
&message.channel,
&message.sender.platform_id,
message.sender.openfang_user.as_deref(),
);
let agent_id = match agent_id {
Some(id) => id,
None => {
let fallback = handle.find_agent_by_name("assistant").await.ok().flatten();
let fallback = match fallback {
Some(id) => Some(id),
None => handle
.list_agents()
.await
.ok()
.and_then(|agents| agents.first().map(|(id, _)| *id)),
};
match fallback {
Some(id) => {
router.set_user_default(message.sender.platform_id.clone(), id);
id
}
None => {
send_response(
adapter,
&message.sender,
"No agents available. Start the dashboard at http://127.0.0.1:4200 to create one.".to_string(),
thread_id,
output_format,
).await;
return;
}
}
}
};
// RBAC check
if let Err(denied) = handle
.authorize_channel_user(ct_str, &message.sender.platform_id, "chat")
.await
{
send_response(
adapter,
&message.sender,
format!("Access denied: {denied}"),
thread_id,
output_format,
)
.await;
return;
}
let _ = adapter.send_typing(&message.sender).await;
// Lifecycle reaction: ⏳ Queued → 🤔 Thinking → ✅ Done / ❌ Error
let msg_id = &message.platform_message_id;
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Queued).await;
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Thinking).await;
}
match handle.send_message_with_blocks(agent_id, blocks).await {
Ok(response) => {
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
}
send_response(adapter, &message.sender, response, thread_id, output_format).await;
handle
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
.await;
}
Err(e) => {
if lifecycle_reactions {
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
}
warn!("Agent error for {agent_id}: {e}");
let err_msg = sanitize_agent_error(&e.to_string());
send_response(
adapter,
&message.sender,
err_msg.clone(),
thread_id,
output_format,
)
.await;
handle
.record_delivery(
agent_id,
ct_str,
&message.sender.platform_id,
false,
Some(&err_msg),
thread_id,
)
.await;
}
@@ -1088,4 +1544,102 @@ mod tests {
"irc"
);
}
#[tokio::test]
async fn test_send_message_with_blocks_default_fallback() {
// The default implementation of send_message_with_blocks extracts text
// from blocks and calls send_message
let agent_id = AgentId::new();
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
agents: Mutex::new(vec![(agent_id, "vision-agent".to_string())]),
});
let blocks = vec![
ContentBlock::Text {
text: "What is in this photo?".to_string(),
provider_metadata: None,
},
ContentBlock::Image {
media_type: "image/jpeg".to_string(),
data: "base64data".to_string(),
},
];
// Default impl should extract text and call send_message
let result = handle
.send_message_with_blocks(agent_id, blocks)
.await
.unwrap();
assert_eq!(result, "Echo: What is in this photo?");
}
#[tokio::test]
async fn test_send_message_with_blocks_image_only() {
// When there's no text block, the default should still work
let agent_id = AgentId::new();
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
agents: Mutex::new(vec![(agent_id, "vision-agent".to_string())]),
});
let blocks = vec![ContentBlock::Image {
media_type: "image/png".to_string(),
data: "base64data".to_string(),
}];
// Default impl sends empty text when no text blocks
let result = handle
.send_message_with_blocks(agent_id, blocks)
.await
.unwrap();
assert_eq!(result, "Echo: ");
}
#[test]
fn test_detect_image_magic_jpeg() {
let bytes = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
assert_eq!(detect_image_magic(&bytes), Some("image/jpeg".to_string()));
}
#[test]
fn test_detect_image_magic_png() {
let bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
assert_eq!(detect_image_magic(&bytes), Some("image/png".to_string()));
}
#[test]
fn test_detect_image_magic_gif() {
let bytes = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
assert_eq!(detect_image_magic(&bytes), Some("image/gif".to_string()));
}
#[test]
fn test_detect_image_magic_webp() {
let bytes = [
0x52, 0x49, 0x46, 0x46, // RIFF
0x00, 0x00, 0x00, 0x00, // size (don't care)
0x57, 0x45, 0x42, 0x50, // WEBP
];
assert_eq!(detect_image_magic(&bytes), Some("image/webp".to_string()));
}
#[test]
fn test_detect_image_magic_unknown() {
let bytes = [0x00, 0x01, 0x02, 0x03];
assert_eq!(detect_image_magic(&bytes), None);
}
#[test]
fn test_detect_image_magic_empty() {
assert_eq!(detect_image_magic(&[]), None);
}
#[test]
fn test_media_type_from_url() {
assert_eq!(media_type_from_url("https://example.com/photo.png"), "image/png");
assert_eq!(media_type_from_url("https://example.com/anim.gif"), "image/gif");
assert_eq!(media_type_from_url("https://example.com/img.webp"), "image/webp");
assert_eq!(media_type_from_url("https://example.com/photo.jpg"), "image/jpeg");
// No extension — defaults to JPEG
assert_eq!(media_type_from_url("https://api.telegram.org/file/bot123/photos/file_42"), "image/jpeg");
}
}
+196 -20
View File
@@ -38,7 +38,9 @@ pub struct DiscordAdapter {
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
token: Zeroizing<String>,
client: reqwest::Client,
allowed_guilds: Vec<u64>,
allowed_guilds: Vec<String>,
allowed_users: Vec<String>,
ignore_bots: bool,
intents: u64,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
@@ -51,12 +53,20 @@ pub struct DiscordAdapter {
}
impl DiscordAdapter {
pub fn new(token: String, allowed_guilds: Vec<u64>, intents: u64) -> Self {
pub fn new(
token: String,
allowed_guilds: Vec<String>,
allowed_users: Vec<String>,
ignore_bots: bool,
intents: u64,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
token: Zeroizing::new(token),
client: reqwest::Client::new(),
allowed_guilds,
allowed_users,
ignore_bots,
intents,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
@@ -147,6 +157,8 @@ impl ChannelAdapter for DiscordAdapter {
let token = self.token.clone();
let intents = self.intents;
let allowed_guilds = self.allowed_guilds.clone();
let allowed_users = self.allowed_users.clone();
let ignore_bots = self.ignore_bots;
let bot_user_id = self.bot_user_id.clone();
let session_id_store = self.session_id.clone();
let resume_url_store = self.resume_gateway_url.clone();
@@ -307,7 +319,7 @@ impl ChannelAdapter for DiscordAdapter {
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
if let Some(msg) =
parse_discord_message(d, &bot_user_id, &allowed_guilds)
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users, ignore_bots)
.await
{
debug!(
@@ -422,7 +434,9 @@ impl ChannelAdapter for DiscordAdapter {
async fn parse_discord_message(
d: &serde_json::Value,
bot_user_id: &Arc<RwLock<Option<String>>>,
allowed_guilds: &[u64],
allowed_guilds: &[String],
allowed_users: &[String],
ignore_bots: bool,
) -> Option<ChannelMessage> {
let author = d.get("author")?;
let author_id = author["id"].as_str()?;
@@ -434,16 +448,21 @@ async fn parse_discord_message(
}
}
// Filter out other bots
if author["bot"].as_bool() == Some(true) {
// Filter out other bots (configurable via ignore_bots)
if ignore_bots && author["bot"].as_bool() == Some(true) {
return None;
}
// Filter by allowed users
if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == author_id) {
debug!("Discord: ignoring message from unlisted user {author_id}");
return None;
}
// Filter by allowed guilds
if !allowed_guilds.is_empty() {
if let Some(guild_id) = d["guild_id"].as_str() {
let gid: u64 = guild_id.parse().unwrap_or(0);
if !allowed_guilds.contains(&gid) {
if !allowed_guilds.iter().any(|g| g == guild_id) {
return None;
}
}
@@ -487,6 +506,29 @@ async fn parse_discord_message(
ChannelContent::Text(content_text.to_string())
};
// Determine if this is a group message (guild_id present = server channel)
let is_group = d["guild_id"].as_str().is_some();
// Check if bot was @mentioned (for MentionOnly policy enforcement)
let was_mentioned = if let Some(ref bid) = *bot_user_id.read().await {
// Check Discord mentions array
let mentioned_in_array = d["mentions"]
.as_array()
.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}>"));
mentioned_in_array || mentioned_in_content
} else {
false
};
let mut metadata = HashMap::new();
if was_mentioned {
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
}
Some(ChannelMessage {
channel: ChannelType::Discord,
platform_message_id: message_id.to_string(),
@@ -498,9 +540,9 @@ async fn parse_discord_message(
content,
target_agent: None,
timestamp,
is_group: true,
is_group,
thread_id: None,
metadata: HashMap::new(),
metadata,
})
}
@@ -524,7 +566,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).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");
@@ -546,7 +588,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
assert!(msg.is_none());
}
@@ -566,7 +608,52 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
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());
}
@@ -587,11 +674,11 @@ mod tests {
});
// Not in allowed guilds
let msg = parse_discord_message(&d, &bot_id, &[111, 222]).await;
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
assert!(msg.is_none());
// In allowed guilds
let msg = parse_discord_message(&d, &bot_id, &[999]).await;
let msg = parse_discord_message(&d, &bot_id, &["999".into()], &[], true).await;
assert!(msg.is_some());
}
@@ -610,7 +697,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -635,7 +722,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await;
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
assert!(msg.is_none());
}
@@ -654,7 +741,7 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[]).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
assert_eq!(msg.sender.display_name, "alice#1234");
}
@@ -676,16 +763,105 @@ mod tests {
});
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
let msg = parse_discord_message(&d, &bot_id, &[]).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")
);
}
#[tokio::test]
async fn test_parse_discord_allowed_users_filter() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
let d = serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"content": "Hello",
"author": {
"id": "user999",
"username": "bob",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
// Not in allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()], true).await;
assert!(msg.is_none());
// In allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()], true).await;
assert!(msg.is_some());
// Empty allowed_users = allow all
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await;
assert!(msg.is_some());
}
#[tokio::test]
async fn test_parse_discord_mention_detection() {
let bot_id = Arc::new(RwLock::new(Some("bot123".to_string())));
// Message with bot mentioned in mentions array
let d = serde_json::json!({
"id": "msg1",
"channel_id": "ch1",
"guild_id": "guild1",
"content": "Hey <@bot123> help me",
"mentions": [{"id": "bot123", "username": "openfang"}],
"author": {
"id": "user1",
"username": "alice",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
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));
// Message without mention in group
let d2 = serde_json::json!({
"id": "msg2",
"channel_id": "ch1",
"guild_id": "guild1",
"content": "Just chatting",
"author": {
"id": "user1",
"username": "alice",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true).await.unwrap();
assert!(msg2.is_group);
assert!(!msg2.metadata.contains_key("was_mentioned"));
}
#[tokio::test]
async fn test_parse_discord_dm_not_group() {
let bot_id = Arc::new(RwLock::new(None));
let d = serde_json::json!({
"id": "msg1",
"channel_id": "dm-ch1",
"content": "Hello",
"author": {
"id": "user1",
"username": "alice",
"discriminator": "0"
},
"timestamp": "2024-01-01T00:00:00+00:00"
});
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, 456], 33280);
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);
}
+387 -35
View File
@@ -1,18 +1,47 @@
//! Email channel adapter (IMAP + SMTP).
//!
//! Polls IMAP for new emails and sends responses via SMTP.
//! Polls IMAP for new emails and sends responses via SMTP using `lettre`.
//! Uses the subject line for agent routing (e.g., "\[coder\] Fix this bug").
use crate::types::{ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser};
use async_trait::async_trait;
use chrono::Utc;
use dashmap::DashMap;
use futures::Stream;
use lettre::message::Mailbox;
use lettre::transport::smtp::authentication::Credentials;
use lettre::AsyncSmtpTransport;
use lettre::AsyncTransport;
use lettre::Tokio1Executor;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tracing::{debug, info};
use tracing::{debug, error, info, warn};
use zeroize::Zeroizing;
/// SASL PLAIN authenticator for IMAP servers that reject LOGIN
/// (e.g., Lark/Larksuite which only advertise AUTH=PLAIN).
struct PlainAuthenticator {
username: String,
password: String,
}
impl imap::Authenticator for PlainAuthenticator {
type Response = String;
fn process(&self, _data: &[u8]) -> Self::Response {
// SASL PLAIN: \0<username>\0<password>
format!("\x00{}\x00{}", self.username, self.password)
}
}
/// Reply context for email threading (In-Reply-To / Subject continuity).
#[derive(Debug, Clone)]
struct ReplyCtx {
subject: String,
message_id: String,
}
/// Email channel adapter using IMAP for receiving and SMTP for sending.
pub struct EmailAdapter {
/// IMAP server host.
@@ -21,7 +50,7 @@ pub struct EmailAdapter {
imap_port: u16,
/// SMTP server host.
smtp_host: String,
/// SMTP port (587 for STARTTLS).
/// SMTP port (587 for STARTTLS, 465 for implicit TLS).
smtp_port: u16,
/// Email address (used for both IMAP and SMTP).
username: String,
@@ -36,6 +65,8 @@ pub struct EmailAdapter {
/// Shutdown signal.
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
/// Tracks reply context per sender for email threading.
reply_ctx: Arc<DashMap<String, ReplyCtx>>,
}
impl EmailAdapter {
@@ -69,16 +100,17 @@ impl EmailAdapter {
allowed_senders,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
reply_ctx: Arc::new(DashMap::new()),
}
}
/// Check if a sender is in the allowlist (empty = allow all). Used in tests.
#[allow(dead_code)]
fn is_allowed_sender(&self, sender: &str) -> bool {
self.allowed_senders.is_empty() || self.allowed_senders.iter().any(|s| sender.contains(s))
}
/// Extract agent name from subject line brackets, e.g., "[coder] Fix the bug" -> Some("coder")
#[allow(dead_code)]
fn extract_agent_from_subject(subject: &str) -> Option<String> {
let subject = subject.trim();
if subject.starts_with('[') {
@@ -93,7 +125,6 @@ impl EmailAdapter {
}
/// Strip the agent tag from a subject line.
#[allow(dead_code)]
fn strip_agent_tag(subject: &str) -> String {
let subject = subject.trim();
if subject.starts_with('[') {
@@ -103,6 +134,175 @@ impl EmailAdapter {
}
subject.to_string()
}
/// Build an async SMTP transport for sending emails.
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 transport = if self.smtp_port == 465 {
// Implicit TLS (port 465)
AsyncSmtpTransport::<Tokio1Executor>::relay(&self.smtp_host)?
.port(self.smtp_port)
.credentials(creds)
.build()
} else {
// STARTTLS (port 587 or other)
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.smtp_host)?
.port(self.smtp_port)
.credentials(creds)
.build()
};
Ok(transport)
}
}
/// Extract `user@domain` from a potentially formatted email string like `"Name <user@domain>"`.
fn extract_email_addr(raw: &str) -> String {
let raw = raw.trim();
if let Some(start) = raw.find('<') {
if let Some(end) = raw.find('>') {
if end > start {
return raw[start + 1..end].trim().to_string();
}
}
}
raw.to_string()
}
/// Get a specific header value from a parsed email.
fn get_header(parsed: &mailparse::ParsedMail<'_>, name: &str) -> Option<String> {
parsed
.headers
.iter()
.find(|h| h.get_key().eq_ignore_ascii_case(name))
.map(|h| h.get_value())
}
/// Extract the text/plain body from a parsed email (handles multipart).
fn extract_text_body(parsed: &mailparse::ParsedMail<'_>) -> String {
if parsed.subparts.is_empty() {
return parsed.get_body().unwrap_or_default();
}
// Walk subparts looking for text/plain
for part in &parsed.subparts {
let ct = part.ctype.mimetype.to_lowercase();
if ct == "text/plain" {
return part.get_body().unwrap_or_default();
}
}
// Fallback: first subpart body
parsed
.subparts
.first()
.and_then(|p| p.get_body().ok())
.unwrap_or_default()
}
/// Fetch unseen emails from IMAP using blocking I/O.
/// Returns a Vec of (from_addr, subject, message_id, body).
fn fetch_unseen_emails(
host: &str,
port: u16,
username: &str,
password: &str,
folders: &[String],
) -> Result<Vec<(String, String, String, String)>, String> {
let tls = native_tls::TlsConnector::builder()
.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}"))?;
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
// that reject LOGIN and only support AUTH=PLAIN (SASL).
let mut session = match client.login(username, password) {
Ok(s) => s,
Err((login_err, client)) => {
let authenticator = PlainAuthenticator {
username: username.to_string(),
password: password.to_string(),
};
client
.authenticate("PLAIN", &authenticator)
.map_err(|(e, _)| {
format!("IMAP login failed: {login_err}; AUTH=PLAIN also failed: {e}")
})?
}
};
let mut results = Vec::new();
for folder in folders {
if let Err(e) = session.select(folder) {
warn!(folder, error = %e, "IMAP SELECT failed, skipping folder");
continue;
}
let uids = match session.uid_search("UNSEEN") {
Ok(uids) => uids,
Err(e) => {
warn!(folder, error = %e, "IMAP SEARCH UNSEEN failed");
continue;
}
};
if uids.is_empty() {
debug!(folder, "No unseen emails");
continue;
}
// Fetch in batches of up to 50 to avoid huge responses
let uid_list: Vec<u32> = uids.into_iter().take(50).collect();
let uid_set: String = uid_list
.iter()
.map(|u| u.to_string())
.collect::<Vec<_>>()
.join(",");
let fetches = match session.uid_fetch(&uid_set, "RFC822") {
Ok(f) => f,
Err(e) => {
warn!(folder, error = %e, "IMAP FETCH failed");
continue;
}
};
for fetch in fetches.iter() {
let body_bytes = match fetch.body() {
Some(b) => b,
None => continue,
};
let parsed = match mailparse::parse_mail(body_bytes) {
Ok(p) => p,
Err(e) => {
warn!(error = %e, "Failed to parse email");
continue;
}
};
let from = get_header(&parsed, "From").unwrap_or_default();
let subject = get_header(&parsed, "Subject").unwrap_or_default();
let message_id = get_header(&parsed, "Message-ID").unwrap_or_default();
let text_body = extract_text_body(&parsed);
let from_addr = extract_email_addr(&from);
results.push((from_addr, subject, message_id, text_body));
}
// Mark fetched messages as Seen
if let Err(e) = session.uid_store(&uid_set, "+FLAGS (\\Seen)") {
warn!(error = %e, "Failed to mark emails as Seen");
}
}
let _ = session.logout();
Ok(results)
}
#[async_trait]
@@ -119,25 +319,23 @@ impl ChannelAdapter for EmailAdapter {
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let (_tx, rx) = mpsc::channel::<ChannelMessage>(256);
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let poll_interval = self.poll_interval;
let _allowed_senders = self.allowed_senders.clone();
let imap_host = self.imap_host.clone();
let imap_port = self.imap_port;
let _username = self.username.clone();
let _password = self.password.clone();
let _folders = self.folders.clone();
let username = self.username.clone();
let password = self.password.clone();
let folders = self.folders.clone();
let allowed_senders = self.allowed_senders.clone();
let mut shutdown_rx = self.shutdown_rx.clone();
let reply_ctx = self.reply_ctx.clone();
info!(
"Starting email adapter (IMAP: {}:{}, polling every {:?})",
imap_host, imap_port, poll_interval
"Starting email adapter (IMAP: {}:{}, SMTP: {}:{}, polling every {:?})",
imap_host, imap_port, self.smtp_host, self.smtp_port, poll_interval
);
tokio::spawn(async move {
// Email polling is blocking I/O, so we'll use spawn_blocking
// For now, implement as a polling loop with placeholder
// Full IMAP implementation requires the `imap` crate
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
@@ -147,14 +345,83 @@ impl ChannelAdapter for EmailAdapter {
_ = tokio::time::sleep(poll_interval) => {}
}
// Placeholder: In a full implementation, this would:
// 1. Connect to IMAP server via TLS
// 2. Select each folder
// 3. Search for UNSEEN messages
// 4. Fetch and parse each message (From, Subject, Body)
// 5. Convert to ChannelMessage
// 6. Mark as seen
debug!("Email poll cycle (IMAP {}:{})", imap_host, imap_port);
// IMAP operations are blocking I/O — run in spawn_blocking
let host = imap_host.clone();
let port = imap_port;
let user = username.clone();
let pass = password.clone();
let fldrs = folders.clone();
let emails = tokio::task::spawn_blocking(move || {
fetch_unseen_emails(&host, port, &user, pass.as_str(), &fldrs)
})
.await;
let emails = match emails {
Ok(Ok(emails)) => emails,
Ok(Err(e)) => {
error!("IMAP poll error: {e}");
continue;
}
Err(e) => {
error!("IMAP spawn_blocking panic: {e}");
continue;
}
};
for (from_addr, subject, message_id, body) in emails {
// Check allowed senders
if !allowed_senders.is_empty()
&& !allowed_senders.iter().any(|s| from_addr.contains(s))
{
debug!(from = %from_addr, "Email from non-allowed sender, skipping");
continue;
}
// Store reply context for threading
if !message_id.is_empty() {
reply_ctx.insert(
from_addr.clone(),
ReplyCtx {
subject: subject.clone(),
message_id: message_id.clone(),
},
);
}
// Extract target agent from subject brackets (stored in metadata for router)
let _target_agent =
EmailAdapter::extract_agent_from_subject(&subject);
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
// Build the message body: prepend subject context
let text = if clean_subject.is_empty() {
body.trim().to_string()
} else {
format!("Subject: {clean_subject}\n\n{}", body.trim())
};
let msg = ChannelMessage {
channel: ChannelType::Email,
platform_message_id: message_id.clone(),
sender: ChannelUser {
platform_id: from_addr.clone(),
display_name: from_addr.clone(),
openfang_user: None,
},
content: ChannelContent::Text(text),
target_agent: None, // Routing handled by bridge AgentRouter
timestamp: Utc::now(),
is_group: false,
thread_id: None,
metadata: std::collections::HashMap::new(),
};
if tx.send(msg).await.is_err() {
info!("Email channel receiver dropped, stopping poll");
return;
}
}
}
});
@@ -168,22 +435,71 @@ impl ChannelAdapter for EmailAdapter {
) -> Result<(), Box<dyn std::error::Error>> {
match content {
ChannelContent::Text(text) => {
// Placeholder: In a full implementation, this would:
// 1. Build email (From, To, Subject, Body) using lettre
// 2. Connect to SMTP server via STARTTLS
// 3. Send the email
// Parse recipient address
let to_addr = extract_email_addr(&user.platform_id);
let to_mailbox: Mailbox = to_addr
.parse()
.map_err(|e| format!("Invalid recipient email '{}': {}", to_addr, e))?;
let from_mailbox: Mailbox = self
.username
.parse()
.map_err(|e| format!("Invalid sender email '{}': {}", self.username, e))?;
// Extract subject from text body convention: "Subject: ...\n\n..."
let (subject, body) = if text.starts_with("Subject: ") {
if let Some(pos) = text.find("\n\n") {
let subj = text[9..pos].trim().to_string();
let body = text[pos + 2..].to_string();
(subj, body)
} else {
("OpenFang Reply".to_string(), text)
}
} else {
// Check reply context for subject continuity
let subj = self
.reply_ctx
.get(&to_addr)
.map(|ctx| format!("Re: {}", ctx.subject))
.unwrap_or_else(|| "OpenFang Reply".to_string());
(subj, text)
};
// Build email message
let mut builder = lettre::Message::builder()
.from(from_mailbox)
.to(to_mailbox)
.subject(&subject);
// Add In-Reply-To header for threading
if let Some(ctx) = self.reply_ctx.get(&to_addr) {
if !ctx.message_id.is_empty() {
builder = builder.in_reply_to(ctx.message_id.clone());
}
}
let email = builder
.body(body)
.map_err(|e| format!("Failed to build email: {e}"))?;
// Send via SMTP
let transport = self.build_smtp_transport().await?;
transport
.send(email)
.await
.map_err(|e| format!("SMTP send failed: {e}"))?;
info!(
"Would send email to {}: {} chars",
user.platform_id,
text.len()
);
debug!(
"SMTP: {}:{} -> {}",
self.smtp_host, self.smtp_port, user.platform_id
to = %to_addr,
subject = %subject,
"Email sent successfully via SMTP"
);
}
_ => {
info!("Unsupported email content type for {}", user.platform_id);
warn!(
"Unsupported email content type for {}, only text is supported",
user.platform_id
);
}
}
Ok(())
@@ -274,4 +590,40 @@ mod tests {
);
assert_eq!(EmailAdapter::strip_agent_tag("No brackets"), "No brackets");
}
#[test]
fn test_extract_email_addr() {
assert_eq!(
extract_email_addr("John Doe <john@example.com>"),
"john@example.com"
);
assert_eq!(extract_email_addr("user@example.com"), "user@example.com");
assert_eq!(extract_email_addr("<user@test.com>"), "user@test.com");
}
#[test]
fn test_subject_extraction_from_body() {
let text = "Subject: Test Subject\n\nThis is the body.";
assert!(text.starts_with("Subject: "));
let pos = text.find("\n\n").unwrap();
let subject = &text[9..pos];
let body = &text[pos + 2..];
assert_eq!(subject, "Test Subject");
assert_eq!(body, "This is the body.");
}
#[test]
fn test_reply_ctx_threading() {
let ctx_map: DashMap<String, ReplyCtx> = DashMap::new();
ctx_map.insert(
"user@test.com".to_string(),
ReplyCtx {
subject: "Original Subject".to_string(),
message_id: "<msg-123@test.com>".to_string(),
},
);
let ctx = ctx_map.get("user@test.com").unwrap();
assert_eq!(ctx.subject, "Original Subject");
assert_eq!(ctx.message_id, "<msg-123@test.com>");
}
}
+6 -1
View File
@@ -21,7 +21,12 @@ pub fn format_for_channel(text: &str, format: OutputFormat) -> String {
///
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`.
fn markdown_to_telegram_html(text: &str) -> String {
let mut result = text.to_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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
// Bold: **text** → <b>text</b>
while let Some(start) = result.find("**") {
+47 -17
View File
@@ -268,38 +268,39 @@ fn parse_mastodon_notification(
fn strip_html_tags(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
let mut tag_buf = String::new();
for ch in html.chars() {
match ch {
'<' => {
in_tag = true;
// Check if this is a <br> or </p> — insert newline
if html[result.len()..].starts_with("<br")
|| html[result.len()..].starts_with("</p")
tag_buf.clear();
}
'>' if in_tag => {
in_tag = false;
// Insert newline for block-level closing tags
let tag_lower = tag_buf.to_lowercase();
if tag_lower.starts_with("br")
|| tag_lower.starts_with("/p")
|| tag_lower.starts_with("/div")
|| tag_lower.starts_with("/li")
{
result.push('\n');
}
tag_buf.clear();
}
'>' => {
in_tag = false;
_ if in_tag => {
tag_buf.push(ch);
}
_ if !in_tag => {
_ => {
result.push(ch);
}
_ => {}
}
}
// Decode common HTML entities
result
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.trim()
.to_string()
// Decode HTML entities (handles named, decimal, and hex entities)
let decoded = html_escape::decode_html_entities(&result);
decoded.trim().to_string()
}
#[async_trait]
@@ -577,6 +578,35 @@ mod tests {
assert_eq!(strip_html_tags("plain text"), "plain text");
}
#[test]
fn test_strip_html_tags_emoji() {
assert_eq!(
strip_html_tags("<p>Hello 🦀🔥 world</p>"),
"Hello 🦀🔥 world"
);
}
#[test]
fn test_strip_html_tags_cjk() {
assert_eq!(
strip_html_tags("<p>你好 <strong>世界</strong></p>"),
"你好 世界"
);
}
#[test]
fn test_strip_html_tags_numeric_entities() {
assert_eq!(strip_html_tags("&#39;hello&#39;"), "'hello'");
}
#[test]
fn test_strip_html_tags_div_newline() {
assert_eq!(
strip_html_tags("<div>one</div><div>two</div>").trim(),
"one\ntwo"
);
}
#[test]
fn test_parse_mastodon_notification_mention() {
let notif = serde_json::json!({
+138 -5
View File
@@ -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,86 @@ 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 +225,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 +254,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 +289,21 @@ 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 +346,38 @@ 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 +389,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() {
+2 -2
View File
@@ -165,7 +165,7 @@ impl ChannelAdapter for NostrAdapter {
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let pubkey = self.derive_pubkey();
info!("Nostr adapter starting (pubkey: {}...)", &pubkey[..16]);
info!("Nostr adapter starting (pubkey: {}...)", openfang_types::truncate_str(&pubkey, 16));
if self.relays.is_empty() {
return Err("Nostr: no relay URLs configured".into());
@@ -339,7 +339,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,
},
+43 -3
View File
@@ -32,6 +32,8 @@ pub struct AgentRouter {
direct_routes: DashMap<(String, String), AgentId>,
/// System-wide default agent.
default_agent: Option<AgentId>,
/// Per-channel-type default agent (e.g., Telegram -> agent_a, Discord -> agent_b).
channel_defaults: DashMap<String, AgentId>,
/// 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.
@@ -47,6 +49,7 @@ impl AgentRouter {
user_defaults: DashMap::new(),
direct_routes: DashMap::new(),
default_agent: None,
channel_defaults: DashMap::new(),
bindings: Mutex::new(Vec::new()),
broadcast: Mutex::new(BroadcastConfig::default()),
agent_name_cache: DashMap::new(),
@@ -58,6 +61,11 @@ impl AgentRouter {
self.default_agent = Some(agent_id);
}
/// Set a per-channel-type default agent (e.g., "Telegram" -> agent_id).
pub fn set_channel_default(&self, channel_key: String, agent_id: AgentId) {
self.channel_defaults.insert(channel_key, 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);
@@ -125,7 +133,7 @@ impl AgentRouter {
// 1. Check direct routes
if let Some(agent) = self
.direct_routes
.get(&(channel_key, platform_user_id.to_string()))
.get(&(channel_key.clone(), platform_user_id.to_string()))
{
return Some(*agent);
}
@@ -141,7 +149,12 @@ impl AgentRouter {
return Some(*agent);
}
// 3. System default
// 3. Per-channel-type default
if let Some(agent) = self.channel_defaults.get(&channel_key) {
return Some(*agent);
}
// 4. System default
self.default_agent
}
@@ -161,7 +174,7 @@ impl AgentRouter {
let channel_key = format!("{channel_type:?}");
if let Some(agent) = self
.direct_routes
.get(&(channel_key, platform_user_id.to_string()))
.get(&(channel_key.clone(), platform_user_id.to_string()))
{
return Some(*agent);
}
@@ -173,6 +186,9 @@ impl AgentRouter {
if let Some(agent) = self.user_defaults.get(platform_user_id) {
return Some(*agent);
}
if let Some(agent) = self.channel_defaults.get(&channel_key) {
return Some(*agent);
}
self.default_agent
}
@@ -501,6 +517,30 @@ mod tests {
assert_eq!(targets[1].1, Some(id2));
}
#[test]
fn test_channel_default_routing() {
let mut router = AgentRouter::new();
let system_default = AgentId::new();
let telegram_default = AgentId::new();
let discord_default = AgentId::new();
router.set_default(system_default);
router.set_channel_default("Telegram".to_string(), telegram_default);
router.set_channel_default("Discord".to_string(), discord_default);
// Telegram should use Telegram-specific default
let resolved = router.resolve(&ChannelType::Telegram, "user1", None);
assert_eq!(resolved, Some(telegram_default));
// Discord should use Discord-specific default
let resolved = router.resolve(&ChannelType::Discord, "user1", None);
assert_eq!(resolved, Some(discord_default));
// WhatsApp has no channel default — falls to system default
let resolved = router.resolve(&ChannelType::WhatsApp, "user1", None);
assert_eq!(resolved, Some(system_default));
}
#[test]
fn test_empty_bindings_legacy_behavior() {
let mut router = AgentRouter::new();
+136 -16
View File
@@ -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,22 @@ 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,
}
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,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
app_token: Zeroizing::new(app_token),
@@ -45,6 +58,9 @@ 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,
}
}
@@ -76,14 +92,18 @@ 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,
});
if let Some(ts) = thread_ts {
body["thread_ts"] = serde_json::json!(ts);
}
let resp: serde_json::Value = self
.client
@@ -133,6 +153,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;
@@ -241,7 +285,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
parse_slack_event(
event,
&bot_user_id,
&allowed_channels,
&active_threads,
auto_thread_reply,
)
.await
{
debug!(
"Slack message from {}: {:?}",
@@ -289,16 +340,40 @@ 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?;
}
}
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
@@ -335,6 +410,8 @@ 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" {
@@ -413,6 +490,47 @@ 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();
// 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 +543,8 @@ async fn parse_slack_event(
target_agent: None,
timestamp,
is_group: true,
thread_id: None,
metadata: HashMap::new(),
thread_id,
metadata,
})
}
@@ -445,7 +563,7 @@ 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 +581,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 +596,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());
}
@@ -495,11 +613,11 @@ mod tests {
// Not in allowed channels
let msg =
parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()]).await;
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 +634,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 +649,7 @@ 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 +674,7 @@ 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,6 +686,8 @@ mod tests {
"xapp-test".to_string(),
"xoxb-test".to_string(),
vec!["C123".to_string()],
true,
24,
);
assert_eq!(adapter.name(), "slack");
assert_eq!(adapter.channel_type(), ChannelType::Slack);
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -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,
@@ -278,8 +285,9 @@ pub fn split_message(text: &str, max_len: usize) -> Vec<&str> {
chunks.push(remaining);
break;
}
// Try to split at a newline near the boundary
let split_at = remaining[..max_len].rfind('\n').unwrap_or(max_len);
// Try to split at a newline near the boundary (UTF-8 safe)
let safe_end = openfang_types::truncate_str(remaining, max_len).len();
let split_at = remaining[..safe_end].rfind('\n').unwrap_or(safe_end);
let (chunk, rest) = remaining.split_at(split_at);
chunks.push(chunk);
// Skip the newline (and optional \r) we split on
@@ -379,8 +379,8 @@ async fn test_bridge_dispatch_no_agent_assigned() {
let sent = adapter_ref.get_sent();
assert_eq!(sent.len(), 1);
assert!(
sent[0].1.contains("No agent assigned"),
"Expected 'No agent assigned' message, got: {}",
sent[0].1.contains("No agents available"),
"Expected 'No agents available' message, got: {}",
sent[0].1
);
+56
View File
@@ -0,0 +1,56 @@
//! Compile-time embedded agent templates.
//!
//! All 30 bundled agent templates are embedded into the binary via `include_str!`.
//! This ensures `openfang agent new` works immediately after install — no filesystem
//! discovery needed.
/// 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")),
("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")),
("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")),
("tutor", include_str!("../../../agents/tutor/agent.toml")),
("writer", include_str!("../../../agents/writer/agent.toml")),
]
}
/// Install bundled agent templates to `~/.openfang/agents/`.
/// Skips any template that already exists on disk (user customization preserved).
pub fn install_bundled_agents(agents_dir: &std::path::Path) {
for (name, content) in bundled_agents() {
let dest_dir = agents_dir.join(name);
let dest_file = dest_dir.join("agent.toml");
if dest_file.exists() {
continue; // Preserve user customization
}
if std::fs::create_dir_all(&dest_dir).is_ok() {
let _ = std::fs::write(&dest_file, content);
}
}
}
+28 -8
View File
@@ -6,24 +6,45 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
/// Return the path to `~/.openfang/.env`.
pub fn env_file_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".openfang").join(".env"))
/// Get the OpenFang home directory, respecting OPENFANG_HOME env var.
fn dotenv_openfang_home() -> Option<PathBuf> {
if let Ok(home) = std::env::var("OPENFANG_HOME") {
return Some(PathBuf::from(home));
}
dirs::home_dir().map(|h| h.join(".openfang"))
}
/// Load `~/.openfang/.env` into `std::env`.
/// Return the path to `~/.openfang/.env`.
pub fn env_file_path() -> Option<PathBuf> {
dotenv_openfang_home().map(|h| h.join(".env"))
}
/// Load `~/.openfang/.env` and `~/.openfang/secrets.env` into `std::env`.
///
/// System env vars take priority — existing vars are NOT overridden.
/// Silently does nothing if the file doesn't exist.
/// `secrets.env` is loaded second so `.env` values take priority over secrets
/// (but both yield to system env vars).
/// Silently does nothing if the files don't exist.
pub fn load_dotenv() {
let path = match env_file_path() {
load_env_file(env_file_path());
// Also load secrets.env (written by dashboard "Set API Key" button)
load_env_file(secrets_env_path());
}
/// Return the path to `~/.openfang/secrets.env`.
pub fn secrets_env_path() -> Option<PathBuf> {
dotenv_openfang_home().map(|h| h.join("secrets.env"))
}
fn load_env_file(path: Option<PathBuf>) {
let path = match path {
Some(p) => p,
None => return,
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return, // file doesn't exist or unreadable — that's fine
Err(_) => return,
};
for line in content.lines() {
@@ -33,7 +54,6 @@ pub fn load_dotenv() {
}
if let Some((key, value)) = parse_env_line(trimmed) {
// Only set if not already in environment (system env takes priority)
if std::env::var(&key).is_err() {
std::env::set_var(&key, &value);
}
+8 -4
View File
@@ -39,11 +39,15 @@ fn detect_provider() -> Option<(&'static str, &'static str)> {
}
fn is_first_run() -> bool {
let home = match dirs::home_dir() {
Some(h) => h,
None => return true,
let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(h)
} else {
match dirs::home_dir() {
Some(h) => h.join(".openfang"),
None => return true,
}
};
!home.join(".openfang").join("config.toml").exists()
!of_home.join("config.toml").exists()
}
fn has_openclaw() -> bool {
File diff suppressed because it is too large Load Diff
+20 -11
View File
@@ -216,8 +216,13 @@ fn read_message(reader: &mut impl BufRead) -> io::Result<Option<Value>> {
/// Write a Content-Length framed JSON-RPC response to the writer.
fn write_message(writer: &mut impl Write, msg: &Value) {
let body = serde_json::to_string(msg).unwrap_or_default();
let _ = write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body);
let _ = writer.flush();
if let Err(e) = write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body) {
eprintln!("MCP write error: {e}");
return;
}
if let Err(e) = writer.flush() {
eprintln!("MCP flush error: {e}");
}
}
/// Handle a JSON-RPC message and return an optional response.
@@ -225,6 +230,10 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
let method = msg["method"].as_str().unwrap_or("");
let id = msg.get("id").cloned();
// Per JSON-RPC 2.0 spec: requests MUST have an id field.
// Use null if missing so we always send a response.
let rid = id.unwrap_or(Value::Null);
match method {
"initialize" => {
let result = json!({
@@ -234,10 +243,10 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
},
"serverInfo": {
"name": "openfang",
"version": "0.1.0"
"version": env!("CARGO_PKG_VERSION")
}
});
Some(jsonrpc_response(id?, result))
Some(jsonrpc_response(rid, result))
}
"notifications/initialized" => None, // Notification, no response
@@ -269,7 +278,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
})
})
.collect();
Some(jsonrpc_response(id?, json!({ "tools": tools })))
Some(jsonrpc_response(rid, json!({ "tools": tools })))
}
"tools/call" => {
@@ -281,14 +290,14 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
.to_string();
if message.is_empty() {
return Some(jsonrpc_error(id?, -32602, "Missing 'message' argument"));
return Some(jsonrpc_error(rid, -32602, "Missing 'message' argument"));
}
let agent_id = match backend.resolve_tool_agent(tool_name) {
Some(id) => id,
None => {
return Some(jsonrpc_error(
id?,
rid,
-32602,
&format!("Unknown tool: {tool_name}"),
));
@@ -297,7 +306,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
match backend.send_message(&agent_id, &message) {
Ok(response) => Some(jsonrpc_response(
id?,
rid,
json!({
"content": [{
"type": "text",
@@ -306,7 +315,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
}),
)),
Err(e) => Some(jsonrpc_response(
id?,
rid,
json!({
"content": [{
"type": "text",
@@ -319,8 +328,8 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
}
_ => {
// Unknown method
id.map(|id| jsonrpc_error(id, -32601, &format!("Method not found: {method}")))
// Unknown method — always respond with error
Some(jsonrpc_error(rid, -32601, &format!("Method not found: {method}")))
}
}
}
+24 -4
View File
@@ -35,9 +35,16 @@ pub fn discover_template_dirs() -> Vec<PathBuf> {
}
}
// Installed templates
if let Some(home) = dirs::home_dir() {
let agents = home.join(".openfang").join("agents");
// Installed templates (respects OPENFANG_HOME)
let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
PathBuf::from(h)
} else if let Some(home) = dirs::home_dir() {
home.join(".openfang")
} else {
std::env::temp_dir().join(".openfang")
};
{
let agents = of_home.join("agents");
if agents.is_dir() && !dirs.contains(&agents) {
dirs.push(agents);
}
@@ -54,11 +61,12 @@ pub fn discover_template_dirs() -> Vec<PathBuf> {
dirs
}
/// Load all templates from discovered directories.
/// Load all templates from discovered directories, falling back to bundled templates.
pub fn load_all_templates() -> Vec<AgentTemplate> {
let mut templates = Vec::new();
let mut seen_names = std::collections::HashSet::new();
// First: load from filesystem (user-installed or dev repo)
for dir in discover_template_dirs() {
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
@@ -86,6 +94,18 @@ pub fn load_all_templates() -> Vec<AgentTemplate> {
}
}
// Fallback: load bundled templates for any not found on disk
for (name, content) in crate::bundled_agents::bundled_agents() {
if seen_names.insert(name.to_string()) {
let description = extract_description(content);
templates.push(AgentTemplate {
name: name.to_string(),
description,
content: content.to_string(),
});
}
}
templates.sort_by(|a, b| a.name.cmp(&b.name));
templates
}
+173 -8
View File
@@ -175,6 +175,7 @@ impl StandaloneChat {
self.chat.last_tokens =
Some((r.total_usage.input_tokens, r.total_usage.output_tokens));
}
self.chat.last_cost_usd = r.cost_usd;
}
Err(e) => {
self.chat.status_msg = Some(format!("Error: {e}"));
@@ -219,6 +220,8 @@ impl StandaloneChat {
}
ChatAction::SendMessage(msg) => self.send_message(msg),
ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
ChatAction::OpenModelPicker => self.open_model_picker(),
ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
}
}
@@ -227,6 +230,7 @@ impl StandaloneChat {
self.chat.thinking = true;
self.chat.streaming_chars = 0;
self.chat.last_tokens = None;
self.chat.last_cost_usd = None;
self.chat.status_msg = None;
match &self.backend {
@@ -265,12 +269,13 @@ impl StandaloneChat {
self.chat.push_message(
Role::System,
[
"/help \u{2014} show this help",
"/status \u{2014} connection & agent info",
"/model \u{2014} show current model",
"/clear \u{2014} clear chat history",
"/kill \u{2014} kill the current agent & quit",
"/exit \u{2014} end chat session",
"/help \u{2014} show this help",
"/model \u{2014} open model picker (Ctrl+M)",
"/model <name> \u{2014} switch to model directly",
"/status \u{2014} connection & agent info",
"/clear \u{2014} clear chat history",
"/kill \u{2014} kill the current agent & quit",
"/exit \u{2014} end chat session",
]
.join("\n"),
);
@@ -292,8 +297,14 @@ impl StandaloneChat {
self.chat.push_message(Role::System, s.join("\n"));
}
"/model" => {
self.chat
.push_message(Role::System, format!("Model: {}", self.chat.model_label));
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
if args.is_empty() {
// No argument: open the model picker
self.open_model_picker();
} else {
// With argument: switch directly
self.switch_model(args);
}
}
"/clear" => {
let name = self.chat.agent_name.clone();
@@ -362,6 +373,160 @@ impl StandaloneChat {
}
}
// ── Model picker helpers ──────────────────────────────────────────────────
fn open_model_picker(&mut self) {
use super::screens::chat::ModelEntry;
let models = match &self.backend {
Backend::Daemon { base_url } => {
let client = crate::daemon_client();
match client.get(format!("{base_url}/api/models")).send() {
Ok(resp) => match resp.json::<serde_json::Value>() {
Ok(body) => body["models"]
.as_array()
.map(|arr| {
arr.iter()
.filter(|m| m["available"].as_bool().unwrap_or(false))
.map(|m| ModelEntry {
id: m["id"].as_str().unwrap_or("").to_string(),
display_name: m["display_name"]
.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()
})
.unwrap_or_default(),
Err(_) => Vec::new(),
},
Err(_) => Vec::new(),
}
}
Backend::InProcess { kernel } => {
let catalog = kernel.model_catalog.read().unwrap();
catalog
.available_models()
.into_iter()
.map(|e| ModelEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
provider: e.provider.clone(),
tier: format!("{:?}", e.tier),
})
.collect()
}
Backend::None => Vec::new(),
};
if models.is_empty() {
self.chat
.push_message(Role::System, "No models available.".to_string());
return;
}
self.chat.model_picker_models = models;
self.chat.model_picker_filter.clear();
self.chat.model_picker_idx = 0;
self.chat.show_model_picker = true;
}
fn switch_model(&mut self, model_id: &str) {
// Skip if already on this model
if self.chat.model_label.ends_with(model_id) {
return;
}
match &self.backend {
Backend::Daemon { base_url } => {
if let Some(ref agent_id) = self.agent_id_daemon {
let client = crate::daemon_client();
let url = format!("{base_url}/api/agents/{agent_id}/model");
match client
.put(&url)
.json(&serde_json::json!({"model": model_id}))
.send()
{
Ok(r) if r.status().is_success() => {
// Re-fetch agent to get updated provider/model
if let Ok(resp) = client
.get(format!("{base_url}/api/agents/{agent_id}"))
.send()
{
if let Ok(body) = resp.json::<serde_json::Value>() {
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!("Failed to switch to {model_id}"),
);
}
}
}
}
Backend::InProcess { kernel } => {
if let Some(id) = self.agent_id_inprocess {
let provider = kernel
.model_catalog
.read()
.unwrap()
.find_model(model_id)
.map(|e| e.provider.clone());
let result = if let Some(ref prov) = provider {
kernel.registry.update_model_and_provider(
id,
model_id.to_string(),
prov.clone(),
)
} else {
kernel.registry.update_model(id, model_id.to_string())
};
match result {
Ok(()) => {
let prov_label = provider.unwrap_or_else(|| {
kernel
.registry
.get(id)
.map(|e| e.manifest.model.provider.clone())
.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}"),
);
}
Err(e) => {
self.chat.push_message(
Role::System,
format!("Switch failed: {e}"),
);
}
}
}
}
Backend::None => {
self.chat
.push_message(Role::System, "No backend connected.".to_string());
}
}
}
// ── Agent resolution helpers ─────────────────────────────────────────────
fn enter_chat_daemon(&mut self, id: String, name: String) {
+191 -1
View File
@@ -189,6 +189,17 @@ pub enum AppEvent {
AgentSkillsUpdated(String),
/// Agent MCP servers updated.
AgentMcpServersUpdated(String),
/// Comms topology loaded.
CommsTopologyLoaded {
nodes: Vec<super::screens::comms::CommsNode>,
edges: Vec<super::screens::comms::CommsEdge>,
},
/// Comms events loaded.
CommsEventsLoaded(Vec<super::screens::comms::CommsEventItem>),
/// Comms send result.
CommsSendResult(String),
/// Comms task post result.
CommsTaskResult(String),
}
/// Spawn the crossterm polling + tick thread. Returns sender + receiver.
@@ -1481,7 +1492,13 @@ pub fn spawn_browse_clawhub(backend: BackendRef, sort: String, tx: mpsc::Sender<
}
fn parse_clawhub_results(body: &serde_json::Value) -> Vec<ClawHubResult> {
body.as_array()
// API returns {"items": [...]} wrapper, fall back to bare array for compat
let items = body
.get("items")
.and_then(|v| v.as_array())
.or_else(|| body.as_array());
items
.map(|arr| {
arr.iter()
.map(|r| ClawHubResult {
@@ -2592,3 +2609,176 @@ pub fn spawn_reconnect_extension(backend: BackendRef, id: String, tx: mpsc::Send
}
});
}
/// Fetch comms topology + events.
pub fn spawn_fetch_comms(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
use super::screens::comms::{CommsEdge, CommsEventItem, CommsNode};
std::thread::spawn(move || match backend {
BackendRef::Daemon(base_url) => {
let client = daemon_client();
// Fetch topology
if let Ok(resp) = client.get(format!("{base_url}/api/comms/topology")).send() {
if let Ok(body) = resp.json::<serde_json::Value>() {
let nodes: Vec<CommsNode> = body["nodes"]
.as_array()
.map(|arr| {
arr.iter()
.map(|n| CommsNode {
id: n["id"].as_str().unwrap_or("").to_string(),
name: n["name"].as_str().unwrap_or("").to_string(),
state: n["state"].as_str().unwrap_or("").to_string(),
model: n["model"].as_str().unwrap_or("").to_string(),
})
.collect()
})
.unwrap_or_default();
let edges: Vec<CommsEdge> = body["edges"]
.as_array()
.map(|arr| {
arr.iter()
.map(|e| CommsEdge {
from: e["from"].as_str().unwrap_or("").to_string(),
to: e["to"].as_str().unwrap_or("").to_string(),
kind: e["kind"].as_str().unwrap_or("").to_string(),
})
.collect()
})
.unwrap_or_default();
let _ = tx.send(AppEvent::CommsTopologyLoaded { nodes, edges });
}
}
// Fetch events
if let Ok(resp) = client
.get(format!("{base_url}/api/comms/events?limit=100"))
.send()
{
if let Ok(body) = resp.json::<serde_json::Value>() {
let events: Vec<CommsEventItem> = body
.as_array()
.map(|arr| {
arr.iter()
.map(|e| CommsEventItem {
id: e["id"].as_str().unwrap_or("").to_string(),
timestamp: e["timestamp"].as_str().unwrap_or("").to_string(),
kind: e["kind"].as_str().unwrap_or("").to_string(),
source_name: e["source_name"]
.as_str()
.unwrap_or("")
.to_string(),
target_name: e["target_name"]
.as_str()
.unwrap_or("")
.to_string(),
detail: e["detail"].as_str().unwrap_or("").to_string(),
})
.collect()
})
.unwrap_or_default();
let _ = tx.send(AppEvent::CommsEventsLoaded(events));
}
}
}
BackendRef::InProcess(_) => {
let _ = tx.send(AppEvent::CommsTopologyLoaded {
nodes: Vec::new(),
edges: Vec::new(),
});
let _ = tx.send(AppEvent::CommsEventsLoaded(Vec::new()));
}
});
}
/// Send a message between agents via comms endpoint.
pub fn spawn_comms_send(
backend: BackendRef,
from: String,
to: String,
msg: String,
tx: mpsc::Sender<AppEvent>,
) {
std::thread::spawn(move || match backend {
BackendRef::Daemon(base_url) => {
let client = daemon_client();
let body = serde_json::json!({
"from_agent_id": from,
"to_agent_id": to,
"message": msg,
});
match client
.post(format!("{base_url}/api/comms/send"))
.json(&body)
.send()
{
Ok(resp) => {
if resp.status().is_success() {
let _ = tx.send(AppEvent::CommsSendResult("Message sent".to_string()));
} else {
let err = resp
.json::<serde_json::Value>()
.ok()
.and_then(|v| v["error"].as_str().map(String::from))
.unwrap_or_else(|| "Send failed".to_string());
let _ = tx.send(AppEvent::CommsSendResult(err));
}
}
Err(e) => {
let _ = tx.send(AppEvent::CommsSendResult(format!("Error: {e}")));
}
}
}
BackendRef::InProcess(_) => {
let _ = tx.send(AppEvent::CommsSendResult(
"Send not supported in-process".to_string(),
));
}
});
}
/// Post a task via comms endpoint.
pub fn spawn_comms_task(
backend: BackendRef,
title: String,
desc: String,
assign: String,
tx: mpsc::Sender<AppEvent>,
) {
std::thread::spawn(move || match backend {
BackendRef::Daemon(base_url) => {
let client = daemon_client();
let mut body = serde_json::json!({
"title": title,
"description": desc,
});
if !assign.is_empty() {
body["assigned_to"] = serde_json::Value::String(assign);
}
match client
.post(format!("{base_url}/api/comms/task"))
.json(&body)
.send()
{
Ok(resp) => {
if resp.status().is_success() {
let _ = tx.send(AppEvent::CommsTaskResult("Task posted".to_string()));
} else {
let err = resp
.json::<serde_json::Value>()
.ok()
.and_then(|v| v["error"].as_str().map(String::from))
.unwrap_or_else(|| "Post failed".to_string());
let _ = tx.send(AppEvent::CommsTaskResult(err));
}
}
Err(e) => {
let _ = tx.send(AppEvent::CommsTaskResult(format!("Error: {e}")));
}
}
}
BackendRef::InProcess(_) => {
let _ = tx.send(AppEvent::CommsTaskResult(
"Task post not supported in-process".to_string(),
));
}
});
}
+232 -13
View File
@@ -12,8 +12,8 @@ use openfang_kernel::OpenFangKernel;
use openfang_runtime::llm_driver::StreamEvent;
use openfang_types::agent::AgentId;
use screens::{
agents, audit, channels, chat, dashboard, extensions, hands, logs, memory, peers, security,
sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers,
security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
};
use std::path::PathBuf;
use std::sync::{mpsc, Arc};
@@ -53,6 +53,7 @@ enum Tab {
Extensions,
Templates,
Peers,
Comms,
Security,
Audit,
Usage,
@@ -74,6 +75,7 @@ const TABS: &[Tab] = &[
Tab::Extensions,
Tab::Templates,
Tab::Peers,
Tab::Comms,
Tab::Security,
Tab::Audit,
Tab::Usage,
@@ -97,6 +99,7 @@ impl Tab {
Tab::Extensions => "Extensions",
Tab::Templates => "Templates",
Tab::Peers => "Peers",
Tab::Comms => "Comms",
Tab::Security => "Security",
Tab::Audit => "Audit",
Tab::Usage => "Usage",
@@ -169,6 +172,7 @@ struct App {
usage: usage::UsageState,
settings: settings::SettingsState,
peers: peers::PeersState,
comms: comms::CommsState,
logs: logs::LogsState,
kernel_booting: bool,
@@ -207,6 +211,7 @@ impl App {
usage: usage::UsageState::new(),
settings: settings::SettingsState::new(),
peers: peers::PeersState::new(),
comms: comms::CommsState::new(),
logs: logs::LogsState::new(),
kernel_booting: false,
kernel_boot_error: None,
@@ -504,6 +509,26 @@ impl App {
}
self.peers.loading = false;
}
AppEvent::CommsTopologyLoaded { nodes, edges } => {
self.comms.nodes = nodes;
self.comms.edges = edges;
self.comms.loading = false;
}
AppEvent::CommsEventsLoaded(events) => {
self.comms.events = events;
if !self.comms.events.is_empty()
&& self.comms.event_list_state.selected().is_none()
{
self.comms.event_list_state.select(Some(0));
}
}
AppEvent::CommsSendResult(msg) => {
self.comms.status_msg = msg;
self.refresh_comms();
}
AppEvent::CommsTaskResult(msg) => {
self.comms.status_msg = msg;
}
AppEvent::LogsLoaded(entries) => {
self.logs.entries = entries;
self.logs.refilter();
@@ -845,6 +870,10 @@ impl App {
let action = self.peers.handle_key(key);
self.handle_peers_action(action);
}
Tab::Comms => {
let action = self.comms.handle_key(key);
self.handle_comms_action(action);
}
Tab::Logs => {
let action = self.logs.handle_key(key);
self.handle_logs_action(action);
@@ -876,6 +905,7 @@ impl App {
self.usage.tick();
self.settings.tick();
self.peers.tick();
self.comms.tick();
self.logs.tick();
// Auto-poll for active tabs
@@ -883,6 +913,7 @@ impl App {
match self.active_tab {
Tab::Logs if self.logs.should_poll() => self.refresh_logs(),
Tab::Peers if self.peers.should_poll() => self.refresh_peers(),
Tab::Comms if self.comms.should_poll() => self.refresh_comms(),
_ => {}
}
}
@@ -932,6 +963,7 @@ impl App {
Tab::Usage => self.refresh_usage(),
Tab::Settings => self.refresh_settings_providers(),
Tab::Peers => self.refresh_peers(),
Tab::Comms => self.refresh_comms(),
Tab::Logs => self.refresh_logs(),
Tab::Chat => {} // Chat doesn't need refresh on enter
}
@@ -1089,6 +1121,13 @@ impl App {
}
}
fn refresh_comms(&mut self) {
if let Some(backend) = self.backend.to_ref() {
self.comms.loading = true;
event::spawn_fetch_comms(backend, self.event_tx.clone());
}
}
fn refresh_logs(&mut self) {
if let Some(backend) = self.backend.to_ref() {
self.logs.loading = true;
@@ -1325,6 +1364,8 @@ impl App {
}
chat::ChatAction::SendMessage(msg) => self.send_message(msg),
chat::ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
chat::ChatAction::OpenModelPicker => self.open_model_picker(),
chat::ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
}
}
@@ -1660,6 +1701,27 @@ impl App {
}
}
fn handle_comms_action(&mut self, action: comms::CommsAction) {
match action {
comms::CommsAction::Continue => {}
comms::CommsAction::Refresh => self.refresh_comms(),
comms::CommsAction::SendMessage { from, to, msg } => {
if let Some(backend) = self.backend.to_ref() {
event::spawn_comms_send(backend, from, to, msg, self.event_tx.clone());
}
}
comms::CommsAction::PostTask {
title,
desc,
assign,
} => {
if let Some(backend) = self.backend.to_ref() {
event::spawn_comms_task(backend, title, desc, assign, self.event_tx.clone());
}
}
}
}
fn handle_logs_action(&mut self, action: logs::LogsAction) {
match action {
logs::LogsAction::Continue => {}
@@ -1788,6 +1850,159 @@ impl App {
}
}
// ─── Model picker ────────────────────────────────────────────────────────
fn open_model_picker(&mut self) {
let models = match &self.backend {
Backend::Daemon { base_url } => {
let client = crate::daemon_client();
match client.get(format!("{base_url}/api/models")).send() {
Ok(resp) => match resp.json::<serde_json::Value>() {
Ok(body) => body["models"]
.as_array()
.map(|arr| {
arr.iter()
.filter(|m| m["available"].as_bool().unwrap_or(false))
.map(|m| chat::ModelEntry {
id: m["id"].as_str().unwrap_or("").to_string(),
display_name: m["display_name"]
.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()
})
.unwrap_or_default(),
Err(_) => Vec::new(),
},
Err(_) => Vec::new(),
}
}
Backend::InProcess { kernel } => {
let catalog = kernel.model_catalog.read().unwrap();
catalog
.available_models()
.into_iter()
.map(|e| chat::ModelEntry {
id: e.id.clone(),
display_name: e.display_name.clone(),
provider: e.provider.clone(),
tier: format!("{:?}", e.tier),
})
.collect()
}
Backend::None => Vec::new(),
};
if models.is_empty() {
self.chat
.push_message(chat::Role::System, "No models available.".to_string());
return;
}
self.chat.model_picker_models = models;
self.chat.model_picker_filter.clear();
self.chat.model_picker_idx = 0;
self.chat.show_model_picker = true;
}
fn switch_model(&mut self, model_id: &str) {
if self.chat.model_label.ends_with(model_id) {
return;
}
match (&self.backend, &self.chat_target) {
(Backend::Daemon { base_url }, Some(target)) => {
if let Some(ref agent_id) = target.agent_id_daemon {
let client = crate::daemon_client();
let url = format!("{base_url}/api/agents/{agent_id}/model");
match client
.put(&url)
.json(&serde_json::json!({"model": model_id}))
.send()
{
Ok(r) if r.status().is_success() => {
if let Ok(resp) = client
.get(format!("{base_url}/api/agents/{agent_id}"))
.send()
{
if let Ok(body) = resp.json::<serde_json::Value>() {
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(
chat::Role::System,
format!("Switched to {model_id}"),
);
}
_ => {
self.chat.push_message(
chat::Role::System,
format!("Failed to switch to {model_id}"),
);
}
}
}
}
(Backend::InProcess { kernel }, Some(target)) => {
if let Some(id) = target.agent_id_inprocess {
let provider = kernel
.model_catalog
.read()
.unwrap()
.find_model(model_id)
.map(|e| e.provider.clone());
let result = if let Some(ref prov) = provider {
kernel.registry.update_model_and_provider(
id,
model_id.to_string(),
prov.clone(),
)
} else {
kernel.registry.update_model(id, model_id.to_string())
};
match result {
Ok(()) => {
let prov_label = provider.unwrap_or_else(|| {
kernel
.registry
.get(id)
.map(|e| e.manifest.model.provider.clone())
.unwrap_or_else(|| "?".to_string())
});
self.chat.model_label = format!("{prov_label}/{model_id}");
self.chat.push_message(
chat::Role::System,
format!("Switched to {model_id}"),
);
}
Err(e) => {
self.chat.push_message(
chat::Role::System,
format!("Switch failed: {e}"),
);
}
}
}
}
_ => {
self.chat
.push_message(chat::Role::System, "No backend connected.".to_string());
}
}
}
// ─── Slash commands ──────────────────────────────────────────────────────
fn handle_slash_command(&mut self, cmd: &str) {
@@ -1798,13 +2013,14 @@ impl App {
self.chat.push_message(
chat::Role::System,
[
"/help \u{2014} show this help",
"/status \u{2014} connection & agent info",
"/agents \u{2014} list running agents",
"/model \u{2014} show current model",
"/clear \u{2014} clear chat history",
"/kill \u{2014} kill the current agent",
"/exit \u{2014} end chat session",
"/help \u{2014} show this help",
"/model \u{2014} open model picker (Ctrl+M)",
"/model <name> \u{2014} switch to model directly",
"/status \u{2014} connection & agent info",
"/agents \u{2014} list running agents",
"/clear \u{2014} clear chat history",
"/kill \u{2014} kill the current agent",
"/exit \u{2014} end chat session",
]
.join("\n"),
);
@@ -1929,10 +2145,12 @@ impl App {
}
}
"/model" => {
self.chat.push_message(
chat::Role::System,
format!("Model: {}", self.chat.model_label),
);
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
if args.is_empty() {
self.open_model_picker();
} else {
self.switch_model(args);
}
}
"/hands" => match &self.backend {
Backend::InProcess { kernel } => {
@@ -2029,6 +2247,7 @@ impl App {
Tab::Usage => usage::draw(frame, chunks[1], &mut self.usage),
Tab::Settings => settings::draw(frame, chunks[1], &mut self.settings),
Tab::Peers => peers::draw(frame, chunks[1], &mut self.peers),
Tab::Comms => comms::draw(frame, chunks[1], &mut self.comms),
Tab::Logs => logs::draw(frame, chunks[1], &mut self.logs),
}
}
@@ -1524,6 +1524,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
+1 -1
View File
@@ -341,6 +341,6 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max - 1])
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
}
}
+235 -6
View File
@@ -5,9 +5,18 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Padding, Paragraph};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph};
use ratatui::Frame;
/// Model entry for the picker.
#[derive(Clone)]
pub struct ModelEntry {
pub id: String,
pub display_name: String,
pub provider: String,
pub tier: String,
}
/// Tool call metadata for rich rendering.
#[derive(Clone)]
pub struct ToolInfo {
@@ -58,6 +67,8 @@ pub struct ChatState {
pub scroll_offset: u16,
/// Token usage from last response.
pub last_tokens: Option<(u64, u64)>,
/// Cost in USD from last response.
pub last_cost_usd: Option<f64>,
/// Characters received during current stream (~4 chars ≈ 1 token).
pub streaming_chars: usize,
/// Status message (errors, etc.)
@@ -66,6 +77,14 @@ pub struct ChatState {
pub staged_messages: Vec<String>,
/// Accumulates ToolInputDelta text for the current tool call.
pub tool_input_buf: String,
/// Model picker overlay state.
pub show_model_picker: bool,
/// Available models for the picker.
pub model_picker_models: Vec<ModelEntry>,
/// Filter text for model search.
pub model_picker_filter: String,
/// Selected index in the filtered model list.
pub model_picker_idx: usize,
}
pub enum ChatAction {
@@ -73,6 +92,10 @@ pub enum ChatAction {
SendMessage(String),
Back,
SlashCommand(String),
/// Open the model picker (fetch models first).
OpenModelPicker,
/// Switch to a specific model by id.
SwitchModel(String),
}
impl ChatState {
@@ -90,10 +113,15 @@ impl ChatState {
input: String::new(),
scroll_offset: 0,
last_tokens: None,
last_cost_usd: None,
streaming_chars: 0,
status_msg: None,
staged_messages: Vec::new(),
tool_input_buf: String::new(),
show_model_picker: false,
model_picker_models: Vec::new(),
model_picker_filter: String::new(),
model_picker_idx: 0,
}
}
@@ -107,10 +135,14 @@ impl ChatState {
self.input.clear();
self.scroll_offset = 0;
self.last_tokens = None;
self.last_cost_usd = None;
self.streaming_chars = 0;
self.status_msg = None;
self.staged_messages.clear();
self.tool_input_buf.clear();
self.show_model_picker = false;
self.model_picker_filter.clear();
self.model_picker_idx = 0;
}
/// Push a completed message into history.
@@ -201,11 +233,81 @@ impl ChatState {
}
}
/// Return filtered models based on the current picker filter.
pub fn filtered_models(&self) -> Vec<&ModelEntry> {
if self.model_picker_filter.is_empty() {
return self.model_picker_models.iter().collect();
}
let f = self.model_picker_filter.to_lowercase();
self.model_picker_models
.iter()
.filter(|m| {
m.id.to_lowercase().contains(&f)
|| m.display_name.to_lowercase().contains(&f)
|| m.provider.to_lowercase().contains(&f)
})
.collect()
}
pub fn handle_key(&mut self, key: KeyEvent) -> ChatAction {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
if self.show_model_picker {
self.show_model_picker = false;
return ChatAction::Continue;
}
return ChatAction::Back;
}
// Ctrl+M: toggle model picker
if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
if self.is_streaming {
return ChatAction::Continue;
}
if self.show_model_picker {
self.show_model_picker = false;
return ChatAction::Continue;
}
return ChatAction::OpenModelPicker;
}
// Model picker mode: intercept all keys
if self.show_model_picker {
match key.code {
KeyCode::Esc => {
self.show_model_picker = false;
}
KeyCode::Up => {
self.model_picker_idx = self.model_picker_idx.saturating_sub(1);
}
KeyCode::Down => {
let max = self.filtered_models().len().saturating_sub(1);
if self.model_picker_idx < max {
self.model_picker_idx += 1;
}
}
KeyCode::Enter => {
let filtered = self.filtered_models();
if let Some(entry) = filtered.get(self.model_picker_idx) {
let model_id = entry.id.clone();
self.show_model_picker = false;
self.model_picker_filter.clear();
self.model_picker_idx = 0;
return ChatAction::SwitchModel(model_id);
}
}
KeyCode::Backspace => {
self.model_picker_filter.pop();
self.model_picker_idx = 0;
}
KeyCode::Char(c) => {
self.model_picker_filter.push(c);
self.model_picker_idx = 0;
}
_ => {}
}
return ChatAction::Continue;
}
// When streaming, allow typing + staging messages, scrolling, and Esc
if self.is_streaming {
match key.code {
@@ -357,13 +459,136 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut ChatState) {
f.render_widget(Paragraph::new(input_line), chunks[2]);
// ── Hints ────────────────────────────────────────────────────────────────
let hints = if state.is_streaming {
let hints = if state.show_model_picker {
" [\u{2191}\u{2193}] Navigate [Enter] Select [Esc] Close [type] Filter"
} else if state.is_streaming {
" [Enter] Stage [\u{2191}\u{2193}] Scroll [Esc] Stop"
} else {
" [Enter] Send [\u{2191}\u{2193}/PgUp/PgDn] Scroll [Esc] Back"
" [Enter] Send [Ctrl+M] Models [\u{2191}\u{2193}] Scroll [Esc] Back"
};
let hints = Paragraph::new(Line::from(vec![Span::styled(hints, theme::hint_style())]));
f.render_widget(hints, chunks[3]);
// ── Model picker overlay ────────────────────────────────────────────────
if state.show_model_picker {
draw_model_picker(f, inner, state);
}
}
fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
let filtered = state.filtered_models();
// Center a popup — width ~50 cols, height capped at area
if area.height < 6 || area.width < 20 {
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 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);
// Clear background
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(Line::from(vec![Span::styled(
" Switch Model ",
theme::title_style(),
)]))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::ACCENT))
.padding(Padding::horizontal(1));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
if inner.height < 2 || inner.width < 10 {
return;
}
// Layout: search bar | model list
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
// Search bar
let search_line = Line::from(vec![
Span::styled("/ ", theme::dim_style()),
Span::raw(&state.model_picker_filter),
Span::styled(
"\u{2588}",
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::SLOW_BLINK),
),
]);
f.render_widget(Paragraph::new(search_line), chunks[0]);
// Model list
let visible_h = chunks[1].height as usize;
let total = filtered.len();
if total == 0 {
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" No models match",
theme::dim_style(),
)])),
chunks[1],
);
return;
}
// Scroll window: keep selected item visible
let scroll_start = if state.model_picker_idx >= visible_h {
state.model_picker_idx - visible_h + 1
} else {
0
};
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) {
let selected = i == state.model_picker_idx;
let indicator = if selected { "\u{25b6} " } else { " " };
let name = if entry.display_name.is_empty() {
&entry.id
} else {
&entry.display_name
};
let name_display = if name.len() > max_name && max_name > 1 {
let truncated = openfang_types::truncate_str(name, max_name.saturating_sub(1));
format!("{truncated}\u{2026}")
} else {
name.to_string()
};
let tier_style = match entry.tier.to_lowercase().as_str() {
"frontier" => Style::default().fg(theme::PURPLE),
"smart" => Style::default().fg(theme::BLUE),
"balanced" => Style::default().fg(theme::GREEN),
"fast" => Style::default().fg(theme::YELLOW),
_ => theme::dim_style(),
};
let bg = if selected {
Style::default()
.fg(theme::TEXT_PRIMARY)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::TEXT_SECONDARY)
};
lines.push(Line::from(vec![
Span::styled(indicator, Style::default().fg(theme::ACCENT)),
Span::styled(name_display, bg),
Span::raw(" "),
Span::styled(entry.tier.to_lowercase(), tier_style),
]));
}
f.render_widget(Paragraph::new(lines), chunks[1]);
}
fn draw_messages(f: &mut Frame, area: Rect, state: &ChatState) {
@@ -547,11 +772,15 @@ fn draw_messages(f: &mut Frame, area: Rect, state: &ChatState) {
)]));
}
// Add token usage if available
// Add token usage and cost if available
if let Some((input, output)) = state.last_tokens {
if input > 0 || output > 0 {
let cost_str = match state.last_cost_usd {
Some(c) if c > 0.0 => format!(" | ${:.4}", c),
_ => String::new(),
};
lines.push(Line::from(vec![Span::styled(
format!(" [tokens: {} in / {} out]", input, output),
format!(" [tokens: {} in / {} out{}]", input, output, cost_str),
theme::dim_style(),
)]));
}
@@ -653,6 +882,6 @@ fn truncate_line(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}\u{2026}", &s[..max_len.saturating_sub(1)])
format!("{}\u{2026}", openfang_types::truncate_str(s, max_len.saturating_sub(1)))
}
}
@@ -0,0 +1,763 @@
//! Comms screen: Agent communication topology + live event feed.
use crate::tui::theme;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph};
use ratatui::Frame;
// ── Data types ──────────────────────────────────────────────────────────────
#[derive(Clone, Default)]
pub struct CommsNode {
pub id: String,
pub name: String,
pub state: String,
pub model: String,
}
#[derive(Clone, Default)]
pub struct CommsEdge {
pub from: String,
pub to: String,
pub kind: String, // "parent_child" or "peer"
}
#[derive(Clone, Default)]
pub struct CommsEventItem {
/// Event ID — used by the dashboard for dedup, kept for wire compat.
#[allow(dead_code)]
pub id: String,
pub timestamp: String,
pub kind: String,
pub source_name: String,
pub target_name: String,
pub detail: String,
}
// ── State ───────────────────────────────────────────────────────────────────
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CommsFocus {
Topology,
EventList,
}
pub struct CommsState {
pub nodes: Vec<CommsNode>,
pub edges: Vec<CommsEdge>,
pub events: Vec<CommsEventItem>,
pub event_list_state: ListState,
pub focus: CommsFocus,
pub loading: bool,
pub tick: usize,
pub poll_tick: usize,
// Send modal
pub show_send_modal: bool,
pub send_from: String,
pub send_to: String,
pub send_msg: String,
pub send_field: usize,
// Task modal
pub show_task_modal: bool,
pub task_title: String,
pub task_desc: String,
pub task_assign: String,
pub task_field: usize,
// Status
pub status_msg: String,
}
pub enum CommsAction {
Continue,
Refresh,
SendMessage {
from: String,
to: String,
msg: String,
},
PostTask {
title: String,
desc: String,
assign: String,
},
}
impl CommsState {
pub fn new() -> Self {
Self {
nodes: Vec::new(),
edges: Vec::new(),
events: Vec::new(),
event_list_state: ListState::default(),
focus: CommsFocus::Topology,
loading: false,
tick: 0,
poll_tick: 0,
show_send_modal: false,
send_from: String::new(),
send_to: String::new(),
send_msg: String::new(),
send_field: 0,
show_task_modal: false,
task_title: String::new(),
task_desc: String::new(),
task_assign: String::new(),
task_field: 0,
status_msg: String::new(),
}
}
pub fn tick(&mut self) {
self.tick = self.tick.wrapping_add(1);
self.poll_tick = self.poll_tick.wrapping_add(1);
}
/// Auto-refresh every ~5s at 20fps tick rate.
pub fn should_poll(&self) -> bool {
self.poll_tick > 0 && self.poll_tick.is_multiple_of(100)
}
pub fn handle_key(&mut self, key: KeyEvent) -> CommsAction {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return CommsAction::Continue;
}
// Modal key handling
if self.show_send_modal {
return self.handle_send_modal_key(key);
}
if self.show_task_modal {
return self.handle_task_modal_key(key);
}
match key.code {
KeyCode::Tab => {
self.focus = match self.focus {
CommsFocus::Topology => CommsFocus::EventList,
CommsFocus::EventList => CommsFocus::Topology,
};
}
KeyCode::Char('s') => {
self.show_send_modal = true;
self.send_from.clear();
self.send_to.clear();
self.send_msg.clear();
self.send_field = 0;
}
KeyCode::Char('t') => {
self.show_task_modal = true;
self.task_title.clear();
self.task_desc.clear();
self.task_assign.clear();
self.task_field = 0;
}
KeyCode::Char('r') => return CommsAction::Refresh,
KeyCode::Up | KeyCode::Char('k') => {
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
let i = self.event_list_state.selected().unwrap_or(0);
let next = if i == 0 {
self.events.len() - 1
} else {
i - 1
};
self.event_list_state.select(Some(next));
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
let i = self.event_list_state.selected().unwrap_or(0);
let next = (i + 1) % self.events.len();
self.event_list_state.select(Some(next));
}
}
_ => {}
}
CommsAction::Continue
}
fn handle_send_modal_key(&mut self, key: KeyEvent) -> CommsAction {
match key.code {
KeyCode::Esc => {
self.show_send_modal = false;
}
KeyCode::Tab => {
self.send_field = (self.send_field + 1) % 3;
}
KeyCode::BackTab => {
self.send_field = if self.send_field == 0 {
2
} else {
self.send_field - 1
};
}
KeyCode::Enter => {
if !self.send_from.is_empty()
&& !self.send_to.is_empty()
&& !self.send_msg.is_empty()
{
self.show_send_modal = false;
return CommsAction::SendMessage {
from: self.send_from.clone(),
to: self.send_to.clone(),
msg: self.send_msg.clone(),
};
}
}
KeyCode::Char(c) => match self.send_field {
0 => self.send_from.push(c),
1 => self.send_to.push(c),
_ => self.send_msg.push(c),
},
KeyCode::Backspace => match self.send_field {
0 => {
self.send_from.pop();
}
1 => {
self.send_to.pop();
}
_ => {
self.send_msg.pop();
}
},
_ => {}
}
CommsAction::Continue
}
fn handle_task_modal_key(&mut self, key: KeyEvent) -> CommsAction {
match key.code {
KeyCode::Esc => {
self.show_task_modal = false;
}
KeyCode::Tab => {
self.task_field = (self.task_field + 1) % 3;
}
KeyCode::BackTab => {
self.task_field = if self.task_field == 0 {
2
} else {
self.task_field - 1
};
}
KeyCode::Enter => {
if !self.task_title.is_empty() {
self.show_task_modal = false;
return CommsAction::PostTask {
title: self.task_title.clone(),
desc: self.task_desc.clone(),
assign: self.task_assign.clone(),
};
}
}
KeyCode::Char(c) => match self.task_field {
0 => self.task_title.push(c),
1 => self.task_desc.push(c),
_ => self.task_assign.push(c),
},
KeyCode::Backspace => match self.task_field {
0 => {
self.task_title.pop();
}
1 => {
self.task_desc.pop();
}
_ => {
self.task_assign.pop();
}
},
_ => {}
}
CommsAction::Continue
}
// ── Topology helpers ─────────────────────────────────────────────────────
fn root_nodes(&self) -> Vec<&CommsNode> {
let child_ids: std::collections::HashSet<&str> = self
.edges
.iter()
.filter(|e| e.kind == "parent_child")
.map(|e| e.to.as_str())
.collect();
self.nodes
.iter()
.filter(|n| !child_ids.contains(n.id.as_str()))
.collect()
}
fn children_of(&self, id: &str) -> Vec<&CommsNode> {
let child_ids: Vec<&str> = self
.edges
.iter()
.filter(|e| e.kind == "parent_child" && e.from == id)
.map(|e| e.to.as_str())
.collect();
self.nodes
.iter()
.filter(|n| child_ids.contains(&n.id.as_str()))
.collect()
}
fn peers_of(&self, id: &str) -> Vec<&CommsNode> {
let peer_ids: std::collections::HashSet<&str> = self
.edges
.iter()
.filter(|e| e.kind == "peer")
.filter_map(|e| {
if e.from == id {
Some(e.to.as_str())
} else if e.to == id {
Some(e.from.as_str())
} else {
None
}
})
.collect();
self.nodes
.iter()
.filter(|n| peer_ids.contains(n.id.as_str()))
.collect()
}
}
// ── Drawing ─────────────────────────────────────────────────────────────────
pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) {
let block = Block::default()
.title(Line::from(vec![Span::styled(
" Comms ",
theme::title_style(),
)]))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::ACCENT))
.padding(Padding::horizontal(1));
let inner = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::vertical([
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
])
.split(inner);
// Header
f.render_widget(
Paragraph::new(vec![
Line::from(vec![Span::styled(
format!(
" Agent Topology ({} agents, {} edges)",
state.nodes.len(),
state.edges.len()
),
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD),
)]),
Line::from(""),
]),
chunks[0],
);
// Separator
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"\u{2500}".repeat(inner.width as usize),
theme::dim_style(),
))),
chunks[1],
);
// Topology tree
draw_topology(f, chunks[2], state);
// Separator
let event_label = if state.focus == CommsFocus::EventList {
" \u{25b6} Live Event Feed"
} else {
" Live Event Feed"
};
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
event_label,
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ({} events)", state.events.len()),
theme::dim_style(),
),
])),
chunks[3],
);
// Event list
draw_event_list(f, chunks[4], state);
// Status message or hints
let hint_text = if !state.status_msg.is_empty() {
format!(
" {} | [s]end [t]ask [r]efresh [Tab] focus [\u{2191}\u{2193}] scroll",
state.status_msg
)
} else {
" [s]end [t]ask [r]efresh [Tab] focus [\u{2191}\u{2193}] scroll".to_string()
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(hint_text, theme::hint_style()))),
chunks[5],
);
// Modal overlays
if state.show_send_modal {
draw_send_modal(f, area, state);
}
if state.show_task_modal {
draw_task_modal(f, area, state);
}
}
fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
if state.loading && state.nodes.is_empty() {
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
Span::styled("Loading topology\u{2026}", theme::dim_style()),
])),
area,
);
return;
}
if state.nodes.is_empty() {
f.render_widget(
Paragraph::new(Span::styled(
" No agents running.",
theme::dim_style(),
)),
area,
);
return;
}
let focus_highlight = state.focus == CommsFocus::Topology;
let mut lines = Vec::new();
for root in state.root_nodes() {
let state_style = state_color(&root.state);
let mut spans = vec![
Span::styled(" ", Style::default()),
Span::styled(format!("[{}]", &root.state), state_style),
Span::styled(
format!(" {} ", root.name),
Style::default()
.fg(if focus_highlight {
theme::CYAN
} else {
theme::TEXT
})
.add_modifier(Modifier::BOLD),
),
Span::styled(format!("({})", root.model), theme::dim_style()),
];
// Peer annotations
for peer in state.peers_of(&root.id) {
spans.push(Span::styled(
format!(" \u{2194} {}", peer.name),
Style::default().fg(theme::PURPLE),
));
}
lines.push(Line::from(spans));
// Children
let children = state.children_of(&root.id);
for (i, child) in children.iter().enumerate() {
let branch = if i < children.len() - 1 {
"\u{251c}\u{2500}\u{2500} "
} else {
"\u{2514}\u{2500}\u{2500} "
};
lines.push(Line::from(vec![
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.model), theme::dim_style()),
]));
}
}
f.render_widget(Paragraph::new(lines), area);
}
fn draw_event_list(f: &mut Frame, area: Rect, state: &mut CommsState) {
if state.events.is_empty() {
f.render_widget(
Paragraph::new(Span::styled(
" No inter-agent events yet.",
theme::dim_style(),
)),
area,
);
return;
}
let items: Vec<ListItem> = state
.events
.iter()
.map(|ev| {
let kind_style = kind_color(&ev.kind);
let kind_label = kind_short(&ev.kind);
let target_part = if ev.target_name.is_empty() {
String::new()
} else {
format!(" \u{2192} {}", ev.target_name)
};
let detail = truncate(&ev.detail, 50);
ListItem::new(Line::from(vec![
Span::styled(
format!(" {:<8}", short_time(&ev.timestamp)),
theme::dim_style(),
),
Span::styled(format!(" {:<10}", kind_label), kind_style),
Span::styled(
format!(" {}", ev.source_name),
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD),
),
Span::styled(target_part, Style::default().fg(theme::PURPLE)),
Span::styled(format!(" {detail}"), theme::dim_style()),
]))
})
.collect();
let list = List::new(items)
.highlight_style(theme::selected_style())
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut state.event_list_state);
}
fn draw_send_modal(f: &mut Frame, area: Rect, state: &CommsState) {
let modal = centered_rect(50, 12, area);
f.render_widget(Clear, modal);
let block = Block::default()
.title(Span::styled(" Send Message ", theme::title_style()))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::ACCENT))
.padding(Padding::uniform(1));
let inner = block.inner(modal);
f.render_widget(block, modal);
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
let field_style = |idx: usize| {
if state.send_field == idx {
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD)
} else {
theme::dim_style()
}
};
f.render_widget(
Paragraph::new(Span::styled("From (agent ID):", field_style(0))),
rows[0],
);
f.render_widget(
Paragraph::new(Span::styled(
format!(" {}\u{2588}", &state.send_from),
Style::default().fg(theme::TEXT),
)),
rows[1],
);
f.render_widget(
Paragraph::new(Span::styled("To (agent ID):", field_style(1))),
rows[2],
);
f.render_widget(
Paragraph::new(Span::styled(
format!(" {}\u{2588}", &state.send_to),
Style::default().fg(theme::TEXT),
)),
rows[3],
);
f.render_widget(
Paragraph::new(Span::styled("Message:", field_style(2))),
rows[4],
);
f.render_widget(
Paragraph::new(Span::styled(
format!(" {}\u{2588}", &state.send_msg),
Style::default().fg(theme::TEXT),
)),
rows[5],
);
f.render_widget(
Paragraph::new(Span::styled(
"[Tab] field [Enter] send [Esc] cancel",
theme::hint_style(),
)),
rows[6],
);
}
fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) {
let modal = centered_rect(50, 12, area);
f.render_widget(Clear, modal);
let block = Block::default()
.title(Span::styled(" Post Task ", theme::title_style()))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::ACCENT))
.padding(Padding::uniform(1));
let inner = block.inner(modal);
f.render_widget(block, modal);
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
let field_style = |idx: usize| {
if state.task_field == idx {
Style::default()
.fg(theme::CYAN)
.add_modifier(Modifier::BOLD)
} else {
theme::dim_style()
}
};
f.render_widget(
Paragraph::new(Span::styled("Title:", field_style(0))),
rows[0],
);
f.render_widget(
Paragraph::new(Span::styled(
format!(" {}\u{2588}", &state.task_title),
Style::default().fg(theme::TEXT),
)),
rows[1],
);
f.render_widget(
Paragraph::new(Span::styled("Description:", field_style(1))),
rows[2],
);
f.render_widget(
Paragraph::new(Span::styled(
format!(" {}\u{2588}", &state.task_desc),
Style::default().fg(theme::TEXT),
)),
rows[3],
);
f.render_widget(
Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))),
rows[4],
);
f.render_widget(
Paragraph::new(Span::styled(
format!(" {}\u{2588}", &state.task_assign),
Style::default().fg(theme::TEXT),
)),
rows[5],
);
f.render_widget(
Paragraph::new(Span::styled(
"[Tab] field [Enter] post [Esc] cancel",
theme::hint_style(),
)),
rows[6],
);
}
// ── Helpers ─────────────────────────────────────────────────────────────────
fn state_color(state: &str) -> Style {
match state {
"Running" => Style::default().fg(theme::GREEN),
"Suspended" => Style::default().fg(theme::YELLOW),
"Terminated" | "Crashed" => Style::default().fg(theme::RED),
_ => theme::dim_style(),
}
}
fn kind_color(kind: &str) -> Style {
match kind {
"agent_message" => Style::default().fg(theme::CYAN),
"agent_spawned" => Style::default().fg(theme::GREEN),
"agent_terminated" => Style::default().fg(theme::RED),
"task_posted" => Style::default().fg(theme::YELLOW),
"task_claimed" => Style::default().fg(theme::CYAN),
"task_completed" => Style::default().fg(theme::GREEN),
_ => theme::dim_style(),
}
}
fn kind_short(kind: &str) -> &str {
match kind {
"agent_message" => "MSG",
"agent_spawned" => "SPAWNED",
"agent_terminated" => "KILLED",
"task_posted" => "TASK+",
"task_claimed" => "CLAIM",
"task_completed" => "DONE",
_ => kind,
}
}
fn short_time(ts: &str) -> String {
// Extract HH:MM:SS from ISO-8601
if let Some(t_pos) = ts.find('T') {
let time_part = &ts[t_pos + 1..];
if time_part.len() >= 8 {
return time_part[..8].to_string();
}
}
ts.chars().take(8).collect()
}
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))
)
}
}
fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect {
let w = area.width * percent_x / 100;
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect::new(x, y, w, height.min(area.height))
}

Some files were not shown because too many files have changed in this diff Show More