Compare commits

...
65 Commits
Author SHA1 Message Date
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
197 changed files with 21534 additions and 2734 deletions
+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
+5
View File
@@ -181,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
+339 -326
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.2.2"
version = "0.3.46"
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"
@@ -119,9 +120,18 @@ 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"
+10 -4
View File
@@ -19,7 +19,7 @@
<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>
@@ -27,9 +27,9 @@
---
> **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)
---
@@ -371,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 comprehensive. 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)
@@ -382,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.
+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"
+2 -1
View File
@@ -33,9 +33,10 @@ governor = { workspace = true }
tokio-stream = { workspace = true }
subtle = { workspace = true }
base64 = { workspace = true }
socket2 = { workspace = true }
reqwest = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
reqwest = { workspace = true }
tempfile = { workspace = true }
uuid = { workspace = true }
+128 -40
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 } => {
@@ -488,7 +521,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 +544,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}"),
}
@@ -542,7 +577,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
};
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 +598,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 +640,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
)
@@ -648,7 +686,16 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
self.kernel
.set_agent_model(agent_id, model)
.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 +821,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
recipient: &str,
success: bool,
error: Option<&str>,
thread_id: Option<&str>,
) {
let receipt = if success {
openfang_kernel::DeliveryTracker::sent_receipt(channel, recipient)
@@ -786,9 +834,13 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
};
self.kernel.delivery_tracker.record(agent_id, receipt);
// Persist last channel for cron CronDelivery::LastChannel
// Persist last channel for cron CronDelivery::LastChannel.
// Include thread_id when present so forum-topic context survives restarts.
if success {
let kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
let mut kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
if let Some(tid) = thread_id {
kv_val["thread_id"] = serde_json::json!(tid);
}
let _ = self
.kernel
.memory
@@ -934,30 +986,6 @@ 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}");
}
},
}
}
/// 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) {
@@ -1054,6 +1082,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()));
}
@@ -1065,6 +1094,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()));
@@ -1545,12 +1576,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;
}
}
}
}
@@ -1616,6 +1675,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));
+75 -50
View File
@@ -45,84 +45,105 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
/// 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).
pub async fn auth(
axum::extract::State(api_key): axum::extract::State<String>,
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"
|| path == "/api/models/aliases"
|| path == "/api/providers"
|| path == "/api/budget"
|| path == "/api/budget/agents"
|| path.starts_with("/api/budget/agents/")
|| path == "/api/network/status"
|| path == "/api/a2a/agents"
|| path == "/api/approvals"
|| path.starts_with("/api/approvals/")
|| path == "/api/channels"
|| path == "/api/skills"
|| path == "/api/sessions"
|| path == "/api/integrations"
|| path == "/api/integrations/available"
|| path == "/api/integrations/health"
|| path.starts_with("/api/cron/")
|| path.starts_with("/api/providers/github-copilot/oauth/")
{
|| (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/");
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 = api_key.trim();
if api_key.is_empty() {
return next.run(request).await;
}
// 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;
@@ -190,6 +211,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
}
+3 -4
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 ──────────────────────────────────────────────────────
@@ -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
+101 -7
View File
@@ -49,11 +49,13 @@ pub async fn build_router(
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![
@@ -101,7 +103,8 @@ 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 gcra_limiter = rate_limiter::create_rate_limiter();
let app = Router::new()
@@ -125,7 +128,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",
@@ -156,6 +159,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),
@@ -168,6 +175,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),
@@ -312,12 +323,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),
@@ -335,6 +354,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),
@@ -377,6 +401,27 @@ 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),
)
// Tools endpoint
.route("/api/tools", axum::routing::get(routes::list_tools))
// Config endpoints
@@ -421,7 +466,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))
@@ -450,6 +496,14 @@ 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)
@@ -688,7 +742,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
@@ -697,7 +752,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);
}
@@ -719,7 +775,22 @@ 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
@@ -839,3 +910,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)
}
}
+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)]
+7 -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)]
+4
View File
@@ -123,6 +123,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",
+107 -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
@@ -796,7 +811,19 @@ async fn handle_command(
} else {
match state.kernel.set_agent_model(agent_id, args) {
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,20 +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 => {
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
// Claude Code CLI errors have actionable messages — pass them through
if inner.contains("Claude Code CLI") || inner.contains("claude auth") {
classified.raw_message.clone()
} else {
classified.sanitized_message.clone()
}
}
_ => classified.sanitized_message,
}
@@ -1120,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..];
@@ -1147,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
// ---------------------------------------------------------------------------
@@ -1217,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>"), "");
}
}
+168 -3
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;
@@ -3072,4 +3225,16 @@ 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; }
@@ -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;
+540 -96
View File
@@ -4,7 +4,8 @@
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '' }">
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
</div>
@@ -96,6 +97,10 @@
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
<span class="nav-label">Approvals</span>
</a>
<a class="nav-item" :class="{ active: page === 'comms' }" @click="navigate('comms')" :aria-current="page === 'comms' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
<span class="nav-label">Comms</span>
</a>
</div>
</template>
</div>
@@ -152,6 +157,10 @@
</div>
<template x-if="!collapsed">
<div x-transition>
<a class="nav-item" :class="{ active: page === 'runtime' }" @click="navigate('runtime')" :aria-current="page === 'runtime' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></span>
<span class="nav-label">Runtime</span>
</a>
<a class="nav-item" :class="{ active: page === 'settings' }" @click="navigate('settings')" :aria-current="page === 'settings' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/></svg></span>
<span class="nav-label">Settings</span>
@@ -565,7 +574,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</button>
<button class="btn btn-ghost btn-sm" @click="$store.app.toggleFocusMode()" title="Ctrl+Shift+F">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><template x-if="!$store.app.focusMode"><g><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g></template><template x-if="$store.app.focusMode"><g><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></template></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g x-show="!$store.app.focusMode"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g><g x-show="$store.app.focusMode"><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></svg>
</button>
<button class="btn btn-danger btn-sm" @click="killAgent()">Stop</button>
</div>
@@ -581,6 +590,14 @@
<!-- Messages area -->
<div class="messages" id="messages" @dragover.prevent="dragOver = true" @dragleave="dragOver = false" @drop.prevent="handleDrop($event); dragOver = false">
<!-- Empty state: no agent selected -->
<template x-if="!currentAgent">
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center;padding:32px;opacity:0.8">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:16px;opacity:0.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<h3 style="margin:0 0 8px;font-size:16px;font-weight:600">Select an agent to start chatting</h3>
<p class="text-dim" style="font-size:13px;max-width:320px">Choose an agent from the sidebar or go to the Agents tab to create a new one.</p>
</div>
</template>
<!-- Message list -->
<template x-if="currentAgent">
<div>
@@ -692,6 +709,16 @@
</div>
</template>
</div>
<!-- Model autocomplete picker -->
<div x-show="showModelPicker && filteredModelPicker.length" class="slash-menu" style="max-height:280px;overflow-y:auto">
<div class="text-xs text-dim" style="padding:4px 10px;border-bottom:1px solid var(--border)">Available models — pick one or keep typing</div>
<template x-for="(m, idx) in filteredModelPicker" :key="m.id">
<div class="slash-menu-item" :class="{ 'slash-active': idx === modelPickerIdx }" @click="pickModel(m.id)" @mouseenter="modelPickerIdx = idx">
<span class="font-bold" style="font-size:12px;font-family:var(--font-mono)" x-text="m.id"></span>
<span class="text-xs text-dim" x-text="m.provider + (m.display_name && m.display_name !== m.id ? ' · ' + m.display_name : '')"></span>
</div>
</template>
</div>
<!-- Input row -->
<div class="input-row">
<button class="btn btn-ghost btn-sm" @click="$refs.fileInput.click()" title="Attach file" style="padding:6px 8px;flex-shrink:0">
@@ -709,10 +736,10 @@
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
</div>
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
@keydown.enter.prevent="if(!$event.shiftKey){if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.escape="showSlashMenu = false"
@keydown.arrow-up.prevent="if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
@keydown.arrow-down.prevent="if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
@keydown.enter.prevent="if(!$event.isComposing && $event.keyCode !== 229 && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.escape="showSlashMenu = false; showModelPicker = false"
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
@input="$el.style.height='auto';$el.style.height=Math.min($el.scrollHeight,150)+'px'"
x-model="inputText"
:class="{ 'streaming-active': sending }"></textarea>
@@ -728,9 +755,61 @@
</button>
</template>
</div>
<!-- Footer: tokens + queue + tips -->
<!-- Footer: model switcher + tokens + queue + tips -->
<div class="input-footer">
<div class="flex items-center gap-2">
<!-- Model Switcher -->
<div style="position:relative" x-show="currentAgent" @click.outside="showModelSwitcher = false" @keydown.escape.window="showModelSwitcher = false">
<button class="model-switcher-btn" @click="toggleModelSwitcher()" :disabled="sending" title="Switch model (Ctrl+M)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
<span class="model-switcher-label" x-text="modelDisplayName || 'Model'"></span>
<svg class="model-switcher-chevron" :class="{'open': showModelSwitcher}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<!-- Dropdown -->
<div class="model-switcher-dropdown" x-show="showModelSwitcher" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform translate-y-1" x-transition:enter-end="opacity-100 transform translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
<div class="model-switcher-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="!$event.isComposing && $event.keyCode !== 229 && filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
<select x-model="modelSwitcherProviderFilter" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;padding:2px 6px;cursor:pointer;font-family:var(--font-mono);flex-shrink:0">
<option value="">All</option>
<template x-for="pn in switcherProviders" :key="pn">
<option :value="pn" x-text="pn"></option>
</template>
</select>
</div>
<div x-show="modelSwitching" style="display:flex;align-items:center;justify-content:center;padding:12px;gap:8px">
<div class="tool-card-spinner"></div>
<span class="text-xs text-dim">Switching...</span>
</div>
<div class="model-switcher-list" x-show="!modelSwitching">
<template x-if="groupedSwitcherModels.length === 0">
<div style="padding:16px;text-align:center" class="text-xs text-dim">No models found</div>
</template>
<template x-for="group in groupedSwitcherModels" :key="group.provider">
<div>
<div class="model-switcher-group-header" x-text="group.provider"></div>
<template x-for="(m, mi) in group.models" :key="m.id">
<div class="model-switcher-item" :class="{'active': currentAgent && m.id === currentAgent.model_name}" @click="switchModel(m)" @mouseenter="modelSwitcherIdx = filteredSwitcherModels.indexOf(m)">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px">
<span class="model-switcher-item-name" x-text="m.display_name || m.id"></span>
<span class="model-switcher-tier" :class="'tier-' + (m.tier || 'balanced').toLowerCase()" x-text="m.tier || 'Balanced'"></span>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
<span class="text-xs text-dim" x-text="m.id" style="font-family:var(--font-mono)"></span>
<span class="text-xs text-dim" x-show="m.context_window" x-text="m.context_window >= 1000000 ? (m.context_window/1000000).toFixed(1)+'M' : Math.round(m.context_window/1000)+'K'"></span>
<svg x-show="m.supports_vision" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.5" title="Vision"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg x-show="m.supports_tools" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.5" title="Tools"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</div>
</div>
<svg x-show="currentAgent && m.id === currentAgent.model_name" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
<span class="text-xs text-dim" x-text="tokenCount > 0 ? '~' + tokenCount + ' tokens' : (attachments.length ? attachments.length + ' file(s)' : '')"></span>
<span class="queue-badge" x-show="messageQueue.length > 0" x-text="messageQueue.length + ' queued'"></span>
</div>
@@ -769,6 +848,9 @@
<div class="text-xs text-dim font-mono" style="font-size:11px" x-text="agent.model_name"></div>
</div>
<span class="badge" :class="'badge-' + agent.state.toLowerCase()" x-text="agent.state" style="font-size:10px"></span>
<button class="agent-chip-config-btn" @click.stop="showDetail(agent)" title="Agent settings" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--text-dim);transition:all 0.15s;flex-shrink:0" @mouseenter="$el.style.borderColor='var(--accent)';$el.style.color='var(--accent)';$el.style.background='var(--surface2)'" @mouseleave="$el.style.borderColor='var(--border)';$el.style.color='var(--text-dim)';$el.style.background='transparent'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
</template>
</div>
@@ -807,7 +889,7 @@
<div class="tabs" style="margin-bottom:16px">
<div class="tab" :class="{ active: detailTab === 'info' }" @click="detailTab = 'info'">Info</div>
<div class="tab" :class="{ active: detailTab === 'files' }" @click="detailTab = 'files'; loadAgentFiles()">Files</div>
<div class="tab" :class="{ active: detailTab === 'config' }" @click="detailTab = 'config'">Config</div>
<div class="tab" :class="{ active: detailTab === 'config' }" @click="detailTab = 'config'; loadToolFilters()">Config</div>
</div>
<!-- Tab: Info -->
@@ -822,12 +904,59 @@
</div>
<div class="detail-row" x-show="detailAgent.profile"><span class="detail-label">Profile</span><span class="detail-value" style="text-transform:capitalize" x-text="detailAgent.profile || '-'"></span></div>
<div class="detail-row"><span class="detail-label">Provider</span><span class="detail-value" x-text="detailAgent.model_provider"></span></div>
<div class="detail-row"><span class="detail-label">Model</span><span class="detail-value" x-text="detailAgent.model_name"></span></div>
<div class="detail-row"><span class="detail-label">Model</span>
<template x-if="!editingModel">
<span>
<span class="detail-value" x-text="detailAgent.model_name"></span>
<button class="btn btn-ghost btn-sm" style="margin-left:8px;padding:2px 8px;font-size:11px" @click="editingModel = true; newModelValue = detailAgent.model_provider + '/' + detailAgent.model_name">Change</button>
</span>
</template>
<template x-if="editingModel">
<span class="flex gap-1" style="align-items:center">
<input class="form-input" style="width:240px;font-size:12px" x-model="newModelValue" placeholder="provider/model" @keydown.enter="changeModel()" @keydown.escape="editingModel = false">
<button class="btn btn-primary btn-sm" @click="changeModel()" :disabled="modelSaving" style="padding:2px 10px">
<span x-show="!modelSaving">Save</span><span x-show="modelSaving">...</span>
</button>
<button class="btn btn-ghost btn-sm" @click="editingModel = false" style="padding:2px 8px">Cancel</button>
</span>
</template>
</div>
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value" x-text="detailAgent.created_at ? new Date(detailAgent.created_at).toLocaleString() : '-'"></span></div>
<!-- Fallback Model Chain -->
<div class="detail-row" style="align-items:flex-start">
<span class="detail-label">Fallbacks</span>
<div style="flex:1">
<template x-if="detailAgent._fallbacks && detailAgent._fallbacks.length > 0">
<div>
<template x-for="(fb, idx) in detailAgent._fallbacks" :key="idx">
<div class="flex gap-1 items-center" style="margin-bottom:4px">
<span class="badge" style="font-size:11px;font-family:var(--font-mono)" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger)" @click="removeFallback(idx)">&times;</button>
</div>
</template>
</div>
</template>
<template x-if="!detailAgent._fallbacks || detailAgent._fallbacks.length === 0">
<span class="text-dim" style="font-size:12px">None — add a fallback chain</span>
</template>
<template x-if="!editingFallback">
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px;margin-top:4px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
</template>
<template x-if="editingFallback">
<div class="flex gap-1 mt-1" style="align-items:center">
<input class="form-input" style="width:220px;font-size:12px" x-model="newFallbackValue" placeholder="provider/model" @keydown.enter="addFallback()" @keydown.escape="editingFallback = false">
<button class="btn btn-primary btn-sm" @click="addFallback()" style="padding:2px 10px;font-size:11px">Add</button>
<button class="btn btn-ghost btn-sm" @click="editingFallback = false" style="padding:2px 8px;font-size:11px">Cancel</button>
</div>
</template>
</div>
</div>
</div>
<div class="flex gap-2 mt-4">
<button class="btn btn-primary" @click="chatWithAgent(detailAgent); showDetailModal = false">Chat</button>
<button class="btn btn-ghost" @click="cloneAgent(detailAgent)">Clone</button>
<button class="btn btn-ghost" @click="clearHistory(detailAgent)">Clear History</button>
<button class="btn btn-danger" @click="killAgent(detailAgent)">Stop</button>
</div>
</div>
@@ -886,6 +1015,36 @@
<button class="btn btn-primary mt-4" @click="saveConfig()" :disabled="configSaving">
<span x-show="!configSaving">Save Config</span><span x-show="configSaving">Saving...</span>
</button>
<!-- Tool Filters -->
<div class="mt-4" style="border-top:1px solid var(--border);padding-top:16px">
<h4 style="margin-bottom:8px;font-size:13px">Tool Filters</h4>
<p class="text-xs text-dim" style="margin-bottom:12px">Allowlist: only these tools available (empty = all). Blocklist: these tools excluded.</p>
<div class="form-group">
<label style="font-size:12px">Allowlist <span class="text-dim" x-text="'(' + toolFilters.tool_allowlist.length + ')'"></span></label>
<div class="flex flex-wrap gap-1 mb-1">
<template x-for="(t, i) in toolFilters.tool_allowlist" :key="'al-'+i">
<span class="badge" style="cursor:pointer" @click="removeAllowTool(t)" :title="'Click to remove ' + t"><span x-text="t"></span> &times;</span>
</template>
</div>
<div class="flex gap-1">
<input class="form-input" style="font-size:12px;flex:1" x-model="newAllowTool" placeholder="tool name" @keydown.enter="addAllowTool()">
<button class="btn btn-ghost btn-sm" @click="addAllowTool()">Add</button>
</div>
</div>
<div class="form-group">
<label style="font-size:12px">Blocklist <span class="text-dim" x-text="'(' + toolFilters.tool_blocklist.length + ')'"></span></label>
<div class="flex flex-wrap gap-1 mb-1">
<template x-for="(t, i) in toolFilters.tool_blocklist" :key="'bl-'+i">
<span class="badge badge-danger" style="cursor:pointer" @click="removeBlockTool(t)" :title="'Click to remove ' + t"><span x-text="t"></span> &times;</span>
</template>
</div>
<div class="flex gap-1">
<input class="form-input" style="font-size:12px;flex:1" x-model="newBlockTool" placeholder="tool name" @keydown.enter="addBlockTool()">
<button class="btn btn-ghost btn-sm" @click="addBlockTool()">Add</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -932,7 +1091,7 @@
<div x-show="spawnStep === 1">
<div class="form-group">
<label>Agent Name</label>
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="nextStep()">
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) nextStep()">
</div>
<div class="form-group">
<label>Emoji</label>
@@ -1288,63 +1447,8 @@
</defs>
<rect x="-2000" y="-2000" width="6000" height="6000" fill="url(#wf-grid)"/>
<!-- Connections -->
<template x-for="conn in connections" :key="conn.id">
<g>
<path :d="getConnectionPath(conn)" fill="none" stroke="var(--text-dim)" stroke-width="2"
style="cursor:pointer" @click.stop="selectedConnection = conn"
:stroke="selectedConnection && selectedConnection.id === conn.id ? 'var(--accent)' : 'var(--text-dim)'"
:stroke-width="selectedConnection && selectedConnection.id === conn.id ? 3 : 2"/>
<!-- Arrow at midpoint -->
</g>
</template>
<!-- Connection preview line -->
<template x-if="connecting && connectPreview">
<path :d="getPreviewPath()" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="6,3"/>
</template>
<!-- Nodes -->
<template x-for="node in nodes" :key="node.id">
<g class="wf-node" :transform="'translate(' + node.x + ',' + node.y + ')'"
@mousedown="onNodeMouseDown(node, $event)" @dblclick="editNode(node)">
<!-- Node body -->
<rect x="0" y="0" :width="node.width" :height="node.height" rx="8" ry="8"
:fill="selectedNode && selectedNode.id === node.id ? 'var(--card-bg)' : 'var(--bg-secondary)'"
:stroke="selectedNode && selectedNode.id === node.id ? node.color : 'var(--border)'"
stroke-width="2" style="cursor:grab"/>
<!-- Color accent bar -->
<rect x="0" y="0" width="6" :height="node.height" rx="3" ry="0" :fill="node.color"/>
<!-- Icon -->
<circle cx="28" :cy="node.height/2" r="14" :fill="node.color" opacity="0.15"/>
<text x="28" :y="node.height/2 + 4" text-anchor="middle" :fill="node.color"
style="font-size:12px;font-weight:700;pointer-events:none" x-text="node.icon"></text>
<!-- Label -->
<text x="50" :y="node.height/2 - 4" fill="var(--text)" style="font-size:12px;font-weight:600;pointer-events:none"
x-text="node.label"></text>
<!-- Sub-label (config hint) -->
<text x="50" :y="node.height/2 + 12" fill="var(--text-dim)" style="font-size:10px;pointer-events:none"
x-text="node.type === 'agent' ? (node.config.agent_name || 'No agent') :
node.type === 'condition' ? (node.config.expression || 'No condition') :
node.type === 'loop' ? ('max ' + (node.config.max_iterations || 5) + ' iters') :
node.type === 'parallel' ? (node.config.fan_count + ' branches') :
node.type === 'collect' ? node.config.strategy : ''"></text>
<!-- Input ports -->
<template x-for="pi in Array.from({length: node.ports.in}, function(_,i){return i})" :key="'in-'+pi">
<circle class="wf-port wf-port-in" :cx="node.width / (node.ports.in + 1) * (pi + 1)" cy="0" r="6"
fill="var(--bg-secondary)" stroke="var(--text-dim)" stroke-width="2"
@mouseup.stop="endConnect(node.id, pi, $event)"/>
</template>
<!-- Output ports -->
<template x-for="po in Array.from({length: node.ports.out}, function(_,i){return i})" :key="'out-'+po">
<circle class="wf-port wf-port-out" :cx="node.width / (node.ports.out + 1) * (po + 1)" :cy="node.height" r="6"
fill="var(--bg-secondary)" :stroke="node.color" stroke-width="2"
@mousedown.stop="startConnect(node.id, po, $event)"/>
</template>
</g>
</template>
<!-- Manually rendered nodes & connections (Alpine x-for breaks inside SVG) -->
<g id="wf-render-group" x-effect="nodes.length; connections.length; selectedNode; selectedConnection; connecting; connectPreview; scheduleRender()"></g>
</g>
</svg>
@@ -1364,7 +1468,7 @@
<div>
<div class="form-group">
<label class="text-xs">Label</label>
<input class="form-input" x-model="selectedNode.label" style="font-size:11px">
<input class="form-input" x-model="selectedNode.label" @input="applyNodeEdit()" style="font-size:11px">
</div>
<!-- Agent config -->
@@ -1372,7 +1476,7 @@
<div>
<div class="form-group">
<label class="text-xs">Agent</label>
<select class="form-select" x-model="selectedNode.config.agent_name" style="font-size:11px">
<select class="form-select" x-model="selectedNode.config.agent_name" @change="applyNodeEdit()" style="font-size:11px">
<option value="">Select agent...</option>
<template x-for="a in agents" :key="a.id || a.name">
<option :value="a.name" x-text="a.name"></option>
@@ -1381,11 +1485,11 @@
</div>
<div class="form-group">
<label class="text-xs">Prompt Template</label>
<textarea class="form-textarea" x-model="selectedNode.config.prompt" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
<textarea class="form-textarea" x-model="selectedNode.config.prompt" @input="applyNodeEdit()" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
</div>
<div class="form-group">
<label class="text-xs">Model (optional)</label>
<input class="form-input" x-model="selectedNode.config.model" style="font-size:11px" placeholder="Default model">
<input class="form-input" x-model="selectedNode.config.model" @input="applyNodeEdit()" style="font-size:11px" placeholder="Default model">
</div>
</div>
</template>
@@ -1395,7 +1499,7 @@
<div>
<div class="form-group">
<label class="text-xs">Expression</label>
<input class="form-input" x-model="selectedNode.config.expression" style="font-size:11px" placeholder="output.contains('yes')">
<input class="form-input" x-model="selectedNode.config.expression" @input="applyNodeEdit()" style="font-size:11px" placeholder="output.contains('yes')">
</div>
<div class="text-xs text-dim">Top port = true, bottom port = false</div>
</div>
@@ -1406,11 +1510,11 @@
<div>
<div class="form-group">
<label class="text-xs">Max Iterations</label>
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" style="font-size:11px" min="1" max="100">
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" @input="applyNodeEdit()" style="font-size:11px" min="1" max="100">
</div>
<div class="form-group">
<label class="text-xs">Until (stop condition)</label>
<input class="form-input" x-model="selectedNode.config.until" style="font-size:11px" placeholder="output === 'done'">
<input class="form-input" x-model="selectedNode.config.until" @input="applyNodeEdit()" style="font-size:11px" placeholder="output === 'done'">
</div>
</div>
</template>
@@ -1420,7 +1524,7 @@
<div>
<div class="form-group">
<label class="text-xs">Fan-out Count</label>
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" style="font-size:11px" min="2" max="10">
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" @input="applyNodeEdit()" style="font-size:11px" min="2" max="10">
</div>
</div>
</template>
@@ -1430,7 +1534,7 @@
<div>
<div class="form-group">
<label class="text-xs">Strategy</label>
<select class="form-select" x-model="selectedNode.config.strategy" style="font-size:11px">
<select class="form-select" x-model="selectedNode.config.strategy" @change="applyNodeEdit()" style="font-size:11px">
<option value="all">Wait for all</option>
<option value="first">First to finish</option>
<option value="majority">Majority vote</option>
@@ -2068,7 +2172,7 @@
<!-- Search bar with live search and clear button -->
<div class="search-input mb-4" style="position:relative">
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
<button x-show="clawhubSearch" @click="clearSearch()" class="search-clear-btn" title="Clear search (Esc)">&times;</button>
</div>
@@ -2299,7 +2403,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</template>
</div>
<div class="flex gap-2">
<button class="btn btn-primary btn-block" @click="installFromClawHub(skillDetail.slug)" :disabled="installingSlug === skillDetail.slug || skillDetail.installed || isSkillInstalled(skillDetail.slug)" x-text="skillDetail.installed || isSkillInstalled(skillDetail.slug) ? 'Already Installed' : installingSlug === skillDetail.slug ? 'Installing...' : 'Install from ClawHub'"></button>
<button class="btn btn-primary" style="flex:1" @click="installFromClawHub(skillDetail.slug)" :disabled="installingSlug === skillDetail.slug || skillDetail.installed || isSkillInstalled(skillDetail.slug)" x-text="skillDetail.installed || isSkillInstalled(skillDetail.slug) ? 'Already Installed' : installingSlug === skillDetail.slug ? 'Installing...' : 'Install from ClawHub'"></button>
<button class="btn btn-ghost" @click="viewSkillCode(skillDetail.slug)" :disabled="skillCodeLoading" x-text="skillCodeLoading ? 'Loading...' : showSkillCode ? 'Hide Code' : 'View Code'"></button>
</div>
<div x-show="showSkillCode && skillCode" x-transition class="mt-3" style="max-height:300px;overflow:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-inset)">
<div class="flex justify-between items-center" style="padding:6px 12px;border-bottom:1px solid var(--border)">
<span class="text-xs text-dim" x-text="skillCodeFilename"></span>
</div>
<pre style="margin:0;padding:12px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all" x-text="skillCode"></pre>
</div>
<div class="text-xs text-dim mt-2 text-center">Skills are security-scanned before installation. Prompt injection and malware patterns are blocked.</div>
</div>
@@ -2401,7 +2512,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<span style="font-size:1.4rem" x-text="getHandIcon(inst.hand_id)"></span>
<div class="card-header" style="margin:0" x-text="inst.agent_name || inst.hand_id"></div>
</div>
<span class="badge" :class="inst.status === 'Active' ? 'badge-success' : inst.status === 'Paused' ? 'badge-dim' : 'badge-info'" x-text="inst.status"></span>
<span class="badge" :class="{ 'badge-success': inst.status === 'Active', 'badge-dim': inst.status === 'Paused', 'badge-warn': inst.status && inst.status.startsWith('Error'), 'badge-info': inst.status === 'Inactive' }" x-text="inst.status"></span>
</div>
<div class="text-xs text-dim" x-text="'Activated: ' + new Date(inst.activated_at).toLocaleString()"></div>
<div class="text-xs text-dim" x-show="inst.agent_id" x-text="'Agent: ' + inst.agent_id"></div>
@@ -2428,6 +2539,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<template x-if="inst.status === 'Paused'">
<button class="btn btn-ghost btn-sm" @click="resumeHand(inst)">Resume</button>
</template>
<template x-if="inst.status && inst.status.startsWith('Error')">
<span class="text-xs text-dim">Error — deactivate and reactivate</span>
</template>
<button class="btn btn-danger btn-sm" @click="deactivate(inst)">Deactivate</button>
</div>
</div>
@@ -2713,7 +2827,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Text type -->
<template x-if="setting.setting_type === 'text'">
<input type="text" class="input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
<input type="text" class="form-input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
</template>
</div>
</template>
@@ -2929,6 +3043,30 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</template>
</div>
<!-- Add Custom Provider -->
<div class="info-card mt-4" style="border:1px solid var(--border)">
<h4 style="margin-top:0">Add Custom Provider</h4>
<p class="text-xs text-dim mb-2">Connect any OpenAI-compatible API (vLLM, LiteLLM, LocalAI, etc.)</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
<div>
<label class="text-xs text-dim">Provider Name</label>
<input class="form-input" x-model="customProviderName" placeholder="e.g. my-local-llm">
</div>
<div>
<label class="text-xs text-dim">Base URL (required)</label>
<input class="form-input" x-model="customProviderUrl" placeholder="http://localhost:8080/v1">
</div>
</div>
<div class="mt-2">
<label class="text-xs text-dim">API Key (optional)</label>
<input class="form-input" type="password" x-model="customProviderKey" placeholder="sk-... (leave blank if not needed)">
</div>
<button class="btn btn-primary btn-sm mt-2" @click="addCustomProvider()" :disabled="!customProviderName.trim() || !customProviderUrl.trim() || addingCustomProvider">
<span x-show="!addingCustomProvider">Add Provider</span>
<span x-show="addingCustomProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
</button>
<span class="text-xs text-dim ml-2" x-text="customProviderStatus"></span>
</div>
<div class="empty-state" x-show="!providers.length">
<h4>No providers found</h4>
<p class="hint">Provider information could not be loaded. Check that the API is running.</p>
@@ -2958,21 +3096,53 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<option :value="t" x-text="t"></option>
</template>
</select>
<button class="btn btn-primary btn-sm" @click="showCustomModelForm = !showCustomModelForm" x-text="showCustomModelForm ? 'Cancel' : '+ Custom Model'"></button>
</div>
<!-- Custom model form -->
<div x-show="showCustomModelForm" class="info-card mb-4" style="border:1px solid var(--accent,#7c3aed)">
<h4 style="margin-top:0">Add Custom Model</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
<div>
<label class="text-xs text-dim">Model ID (required)</label>
<input class="form-input" x-model="customModelId" placeholder="e.g. my-org/my-model">
</div>
<div>
<label class="text-xs text-dim">Provider</label>
<input class="form-input" x-model="customModelProvider" placeholder="openrouter">
</div>
<div>
<label class="text-xs text-dim">Context Window</label>
<input class="form-input" type="number" x-model.number="customModelContext" placeholder="128000">
</div>
<div>
<label class="text-xs text-dim">Max Output Tokens</label>
<input class="form-input" type="number" x-model.number="customModelMaxOutput" placeholder="8192">
</div>
</div>
<button class="btn btn-primary btn-sm mt-2" @click="addCustomModel()" :disabled="!customModelId.trim()">Add Model</button>
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
</div>
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
<div x-show="!filteredModels.length && !settingsLoading" style="text-align:center;padding:32px 16px">
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">&#x1F916;</div>
<h3 style="margin:0 0 4px;font-size:14px" x-text="models.length ? 'No models match your search' : 'No models available'"></h3>
<p class="text-xs text-dim" x-text="models.length ? 'Try a different search term or clear filters.' : 'Configure an LLM provider to see available models.'"></p>
<button class="btn btn-ghost btn-sm mt-2" x-show="models.length && (modelSearch || modelProviderFilter)" @click="modelSearch=''; modelProviderFilter=''">Clear Filters</button>
</div>
<div class="table-wrap" x-show="filteredModels.length">
<table>
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th></tr></thead>
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th><th></th></tr></thead>
<tbody>
<template x-for="m in filteredModels" :key="m.id">
<tr>
<td class="font-bold" style="font-size:11px" x-text="m.display_name || m.id"></td>
<td style="font-size:11px"><div class="font-bold" x-text="m.display_name || m.id"></div><div class="text-xs text-dim" style="font-family:var(--font-mono);opacity:0.7;user-select:all" x-show="m.display_name && m.display_name !== m.id" x-text="m.id"></div></td>
<td class="text-dim" x-text="m.provider"></td>
<td><span class="tier-badge" :class="tierBadgeClass(m.tier)" x-text="m.tier || '-'"></span></td>
<td class="text-xs" x-text="formatContext(m.context_window)"></td>
<td class="text-xs" x-text="formatCost(m.input_cost_per_m)"></td>
<td class="text-xs" x-text="formatCost(m.output_cost_per_m)"></td>
<td><span class="badge" :class="m.available ? 'badge-success' : 'badge-muted'" x-text="m.available ? 'Available' : 'Needs Key'"></span></td>
<td><button x-show="m.tier === 'custom'" class="btn btn-ghost btn-sm" @click="deleteCustomModel(m.id)" title="Delete custom model" style="padding:2px 6px;font-size:11px;color:var(--text-muted)">&#x2715;</button></td>
</tr>
</template>
</tbody>
@@ -2987,6 +3157,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<input placeholder="Search tools..." x-model="toolSearch">
</div>
<div class="text-xs text-dim mb-2" x-text="filteredTools.length + ' of ' + tools.length + ' tools'"></div>
<div x-show="!filteredTools.length && !settingsLoading" style="text-align:center;padding:32px 16px">
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">&#x1F527;</div>
<h3 style="margin:0 0 4px;font-size:14px" x-text="tools.length ? 'No tools match your search' : 'No tools available'"></h3>
<p class="text-xs text-dim" x-text="tools.length ? 'Try a different search term.' : 'Tools will appear once agents are configured.'"></p>
<button class="btn btn-ghost btn-sm mt-2" x-show="tools.length && toolSearch" @click="toolSearch=''">Clear Search</button>
</div>
<div class="table-wrap" x-show="filteredTools.length">
<table>
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
@@ -3196,11 +3372,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Network tab -->
<div x-show="tab === 'network'" x-data="{
netStatus: null, a2aAgents: [], a2aDiscoverUrl: '', a2aDiscovering: false,
async loadNetStatus() { try { this.netStatus = await (await fetch('/api/network/status')).json(); } catch(e) {} },
async loadA2aAgents() { try { let r = await (await fetch('/api/a2a/agents')).json(); this.a2aAgents = r.agents || []; } catch(e) {} },
async loadNetStatus() { try { this.netStatus = await OpenFangAPI.get('/api/network/status'); } catch(e) {} },
async loadA2aAgents() { try { let r = await OpenFangAPI.get('/api/a2a/agents'); this.a2aAgents = r.agents || []; } catch(e) {} },
async discoverA2a() {
if (!this.a2aDiscoverUrl) return; this.a2aDiscovering = true;
try { await fetch('/api/a2a/discover', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:this.a2aDiscoverUrl})}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
try { await OpenFangAPI.post('/api/a2a/discover', {url:this.a2aDiscoverUrl}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
this.a2aDiscovering = false;
}
}" x-init="loadNetStatus(); loadA2aAgents()">
@@ -3282,14 +3458,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div x-show="tab === 'budget'" x-data="{
budgetData: null, agentRanking: [], budgetLoading: true,
editMode: false,
editHourly: '', editDaily: '', editMonthly: '', editAlert: '',
editHourly: '', editDaily: '', editMonthly: '', editAlert: '', editTokenLimit: '',
saving: false,
async loadBudget() {
this.budgetLoading = true;
try {
let [b, a] = await Promise.all([
fetch('/api/budget').then(r => r.json()),
fetch('/api/budget/agents').then(r => r.json())
OpenFangAPI.get('/api/budget'),
OpenFangAPI.get('/api/budget/agents')
]);
this.budgetData = b;
this.agentRanking = a.agents || [];
@@ -3301,6 +3477,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
this.editDaily = this.budgetData.daily_limit || 0;
this.editMonthly = this.budgetData.monthly_limit || 0;
this.editAlert = ((this.budgetData.alert_threshold || 0.8) * 100).toFixed(0);
this.editTokenLimit = this.budgetData.default_max_llm_tokens_per_hour || 0;
this.editMode = true;
},
async saveBudget() {
@@ -3312,12 +3489,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
if (+this.editMonthly !== this.budgetData.monthly_limit) body.max_monthly_usd = +this.editMonthly;
let alertVal = (+this.editAlert) / 100;
if (Math.abs(alertVal - this.budgetData.alert_threshold) > 0.001) body.alert_threshold = alertVal;
await fetch('/api/budget', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
if (+this.editTokenLimit !== (this.budgetData.default_max_llm_tokens_per_hour || 0)) body.default_max_llm_tokens_per_hour = +this.editTokenLimit;
await OpenFangAPI.put('/api/budget', body);
this.editMode = false;
await this.loadBudget();
} catch(e) { alert('Failed to save: ' + e); }
} catch(e) { OpenFangToast.error('Failed to save: ' + (e.message || e)); }
this.saving = false;
},
fmtTokens(v) { return v > 0 ? (v >= 1000000 ? (v/1000000).toFixed(1)+'M' : v >= 1000 ? (v/1000).toFixed(0)+'K' : v) : 'per-agent'; },
pctColor(pct) { return pct >= 0.8 ? '#ef4444' : pct >= 0.5 ? '#eab308' : '#22c55e'; },
fmtUsd(v) { return v > 0 ? '$' + v.toFixed(4) : 'unlimited'; }
}" x-init="loadBudget()">
@@ -3357,9 +3536,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</div>
</div>
<div class="text-xs text-dim mb-3" x-show="budgetData.alert_threshold > 0 && !editMode">
<div class="text-xs text-dim mb-1" x-show="budgetData.alert_threshold > 0 && !editMode">
Alert threshold: <span x-text="(budgetData.alert_threshold * 100).toFixed(0) + '%'"></span> of any limit
</div>
<div class="text-xs text-dim mb-3" x-show="!editMode">
Hourly token limit (per agent): <span x-text="fmtTokens(budgetData.default_max_llm_tokens_per_hour || 0)"></span>
</div>
<!-- Edit limits form -->
<div x-show="editMode" class="card" style="margin:12px 0;padding:12px;border:1px solid var(--accent);border-radius:6px">
@@ -3381,7 +3563,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<input type="number" step="5" min="0" max="100" x-model="editAlert" class="input" style="width:100%;margin-top:2px" placeholder="80">
</div>
</div>
<div class="text-xs text-dim">Set to 0 for unlimited. Changes apply immediately (in-memory, not persisted to config.toml).</div>
<div style="margin-bottom:8px">
<label class="text-xs text-dim">Hourly Token Limit (per agent, 0 = use per-agent values)</label>
<input type="number" step="100000" min="0" x-model="editTokenLimit" class="input" style="width:260px;margin-top:2px" placeholder="0 = per-agent default">
</div>
<div class="text-xs text-dim">Set to 0 for unlimited/per-agent default. Changes apply immediately (in-memory, not persisted to config.toml).</div>
<button class="btn btn-sm mt-2" @click="editMode = false" style="margin-right:8px">Cancel</button>
</div>
@@ -3389,7 +3575,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
<div class="table-wrap" x-show="agentRanking.length">
<table>
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th></tr></thead>
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th><th>Token Limit/hr</th></tr></thead>
<tbody>
<template x-for="a in agentRanking" :key="a.agent_id">
<tr>
@@ -3398,6 +3584,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<td class="text-dim" x-text="fmtUsd(a.hourly_limit)"></td>
<td class="text-dim" x-text="fmtUsd(a.daily_limit)"></td>
<td class="text-dim" x-text="fmtUsd(a.monthly_limit)"></td>
<td class="text-dim" x-text="fmtTokens(a.max_llm_tokens_per_hour || 0)"></td>
</tr>
</template>
</tbody>
@@ -3995,6 +4182,176 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</template>
<!-- Page: Comms -->
<template x-if="page === 'comms'">
<div x-data="commsPage" x-init="loadData()" @page-leave.window="stopSSE()">
<div class="page-header">
<h2>Agent Comms</h2>
<div class="flex items-center gap-2">
<button class="btn btn-primary btn-sm" @click="openSendModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/></svg>
Send Message
</button>
<button class="btn btn-ghost btn-sm" @click="openTaskModal()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
Post Task
</button>
<button class="btn btn-ghost btn-sm" @click="loadData()" title="Refresh">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/></svg>
</button>
</div>
</div>
<div class="page-body">
<!-- Loading -->
<div x-show="loading" style="animation:fadeIn 0.2s">
<div class="card mb-4"><div class="skeleton skeleton-text" style="width:160px;margin-bottom:8px"></div><div class="skeleton skeleton-card" style="height:120px"></div></div>
<div class="card"><div class="skeleton skeleton-text" style="width:120px;margin-bottom:8px"></div><div class="skeleton skeleton-card" style="height:200px"></div></div>
</div>
<!-- Error -->
<div x-show="!loading && loadError" class="error-state" style="animation:fadeIn 0.3s">
<h3 style="color:var(--error)">Connection Error</h3>
<p class="text-xs text-dim" x-text="loadError"></p>
<button class="btn btn-primary btn-sm" @click="loadData()" style="margin-top:8px">Retry</button>
</div>
<!-- Content -->
<div x-show="!loading && !loadError" style="animation:fadeIn 0.3s">
<!-- Topology -->
<div class="card mb-4">
<div class="card-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"/></svg>
Agent Topology
<span class="badge badge-dim" style="margin-left:8px;font-weight:400" x-text="topology.nodes.length + ' agents'"></span>
</div>
<div style="padding:8px 0;font-family:var(--font-mono);font-size:12px;line-height:1.8">
<template x-if="topology.nodes.length === 0">
<div class="text-dim" style="text-align:center;padding:24px">No agents running</div>
</template>
<template x-for="root in rootNodes()" :key="root.id">
<div class="comms-topo-tree">
<div class="comms-topo-node" :title="root.id">
<span :class="stateBadgeClass(root.state)" style="font-size:10px;padding:1px 6px" x-text="root.state"></span>
<strong x-text="root.name" style="margin:0 4px"></strong>
<span class="text-dim" x-text="root.model"></span>
<template x-for="peer in peersOf(root.id)" :key="peer.id">
<span class="text-dim" style="margin-left:8px" x-text="'\u2194 ' + peer.name"></span>
</template>
</div>
<template x-for="(child, ci) in childrenOf(root.id)" :key="child.id">
<div class="comms-topo-child">
<span class="comms-topo-branch" x-text="ci < childrenOf(root.id).length - 1 ? '\u251c\u2500\u2500 ' : '\u2514\u2500\u2500 '"></span>
<span :class="stateBadgeClass(child.state)" style="font-size:10px;padding:1px 6px" x-text="child.state"></span>
<strong x-text="child.name" style="margin:0 4px"></strong>
<span class="text-dim" x-text="child.model"></span>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- Live Event Feed -->
<div class="card">
<div class="card-header flex justify-between items-center">
<div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
Live Event Feed
</div>
<div class="flex items-center gap-2">
<span class="badge badge-success" style="font-size:9px;padding:2px 6px;animation:pulse-ring 2s infinite">LIVE</span>
<span class="text-xs text-dim" x-text="events.length + ' events'"></span>
</div>
</div>
<div style="max-height:400px;overflow-y:auto">
<template x-if="events.length === 0">
<div class="text-dim" style="text-align:center;padding:24px">No inter-agent events yet</div>
</template>
<template x-for="ev in events" :key="ev.id">
<div class="comms-event-row">
<span class="comms-event-time text-xs text-dim" x-text="timeAgo(ev.timestamp)"></span>
<span :class="eventBadgeClass(ev.kind)" style="font-size:10px;padding:1px 6px;min-width:70px;text-align:center" x-text="eventLabel(ev.kind)"></span>
<span style="font-weight:600;font-size:12px" x-text="ev.source_name"></span>
<span class="text-dim" x-show="ev.target_name" x-text="'\u2192 ' + ev.target_name"></span>
<span class="comms-event-detail text-dim text-xs" x-text="ev.detail" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
</div>
</template>
</div>
</div>
</div>
<!-- Send Message Modal -->
<div x-show="showSendModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showSendModal=false" x-transition>
<div class="card" style="width:420px;max-width:90vw" @click.stop>
<div class="card-header">Send Agent Message</div>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
<div>
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">From Agent</label>
<select x-model="sendFrom" class="input" style="width:100%">
<option value="">Select agent...</option>
<template x-for="n in topology.nodes" :key="n.id">
<option :value="n.id" x-text="n.name + ' (' + n.state + ')'"></option>
</template>
</select>
</div>
<div>
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">To Agent</label>
<select x-model="sendTo" class="input" style="width:100%">
<option value="">Select agent...</option>
<template x-for="n in topology.nodes" :key="n.id">
<option :value="n.id" x-text="n.name + ' (' + n.state + ')'"></option>
</template>
</select>
</div>
<div>
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Message</label>
<textarea x-model="sendMsg" class="input" rows="3" placeholder="Type a message..." style="width:100%;resize:vertical"></textarea>
</div>
<div class="flex gap-2" style="justify-content:flex-end">
<button class="btn btn-ghost btn-sm" @click="showSendModal=false">Cancel</button>
<button class="btn btn-primary btn-sm" @click="submitSend()" :disabled="sendLoading || !sendFrom || !sendTo || !sendMsg.trim()">
<span x-show="sendLoading">Sending...</span>
<span x-show="!sendLoading">Send</span>
</button>
</div>
</div>
</div>
</div>
<!-- Post Task Modal -->
<div x-show="showTaskModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showTaskModal=false" x-transition>
<div class="card" style="width:420px;max-width:90vw" @click.stop>
<div class="card-header">Post Task</div>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
<div>
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Title</label>
<input type="text" x-model="taskTitle" class="input" placeholder="Task title..." style="width:100%">
</div>
<div>
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Description</label>
<textarea x-model="taskDesc" class="input" rows="3" placeholder="Task description..." style="width:100%;resize:vertical"></textarea>
</div>
<div>
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Assign To (optional)</label>
<select x-model="taskAssign" class="input" style="width:100%">
<option value="">Unassigned</option>
<template x-for="n in topology.nodes" :key="n.id">
<option :value="n.id" x-text="n.name"></option>
</template>
</select>
</div>
<div class="flex gap-2" style="justify-content:flex-end">
<button class="btn btn-ghost btn-sm" @click="showTaskModal=false">Cancel</button>
<button class="btn btn-primary btn-sm" @click="submitTask()" :disabled="taskLoading || !taskTitle.trim()">
<span x-show="taskLoading">Posting...</span>
<span x-show="!taskLoading">Post Task</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Page: Setup Wizard -->
<template x-if="page === 'wizard'">
<div x-data="wizardPage">
@@ -4107,7 +4464,29 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</div>
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj)">
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider === 'claude-code'">
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
<div class="card-header">Configure Claude Code</div>
<div class="text-xs text-dim mb-2" style="line-height:1.8">
Claude Code uses its own CLI authentication &mdash; no API key needed.
</div>
<div style="background:var(--bg);border-radius:4px;padding:10px 12px;margin-bottom:12px;font-size:12px;line-height:1.8">
<div><span style="color:var(--accent)">1.</span> Install: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">npm install -g @anthropic-ai/claude-code</code></div>
<div><span style="color:var(--accent)">2.</span> Authenticate: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">claude auth</code></div>
<div><span style="color:var(--accent)">3.</span> Click <strong>Detect</strong> below to verify</div>
</div>
<button class="btn btn-primary btn-sm" @click="detectClaudeCode()" :disabled="testingProvider">
<span x-show="!testingProvider">Detect Claude Code</span>
<span x-show="testingProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
</button>
<div x-show="testResult" class="mt-2">
<div x-show="testResult && testResult.status === 'ok'" class="badge badge-success" style="padding:6px 12px">Claude Code detected<span x-show="testResult && testResult.latency_ms" x-text="' (' + (testResult ? testResult.latency_ms : '') + 'ms)'"></span></div>
<div x-show="testResult && testResult.status !== 'ok'" class="badge badge-error" style="padding:6px 12px">Claude Code CLI not detected. Make sure you&rsquo;ve run: <code>npm install -g @anthropic-ai/claude-code &amp;&amp; claude auth</code></div>
</div>
</div>
</template>
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider !== 'claude-code'">
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
<div class="card-header" x-text="'Configure ' + selectedProviderObj.display_name"></div>
<div class="text-xs text-dim mb-2" x-show="selectedProviderObj.api_key_env">
@@ -4197,7 +4576,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div class="card" style="border-left:3px solid var(--accent)">
<div class="form-group" style="margin-bottom:8px">
<label>Agent Name</label>
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="createAgent()">
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) createAgent()">
</div>
<div class="text-xs text-dim" x-text="'Will use ' + templates[selectedTemplate].provider + ' / ' + templates[selectedTemplate].model + ' with ' + profileInfo(templates[selectedTemplate].profile).label + ' profile'"></div>
<div class="mt-2">
@@ -4243,7 +4622,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Input -->
<div style="display:flex;gap:8px;margin-top:12px">
<input class="form-input" type="text" x-model="tryItInput" placeholder="Type a message..."
@keydown.enter="sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
@keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
<button class="btn btn-primary btn-sm" @click="sendTryItMessage(tryItInput)" :disabled="tryItSending || !tryItInput.trim()">Send</button>
</div>
</div>
@@ -4359,6 +4738,71 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</template>
<!-- ─── Runtime page ─── -->
<template x-if="page === 'runtime'">
<div x-data="runtimePage">
<div class="page-header"><h2>Runtime</h2></div>
<div class="page-body" x-init="loadData()">
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading runtime info...</span></div>
<div x-show="!loading">
<div class="grid grid-cols-4" style="gap:16px;margin-bottom:24px">
<div class="card stat-card">
<div class="stat-label">Uptime</div>
<div class="stat-value" x-text="uptime"></div>
</div>
<div class="card stat-card">
<div class="stat-label">Agents</div>
<div class="stat-value" x-text="agentCount"></div>
</div>
<div class="card stat-card">
<div class="stat-label">Version</div>
<div class="stat-value" x-text="version"></div>
</div>
<div class="card stat-card">
<div class="stat-label">Default Model</div>
<div class="stat-value" style="font-size:13px" x-text="defaultModel"></div>
</div>
</div>
<div class="card" style="margin-bottom:16px">
<div class="card-header">System</div>
<table class="table" style="margin-top:8px">
<tbody>
<tr><td style="width:180px;font-weight:500">Platform</td><td x-text="platform"></td></tr>
<tr><td style="font-weight:500">Architecture</td><td x-text="arch"></td></tr>
<tr><td style="font-weight:500">API Listen</td><td x-text="apiListen"></td></tr>
<tr><td style="font-weight:500">Home Directory</td><td x-text="homeDir"></td></tr>
<tr><td style="font-weight:500">Log Level</td><td x-text="logLevel"></td></tr>
<tr><td style="font-weight:500">Network</td><td x-text="networkEnabled ? 'Enabled' : 'Disabled'"></td></tr>
</tbody>
</table>
</div>
<div class="card" style="margin-bottom:16px">
<div class="card-header">Providers</div>
<table class="table" style="margin-top:8px">
<thead><tr><th>Provider</th><th>Status</th><th>Models</th><th>Latency</th></tr></thead>
<tbody>
<template x-for="p in providers" :key="p.id">
<tr>
<td x-text="p.display_name || p.id"></td>
<td><span class="badge" :class="p.reachable ? 'badge-success' : (p.auth_status === 'Configured' ? 'badge-success' : 'badge-dim')" x-text="p.reachable ? 'Online' : (p.auth_status === 'Configured' ? 'Ready' : 'Not configured')"></span></td>
<td x-text="p.model_count"></td>
<td x-text="p.latency_ms ? p.latency_ms + 'ms' : '-'"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="flex gap-2">
<button class="btn btn-ghost btn-sm" @click="loadData()">Refresh</button>
</div>
</div>
</div>
</div>
</template>
</main>
</div>
+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 {
+12 -3
View File
@@ -155,10 +155,19 @@ document.addEventListener('alpine:init', function() {
async checkAuth() {
try {
await OpenFangAPI.get('/api/providers');
// Use a protected endpoint (not in the public allowlist) to detect
// whether the server requires an API key.
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)) {
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
// Only show prompt if we don't already have a saved key
var saved = localStorage.getItem('openfang-api-key');
if (saved) {
// Saved key might be stale — clear it and show prompt
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
}
this.showAuthPrompt = true;
}
}
@@ -209,7 +218,7 @@ function app() {
});
// 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',
+159 -6
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) {
@@ -391,7 +426,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 +435,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 +594,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;
+152 -6
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(); });
}
@@ -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;
}
};
}
@@ -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;
}
};
});
});
@@ -14,12 +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: '',
@@ -213,8 +224,13 @@ function settingsPage() {
this.providers = data.providers || [];
for (var i = 0; i < this.providers.length; i++) {
var p = this.providers[i];
if (p.is_local && p.base_url && !this.providerUrlInputs[p.id]) {
this.providerUrlInputs[p.id] = p.base_url;
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 = []; }
@@ -227,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([
@@ -248,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);
}
@@ -302,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';
},
@@ -457,6 +510,34 @@ function settingsPage() {
this.providerUrlSaving[provider.id] = false;
},
async addCustomProvider() {
var name = this.customProviderName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
if (!name) { OpenFangToast.error('Please enter a provider name'); return; }
var url = this.customProviderUrl.trim();
if (!url) { OpenFangToast.error('Please enter a base URL'); return; }
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
OpenFangToast.error('URL must start with http:// or https://'); return;
}
this.addingCustomProvider = true;
this.customProviderStatus = '';
try {
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(name) + '/url', { base_url: url });
if (this.customProviderKey.trim()) {
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(name) + '/key', { key: this.customProviderKey.trim() });
}
this.customProviderName = '';
this.customProviderUrl = '';
this.customProviderKey = '';
this.customProviderStatus = '';
OpenFangToast.success('Provider "' + name + '" added' + (result.reachable ? ' (reachable)' : ' (not reachable yet)'));
await this.loadProviders();
} catch(e) {
this.customProviderStatus = 'Error: ' + (e.message || 'Failed');
OpenFangToast.error('Failed to add provider: ' + e.message);
}
this.addingCustomProvider = false;
},
// -- Security methods --
async loadSecurity() {
this.secLoading = true;
+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 ────────────────────────────────────
@@ -76,6 +76,8 @@ async fn start_test_server_with_provider(
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()
@@ -221,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]
@@ -246,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()
@@ -254,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
@@ -269,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()
@@ -277,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]
@@ -616,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
@@ -632,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
@@ -642,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]] {
@@ -660,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);
}
// ---------------------------------------------------------------------------
@@ -702,6 +705,8 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
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();
@@ -113,6 +113,8 @@ async fn test_full_daemon_lifecycle() {
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()
@@ -236,6 +238,8 @@ async fn test_server_immediate_responsiveness() {
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()
+3 -1
View File
@@ -57,6 +57,8 @@ async fn start_test_server() -> TestServer {
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()
@@ -540,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.
+1
View File
@@ -26,6 +26,7 @@ hmac = { workspace = true }
sha2 = { workspace = true }
base64 = { workspace = true }
hex = { workspace = true }
html-escape = { workspace = true }
lettre = { workspace = true }
imap = { 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)
+412 -32
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,10 +448,15 @@ 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 thread_id = if threading_enabled {
message.thread_id.as_deref()
@@ -402,8 +482,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 +521,53 @@ 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,
)
.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}]")
}
};
// Check if it's a slash command embedded in text (e.g. "/agents")
@@ -577,14 +693,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 +744,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,15 +752,22 @@ 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;
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) => {
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) => {
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}");
send_response(
@@ -643,6 +785,196 @@ async fn dispatch_message(
&message.sender.platform_id,
false,
Some(&err_msg),
thread_id,
)
.await;
}
}
}
/// 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}]"),
}];
}
};
// Detect media type from Content-Type header, fall back to URL extension
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string());
let media_type = content_type.unwrap_or_else(|| {
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 {
"image/jpeg".to_string()
}
});
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}]"),
}];
}
};
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 }];
}
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(),
});
}
}
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,
) {
// 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;
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) => {
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) => {
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}");
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 +1420,52 @@ 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(),
},
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: ");
}
}
+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);
}
+31 -3
View File
@@ -20,6 +20,21 @@ use tokio::sync::{mpsc, watch};
use tracing::{debug, error, info, warn};
use zeroize::Zeroizing;
/// SASL PLAIN authenticator for IMAP servers that reject LOGIN
/// (e.g., Lark/Larksuite which only advertise AUTH=PLAIN).
struct PlainAuthenticator {
username: String,
password: String,
}
impl imap::Authenticator for PlainAuthenticator {
type Response = String;
fn process(&self, _data: &[u8]) -> Self::Response {
// SASL PLAIN: \0<username>\0<password>
format!("\x00{}\x00{}", self.username, self.password)
}
}
/// Reply context for email threading (In-Reply-To / Subject continuity).
#[derive(Debug, Clone)]
struct ReplyCtx {
@@ -203,9 +218,22 @@ fn fetch_unseen_emails(
let client = imap::connect((host, port), host, &tls)
.map_err(|e| format!("IMAP connect failed: {e}"))?;
let mut session = client
.login(username, password)
.map_err(|(e, _)| format!("IMAP login failed: {e}"))?;
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
// that reject LOGIN and only support AUTH=PLAIN (SASL).
let mut session = match client.login(username, password) {
Ok(s) => s,
Err((login_err, client)) => {
let authenticator = PlainAuthenticator {
username: username.to_string(),
password: password.to_string(),
};
client
.authenticate("PLAIN", &authenticator)
.map_err(|(e, _)| {
format!("IMAP login failed: {login_err}; AUTH=PLAIN also failed: {e}")
})?
}
};
let mut results = Vec::new();
+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("**") {
+2 -11
View File
@@ -298,17 +298,8 @@ fn strip_html_tags(html: &str) -> String {
}
}
// Decode HTML entities
let decoded = result
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&#x27;", "'")
.replace("&nbsp;", " ");
// Decode HTML entities (handles named, decimal, and hex entities)
let decoded = html_escape::decode_html_entities(&result);
decoded.trim().to_string()
}
+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();
+681 -76
View File
@@ -5,6 +5,7 @@
use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
LifecycleReaction,
};
use async_trait::async_trait;
use futures::Stream;
@@ -13,7 +14,7 @@ use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tracing::{debug, error, info, warn};
use tracing::{debug, info, warn};
use zeroize::Zeroizing;
/// Maximum backoff duration on API failures.
@@ -23,13 +24,18 @@ const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
/// Telegram long-polling timeout (seconds) — sent as the `timeout` parameter to getUpdates.
const LONG_POLL_TIMEOUT: u64 = 30;
/// Default Telegram Bot API base URL.
const DEFAULT_API_URL: &str = "https://api.telegram.org";
/// Telegram Bot API adapter using long-polling.
pub struct TelegramAdapter {
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
token: Zeroizing<String>,
client: reqwest::Client,
allowed_users: Vec<i64>,
allowed_users: Vec<String>,
poll_interval: Duration,
/// Base URL for Telegram Bot API (supports proxies/mirrors).
api_base_url: String,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
}
@@ -39,13 +45,24 @@ impl TelegramAdapter {
///
/// `token` is the raw bot token (read from env by the caller).
/// `allowed_users` is the list of Telegram user IDs allowed to interact (empty = allow all).
pub fn new(token: String, allowed_users: Vec<i64>, poll_interval: Duration) -> Self {
/// `api_url` overrides the Telegram Bot API base URL (for proxies/mirrors).
pub fn new(
token: String,
allowed_users: Vec<String>,
poll_interval: Duration,
api_url: Option<String>,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let api_base_url = api_url
.unwrap_or_else(|| DEFAULT_API_URL.to_string())
.trim_end_matches('/')
.to_string();
Self {
token: Zeroizing::new(token),
client: reqwest::Client::new(),
allowed_users,
poll_interval,
api_base_url,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
}
@@ -53,7 +70,7 @@ impl TelegramAdapter {
/// Validate the bot token by calling `getMe`.
pub async fn validate_token(&self) -> Result<String, Box<dyn std::error::Error>> {
let url = format!("https://api.telegram.org/bot{}/getMe", self.token.as_str());
let url = format!("{}/bot{}/getMe", self.api_base_url, self.token.as_str());
let resp: serde_json::Value = self.client.get(&url).send().await?.json().await?;
if resp["ok"].as_bool() != Some(true) {
@@ -69,23 +86,37 @@ impl TelegramAdapter {
}
/// Call `sendMessage` on the Telegram API.
///
/// When `thread_id` is provided, includes `message_thread_id` in the request
/// so the message lands in the correct forum topic.
async fn api_send_message(
&self,
chat_id: i64,
text: &str,
thread_id: Option<i64>,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://api.telegram.org/bot{}/sendMessage",
"{}/bot{}/sendMessage",
self.api_base_url,
self.token.as_str()
);
// Sanitize: strip unsupported HTML tags so Telegram doesn't reject with 400.
// Telegram only allows: b, i, u, s, tg-spoiler, a, code, pre, blockquote.
// Any other tag (e.g. <name>, <thinking>) causes a 400 Bad Request.
let sanitized = sanitize_telegram_html(text);
// Telegram has a 4096 character limit per message — split if needed
let chunks = split_message(text, 4096);
let chunks = split_message(&sanitized, 4096);
for chunk in chunks {
let body = serde_json::json!({
let mut body = serde_json::json!({
"chat_id": chat_id,
"text": chunk,
"parse_mode": "HTML",
});
if let Some(tid) = thread_id {
body["message_thread_id"] = serde_json::json!(tid);
}
let resp = self.client.post(&url).json(&body).send().await?;
let status = resp.status();
@@ -97,17 +128,221 @@ impl TelegramAdapter {
Ok(())
}
/// Call `sendChatAction` to show "typing..." indicator.
async fn api_send_typing(&self, chat_id: i64) -> Result<(), Box<dyn std::error::Error>> {
/// Call `sendPhoto` on the Telegram API.
async fn api_send_photo(
&self,
chat_id: i64,
photo_url: &str,
caption: Option<&str>,
thread_id: Option<i64>,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"https://api.telegram.org/bot{}/sendChatAction",
"{}/bot{}/sendPhoto",
self.api_base_url,
self.token.as_str()
);
let mut body = serde_json::json!({
"chat_id": chat_id,
"photo": photo_url,
});
if let Some(cap) = caption {
body["caption"] = serde_json::Value::String(cap.to_string());
body["parse_mode"] = serde_json::Value::String("HTML".to_string());
}
if let Some(tid) = thread_id {
body["message_thread_id"] = serde_json::json!(tid);
}
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendPhoto failed: {body_text}");
}
Ok(())
}
/// Call `sendDocument` on the Telegram API.
async fn api_send_document(
&self,
chat_id: i64,
document_url: &str,
filename: &str,
thread_id: Option<i64>,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"{}/bot{}/sendDocument",
self.api_base_url,
self.token.as_str()
);
let mut body = serde_json::json!({
"chat_id": chat_id,
"document": document_url,
"caption": filename,
});
if let Some(tid) = thread_id {
body["message_thread_id"] = serde_json::json!(tid);
}
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendDocument failed: {body_text}");
}
Ok(())
}
/// Call `sendVoice` on the Telegram API.
async fn api_send_voice(
&self,
chat_id: i64,
voice_url: &str,
thread_id: Option<i64>,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"{}/bot{}/sendVoice",
self.api_base_url,
self.token.as_str()
);
let mut body = serde_json::json!({
"chat_id": chat_id,
"voice": voice_url,
});
if let Some(tid) = thread_id {
body["message_thread_id"] = serde_json::json!(tid);
}
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendVoice failed: {body_text}");
}
Ok(())
}
/// Call `sendLocation` on the Telegram API.
async fn api_send_location(
&self,
chat_id: i64,
lat: f64,
lon: f64,
thread_id: Option<i64>,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"{}/bot{}/sendLocation",
self.api_base_url,
self.token.as_str()
);
let mut body = serde_json::json!({
"chat_id": chat_id,
"latitude": lat,
"longitude": lon,
});
if let Some(tid) = thread_id {
body["message_thread_id"] = serde_json::json!(tid);
}
let resp = self.client.post(&url).json(&body).send().await?;
if !resp.status().is_success() {
let body_text = resp.text().await.unwrap_or_default();
warn!("Telegram sendLocation failed: {body_text}");
}
Ok(())
}
/// Call `sendChatAction` to show "typing..." indicator.
///
/// When `thread_id` is provided, the typing indicator appears in the forum topic.
async fn api_send_typing(
&self,
chat_id: i64,
thread_id: Option<i64>,
) -> Result<(), Box<dyn std::error::Error>> {
let url = format!(
"{}/bot{}/sendChatAction",
self.api_base_url,
self.token.as_str()
);
let mut body = serde_json::json!({
"chat_id": chat_id,
"action": "typing",
});
if let Some(tid) = thread_id {
body["message_thread_id"] = serde_json::json!(tid);
}
let _ = self.client.post(&url).json(&body).send().await?;
Ok(())
}
/// Call `setMessageReaction` on the Telegram API (fire-and-forget).
///
/// Sets or replaces the bot's emoji reaction on a message. Each new call
/// automatically replaces the previous reaction, so there is no need to
/// explicitly remove old ones.
fn fire_reaction(&self, chat_id: i64, message_id: i64, emoji: &str) {
let url = format!(
"{}/bot{}/setMessageReaction",
self.api_base_url,
self.token.as_str()
);
let body = serde_json::json!({
"chat_id": chat_id,
"action": "typing",
"message_id": message_id,
"reaction": [{"type": "emoji", "emoji": emoji}],
});
let _ = self.client.post(&url).json(&body).send().await?;
let client = self.client.clone();
tokio::spawn(async move {
match client.post(&url).json(&body).send().await {
Ok(resp) if !resp.status().is_success() => {
let body_text = resp.text().await.unwrap_or_default();
debug!("Telegram setMessageReaction failed: {body_text}");
}
Err(e) => {
debug!("Telegram setMessageReaction error: {e}");
}
_ => {}
}
});
}
}
impl TelegramAdapter {
/// Internal helper: send content with optional forum-topic thread_id.
///
/// Both `send()` and `send_in_thread()` delegate here. When `thread_id` is
/// `Some(id)`, every outbound Telegram API call includes `message_thread_id`
/// so the message lands in the correct forum topic.
async fn send_content(
&self,
user: &ChannelUser,
content: ChannelContent,
thread_id: Option<i64>,
) -> Result<(), Box<dyn std::error::Error>> {
let chat_id: i64 = user
.platform_id
.parse()
.map_err(|_| format!("Invalid Telegram chat_id: {}", user.platform_id))?;
match content {
ChannelContent::Text(text) => {
self.api_send_message(chat_id, &text, thread_id).await?;
}
ChannelContent::Image { url, caption } => {
self.api_send_photo(chat_id, &url, caption.as_deref(), thread_id)
.await?;
}
ChannelContent::File { url, filename } => {
self.api_send_document(chat_id, &url, &filename, thread_id)
.await?;
}
ChannelContent::Voice { url, .. } => {
self.api_send_voice(chat_id, &url, thread_id).await?;
}
ChannelContent::Location { lat, lon } => {
self.api_send_location(chat_id, lat, lon, thread_id)
.await?;
}
ChannelContent::Command { name, args } => {
let text = format!("/{name} {}", args.join(" "));
self.api_send_message(chat_id, text.trim(), thread_id)
.await?;
}
}
Ok(())
}
}
@@ -130,12 +365,34 @@ impl ChannelAdapter for TelegramAdapter {
let bot_name = self.validate_token().await?;
info!("Telegram bot @{bot_name} connected");
// Clear any existing webhook to avoid 409 Conflict during getUpdates polling.
// This is necessary when the daemon restarts — the old polling session may
// still be active on Telegram's side for ~30s, causing 409 errors.
{
let delete_url = format!(
"{}/bot{}/deleteWebhook",
self.api_base_url,
self.token.as_str()
);
match self
.client
.post(&delete_url)
.json(&serde_json::json!({"drop_pending_updates": true}))
.send()
.await
{
Ok(_) => info!("Telegram: cleared webhook, polling mode active"),
Err(e) => tracing::warn!("Telegram: deleteWebhook failed (non-fatal): {e}"),
}
}
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let token = self.token.clone();
let client = self.client.clone();
let allowed_users = self.allowed_users.clone();
let poll_interval = self.poll_interval;
let api_base_url = self.api_base_url.clone();
let mut shutdown = self.shutdown_rx.clone();
tokio::spawn(async move {
@@ -149,7 +406,7 @@ impl ChannelAdapter for TelegramAdapter {
}
// Build getUpdates request
let url = format!("https://api.telegram.org/bot{}/getUpdates", token.as_str());
let url = format!("{}/bot{}/getUpdates", api_base_url, token.as_str());
let mut params = serde_json::json!({
"timeout": LONG_POLL_TIMEOUT,
"allowed_updates": ["message", "edited_message"],
@@ -195,10 +452,14 @@ impl ChannelAdapter for TelegramAdapter {
continue;
}
// Handle conflict (another bot instance polling)
// Handle conflict (another bot instance or stale session polling).
// On daemon restart, the old long-poll may still be active on Telegram's
// side for up to 30s. Retry with backoff instead of stopping permanently.
if status.as_u16() == 409 {
error!("Telegram 409 Conflict — another bot instance is running. Stopping.");
break;
warn!("Telegram 409 Conflict — stale polling session, retrying in {backoff:?}");
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(MAX_BACKOFF);
continue;
}
if !status.is_success() {
@@ -244,7 +505,7 @@ impl ChannelAdapter for TelegramAdapter {
}
// Parse the message
let msg = match parse_telegram_update(update, &allowed_users) {
let msg = match parse_telegram_update(update, &allowed_users, token.as_str(), &client, &api_base_url).await {
Some(m) => m,
None => continue, // filtered out or unparseable
};
@@ -276,21 +537,7 @@ impl ChannelAdapter for TelegramAdapter {
user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let chat_id: i64 = user
.platform_id
.parse()
.map_err(|_| format!("Invalid Telegram chat_id: {}", user.platform_id))?;
match content {
ChannelContent::Text(text) => {
self.api_send_message(chat_id, &text).await?;
}
_ => {
self.api_send_message(chat_id, "(Unsupported content type)")
.await?;
}
}
Ok(())
self.send_content(user, content, None).await
}
async fn send_typing(&self, user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
@@ -298,7 +545,34 @@ impl ChannelAdapter for TelegramAdapter {
.platform_id
.parse()
.map_err(|_| format!("Invalid Telegram chat_id: {}", user.platform_id))?;
self.api_send_typing(chat_id).await
self.api_send_typing(chat_id, None).await
}
async fn send_in_thread(
&self,
user: &ChannelUser,
content: ChannelContent,
thread_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let tid: Option<i64> = thread_id.parse().ok();
self.send_content(user, content, tid).await
}
async fn send_reaction(
&self,
user: &ChannelUser,
message_id: &str,
reaction: &LifecycleReaction,
) -> Result<(), Box<dyn std::error::Error>> {
let chat_id: i64 = user
.platform_id
.parse()
.map_err(|_| format!("Invalid Telegram chat_id: {}", user.platform_id))?;
let msg_id: i64 = message_id
.parse()
.map_err(|_| format!("Invalid Telegram message_id: {message_id}"))?;
self.fire_reaction(chat_id, msg_id, &reaction.emoji);
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
@@ -309,9 +583,36 @@ impl ChannelAdapter for TelegramAdapter {
/// Parse a Telegram update JSON into a `ChannelMessage`, or `None` if filtered/unparseable.
/// Handles both `message` and `edited_message` update types.
fn parse_telegram_update(
/// Resolve a Telegram file_id to a download URL via the Bot API.
async fn telegram_get_file_url(
token: &str,
client: &reqwest::Client,
file_id: &str,
api_base_url: &str,
) -> Option<String> {
let url = format!("{api_base_url}/bot{token}/getFile");
let resp = client
.post(&url)
.json(&serde_json::json!({"file_id": file_id}))
.send()
.await
.ok()?;
let body: serde_json::Value = resp.json().await.ok()?;
if body["ok"].as_bool() != Some(true) {
return None;
}
let file_path = body["result"]["file_path"].as_str()?;
Some(format!(
"{api_base_url}/file/bot{token}/{file_path}"
))
}
async fn parse_telegram_update(
update: &serde_json::Value,
allowed_users: &[i64],
allowed_users: &[String],
token: &str,
client: &reqwest::Client,
api_base_url: &str,
) -> Option<ChannelMessage> {
let message = update
.get("message")
@@ -319,8 +620,9 @@ fn parse_telegram_update(
let from = message.get("from")?;
let user_id = from["id"].as_i64()?;
// Security: check allowed_users
if !allowed_users.is_empty() && !allowed_users.contains(&user_id) {
// Security: check allowed_users (compare as strings for consistency)
let user_id_str = user_id.to_string();
if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == &user_id_str) {
debug!("Telegram: ignoring message from unlisted user {user_id}");
return None;
}
@@ -336,41 +638,87 @@ fn parse_telegram_update(
let chat_type = message["chat"]["type"].as_str().unwrap_or("private");
let is_group = chat_type == "group" || chat_type == "supergroup";
let text = message["text"].as_str()?;
let message_id = message["message_id"].as_i64().unwrap_or(0);
let timestamp = message["date"]
.as_i64()
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
.unwrap_or_else(chrono::Utc::now);
// Parse bot commands (Telegram sends entities for /commands)
let content = if let Some(entities) = message["entities"].as_array() {
let is_bot_command = entities
.iter()
.any(|e| e["type"].as_str() == Some("bot_command") && e["offset"].as_i64() == Some(0));
if is_bot_command {
let parts: Vec<&str> = text.splitn(2, ' ').collect();
let cmd_name = parts[0].trim_start_matches('/');
// Strip @botname from command (e.g. /agents@mybot -> agents)
let cmd_name = cmd_name.split('@').next().unwrap_or(cmd_name);
let args = if parts.len() > 1 {
parts[1].split_whitespace().map(String::from).collect()
// Determine content: text, photo, document, voice, or location
let content = if let Some(text) = message["text"].as_str() {
// Parse bot commands (Telegram sends entities for /commands)
if let Some(entities) = message["entities"].as_array() {
let is_bot_command = entities.iter().any(|e| {
e["type"].as_str() == Some("bot_command") && e["offset"].as_i64() == Some(0)
});
if is_bot_command {
let parts: Vec<&str> = text.splitn(2, ' ').collect();
let cmd_name = parts[0].trim_start_matches('/');
let cmd_name = cmd_name.split('@').next().unwrap_or(cmd_name);
let args = if parts.len() > 1 {
parts[1].split_whitespace().map(String::from).collect()
} else {
vec![]
};
ChannelContent::Command {
name: cmd_name.to_string(),
args,
}
} else {
vec![]
};
ChannelContent::Command {
name: cmd_name.to_string(),
args,
ChannelContent::Text(text.to_string())
}
} else {
ChannelContent::Text(text.to_string())
}
} else if let Some(photos) = message["photo"].as_array() {
// Photos come as array of sizes; pick the largest (last)
let file_id = photos
.last()
.and_then(|p| p["file_id"].as_str())
.unwrap_or("");
let caption = message["caption"].as_str().map(String::from);
match telegram_get_file_url(token, client, file_id, api_base_url).await {
Some(url) => ChannelContent::Image { url, caption },
None => ChannelContent::Text(format!(
"[Photo received{}]",
caption.as_deref().map(|c| format!(": {c}")).unwrap_or_default()
)),
}
} else if message.get("document").is_some() {
let file_id = message["document"]["file_id"].as_str().unwrap_or("");
let filename = message["document"]["file_name"]
.as_str()
.unwrap_or("document")
.to_string();
match telegram_get_file_url(token, client, file_id, api_base_url).await {
Some(url) => ChannelContent::File { url, filename },
None => ChannelContent::Text(format!("[Document received: {filename}]")),
}
} else if message.get("voice").is_some() {
let file_id = message["voice"]["file_id"].as_str().unwrap_or("");
let duration = message["voice"]["duration"].as_u64().unwrap_or(0) as u32;
match telegram_get_file_url(token, client, file_id, api_base_url).await {
Some(url) => ChannelContent::Voice {
url,
duration_seconds: duration,
},
None => ChannelContent::Text(format!("[Voice message, {duration}s]")),
}
} else if message.get("location").is_some() {
let lat = message["location"]["latitude"].as_f64().unwrap_or(0.0);
let lon = message["location"]["longitude"].as_f64().unwrap_or(0.0);
ChannelContent::Location { lat, lon }
} else {
ChannelContent::Text(text.to_string())
// Unsupported message type (stickers, polls, etc.)
return None;
};
// Use chat_id as the platform_id (so responses go to the right chat)
// Extract forum topic thread_id (Telegram sends this as `message_thread_id`
// for messages inside forum topics / reply threads).
let thread_id = message["message_thread_id"]
.as_i64()
.map(|tid| tid.to_string());
Some(ChannelMessage {
channel: ChannelType::Telegram,
platform_message_id: message_id.to_string(),
@@ -383,7 +731,7 @@ fn parse_telegram_update(
target_agent: None,
timestamp,
is_group,
thread_id: None,
thread_id,
metadata: HashMap::new(),
})
}
@@ -393,12 +741,73 @@ pub fn calculate_backoff(current: Duration) -> Duration {
(current * 2).min(MAX_BACKOFF)
}
/// Sanitize text for Telegram HTML parse mode.
///
/// Escapes angle brackets that are NOT part of Telegram-allowed HTML tags.
/// Allowed tags: b, i, u, s, tg-spoiler, a, code, pre, blockquote.
/// Everything else (e.g. `<name>`, `<thinking>`) gets escaped to `&lt;...&gt;`.
fn sanitize_telegram_html(text: &str) -> String {
const ALLOWED: &[&str] = &[
"b", "i", "u", "s", "em", "strong", "a", "code", "pre", "blockquote", "tg-spoiler",
"tg-emoji",
];
let mut result = String::with_capacity(text.len());
let mut chars = text.char_indices().peekable();
while let Some(&(i, ch)) = chars.peek() {
if ch == '<' {
// Try to parse an HTML tag
if let Some(end_offset) = text[i..].find('>') {
let tag_end = i + end_offset;
let tag_content = &text[i + 1..tag_end]; // content between < and >
let tag_name = tag_content
.trim_start_matches('/')
.split(|c: char| c.is_whitespace() || c == '/' || c == '>')
.next()
.unwrap_or("")
.to_lowercase();
if !tag_name.is_empty() && ALLOWED.contains(&tag_name.as_str()) {
// Allowed tag — keep as-is
result.push_str(&text[i..tag_end + 1]);
} else {
// Unknown tag — escape both brackets
result.push_str("&lt;");
result.push_str(tag_content);
result.push_str("&gt;");
}
// Advance past the whole tag
while let Some(&(j, _)) = chars.peek() {
chars.next();
if j >= tag_end {
break;
}
}
} else {
// No closing > — escape the lone <
result.push_str("&lt;");
chars.next();
}
} else {
result.push(ch);
chars.next();
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_telegram_update() {
fn test_client() -> reqwest::Client {
reqwest::Client::new()
}
#[tokio::test]
async fn test_parse_telegram_update() {
let update = serde_json::json!({
"update_id": 123456,
"message": {
@@ -417,15 +826,16 @@ mod tests {
}
});
let msg = parse_telegram_update(&update, &[]).unwrap();
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
assert_eq!(msg.channel, ChannelType::Telegram);
assert_eq!(msg.sender.display_name, "Alice Smith");
assert_eq!(msg.sender.platform_id, "111222333");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello, agent!"));
}
#[test]
fn test_parse_telegram_command() {
#[tokio::test]
async fn test_parse_telegram_command() {
let update = serde_json::json!({
"update_id": 123457,
"message": {
@@ -448,7 +858,8 @@ mod tests {
}
});
let msg = parse_telegram_update(&update, &[]).unwrap();
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -458,8 +869,8 @@ mod tests {
}
}
#[test]
fn test_allowed_users_filter() {
#[tokio::test]
async fn test_allowed_users_filter() {
let update = serde_json::json!({
"update_id": 123458,
"message": {
@@ -477,21 +888,25 @@ mod tests {
}
});
let client = test_client();
// Empty allowed_users = allow all
let msg = parse_telegram_update(&update, &[]);
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await;
assert!(msg.is_some());
// Non-matching allowed_users = filter out
let msg = parse_telegram_update(&update, &[111, 222]);
let blocked: Vec<String> = vec!["111".to_string(), "222".to_string()];
let msg = parse_telegram_update(&update, &blocked, "fake:token", &client, DEFAULT_API_URL).await;
assert!(msg.is_none());
// Matching allowed_users = allow
let msg = parse_telegram_update(&update, &[999]);
let allowed: Vec<String> = vec!["999".to_string()];
let msg = parse_telegram_update(&update, &allowed, "fake:token", &client, DEFAULT_API_URL).await;
assert!(msg.is_some());
}
#[test]
fn test_parse_telegram_edited_message() {
#[tokio::test]
async fn test_parse_telegram_edited_message() {
let update = serde_json::json!({
"update_id": 123459,
"edited_message": {
@@ -511,7 +926,8 @@ mod tests {
}
});
let msg = parse_telegram_update(&update, &[]).unwrap();
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
assert_eq!(msg.channel, ChannelType::Telegram);
assert_eq!(msg.sender.display_name, "Alice Smith");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message!"));
@@ -532,8 +948,8 @@ mod tests {
assert_eq!(b4, Duration::from_secs(60)); // stays at cap
}
#[test]
fn test_parse_command_with_botname() {
#[tokio::test]
async fn test_parse_command_with_botname() {
let update = serde_json::json!({
"update_id": 100,
"message": {
@@ -546,7 +962,8 @@ mod tests {
}
});
let msg = parse_telegram_update(&update, &[]).unwrap();
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agents");
@@ -555,4 +972,192 @@ mod tests {
other => panic!("Expected Command, got {other:?}"),
}
}
#[tokio::test]
async fn test_parse_telegram_location() {
let update = serde_json::json!({
"update_id": 200,
"message": {
"message_id": 50,
"from": { "id": 123, "first_name": "Alice" },
"chat": { "id": 123, "type": "private" },
"date": 1700000000,
"location": { "latitude": 51.5074, "longitude": -0.1278 }
}
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
assert!(matches!(msg.content, ChannelContent::Location { .. }));
}
#[tokio::test]
async fn test_parse_telegram_photo_fallback() {
// When getFile fails (fake token), photo messages should fall back to
// a text description rather than being silently dropped.
let update = serde_json::json!({
"update_id": 300,
"message": {
"message_id": 60,
"from": { "id": 123, "first_name": "Alice" },
"chat": { "id": 123, "type": "private" },
"date": 1700000000,
"photo": [
{ "file_id": "small_id", "file_unique_id": "a", "width": 90, "height": 90, "file_size": 1234 },
{ "file_id": "large_id", "file_unique_id": "b", "width": 800, "height": 600, "file_size": 45678 }
],
"caption": "Check this out"
}
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
// With a fake token, getFile will fail, so we get a text fallback
match &msg.content {
ChannelContent::Text(t) => {
assert!(t.contains("Photo received"));
assert!(t.contains("Check this out"));
}
ChannelContent::Image { caption, .. } => {
// If somehow the HTTP call succeeded (unlikely with fake token),
// verify caption was extracted
assert_eq!(caption.as_deref(), Some("Check this out"));
}
other => panic!("Expected Text or Image fallback for photo, got {other:?}"),
}
}
#[tokio::test]
async fn test_parse_telegram_document_fallback() {
let update = serde_json::json!({
"update_id": 301,
"message": {
"message_id": 61,
"from": { "id": 123, "first_name": "Alice" },
"chat": { "id": 123, "type": "private" },
"date": 1700000000,
"document": {
"file_id": "doc_id",
"file_unique_id": "c",
"file_name": "report.pdf",
"file_size": 102400
}
}
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
match &msg.content {
ChannelContent::Text(t) => {
assert!(t.contains("Document received"));
assert!(t.contains("report.pdf"));
}
ChannelContent::File { filename, .. } => {
assert_eq!(filename, "report.pdf");
}
other => panic!("Expected Text or File for document, got {other:?}"),
}
}
#[tokio::test]
async fn test_parse_telegram_voice_fallback() {
let update = serde_json::json!({
"update_id": 302,
"message": {
"message_id": 62,
"from": { "id": 123, "first_name": "Alice" },
"chat": { "id": 123, "type": "private" },
"date": 1700000000,
"voice": {
"file_id": "voice_id",
"file_unique_id": "d",
"duration": 15
}
}
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
match &msg.content {
ChannelContent::Text(t) => {
assert!(t.contains("Voice message"));
assert!(t.contains("15s"));
}
ChannelContent::Voice { duration_seconds, .. } => {
assert_eq!(*duration_seconds, 15);
}
other => panic!("Expected Text or Voice for voice message, got {other:?}"),
}
}
#[tokio::test]
async fn test_parse_telegram_forum_topic_thread_id() {
// Messages inside a Telegram forum topic include `message_thread_id`.
let update = serde_json::json!({
"update_id": 400,
"message": {
"message_id": 70,
"message_thread_id": 42,
"from": { "id": 123, "first_name": "Alice" },
"chat": { "id": -1001234567890_i64, "type": "supergroup" },
"date": 1700000000,
"text": "Hello from a forum topic"
}
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
assert_eq!(msg.thread_id, Some("42".to_string()));
assert!(msg.is_group);
}
#[tokio::test]
async fn test_parse_telegram_no_thread_id_in_private_chat() {
// Private chats should have thread_id = None.
let update = serde_json::json!({
"update_id": 401,
"message": {
"message_id": 71,
"from": { "id": 123, "first_name": "Alice" },
"chat": { "id": 123, "type": "private" },
"date": 1700000000,
"text": "Hello from DM"
}
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
assert_eq!(msg.thread_id, None);
assert!(!msg.is_group);
}
#[tokio::test]
async fn test_parse_telegram_edited_message_in_forum() {
// Edited messages in forum topics should also preserve thread_id.
let update = serde_json::json!({
"update_id": 402,
"edited_message": {
"message_id": 72,
"message_thread_id": 99,
"from": { "id": 123, "first_name": "Alice" },
"chat": { "id": -1001234567890_i64, "type": "supergroup" },
"date": 1700000000,
"edit_date": 1700000060,
"text": "Edited in forum"
}
});
let client = test_client();
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
assert_eq!(msg.thread_id, Some("99".to_string()));
}
#[test]
fn test_sanitize_telegram_html_basic() {
// Allowed tags preserved, unknown tags escaped
let input = "<b>bold</b> <thinking>hmm</thinking>";
let output = sanitize_telegram_html(input);
assert!(output.contains("<b>bold</b>"));
assert!(output.contains("&lt;thinking&gt;"));
}
}
@@ -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
);
+10 -2
View File
@@ -6,9 +6,17 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
/// 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"))
}
/// Return the path to `~/.openfang/.env`.
pub fn env_file_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".openfang").join(".env"))
dotenv_openfang_home().map(|h| h.join(".env"))
}
/// Load `~/.openfang/.env` and `~/.openfang/secrets.env` into `std::env`.
@@ -25,7 +33,7 @@ pub fn load_dotenv() {
/// Return the path to `~/.openfang/secrets.env`.
pub fn secrets_env_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".openfang").join("secrets.env"))
dotenv_openfang_home().map(|h| h.join("secrets.env"))
}
fn load_env_file(path: Option<PathBuf>) {
+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}")))
}
}
}
+10 -3
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);
}
+171 -8
View File
@@ -220,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),
}
}
@@ -267,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"),
);
@@ -294,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();
@@ -364,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),
}
}
+224 -3
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 {
@@ -68,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 {
@@ -75,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 {
@@ -97,6 +118,10 @@ impl ChatState {
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,
}
}
@@ -115,6 +140,9 @@ impl ChatState {
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.
@@ -205,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 {
@@ -361,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) {
@@ -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))
}
@@ -72,7 +72,7 @@ const PROVIDERS: &[ProviderInfo] = &[
name: "openrouter",
display: "OpenRouter",
env_var: "OPENROUTER_API_KEY",
default_model: "openrouter/auto",
default_model: "openrouter/google/gemini-2.5-flash",
needs_key: true,
hint: "",
},
@@ -100,6 +100,102 @@ const PROVIDERS: &[ProviderInfo] = &[
needs_key: true,
hint: "",
},
ProviderInfo {
name: "xai",
display: "xAI (Grok)",
env_var: "XAI_API_KEY",
default_model: "grok-4-0709",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "perplexity",
display: "Perplexity",
env_var: "PERPLEXITY_API_KEY",
default_model: "sonar-pro",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "cohere",
display: "Cohere",
env_var: "COHERE_API_KEY",
default_model: "command-a-03-2025",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "cerebras",
display: "Cerebras",
env_var: "CEREBRAS_API_KEY",
default_model: "llama-4-scout-17b-16e-instruct",
needs_key: true,
hint: "fast inference",
},
ProviderInfo {
name: "sambanova",
display: "SambaNova",
env_var: "SAMBANOVA_API_KEY",
default_model: "DeepSeek-R1",
needs_key: true,
hint: "fast inference",
},
ProviderInfo {
name: "qwen",
display: "Qwen (Alibaba)",
env_var: "QWEN_API_KEY",
default_model: "qwen-plus",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "huggingface",
display: "Hugging Face",
env_var: "HUGGINGFACE_API_KEY",
default_model: "meta-llama/Llama-3.3-70B-Instruct",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "github-copilot",
display: "GitHub Copilot",
env_var: "GITHUB_TOKEN",
default_model: "gpt-4o",
needs_key: true,
hint: "via PAT",
},
ProviderInfo {
name: "replicate",
display: "Replicate",
env_var: "REPLICATE_API_KEY",
default_model: "meta/meta-llama-3-70b-instruct",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "venice",
display: "Venice.ai",
env_var: "VENICE_API_KEY",
default_model: "venice-uncensored",
needs_key: true,
hint: "uncensored",
},
ProviderInfo {
name: "ai21",
display: "AI21",
env_var: "AI21_API_KEY",
default_model: "jamba-1.5-large",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "claude-code",
display: "Claude Code",
env_var: "",
default_model: "claude-code/sonnet",
needs_key: false,
hint: "no API key",
},
ProviderInfo {
name: "ollama",
display: "Ollama",
@@ -116,6 +212,14 @@ const PROVIDERS: &[ProviderInfo] = &[
needs_key: false,
hint: "local",
},
ProviderInfo {
name: "vllm",
display: "vLLM",
env_var: "VLLM_API_KEY",
default_model: "local-model",
needs_key: false,
hint: "local",
},
];
// ── Public result type ─────────────────────────────────────────────────────
@@ -287,15 +391,23 @@ impl State {
self.provider_order.clear();
let gemini_via_google = std::env::var("GOOGLE_API_KEY").is_ok();
for (i, p) in PROVIDERS.iter().enumerate() {
let detected =
std::env::var(p.env_var).is_ok() || (p.name == "gemini" && gemini_via_google);
let detected = if p.name == "claude-code" {
openfang_runtime::drivers::claude_code::claude_code_available()
} else {
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|| (p.name == "gemini" && gemini_via_google)
};
if detected {
self.provider_order.push(i);
}
}
for (i, p) in PROVIDERS.iter().enumerate() {
let detected =
std::env::var(p.env_var).is_ok() || (p.name == "gemini" && gemini_via_google);
let detected = if p.name == "claude-code" {
openfang_runtime::drivers::claude_code::claude_code_available()
} else {
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|| (p.name == "gemini" && gemini_via_google)
};
if !detected {
self.provider_order.push(i);
}
@@ -334,7 +446,10 @@ impl State {
fn is_provider_detected(&self, prov_idx: usize) -> bool {
let p = &PROVIDERS[prov_idx];
std::env::var(p.env_var).is_ok()
if p.name == "claude-code" {
return openfang_runtime::drivers::claude_code::claude_code_available();
}
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|| (p.name == "gemini" && std::env::var("GOOGLE_API_KEY").is_ok())
}
@@ -408,7 +523,7 @@ impl State {
for m in &models {
match m.tier {
ModelTier::Fast | ModelTier::Local => {
ModelTier::Fast | ModelTier::Local | ModelTier::Custom => {
if fast.is_none() {
fast = Some(&m.id);
}
@@ -461,12 +576,20 @@ fn tier_label(tier: ModelTier) -> &'static str {
ModelTier::Balanced => "balanced",
ModelTier::Fast => "fast",
ModelTier::Local => "local",
ModelTier::Custom => "custom",
}
}
// ── Entry point ────────────────────────────────────────────────────────────
pub fn run() -> InitResult {
// Guard against non-TTY environments (Docker, piped, CI/CD)
if !std::io::IsTerminal::is_terminal(&std::io::stdin())
|| !std::io::IsTerminal::is_terminal(&std::io::stdout())
{
return InitResult::Cancelled;
}
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
ratatui::restore();
@@ -824,8 +947,11 @@ fn handle_migration_key(
if yes {
state.migration_phase = MigrationPhase::Running;
let source_dir = state.openclaw_path.clone().unwrap_or_default();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let target_dir = home.join(".openfang");
let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
PathBuf::from(h)
} else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang")
};
let tx = migrate_tx.clone();
std::thread::spawn(move || {
let options = openfang_migrate::MigrateOptions {
@@ -944,15 +1070,17 @@ fn save_config(state: &mut State) {
}
};
let home = match dirs::home_dir() {
Some(h) => h,
None => {
state.save_error = "Could not determine home directory".to_string();
return;
let openfang_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
PathBuf::from(h)
} else {
match dirs::home_dir() {
Some(h) => h.join(".openfang"),
None => {
state.save_error = "Could not determine home directory".to_string();
return;
}
}
};
let openfang_dir = home.join(".openfang");
let _ = std::fs::create_dir_all(openfang_dir.join("agents"));
let _ = std::fs::create_dir_all(openfang_dir.join("data"));
crate::restrict_dir_permissions(&openfang_dir);
@@ -982,6 +1110,12 @@ complex_threshold = 500
};
let config_path = openfang_dir.join("config.toml");
let api_key_line = if p.env_var.is_empty() {
String::new()
} else {
format!("api_key_env = \"{}\"", p.env_var)
};
let config = format!(
r#"# OpenFang Agent OS configuration
# See https://github.com/RightNow-AI/openfang for documentation
@@ -991,13 +1125,12 @@ api_listen = "127.0.0.1:4200"
[default_model]
provider = "{provider}"
model = "{model}"
api_key_env = "{env_var}"
{api_key_line}
[memory]
decay_rate = 0.05
{routing_section}"#,
provider = p.name,
env_var = p.env_var,
);
match std::fs::write(&config_path, &config) {
@@ -1594,7 +1727,13 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
Span::styled(" ", Style::default())
};
let name_span = Span::raw(format!("{:<14}", p.display));
let hint_text = if detected {
let hint_text = if p.name == "claude-code" {
if detected {
"CLI detected".to_string()
} else {
"no API key needed".to_string()
}
} else if detected {
format!("{} detected", p.env_var)
} else if !p.needs_key {
"local, no key needed".to_string()
@@ -1912,11 +2051,7 @@ fn draw_routing_pick(f: &mut Frame, area: Rect, state: &mut State, tier: usize)
.split('/')
.next_back()
.unwrap_or(&state.routing_models[t]);
let display = if short.len() > 14 {
&short[..14]
} else {
short
};
let display = openfang_types::truncate_str(short, 14);
summary_spans.push(Span::styled(
format!("{name}:{display}"),
Style::default().fg(*c),
@@ -317,7 +317,10 @@ fn draw_agent_select(f: &mut Frame, area: Rect, state: &mut MemoryState) {
.iter()
.map(|a| {
let id_short = if a.id.len() > 12 {
format!("{}\u{2026}", &a.id[..12])
format!(
"{}\u{2026}",
openfang_types::truncate_str(&a.id, 12)
)
} else {
a.id.clone()
};
@@ -405,7 +408,10 @@ fn draw_kv_browser(f: &mut Frame, area: Rect, state: &mut MemoryState) {
.iter()
.map(|kv| {
let val_display = if kv.value.len() > 40 {
format!("{}\u{2026}", &kv.value[..39])
format!(
"{}\u{2026}",
openfang_types::truncate_str(&kv.value, 39)
)
} else {
kv.value.clone()
};
@@ -1,6 +1,7 @@
pub mod agents;
pub mod audit;
pub mod channels;
pub mod comms;
pub mod chat;
pub mod dashboard;
pub mod extensions;
+4 -1
View File
@@ -149,7 +149,10 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut PeersState) {
.iter()
.map(|p| {
let id_short = if p.node_id.len() > 12 {
format!("{}\u{2026}", &p.node_id[..12])
format!(
"{}\u{2026}",
openfang_types::truncate_str(&p.node_id, 12)
)
} else {
p.node_id.clone()
};
@@ -251,7 +251,10 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut SessionsState) {
.map(|&idx| {
let s = &state.sessions[idx];
let id_short = if s.id.len() > 12 {
format!("{}\u{2026}", &s.id[..12])
format!(
"{}\u{2026}",
openfang_types::truncate_str(&s.id, 12)
)
} else {
s.id.clone()
};
+107 -17
View File
@@ -40,7 +40,7 @@ const PROVIDERS: &[ProviderInfo] = &[
ProviderInfo {
name: "openrouter",
env_var: "OPENROUTER_API_KEY",
default_model: "anthropic/claude-sonnet-4-20250514",
default_model: "google/gemini-2.5-flash",
needs_key: true,
},
ProviderInfo {
@@ -67,6 +67,72 @@ const PROVIDERS: &[ProviderInfo] = &[
default_model: "accounts/fireworks/models/llama-v3p3-70b-instruct",
needs_key: true,
},
ProviderInfo {
name: "gemini",
env_var: "GEMINI_API_KEY",
default_model: "gemini-2.5-flash",
needs_key: true,
},
ProviderInfo {
name: "xai",
env_var: "XAI_API_KEY",
default_model: "grok-4-0709",
needs_key: true,
},
ProviderInfo {
name: "qwen",
env_var: "DASHSCOPE_API_KEY",
default_model: "qwen-plus",
needs_key: true,
},
ProviderInfo {
name: "perplexity",
env_var: "PERPLEXITY_API_KEY",
default_model: "sonar-pro",
needs_key: true,
},
ProviderInfo {
name: "cohere",
env_var: "CO_API_KEY",
default_model: "command-a",
needs_key: true,
},
ProviderInfo {
name: "cerebras",
env_var: "CEREBRAS_API_KEY",
default_model: "llama-3.3-70b",
needs_key: true,
},
ProviderInfo {
name: "sambanova",
env_var: "SAMBANOVA_API_KEY",
default_model: "Meta-Llama-3.3-70B-Instruct",
needs_key: true,
},
ProviderInfo {
name: "moonshot",
env_var: "MOONSHOT_API_KEY",
default_model: "moonshot-v1-128k",
needs_key: true,
},
ProviderInfo {
name: "zhipu",
env_var: "ZHIPU_API_KEY",
default_model: "glm-4-plus",
needs_key: true,
},
ProviderInfo {
name: "zhipu_coding",
env_var: "ZHIPU_API_KEY",
default_model: "codegeex-4",
needs_key: true,
},
ProviderInfo {
name: "claude-code",
env_var: "",
default_model: "claude-code/sonnet",
needs_key: false,
},
ProviderInfo {
name: "ollama",
env_var: "OLLAMA_API_KEY",
@@ -89,11 +155,15 @@ const PROVIDERS: &[ProviderInfo] = &[
/// Check if first-run setup is needed.
pub fn needs_setup() -> 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()
}
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -151,13 +221,23 @@ impl WizardState {
self.provider_order.clear();
// Detected providers first
for (i, p) in PROVIDERS.iter().enumerate() {
if std::env::var(p.env_var).is_ok() {
let detected = if p.name == "claude-code" {
openfang_runtime::drivers::claude_code::claude_code_available()
} else {
!p.env_var.is_empty() && std::env::var(p.env_var).is_ok()
};
if detected {
self.provider_order.push(i);
}
}
// Then the rest
for (i, p) in PROVIDERS.iter().enumerate() {
if std::env::var(p.env_var).is_err() {
let detected = if p.name == "claude-code" {
openfang_runtime::drivers::claude_code::claude_code_available()
} else {
!p.env_var.is_empty() && std::env::var(p.env_var).is_ok()
};
if !detected {
self.provider_order.push(i);
}
}
@@ -294,22 +374,26 @@ impl WizardState {
}
};
let home = match dirs::home_dir() {
Some(h) => h,
None => {
self.status_msg = "Could not determine home directory".to_string();
self.step = WizardStep::Done;
return;
let openfang_dir = 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 => {
self.status_msg = "Could not determine home directory".to_string();
self.step = WizardStep::Done;
return;
}
}
};
let openfang_dir = home.join(".openfang");
let _ = std::fs::create_dir_all(openfang_dir.join("agents"));
let _ = std::fs::create_dir_all(openfang_dir.join("data"));
crate::restrict_dir_permissions(&openfang_dir);
let api_key_line = if !self.api_key_input.is_empty() {
format!("api_key = \"{}\"", self.api_key_input)
} else if p.env_var.is_empty() {
String::new()
} else {
format!("api_key_env = \"{}\"", p.env_var)
};
@@ -440,9 +524,15 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut WizardState) {
.iter()
.map(|&idx| {
let p = &PROVIDERS[idx];
let hint = if !p.needs_key {
let hint = if p.name == "claude-code" {
if openfang_runtime::drivers::claude_code::claude_code_available() {
"CLI detected".to_string()
} else {
"no API key needed".to_string()
}
} else if !p.needs_key {
"local, no key needed".to_string()
} else if std::env::var(p.env_var).is_ok() {
} else if !p.env_var.is_empty() && std::env::var(p.env_var).is_ok() {
format!("{} detected", p.env_var)
} else {
format!("requires {}", p.env_var)
+1
View File
@@ -34,6 +34,7 @@ pub const PURPLE: Color = Color::Rgb(168, 85, 247); // #A855F7 — decorators
pub const CYAN: Color = BLUE;
pub const DIM: Color = TEXT_SECONDARY;
pub const TEXT: Color = TEXT_PRIMARY;
// ── Reusable styles ─────────────────────────────────────────────────────────
+26 -7
View File
@@ -6,6 +6,7 @@
use openfang_api::server::build_router;
use openfang_kernel::OpenFangKernel;
use std::net::{SocketAddr, TcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::watch;
use tracing::{error, info};
@@ -20,24 +21,40 @@ pub struct ServerHandle {
shutdown_tx: watch::Sender<bool>,
/// Join handle for the background server thread.
server_thread: Option<std::thread::JoinHandle<()>>,
/// Track whether shutdown has already been initiated to prevent double shutdown.
shutdown_initiated: Arc<AtomicBool>,
}
impl ServerHandle {
/// Signal the server to shut down and wait for the background thread.
pub fn shutdown(mut self) {
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.server_thread.take() {
let _ = handle.join();
// Only proceed if shutdown hasn't been initiated yet
if self
.shutdown_initiated
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
{
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.server_thread.take() {
let _ = handle.join();
}
self.kernel.shutdown();
info!("OpenFang embedded server stopped");
}
self.kernel.shutdown();
info!("OpenFang embedded server stopped");
}
}
impl Drop for ServerHandle {
fn drop(&mut self) {
let _ = self.shutdown_tx.send(true);
// Best-effort: don't block in drop, the thread will exit on its own.
// Only send shutdown signal if it hasn't been initiated yet
if self
.shutdown_initiated
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
{
let _ = self.shutdown_tx.send(true);
// Best-effort: don't block in drop, the thread will exit on its own.
}
}
}
@@ -61,6 +78,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let kernel_clone = kernel.clone();
let shutdown_initiated = Arc::new(AtomicBool::new(false));
let server_thread = std::thread::Builder::new()
.name("openfang-server".into())
@@ -83,6 +101,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
kernel,
shutdown_tx,
server_thread: Some(server_thread),
shutdown_initiated,
})
}
@@ -8,7 +8,7 @@ tags = ["cloud", "amazon", "infrastructure", "s3", "ec2", "lambda", "devops"]
[transport]
type = "stdio"
command = "npx"
args = ["@modelcontextprotocol/server-aws"]
args = ["-y", "@aws-mcp/server-aws"]
[[required_env]]
name = "AWS_ACCESS_KEY_ID"
@@ -8,7 +8,7 @@ tags = ["cloud", "microsoft", "infrastructure", "azure", "devops", "enterprise"]
[transport]
type = "stdio"
command = "npx"
args = ["@modelcontextprotocol/server-azure"]
args = ["-y", "@azure/mcp@latest", "server", "start"]
[[required_env]]
name = "AZURE_SUBSCRIPTION_ID"
@@ -8,7 +8,7 @@ tags = ["git", "vcs", "code", "pull-requests", "ci", "atlassian"]
[transport]
type = "stdio"
command = "npx"
args = ["@modelcontextprotocol/server-bitbucket"]
args = ["-y", "@atlassian-mcp-server/bitbucket"]
[[required_env]]
name = "BITBUCKET_USERNAME"
@@ -8,7 +8,7 @@ tags = ["search", "web", "brave", "api", "information-retrieval"]
[transport]
type = "stdio"
command = "npx"
args = ["@modelcontextprotocol/server-brave-search"]
args = ["-y", "@modelcontextprotocol/server-brave-search"]
[[required_env]]
name = "BRAVE_API_KEY"
@@ -8,7 +8,7 @@ tags = ["chat", "messaging", "community", "gaming", "voice"]
[transport]
type = "stdio"
command = "npx"
args = ["@modelcontextprotocol/server-discord"]
args = ["-y", "mcp-discord"]
[[required_env]]
name = "DISCORD_BOT_TOKEN"

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