mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52bacf0946 | ||
|
|
d55e1b8545 | ||
|
|
0c059d1dc1 | ||
|
|
b6b8b4ebe1 | ||
|
|
14f4845170 | ||
|
|
be8a589986 | ||
|
|
951e8d0feb | ||
|
|
98f8d1ca79 | ||
|
|
24f5717ae9 | ||
|
|
f10eefdc0e | ||
|
|
edd0fed518 | ||
|
|
86b50070e8 | ||
|
|
c6aab08faa | ||
|
|
62ec09d0ae | ||
|
|
f6f9cf7e9f | ||
|
|
56aeb499c9 | ||
|
|
b4e6a693f5 | ||
|
|
48d5418c91 | ||
|
|
cc93ef4571 | ||
|
|
3e069798f9 | ||
|
|
ad10aa5e80 | ||
|
|
385aee8e56 | ||
|
|
a00327abe9 | ||
|
|
487555a5e5 | ||
|
|
9d51426cb4 | ||
|
|
6fab720843 | ||
|
|
4667f497ef | ||
|
|
eba9198827 | ||
|
|
f2413949bc | ||
|
|
9e230f423e | ||
|
|
8138b7e0e8 | ||
|
|
cfae867908 | ||
|
|
6857e3cf06 | ||
|
|
772cbdbe38 | ||
|
|
b2e2b1a038 | ||
|
|
d237ecf161 | ||
|
|
4a3d570155 | ||
|
|
ebcdc17c13 | ||
|
|
45e06b9bad | ||
|
|
c6b46ccbe1 | ||
|
|
9fc0fe71bf | ||
|
|
06df0795c8 | ||
|
|
eafeb6a012 | ||
|
|
05431509be | ||
|
|
9d3136e512 | ||
|
|
60566f22fb | ||
|
|
50440e4047 | ||
|
|
f45268aedc | ||
|
|
cc54e14114 | ||
|
|
1037ef768d | ||
|
|
c3dcf02e3c | ||
|
|
b157e3c7e6 | ||
|
|
74ac992420 | ||
|
|
53e1b31777 | ||
|
|
603a94e560 | ||
|
|
fac4ad33e5 | ||
|
|
fe96cd1004 | ||
|
|
7c85308cf6 | ||
|
|
a4a83b1699 | ||
|
|
8942d8c2b6 | ||
|
|
260dd7a125 | ||
|
|
294f0e7af8 | ||
|
|
444d82e4d6 | ||
|
|
62e6e0f088 | ||
|
|
d3385f2cdc | ||
|
|
516f163dfb | ||
|
|
a54bb1cd4f | ||
|
|
6f3c4e7778 | ||
|
|
73ad49a3a1 | ||
|
|
7ec0e024c7 | ||
|
|
7c81c187c4 | ||
|
|
7ae80b1b9f | ||
|
|
e58ae3e304 | ||
|
|
74f5a91fdd | ||
|
|
6de0447e8c | ||
|
|
45dbf617a7 | ||
|
|
b416bf417f | ||
|
|
1cd47d3905 | ||
|
|
a9124c34f2 | ||
|
|
7bd0185695 | ||
|
|
9fc7ed87be | ||
|
|
0bb08f4ae1 | ||
|
|
51c1e154d9 | ||
|
|
036cc14ad5 | ||
|
|
b866ae7055 | ||
|
|
15a859b7f9 | ||
|
|
eda88ba5a2 | ||
|
|
0bb4e6f17b | ||
|
|
a1d6776989 | ||
|
|
ff408248d1 | ||
|
|
94f8c2d60d | ||
|
|
9728eb1118 | ||
|
|
a8fae5f711 |
@@ -0,0 +1 @@
|
||||
github: RightNow-AI
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What happened?
|
||||
placeholder: Describe the bug clearly and concisely.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this?
|
||||
placeholder: |
|
||||
1. Run `openfang start`
|
||||
2. Open dashboard at http://localhost:4200
|
||||
3. Click ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OpenFang Version
|
||||
description: Output of `openfang -V`
|
||||
placeholder: "0.3.23"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- Linux (x86_64)
|
||||
- Linux (aarch64/ARM64)
|
||||
- macOS (Apple Silicon)
|
||||
- macOS (Intel)
|
||||
- Windows
|
||||
- Android (Termux)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Screenshots
|
||||
description: Paste relevant logs or attach screenshots.
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What feature would you like?
|
||||
placeholder: Describe the feature and why it would be useful.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you tried any workarounds?
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context, screenshots, or references.
|
||||
@@ -0,0 +1,17 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
labels:
|
||||
- "ci"
|
||||
@@ -0,0 +1,19 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? Link related issues with "Fixes #123". -->
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- Brief list of what changed. -->
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes
|
||||
- [ ] `cargo test --workspace` passes
|
||||
- [ ] Live integration tested (if applicable)
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] No new unsafe code
|
||||
- [ ] No secrets or API keys in diff
|
||||
- [ ] User input validated at boundaries
|
||||
@@ -42,6 +42,11 @@ jobs:
|
||||
args: "--target x86_64-pc-windows-msvc"
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Windows ARM64
|
||||
os: windows-latest
|
||||
args: "--target aarch64-pc-windows-msvc"
|
||||
rust_target: aarch64-pc-windows-msvc
|
||||
|
||||
runs-on: ${{ matrix.platform.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -138,10 +143,10 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
os: ubuntu-22.04
|
||||
archive: tar.gz
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
os: ubuntu-22.04
|
||||
archive: tar.gz
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
@@ -152,6 +157,9 @@ jobs:
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
archive: zip
|
||||
- target: aarch64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
archive: zip
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -173,6 +181,11 @@ jobs:
|
||||
- name: Build CLI
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: cargo build --release --target ${{ matrix.target }} --bin openfang
|
||||
- name: Ad-hoc codesign CLI binary (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
xattr -cr target/${{ matrix.target }}/release/openfang || true
|
||||
codesign --force --sign - target/${{ matrix.target }}/release/openfang
|
||||
- name: Package (Unix)
|
||||
if: matrix.archive == 'tar.gz'
|
||||
run: |
|
||||
|
||||
Generated
+579
-341
File diff suppressed because it is too large
Load Diff
+19
-3
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.4.1"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -49,6 +49,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
|
||||
# IDs
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
@@ -61,7 +62,7 @@ clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
|
||||
# HTTP client (for LLM drivers)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
|
||||
|
||||
# Async trait
|
||||
async-trait = "0.1"
|
||||
@@ -76,7 +77,7 @@ bytes = "1"
|
||||
futures = "0.3"
|
||||
|
||||
# WebSocket client (for Discord/Slack gateway)
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
|
||||
url = "2"
|
||||
|
||||
# WASM sandbox
|
||||
@@ -119,9 +120,24 @@ colored = "3"
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
|
||||
# HTML entity decoding
|
||||
html-escape = "0.2"
|
||||
|
||||
# Lightweight regex
|
||||
regex-lite = "0.1"
|
||||
|
||||
# Socket options (SO_REUSEADDR)
|
||||
socket2 = "0.5"
|
||||
|
||||
# Zip archive extraction
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Email (SMTP + IMAP)
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
|
||||
imap = "2"
|
||||
native-tls = "0.2"
|
||||
mailparse = "0.15"
|
||||
|
||||
# Testing
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<p align="center">
|
||||
Open-source Agent OS built in Rust. 137K LOC. 14 crates. 1,767+ tests. Zero clippy warnings.<br/>
|
||||
<strong>One binary. Production-grade. Agents that actually work for you.</strong>
|
||||
<strong>One binary. Battle-tested. Agents that actually work for you.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -19,22 +19,23 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
|
||||
<img src="https://img.shields.io/badge/version-0.1.0-green?style=flat-square" alt="v0.1.0" />
|
||||
<img src="https://img.shields.io/badge/version-0.3.30-green?style=flat-square" alt="v0.3.30" />
|
||||
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> **v0.1.0 — First Release (February 2026)**
|
||||
> **v0.3.30 — Security Hardening Release (March 2026)**
|
||||
>
|
||||
> OpenFang is feature-complete but this is the first public release. You may encounter instability, rough edges, or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
> OpenFang is feature-complete but still pre-1.0. You may encounter rough edges or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
|
||||
---
|
||||
|
||||
## What is OpenFang?
|
||||
|
||||
OpenFang is a **production-grade Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust.
|
||||
OpenFang is an **open-source Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust.
|
||||
|
||||
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you** — on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
|
||||
|
||||
@@ -370,7 +371,7 @@ cargo fmt --all -- --check
|
||||
|
||||
## Stability Notice
|
||||
|
||||
OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is production-grade. That said:
|
||||
OpenFang v0.3.30 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
|
||||
- **Breaking changes** may occur between minor versions until v1.0
|
||||
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
|
||||
@@ -381,6 +382,12 @@ We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
To report a security vulnerability, email **jaber@rightnowai.co**. We take all reports seriously and will respond within 48 hours.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — use it however you want.
|
||||
@@ -392,11 +399,31 @@ MIT — use it however you want.
|
||||
- [Website & Documentation](https://openfang.sh)
|
||||
- [Quick Start Guide](https://openfang.sh/docs/getting-started)
|
||||
- [GitHub](https://github.com/RightNow-AI/openfang)
|
||||
- [Discord](https://discord.gg/sSJqgNnq6X)
|
||||
- [Twitter / X](https://x.com/openfangg)
|
||||
|
||||
---
|
||||
|
||||
## Built by RightNow
|
||||
|
||||
<p align="center">
|
||||
<strong>Built with Rust. Secured with 16 layers. Agents that actually work for you.</strong><br/>
|
||||
<sub>OpenFang is developed by <a href="https://github.com/RightNow-AI">RightNow AI</a></sub>
|
||||
<a href="https://www.rightnowai.co/">
|
||||
<img src="public/assets/rightnow-logo.webp" width="60" alt="RightNow Logo" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
OpenFang is built and maintained by <a href="https://x.com/Akashi203"><strong>Jaber</strong></a>, Founder of <a href="https://www.rightnowai.co/"><strong>RightNow</strong></a>.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.rightnowai.co/">Website</a> •
|
||||
<a href="https://x.com/Akashi203">Twitter / X</a> •
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>Built with Rust. Secured with 16 layers. Agents that actually work for you.</strong>
|
||||
</p>
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -33,9 +33,13 @@ governor = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -56,6 +56,8 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use openfang_runtime::str_utils::safe_truncate_str;
|
||||
|
||||
/// Wraps `OpenFangKernel` to implement `ChannelBridgeHandle`.
|
||||
pub struct KernelBridgeAdapter {
|
||||
kernel: Arc<OpenFangKernel>,
|
||||
@@ -73,6 +75,33 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
async fn send_message_with_blocks(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
blocks: Vec<openfang_types::message::ContentBlock>,
|
||||
) -> Result<String, String> {
|
||||
// Extract text for the message parameter (used for memory recall / logging)
|
||||
let text: String = blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
openfang_types::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let text = if text.is_empty() {
|
||||
"[Image]".to_string()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let result = self
|
||||
.kernel
|
||||
.send_message_with_blocks(agent_id, &text, blocks)
|
||||
.await
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(result.response)
|
||||
}
|
||||
|
||||
async fn find_agent_by_name(&self, name: &str) -> Result<Option<AgentId>, String> {
|
||||
Ok(self.kernel.registry.find_by_name(name).map(|e| e.id))
|
||||
}
|
||||
@@ -351,7 +380,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.map(|e| e.name.clone())
|
||||
.unwrap_or_else(|| t.agent_id.to_string());
|
||||
let status = if t.enabled { "on" } else { "off" };
|
||||
let id_short = &t.id.0.to_string()[..8];
|
||||
let id_str = t.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
msg.push_str(&format!(
|
||||
" [{}] {} -> {} ({:?}) fires:{} [{}]\n",
|
||||
id_short,
|
||||
@@ -390,7 +420,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.kernel
|
||||
.triggers
|
||||
.register(agent.id, pattern, prompt.to_string(), 0);
|
||||
let id_short = &trigger_id.0.to_string()[..8];
|
||||
let id_str = trigger_id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Trigger created [{id_short}] for agent '{agent_name}'.")
|
||||
}
|
||||
|
||||
@@ -405,7 +436,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
1 => {
|
||||
let t = matched[0];
|
||||
if self.kernel.triggers.remove(t.id) {
|
||||
format!("Trigger [{}] removed.", &t.id.0.to_string()[..8])
|
||||
let id_str = t.id.0.to_string();
|
||||
format!("Trigger [{}] removed.", safe_truncate_str(&id_str, 8))
|
||||
} else {
|
||||
"Failed to remove trigger.".to_string()
|
||||
}
|
||||
@@ -428,7 +460,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
.map(|e| e.name.clone())
|
||||
.unwrap_or_else(|| job.agent_id.to_string());
|
||||
let status = if job.enabled { "on" } else { "off" };
|
||||
let id_short = &job.id.0.to_string()[..8];
|
||||
let id_str = job.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
let sched = match &job.schedule {
|
||||
openfang_types::scheduler::CronSchedule::Cron { expr, .. } => expr.clone(),
|
||||
openfang_types::scheduler::CronSchedule::Every { every_secs } => {
|
||||
@@ -450,6 +483,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
msg
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn manage_schedule_text(&self, action: &str, args: &[String]) -> String {
|
||||
match action {
|
||||
"add" => {
|
||||
@@ -488,7 +522,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
|
||||
match self.kernel.cron_scheduler.add_job(job, false) {
|
||||
Ok(id) => {
|
||||
let id_short = &id.0.to_string()[..8];
|
||||
let id_str = id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Job [{id_short}] created: '{cron_expr}' -> {agent_name}: \"{message}\"")
|
||||
}
|
||||
Err(e) => format!("Failed to create job: {e}"),
|
||||
@@ -510,7 +545,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
let j = matched[0];
|
||||
match self.kernel.cron_scheduler.remove_job(j.id) {
|
||||
Ok(_) => {
|
||||
format!("Job [{}] '{}' removed.", &j.id.0.to_string()[..8], j.name)
|
||||
let id_str = j.id.0.to_string();
|
||||
format!("Job [{}] '{}' removed.", safe_truncate_str(&id_str, 8), j.name)
|
||||
}
|
||||
Err(e) => format!("Failed to remove job: {e}"),
|
||||
}
|
||||
@@ -539,10 +575,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
openfang_types::scheduler::CronAction::SystemEvent { text } => {
|
||||
text.clone()
|
||||
}
|
||||
openfang_types::scheduler::CronAction::WorkflowRun {
|
||||
workflow_id,
|
||||
input,
|
||||
..
|
||||
} => {
|
||||
format!(
|
||||
"Run workflow {workflow_id}{}",
|
||||
input
|
||||
.as_deref()
|
||||
.map(|i| format!(" with input: {i}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
};
|
||||
match self.kernel.send_message(j.agent_id, &message).await {
|
||||
Ok(result) => {
|
||||
let id_short = &j.id.0.to_string()[..8];
|
||||
let id_str = j.id.0.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
format!("Job [{id_short}] ran:\n{}", result.response)
|
||||
}
|
||||
Err(e) => format!("Failed to run job: {e}"),
|
||||
@@ -562,7 +612,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
}
|
||||
let mut msg = format!("Pending approvals ({}):\n", pending.len());
|
||||
for req in &pending {
|
||||
let id_short = &req.id.to_string()[..8];
|
||||
let id_str = req.id.to_string();
|
||||
let id_short = safe_truncate_str(&id_str, 8);
|
||||
let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds();
|
||||
let age = if age_secs >= 60 {
|
||||
format!("{}m", age_secs / 60)
|
||||
@@ -603,10 +654,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
) {
|
||||
Ok(_) => {
|
||||
let verb = if approve { "Approved" } else { "Rejected" };
|
||||
let id_str = req.id.to_string();
|
||||
format!(
|
||||
"{} [{}] {} — {}",
|
||||
verb,
|
||||
&req.id.to_string()[..8],
|
||||
safe_truncate_str(&id_str, 8),
|
||||
req.tool_name,
|
||||
req.agent_id
|
||||
)
|
||||
@@ -646,9 +698,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
));
|
||||
}
|
||||
self.kernel
|
||||
.set_agent_model(agent_id, model)
|
||||
.set_agent_model(agent_id, model, None)
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(format!("Model switched to: {model}"))
|
||||
// Read back resolved model+provider from registry
|
||||
let entry = self
|
||||
.kernel
|
||||
.registry
|
||||
.get(agent_id)
|
||||
.ok_or_else(|| "Agent not found after model switch".to_string())?;
|
||||
Ok(format!(
|
||||
"Model switched to: {} (provider: {})",
|
||||
entry.manifest.model.model, entry.manifest.model.provider
|
||||
))
|
||||
}
|
||||
|
||||
async fn stop_run(&self, agent_id: AgentId) -> Result<String, String> {
|
||||
@@ -774,6 +835,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
recipient: &str,
|
||||
success: bool,
|
||||
error: Option<&str>,
|
||||
thread_id: Option<&str>,
|
||||
) {
|
||||
let receipt = if success {
|
||||
openfang_kernel::DeliveryTracker::sent_receipt(channel, recipient)
|
||||
@@ -785,6 +847,19 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
)
|
||||
};
|
||||
self.kernel.delivery_tracker.record(agent_id, receipt);
|
||||
|
||||
// Persist last channel for cron CronDelivery::LastChannel.
|
||||
// Include thread_id when present so forum-topic context survives restarts.
|
||||
if success {
|
||||
let mut kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
|
||||
if let Some(tid) = thread_id {
|
||||
kv_val["thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let _ = self
|
||||
.kernel
|
||||
.memory
|
||||
.structured_set(agent_id, "delivery.last_channel", kv_val);
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_auto_reply(&self, agent_id: AgentId, message: &str) -> Option<String> {
|
||||
@@ -884,11 +959,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
msg.push_str(&format!(" {} — {}\n", card.name, url));
|
||||
let desc = &card.description;
|
||||
if !desc.is_empty() {
|
||||
let short = if desc.len() > 60 {
|
||||
&desc[..60]
|
||||
} else {
|
||||
desc.as_str()
|
||||
};
|
||||
let short = openfang_types::truncate_str(desc, 60);
|
||||
msg.push_str(&format!(" {short}\n"));
|
||||
}
|
||||
}
|
||||
@@ -929,40 +1000,40 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a default agent by name — find running or spawn from manifest.
|
||||
async fn resolve_default_agent(
|
||||
handle: &KernelBridgeAdapter,
|
||||
name: &str,
|
||||
router: &mut AgentRouter,
|
||||
adapter_name: &str,
|
||||
) {
|
||||
match handle.find_agent_by_name(name).await {
|
||||
Ok(Some(agent_id)) => {
|
||||
router.set_default(agent_id);
|
||||
info!("{adapter_name} default agent: {name} ({agent_id})");
|
||||
}
|
||||
_ => match handle.spawn_agent_by_name(name).await {
|
||||
Ok(agent_id) => {
|
||||
router.set_default(agent_id);
|
||||
info!("{adapter_name}: spawned default agent {name} ({agent_id})");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{adapter_name}: could not find or spawn default agent '{name}': {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Resolve a token: if the value looks like an actual secret (contains `:`,
|
||||
/// starts with `xoxb-`, `xapp-`, `sk-`, etc.), use it directly.
|
||||
/// Otherwise treat it as an env var name and look it up.
|
||||
fn read_token(env_var_or_token: &str, adapter_name: &str) -> Option<String> {
|
||||
// Heuristic: actual tokens contain `:` (Telegram, Discord) or start with
|
||||
// known prefixes. Env var names are uppercase ASCII identifiers.
|
||||
let looks_like_token = env_var_or_token.contains(':')
|
||||
|| env_var_or_token.starts_with("xoxb-")
|
||||
|| env_var_or_token.starts_with("xapp-")
|
||||
|| env_var_or_token.starts_with("sk-")
|
||||
|| env_var_or_token.starts_with("Bearer ");
|
||||
|
||||
/// Read a token from an env var, returning None with a warning if missing/empty.
|
||||
fn read_token(env_var: &str, adapter_name: &str) -> Option<String> {
|
||||
match std::env::var(env_var) {
|
||||
if looks_like_token {
|
||||
warn!(
|
||||
"{adapter_name}: config field contains what looks like an actual token \
|
||||
rather than an env var name — using it directly. \
|
||||
Tip: store the token in an env var and use the var name instead for security."
|
||||
);
|
||||
return Some(env_var_or_token.to_string());
|
||||
}
|
||||
|
||||
match std::env::var(env_var_or_token) {
|
||||
Ok(t) if !t.is_empty() => Some(t),
|
||||
Ok(_) => {
|
||||
warn!("{adapter_name} bot token env var '{env_var}' is empty, skipping");
|
||||
warn!(
|
||||
"{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping"
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("{adapter_name} bot token env var '{env_var}' not set, skipping");
|
||||
warn!(
|
||||
"{adapter_name} token env var '{env_var_or_token}' not set, skipping. \
|
||||
Set it with: export {env_var_or_token}=<your-token>"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1049,6 +1120,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
token,
|
||||
tg_config.allowed_users.clone(),
|
||||
poll_interval,
|
||||
tg_config.api_url.clone(),
|
||||
));
|
||||
adapters.push((adapter, tg_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1060,6 +1132,8 @@ pub async fn start_channel_bridge_with_config(
|
||||
let adapter = Arc::new(DiscordAdapter::new(
|
||||
token,
|
||||
dc_config.allowed_guilds.clone(),
|
||||
dc_config.allowed_users.clone(),
|
||||
dc_config.ignore_bots,
|
||||
dc_config.intents,
|
||||
));
|
||||
adapters.push((adapter, dc_config.default_agent.clone()));
|
||||
@@ -1074,6 +1148,8 @@ pub async fn start_channel_bridge_with_config(
|
||||
app_token,
|
||||
bot_token,
|
||||
sl_config.allowed_channels.clone(),
|
||||
sl_config.auto_thread_reply,
|
||||
sl_config.thread_ttl_hours,
|
||||
));
|
||||
adapters.push((adapter, sl_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1540,12 +1616,40 @@ pub async fn start_channel_bridge_with_config(
|
||||
return (None, Vec::new());
|
||||
}
|
||||
|
||||
// Resolve default agent from first adapter that has one configured
|
||||
// Resolve per-channel default agents AND set the first one as system-wide fallback
|
||||
let mut router = AgentRouter::new();
|
||||
for (_, default_agent) in &adapters {
|
||||
let mut system_default_set = false;
|
||||
for (adapter, default_agent) in &adapters {
|
||||
if let Some(ref name) = default_agent {
|
||||
resolve_default_agent(&handle, name, &mut router, "Channel bridge").await;
|
||||
break; // Only need one default
|
||||
// Resolve agent name to ID
|
||||
let agent_id = match handle.find_agent_by_name(name).await {
|
||||
Ok(Some(id)) => Some(id),
|
||||
_ => match handle.spawn_agent_by_name(name).await {
|
||||
Ok(id) => Some(id),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"{}: could not find or spawn default agent '{}': {e}",
|
||||
adapter.name(),
|
||||
name
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Some(agent_id) = agent_id {
|
||||
// Register per-channel default
|
||||
let channel_key = format!("{:?}", adapter.channel_type());
|
||||
info!(
|
||||
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
|
||||
adapter.name()
|
||||
);
|
||||
router.set_channel_default(channel_key, agent_id);
|
||||
// First configured default also becomes system-wide fallback
|
||||
if !system_default_set {
|
||||
router.set_default(agent_id);
|
||||
system_default_set = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1571,12 +1675,18 @@ pub async fn start_channel_bridge_with_config(
|
||||
let mut started_names = Vec::new();
|
||||
for (adapter, _) in adapters {
|
||||
let name = adapter.name().to_string();
|
||||
// Register adapter in kernel so agents can use `channel_send` tool
|
||||
kernel
|
||||
.channel_adapters
|
||||
.insert(name.clone(), adapter.clone());
|
||||
match manager.start_adapter(adapter).await {
|
||||
Ok(()) => {
|
||||
info!("{name} channel bridge started");
|
||||
started_names.push(name);
|
||||
}
|
||||
Err(e) => {
|
||||
// Remove from kernel map if start failed
|
||||
kernel.channel_adapters.remove(&name);
|
||||
error!("Failed to start {name} bridge: {e}");
|
||||
}
|
||||
}
|
||||
@@ -1605,6 +1715,35 @@ pub async fn reload_channels_from_disk(
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
// Re-read secrets.env so new API tokens are available in std::env
|
||||
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
|
||||
if secrets_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&secrets_path) {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(eq_pos) = trimmed.find('=') {
|
||||
let key = trimmed[..eq_pos].trim();
|
||||
let mut value = trimmed[eq_pos + 1..].trim().to_string();
|
||||
if !key.is_empty() {
|
||||
// Strip matching quotes
|
||||
if ((value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\'')))
|
||||
&& value.len() >= 2
|
||||
{
|
||||
value = value[1..value.len() - 1].to_string();
|
||||
}
|
||||
// Always overwrite — the file is the source of truth after dashboard edits
|
||||
std::env::set_var(key, &value);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("Reloaded secrets.env for channel hot-reload");
|
||||
}
|
||||
}
|
||||
|
||||
// Re-read config from disk
|
||||
let config_path = state.kernel.config.home_dir.join("config.toml");
|
||||
let fresh_config = openfang_kernel::config::load_config(Some(&config_path));
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod openai_compat;
|
||||
pub mod rate_limiter;
|
||||
pub mod routes;
|
||||
pub mod server;
|
||||
pub mod session_auth;
|
||||
pub mod stream_chunker;
|
||||
pub mod stream_dedup;
|
||||
pub mod types;
|
||||
|
||||
@@ -43,66 +43,121 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
|
||||
response
|
||||
}
|
||||
|
||||
/// Authentication state passed to the auth middleware.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub api_key: String,
|
||||
pub auth_enabled: bool,
|
||||
pub session_secret: String,
|
||||
}
|
||||
|
||||
/// Bearer token authentication middleware.
|
||||
///
|
||||
/// When `api_key` is non-empty, all requests must include
|
||||
/// `Authorization: Bearer <api_key>`. If the key is empty, auth is bypassed.
|
||||
/// When `api_key` is non-empty (after trimming), requests to non-public
|
||||
/// endpoints must include `Authorization: Bearer <api_key>`.
|
||||
/// If the key is empty or whitespace-only, auth is disabled entirely
|
||||
/// (public/local development mode).
|
||||
///
|
||||
/// When dashboard auth is enabled, session cookies are also accepted.
|
||||
pub async fn auth(
|
||||
axum::extract::State(api_key): axum::extract::State<String>,
|
||||
axum::extract::State(auth_state): axum::extract::State<AuthState>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
// If no API key configured, restrict to loopback addresses only.
|
||||
if api_key.is_empty() {
|
||||
// SECURITY: Capture method early for method-aware public endpoint checks.
|
||||
let method = request.method().clone();
|
||||
|
||||
// Shutdown is loopback-only (CLI on same machine) — skip token auth
|
||||
let path = request.uri().path();
|
||||
if path == "/api/shutdown" {
|
||||
let is_loopback = request
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_loopback {
|
||||
tracing::warn!(
|
||||
"Rejected non-localhost request: no API key configured. \
|
||||
Set api_key in config.toml for remote access."
|
||||
);
|
||||
return Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"error": "No API key configured. Remote access denied. Configure api_key in ~/.openfang/config.toml"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap_or_default();
|
||||
.unwrap_or(false); // SECURITY: default-deny — unknown origin is NOT loopback
|
||||
if is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth (dashboard needs these)
|
||||
let path = request.uri().path();
|
||||
if path == "/"
|
||||
// Public endpoints that don't require auth (dashboard needs these).
|
||||
// SECURITY: /api/agents is GET-only (listing). POST (spawn) requires auth.
|
||||
// SECURITY: Public endpoints are GET-only unless explicitly noted.
|
||||
// POST/PUT/DELETE to any endpoint ALWAYS requires auth to prevent
|
||||
// unauthenticated writes (cron job creation, skill install, etc.).
|
||||
let is_get = method == axum::http::Method::GET;
|
||||
let is_public = path == "/"
|
||||
|| path == "/logo.png"
|
||||
|| path == "/favicon.ico"
|
||||
|| (path == "/.well-known/agent.json" && is_get)
|
||||
|| (path.starts_with("/a2a/") && is_get)
|
||||
|| path == "/api/health"
|
||||
|| path == "/api/health/detail"
|
||||
|| path == "/api/status"
|
||||
|| path == "/api/version"
|
||||
|| path == "/api/agents"
|
||||
|| path == "/api/profiles"
|
||||
|| path == "/api/config"
|
||||
|| path.starts_with("/api/uploads/")
|
||||
{
|
||||
|| (path == "/api/agents" && is_get)
|
||||
|| (path == "/api/profiles" && is_get)
|
||||
|| (path == "/api/config" && is_get)
|
||||
|| (path == "/api/config/schema" && is_get)
|
||||
|| (path.starts_with("/api/uploads/") && is_get)
|
||||
// Dashboard read endpoints — allow unauthenticated so the SPA can
|
||||
// render before the user enters their API key.
|
||||
|| (path == "/api/models" && is_get)
|
||||
|| (path == "/api/models/aliases" && is_get)
|
||||
|| (path == "/api/providers" && is_get)
|
||||
|| (path == "/api/budget" && is_get)
|
||||
|| (path == "/api/budget/agents" && is_get)
|
||||
|| (path.starts_with("/api/budget/agents/") && is_get)
|
||||
|| (path == "/api/network/status" && is_get)
|
||||
|| (path == "/api/a2a/agents" && is_get)
|
||||
|| (path == "/api/approvals" && is_get)
|
||||
|| (path.starts_with("/api/approvals/") && is_get)
|
||||
|| (path == "/api/channels" && is_get)
|
||||
|| (path == "/api/hands" && is_get)
|
||||
|| (path == "/api/hands/active" && is_get)
|
||||
|| (path.starts_with("/api/hands/") && is_get)
|
||||
|| (path == "/api/skills" && is_get)
|
||||
|| (path == "/api/sessions" && is_get)
|
||||
|| (path == "/api/integrations" && is_get)
|
||||
|| (path == "/api/integrations/available" && is_get)
|
||||
|| (path == "/api/integrations/health" && is_get)
|
||||
|| (path == "/api/workflows" && is_get)
|
||||
|| path == "/api/logs/stream" // SSE stream, read-only
|
||||
|| (path.starts_with("/api/cron/") && is_get)
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/")
|
||||
|| path == "/api/auth/login"
|
||||
|| path == "/api/auth/logout"
|
||||
|| (path == "/api/auth/check" && is_get);
|
||||
|
||||
if is_public {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Check Authorization: Bearer <token> header
|
||||
// If no API key configured (empty, whitespace-only, or missing), skip auth
|
||||
// entirely. Users who don't set api_key accept that all endpoints are open.
|
||||
// To secure the dashboard, set a non-empty api_key in config.toml.
|
||||
let api_key_trimmed = auth_state.api_key.trim().to_string();
|
||||
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let api_key = api_key_trimmed.as_str();
|
||||
|
||||
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
|
||||
let bearer_token = request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "));
|
||||
|
||||
let api_token = bearer_token.or_else(|| {
|
||||
request
|
||||
.headers()
|
||||
.get("x-api-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
});
|
||||
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks.
|
||||
let header_auth = bearer_token.map(|token| {
|
||||
let header_auth = api_token.map(|token| {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != api_key.len() {
|
||||
return false;
|
||||
@@ -131,6 +186,17 @@ pub async fn auth(
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Check session cookie (dashboard login sessions)
|
||||
if auth_state.auth_enabled {
|
||||
if let Some(token) = extract_session_cookie(&request) {
|
||||
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
|
||||
.is_some()
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine error message: was a credential provided but wrong, or missing entirely?
|
||||
let credential_provided = header_auth.is_some() || query_auth.is_some();
|
||||
let error_msg = if credential_provided {
|
||||
@@ -148,6 +214,19 @@ pub async fn auth(
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Extract the `openfang_session` cookie value from a request.
|
||||
fn extract_session_cookie(request: &Request<Body>) -> Option<String> {
|
||||
request
|
||||
.headers()
|
||||
.get("cookie")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|cookies| {
|
||||
cookies
|
||||
.split(';')
|
||||
.find_map(|c| c.trim().strip_prefix("openfang_session=").map(|v| v.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Security headers middleware — applied to ALL API responses.
|
||||
pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Body> {
|
||||
let mut response = next.run(request).await;
|
||||
@@ -170,6 +249,10 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
"cache-control",
|
||||
"no-store, no-cache, must-revalidate".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"strict-transport-security",
|
||||
"max-age=63072000; includeSubDomains".parse().unwrap(),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
@@ -179,9 +179,8 @@ fn resolve_agent(state: &AppState, model: &str) -> Option<(AgentId, String)> {
|
||||
return Some((entry.id, entry.name.clone()));
|
||||
}
|
||||
|
||||
// 4. Fallback → first registered agent
|
||||
let agents = state.kernel.registry.list();
|
||||
agents.first().map(|e| (e.id, e.name.clone()))
|
||||
// No match — return None so the caller returns a proper 404
|
||||
None
|
||||
}
|
||||
|
||||
// ── Message conversion ──────────────────────────────────────────────────────
|
||||
@@ -204,7 +203,7 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
|
||||
.iter()
|
||||
.filter_map(|part| match part {
|
||||
OaiContentPart::Text { text } => {
|
||||
Some(ContentBlock::Text { text: text.clone() })
|
||||
Some(ContentBlock::Text { text: text.clone(), provider_metadata: None })
|
||||
}
|
||||
OaiContentPart::ImageUrl { image_url } => {
|
||||
// Parse data URI: data:{media_type};base64,{data}
|
||||
@@ -336,7 +335,7 @@ pub async fn chat_completions(
|
||||
index: 0,
|
||||
message: ChoiceMessage {
|
||||
role: "assistant",
|
||||
content: Some(result.response),
|
||||
content: Some(crate::ws::strip_think_tags(&result.response)),
|
||||
tool_calls: None,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
|
||||
+2601
-237
File diff suppressed because it is too large
Load Diff
@@ -48,11 +48,14 @@ pub async fn build_router(
|
||||
peer_registry: kernel.peer_registry.as_ref().map(|r| Arc::new(r.clone())),
|
||||
bridge_manager: tokio::sync::Mutex::new(bridge),
|
||||
channels_config: tokio::sync::RwLock::new(channels_config),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
// CORS: allow localhost origins by default. If API key is set, the API
|
||||
// is protected anyway. For development, permissive CORS is convenient.
|
||||
let cors = if state.kernel.config.api_key.is_empty() {
|
||||
let cors = if state.kernel.config.api_key.trim().is_empty() {
|
||||
// No auth → restrict CORS to localhost origins (include both 127.0.0.1 and localhost)
|
||||
let port = listen_addr.port();
|
||||
let mut origins: Vec<axum::http::HeaderValue> = vec![
|
||||
@@ -100,7 +103,19 @@ pub async fn build_router(
|
||||
.allow_headers(tower_http::cors::Any)
|
||||
};
|
||||
|
||||
let api_key = state.kernel.config.api_key.clone();
|
||||
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
let auth_state = crate::middleware::AuthState {
|
||||
api_key: api_key.clone(),
|
||||
auth_enabled: state.kernel.config.auth.enabled,
|
||||
session_secret: if !api_key.is_empty() {
|
||||
api_key.clone()
|
||||
} else if state.kernel.config.auth.enabled {
|
||||
state.kernel.config.auth.password_hash.clone()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
};
|
||||
let gcra_limiter = rate_limiter::create_rate_limiter();
|
||||
|
||||
let app = Router::new()
|
||||
@@ -124,7 +139,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}",
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent),
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/mode",
|
||||
@@ -155,6 +170,10 @@ pub async fn build_router(
|
||||
"/api/agents/{id}/session/reset",
|
||||
axum::routing::post(routes::reset_session),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/history",
|
||||
axum::routing::delete(routes::clear_agent_history),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/session/compact",
|
||||
axum::routing::post(routes::compact_session),
|
||||
@@ -167,6 +186,10 @@ pub async fn build_router(
|
||||
"/api/agents/{id}/model",
|
||||
axum::routing::put(routes::set_model),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/tools",
|
||||
axum::routing::get(routes::get_agent_tools).put(routes::set_agent_tools),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/skills",
|
||||
axum::routing::get(routes::get_agent_skills).put(routes::set_agent_skills),
|
||||
@@ -276,6 +299,10 @@ pub async fn build_router(
|
||||
"/api/workflows",
|
||||
axum::routing::get(routes::list_workflows).post(routes::create_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/workflows/{id}",
|
||||
axum::routing::get(routes::get_workflow).put(routes::update_workflow).delete(routes::delete_workflow),
|
||||
)
|
||||
.route(
|
||||
"/api/workflows/{id}/run",
|
||||
axum::routing::post(routes::run_workflow),
|
||||
@@ -311,12 +338,20 @@ pub async fn build_router(
|
||||
"/api/clawhub/skill/{slug}",
|
||||
axum::routing::get(routes::clawhub_skill_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/clawhub/skill/{slug}/code",
|
||||
axum::routing::get(routes::clawhub_skill_code),
|
||||
)
|
||||
.route(
|
||||
"/api/clawhub/install",
|
||||
axum::routing::post(routes::clawhub_install),
|
||||
)
|
||||
// Hands endpoints
|
||||
.route("/api/hands", axum::routing::get(routes::list_hands))
|
||||
.route(
|
||||
"/api/hands/install",
|
||||
axum::routing::post(routes::install_hand),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/active",
|
||||
axum::routing::get(routes::list_active_hands),
|
||||
@@ -334,6 +369,11 @@ pub async fn build_router(
|
||||
"/api/hands/{hand_id}/install-deps",
|
||||
axum::routing::post(routes::install_hand_deps),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/{hand_id}/settings",
|
||||
axum::routing::get(routes::get_hand_settings)
|
||||
.put(routes::update_hand_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/instances/{id}/pause",
|
||||
axum::routing::post(routes::pause_hand),
|
||||
@@ -376,6 +416,31 @@ pub async fn build_router(
|
||||
"/api/network/status",
|
||||
axum::routing::get(routes::network_status),
|
||||
)
|
||||
// Agent communication (Comms) endpoints
|
||||
.route(
|
||||
"/api/comms/topology",
|
||||
axum::routing::get(routes::comms_topology),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/events",
|
||||
axum::routing::get(routes::comms_events),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/events/stream",
|
||||
axum::routing::get(routes::comms_events_stream),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/send",
|
||||
axum::routing::post(routes::comms_send),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/task",
|
||||
axum::routing::post(routes::comms_task),
|
||||
)
|
||||
;
|
||||
|
||||
// Split into a second router chunk to stay within axum's type nesting limit.
|
||||
let app = app
|
||||
// Tools endpoint
|
||||
.route("/api/tools", axum::routing::get(routes::list_tools))
|
||||
// Config endpoints
|
||||
@@ -420,7 +485,8 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/budget/agents/{id}",
|
||||
axum::routing::get(routes::agent_budget_status),
|
||||
axum::routing::get(routes::agent_budget_status)
|
||||
.put(routes::update_agent_budget),
|
||||
)
|
||||
// Session endpoints
|
||||
.route("/api/sessions", axum::routing::get(routes::list_sessions))
|
||||
@@ -449,8 +515,25 @@ pub async fn build_router(
|
||||
"/api/models/aliases",
|
||||
axum::routing::get(routes::list_aliases),
|
||||
)
|
||||
.route(
|
||||
"/api/models/custom",
|
||||
axum::routing::post(routes::add_custom_model),
|
||||
)
|
||||
.route(
|
||||
"/api/models/custom/{*id}",
|
||||
axum::routing::delete(routes::remove_custom_model),
|
||||
)
|
||||
.route("/api/models/{*id}", axum::routing::get(routes::get_model))
|
||||
.route("/api/providers", axum::routing::get(routes::list_providers))
|
||||
// Copilot OAuth (must be before parametric {name} routes)
|
||||
.route(
|
||||
"/api/providers/github-copilot/oauth/start",
|
||||
axum::routing::post(routes::copilot_oauth_start),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/github-copilot/oauth/poll/{poll_id}",
|
||||
axum::routing::get(routes::copilot_oauth_poll),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/{name}/key",
|
||||
axum::routing::post(routes::set_provider_key).delete(routes::delete_provider_key),
|
||||
@@ -459,6 +542,10 @@ pub async fn build_router(
|
||||
"/api/providers/{name}/test",
|
||||
axum::routing::post(routes::test_provider),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/{name}/url",
|
||||
axum::routing::put(routes::set_provider_url),
|
||||
)
|
||||
.route(
|
||||
"/api/skills/create",
|
||||
axum::routing::post(routes::create_skill),
|
||||
@@ -603,8 +690,21 @@ pub async fn build_router(
|
||||
"/v1/models",
|
||||
axum::routing::get(crate::openai_compat::list_models),
|
||||
)
|
||||
// Dashboard authentication endpoints
|
||||
.route(
|
||||
"/api/auth/login",
|
||||
axum::routing::post(routes::auth_login),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/logout",
|
||||
axum::routing::post(routes::auth_logout),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/check",
|
||||
axum::routing::get(routes::auth_check),
|
||||
)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
api_key,
|
||||
auth_state,
|
||||
middleware::auth,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
@@ -674,7 +774,8 @@ pub async fn run_daemon(
|
||||
if info_path.exists() {
|
||||
if let Ok(existing) = std::fs::read_to_string(info_path) {
|
||||
if let Ok(info) = serde_json::from_str::<DaemonInfo>(&existing) {
|
||||
if is_process_alive(info.pid) {
|
||||
// PID alive AND the health endpoint responds → truly running
|
||||
if is_process_alive(info.pid) && is_daemon_responding(&info.listen_addr) {
|
||||
return Err(format!(
|
||||
"Another daemon (PID {}) is already running at {}",
|
||||
info.pid, info.listen_addr
|
||||
@@ -683,7 +784,8 @@ pub async fn run_daemon(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stale PID file, remove it
|
||||
// Stale PID file (process dead or different process reused PID), remove it
|
||||
info!("Removing stale daemon info file");
|
||||
let _ = std::fs::remove_file(info_path);
|
||||
}
|
||||
|
||||
@@ -705,16 +807,32 @@ pub async fn run_daemon(
|
||||
info!("WebChat UI available at http://{addr}/",);
|
||||
info!("WebSocket endpoint: ws://{addr}/api/agents/{{id}}/ws",);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
// Use SO_REUSEADDR to allow binding immediately after reboot (avoids TIME_WAIT).
|
||||
let socket = socket2::Socket::new(
|
||||
if addr.is_ipv4() {
|
||||
socket2::Domain::IPV4
|
||||
} else {
|
||||
socket2::Domain::IPV6
|
||||
},
|
||||
socket2::Type::STREAM,
|
||||
None,
|
||||
)?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(1024)?;
|
||||
let listener =
|
||||
tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
|
||||
|
||||
// Run server with graceful shutdown.
|
||||
// SECURITY: `into_make_service_with_connect_info` injects the peer
|
||||
// SocketAddr so the auth middleware can check for loopback connections.
|
||||
let api_shutdown = state.shutdown_notify.clone();
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.with_graceful_shutdown(shutdown_signal(api_shutdown))
|
||||
.await?;
|
||||
|
||||
// Clean up daemon info file
|
||||
@@ -752,11 +870,11 @@ pub fn read_daemon_info(home_dir: &Path) -> Option<DaemonInfo> {
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
/// Wait for an OS termination signal.
|
||||
/// Wait for an OS termination signal OR an API shutdown request.
|
||||
///
|
||||
/// On Unix: listens for SIGINT and SIGTERM.
|
||||
/// On Windows: listens for Ctrl+C.
|
||||
async fn shutdown_signal() {
|
||||
/// On Unix: listens for SIGINT, SIGTERM, and API notify.
|
||||
/// On Windows: listens for Ctrl+C and API notify.
|
||||
async fn shutdown_signal(api_shutdown: Arc<tokio::sync::Notify>) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
@@ -770,15 +888,22 @@ async fn shutdown_signal() {
|
||||
_ = sigterm.recv() => {
|
||||
info!("Received SIGTERM, shutting down...");
|
||||
}
|
||||
_ = api_shutdown.notified() => {
|
||||
info!("Shutdown requested via API, shutting down...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to install Ctrl+C handler");
|
||||
info!("Ctrl+C received, shutting down...");
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("Ctrl+C received, shutting down...");
|
||||
}
|
||||
_ = api_shutdown.notified() => {
|
||||
info!("Shutdown requested via API, shutting down...");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -817,3 +942,26 @@ fn is_process_alive(pid: u32) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an OpenFang daemon is actually responding at the given address.
|
||||
/// This avoids false positives where a different process reused the same PID
|
||||
/// after a system reboot.
|
||||
fn is_daemon_responding(addr: &str) -> bool {
|
||||
// Quick TCP connect check — don't make a full HTTP request to avoid delays
|
||||
let addr_only = addr
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| addr.strip_prefix("https://"))
|
||||
.unwrap_or(addr);
|
||||
if let Ok(sock_addr) = addr_only.parse::<std::net::SocketAddr>() {
|
||||
std::net::TcpStream::connect_timeout(
|
||||
&sock_addr,
|
||||
std::time::Duration::from_millis(500),
|
||||
)
|
||||
.is_ok()
|
||||
} else {
|
||||
// Fallback: try connecting to hostname
|
||||
std::net::TcpStream::connect(addr_only)
|
||||
.map(|_| true)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Stateless session token authentication for the dashboard.
|
||||
//! Tokens are HMAC-SHA256 signed and contain username + expiry.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Create a session token: base64(username:expiry_unix:hmac_hex)
|
||||
pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> String {
|
||||
use base64::Engine;
|
||||
let expiry = chrono::Utc::now().timestamp() + (ttl_hours as i64 * 3600);
|
||||
let payload = format!("{username}:{expiry}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key");
|
||||
mac.update(payload.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
|
||||
}
|
||||
|
||||
/// Verify a session token. Returns the username if valid and not expired.
|
||||
pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
|
||||
use base64::Engine;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(token)
|
||||
.ok()?;
|
||||
let decoded_str = String::from_utf8(decoded).ok()?;
|
||||
let parts: Vec<&str> = decoded_str.splitn(3, ':').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let (username, expiry_str, provided_sig) = (parts[0], parts[1], parts[2]);
|
||||
|
||||
let expiry: i64 = expiry_str.parse().ok()?;
|
||||
if chrono::Utc::now().timestamp() > expiry {
|
||||
return None;
|
||||
}
|
||||
|
||||
let payload = format!("{username}:{expiry_str}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
|
||||
mac.update(payload.as_bytes());
|
||||
let expected_sig = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
if provided_sig.len() != expected_sig.len() {
|
||||
return None;
|
||||
}
|
||||
if provided_sig
|
||||
.as_bytes()
|
||||
.ct_eq(expected_sig.as_bytes())
|
||||
.into()
|
||||
{
|
||||
Some(username.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash a password with SHA256 for config storage.
|
||||
pub fn hash_password(password: &str) -> String {
|
||||
use sha2::Digest;
|
||||
hex::encode(Sha256::digest(password.as_bytes()))
|
||||
}
|
||||
|
||||
/// Verify a password against a stored SHA256 hash (constant-time).
|
||||
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
|
||||
let computed = hash_password(password);
|
||||
use subtle::ConstantTimeEq;
|
||||
if computed.len() != stored_hash.len() {
|
||||
return false;
|
||||
}
|
||||
computed.as_bytes().ct_eq(stored_hash.as_bytes()).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify_password() {
|
||||
let hash = hash_password("secret123");
|
||||
assert!(verify_password("secret123", &hash));
|
||||
assert!(!verify_password("wrong", &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_verify_token() {
|
||||
let token = create_session_token("admin", "my-secret", 1);
|
||||
let user = verify_session_token(&token, "my-secret");
|
||||
assert_eq!(user, Some("admin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_wrong_secret() {
|
||||
let token = create_session_token("admin", "my-secret", 1);
|
||||
let user = verify_session_token(&token, "wrong-secret");
|
||||
assert_eq!(user, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_invalid_base64() {
|
||||
let user = verify_session_token("not-valid-base64!!!", "secret");
|
||||
assert_eq!(user, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_hash_length_mismatch() {
|
||||
assert!(!verify_password("x", "short"));
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Request to spawn an agent from a TOML manifest string.
|
||||
/// Request to spawn an agent from a TOML manifest string or a template name.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SpawnRequest {
|
||||
/// Agent manifest as TOML string.
|
||||
/// Agent manifest as TOML string (optional if `template` is provided).
|
||||
#[serde(default)]
|
||||
pub manifest_toml: String,
|
||||
/// Template name from `~/.openfang/agents/{template}/agent.toml`.
|
||||
/// When provided and `manifest_toml` is empty, the template is loaded automatically.
|
||||
#[serde(default)]
|
||||
pub template: Option<String>,
|
||||
/// Optional Ed25519 signed manifest envelope (JSON).
|
||||
/// When present, the signature is verified before spawning.
|
||||
#[serde(default)]
|
||||
@@ -46,6 +51,8 @@ pub struct MessageResponse {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub iterations: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost_usd: Option<f64>,
|
||||
}
|
||||
|
||||
/// Request to install a skill from the marketplace.
|
||||
|
||||
@@ -81,13 +81,16 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/vendor/github-dark.min.css"),
|
||||
"\n</style>\n",
|
||||
include_str!("../static/index_body.html"),
|
||||
// Vendor libs: marked + highlight first (used by app.js)
|
||||
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
|
||||
"<script>\n",
|
||||
include_str!("../static/vendor/marked.min.js"),
|
||||
"\n</script>\n",
|
||||
"<script>\n",
|
||||
include_str!("../static/vendor/highlight.min.js"),
|
||||
"\n</script>\n",
|
||||
"<script>\n",
|
||||
include_str!("../static/vendor/chart.umd.min.js"),
|
||||
"\n</script>\n",
|
||||
// App code
|
||||
"<script>\n",
|
||||
include_str!("../static/js/api.js"),
|
||||
@@ -123,6 +126,10 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/js/pages/wizard.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/approvals.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/comms.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/runtime.js"),
|
||||
"\n</script>\n",
|
||||
// Alpine.js MUST be last — it processes x-data and fires alpine:init
|
||||
"<script>\n",
|
||||
|
||||
+110
-12
@@ -146,19 +146,30 @@ pub async fn agent_ws(
|
||||
uri: axum::http::Uri,
|
||||
) -> impl IntoResponse {
|
||||
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
|
||||
let api_key = &state.kernel.config.api_key;
|
||||
// Trim whitespace so empty/whitespace-only api_key disables auth.
|
||||
let api_key_raw = &state.kernel.config.api_key;
|
||||
let api_key = api_key_raw.trim();
|
||||
if !api_key.is_empty() {
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks on API key
|
||||
let ct_eq = |token: &str, key: &str| -> bool {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != key.len() {
|
||||
return false;
|
||||
}
|
||||
token.as_bytes().ct_eq(key.as_bytes()).into()
|
||||
};
|
||||
|
||||
let header_auth = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|token| token == api_key)
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
let query_auth = uri
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(|token| token == api_key)
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !header_auth && !query_auth {
|
||||
@@ -621,8 +632,12 @@ async fn handle_text_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip <think>...</think> blocks from model output
|
||||
// (e.g. MiniMax, DeepSeek reasoning tokens)
|
||||
let cleaned_response = strip_think_tags(&result.response);
|
||||
|
||||
// Guard: ensure we never send an empty response
|
||||
let content = if result.response.trim().is_empty() {
|
||||
let content = if cleaned_response.trim().is_empty() {
|
||||
format!(
|
||||
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
|
||||
result.total_usage.input_tokens,
|
||||
@@ -630,7 +645,7 @@ async fn handle_text_message(
|
||||
result.iterations,
|
||||
)
|
||||
} else {
|
||||
result.response
|
||||
cleaned_response
|
||||
};
|
||||
|
||||
// Estimate context pressure from last call
|
||||
@@ -794,9 +809,21 @@ async fn handle_command(
|
||||
serde_json::json!({"type": "error", "content": "Agent not found"})
|
||||
}
|
||||
} else {
|
||||
match state.kernel.set_agent_model(agent_id, args) {
|
||||
match state.kernel.set_agent_model(agent_id, args, None) {
|
||||
Ok(()) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
let model = &entry.manifest.model.model;
|
||||
let provider = &entry.manifest.model.provider;
|
||||
serde_json::json!({
|
||||
"type": "command_result",
|
||||
"command": cmd,
|
||||
"message": format!("Model switched to: {model} (provider: {provider})"),
|
||||
"model": model,
|
||||
"provider": provider
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"type": "error", "content": format!("Model switch failed: {e}")})
|
||||
@@ -1092,6 +1119,9 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
let status = extract_status_code(&inner);
|
||||
let classified = llm_errors::classify_error(&inner, status);
|
||||
|
||||
// Build a user-facing message. The classified.sanitized_message now
|
||||
// includes a redacted excerpt of the raw error (issue #493 fix), so we
|
||||
// use it as the base and only override for cases that need extra context.
|
||||
match classified.category {
|
||||
llm_errors::LlmErrorCategory::ContextOverflow => {
|
||||
"Context is full. Try /compact or /new.".to_string()
|
||||
@@ -1099,17 +1129,33 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
llm_errors::LlmErrorCategory::RateLimit => {
|
||||
if let Some(delay_ms) = classified.suggested_delay_ms {
|
||||
let secs = (delay_ms / 1000).max(1);
|
||||
format!("Provider rate limited. Wait ~{secs}s and try again.")
|
||||
format!("Rate limited. Wait ~{secs}s and try again.")
|
||||
} else {
|
||||
"Provider rate limited. Wait a moment and try again.".to_string()
|
||||
"Rate limited. Wait a moment and try again.".to_string()
|
||||
}
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Billing => {
|
||||
"Check provider account status (billing issue detected).".to_string()
|
||||
format!("Billing issue. {}", classified.sanitized_message)
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Auth => {
|
||||
// Show the actual error detail so users can diagnose (issue #493).
|
||||
// The sanitized_message already redacts secrets.
|
||||
classified.sanitized_message.clone()
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Auth => "Verify your API key in config.".to_string(),
|
||||
llm_errors::LlmErrorCategory::ModelNotFound => {
|
||||
"Model unavailable. Use /model to see options.".to_string()
|
||||
if inner.contains("localhost:11434") || inner.contains("ollama") {
|
||||
"Model not found on Ollama. Run `ollama pull <model>` first. Use /model to see options.".to_string()
|
||||
} else {
|
||||
format!("{}. Use /model to see options.", classified.sanitized_message)
|
||||
}
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Format => {
|
||||
// Claude Code CLI errors have actionable messages — pass them through
|
||||
if inner.contains("Claude Code CLI") || inner.contains("claude auth") {
|
||||
classified.raw_message.clone()
|
||||
} else {
|
||||
classified.sanitized_message.clone()
|
||||
}
|
||||
}
|
||||
_ => classified.sanitized_message,
|
||||
}
|
||||
@@ -1117,6 +1163,14 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
|
||||
/// Try to extract an HTTP status code from an error string.
|
||||
fn extract_status_code(s: &str) -> Option<u16> {
|
||||
// "API error (NNN):" — the format produced by LlmError::Api Display impl
|
||||
if let Some(idx) = s.find("API error (") {
|
||||
let after = &s[idx + 11..];
|
||||
let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(code) = num.parse::<u16>() {
|
||||
return Some(code);
|
||||
}
|
||||
}
|
||||
// "status: NNN"
|
||||
if let Some(idx) = s.find("status: ") {
|
||||
let after = &s[idx + 8..];
|
||||
@@ -1144,6 +1198,27 @@ fn extract_status_code(s: &str) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip `<think>...</think>` blocks from model output.
|
||||
///
|
||||
/// Some models (MiniMax, DeepSeek, etc.) wrap their reasoning in `<think>` tags.
|
||||
/// These are internal chain-of-thought and shouldn't be shown to the user.
|
||||
pub fn strip_think_tags(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut remaining = text;
|
||||
while let Some(start) = remaining.find("<think>") {
|
||||
result.push_str(&remaining[..start]);
|
||||
if let Some(end) = remaining[start..].find("</think>") {
|
||||
remaining = &remaining[(start + end + 8)..]; // 8 = "</think>".len()
|
||||
} else {
|
||||
// Unclosed <think> tag — strip to end
|
||||
remaining = "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1214,10 +1289,33 @@ mod tests {
|
||||
);
|
||||
assert_eq!(extract_status_code("StatusCode(401)"), Some(401));
|
||||
assert_eq!(extract_status_code("some random error"), None);
|
||||
// LlmError::Api Display format (issue #493 fix)
|
||||
assert_eq!(
|
||||
extract_status_code("LLM driver error: API error (403): quota exceeded"),
|
||||
Some(403)
|
||||
);
|
||||
assert_eq!(
|
||||
extract_status_code("API error (401): invalid api key"),
|
||||
Some(401)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_trims_whitespace() {
|
||||
assert_eq!(sanitize_user_input(" hello "), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_think_tags() {
|
||||
assert_eq!(
|
||||
strip_think_tags("<think>reasoning here</think>The answer is 42."),
|
||||
"The answer is 42."
|
||||
);
|
||||
assert_eq!(
|
||||
strip_think_tags("Hello <think>\nsome thinking\n</think> world"),
|
||||
"Hello world"
|
||||
);
|
||||
assert_eq!(strip_think_tags("No thinking here"), "No thinking here");
|
||||
assert_eq!(strip_think_tags("<think>all thinking</think>"), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,32 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Card-based flex containers for agent chips and similar inline layouts */
|
||||
.card-flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Nested list indentation inside cards, detail panels, and modals */
|
||||
.card ul, .card ol,
|
||||
.detail-grid ul, .detail-grid ol,
|
||||
.modal ul, .modal ol,
|
||||
.info-card ul, .info-card ol {
|
||||
padding-left: 18px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.card ul ul, .card ol ol,
|
||||
.modal ul ul, .modal ol ol {
|
||||
padding-left: 16px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
.card li, .modal li, .info-card li {
|
||||
margin-bottom: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Glow effect on card hover */
|
||||
.card-glow {
|
||||
overflow: hidden;
|
||||
@@ -90,13 +116,17 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.badge + .badge { margin-left: 4px; }
|
||||
|
||||
.badge-running { background: rgba(74,222,128,0.12); color: var(--success); }
|
||||
.badge-suspended { background: rgba(245,158,11,0.12); color: var(--warning); }
|
||||
@@ -110,7 +140,7 @@
|
||||
.badge-error { background: rgba(239,68,68,0.12); color: var(--error); }
|
||||
.badge-muted { background: rgba(148,163,184,0.12); color: var(--text-dim); }
|
||||
.badge-info { background: rgba(59,130,246,0.12); color: var(--info); }
|
||||
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; }
|
||||
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; padding: 2px 6px; }
|
||||
.text-danger { color: var(--error); }
|
||||
|
||||
/* Tables */
|
||||
@@ -898,6 +928,129 @@ mark.search-highlight {
|
||||
.slash-menu-item:last-child { border-bottom: none; }
|
||||
.slash-menu-item:hover, .slash-menu-item.slash-active { background: var(--surface2); }
|
||||
|
||||
/* Model switcher dropdown */
|
||||
.model-switcher-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
max-width: 200px;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-switcher-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
.model-switcher-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.model-switcher-btn:disabled:hover { border-color: var(--border); color: var(--text-dim); }
|
||||
.model-switcher-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
.model-switcher-chevron {
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.model-switcher-chevron.open { transform: rotate(180deg); }
|
||||
.model-switcher-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
width: 340px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
.model-switcher-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-switcher-search select {
|
||||
max-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.model-switcher-search select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.model-switcher-search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.model-switcher-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.model-switcher-group-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 6px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-switcher-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.model-switcher-item:hover { background: var(--surface2); }
|
||||
.model-switcher-item.active {
|
||||
background: var(--accent-subtle, rgba(255,92,0,0.06));
|
||||
cursor: default;
|
||||
}
|
||||
.model-switcher-item-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-switcher-tier {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.model-switcher-tier.tier-frontier { background: rgba(168,85,247,0.15); color: #a855f7; }
|
||||
.model-switcher-tier.tier-smart { background: rgba(59,130,246,0.15); color: #3b82f6; }
|
||||
.model-switcher-tier.tier-balanced { background: rgba(34,197,94,0.15); color: #22c55e; }
|
||||
.model-switcher-tier.tier-fast { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
||||
.model-switcher-tier.tier-local { background: rgba(148,163,184,0.12); color: var(--text-dim); }
|
||||
|
||||
/* Sidebar footer */
|
||||
.sidebar-footer {
|
||||
padding: 8px 0;
|
||||
@@ -1098,19 +1251,25 @@ mark.search-highlight {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
/* Theme switcher — 3-mode pill (Light / System / Dark) */
|
||||
.theme-switcher {
|
||||
display: inline-flex;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.theme-toggle:hover { color: var(--accent); border-color: var(--border); }
|
||||
.theme-opt {
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
.theme-opt:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||||
.theme-opt.active { color: var(--accent); background: var(--accent-glow); }
|
||||
|
||||
/* Utility */
|
||||
.flex { display: flex; }
|
||||
@@ -3066,4 +3225,219 @@ mark.search-highlight {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.flex-col { flex-direction: column; }
|
||||
/* Comms page */
|
||||
.comms-topo-tree { padding: 4px 0 4px 8px; }
|
||||
.comms-topo-child { padding: 0 0 0 20px; display: flex; align-items: center; gap: 4px; }
|
||||
.comms-topo-branch { color: var(--text-dim); font-family: var(--font-mono); white-space: pre; }
|
||||
.comms-topo-node { display: flex; align-items: center; gap: 4px; padding: 2px 0; }
|
||||
.comms-event-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; transition: background var(--transition-fast);
|
||||
}
|
||||
.comms-event-row:hover { background: var(--bg-hover); }
|
||||
.comms-event-time { min-width: 50px; text-align: right; }
|
||||
.comms-event-detail { margin-left: auto; }
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
Trader Dashboard
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.trader-dashboard {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 96vw;
|
||||
max-width: 1200px;
|
||||
max-height: 92vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.trader-dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-card);
|
||||
z-index: 10;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
.trader-dashboard-body {
|
||||
padding: 16px 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* KPI Cards */
|
||||
.trader-kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.trader-kpi-row { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.trader-kpi-row { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
.trader-kpi-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.trader-kpi-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.trader-kpi-value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.kpi-positive { color: var(--success) !important; }
|
||||
.kpi-negative { color: var(--error) !important; }
|
||||
|
||||
/* Chart Rows */
|
||||
.trader-chart-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.trader-chart-row { flex-direction: column; }
|
||||
}
|
||||
.trader-chart-panel {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.trader-chart-title {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.trader-chart-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
}
|
||||
.trader-chart-wrap canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
.trader-chart-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Heatmap Table */
|
||||
.trader-heatmap-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.trader-heatmap-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.trader-heatmap-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.trader-heatmap-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.heatmap-positive { color: var(--success); font-weight: 600; }
|
||||
.heatmap-negative { color: var(--error); font-weight: 600; }
|
||||
|
||||
/* Signal Badges */
|
||||
.signal-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.signal-strong_buy, .signal-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.signal-sell, .signal-strong_sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
.signal-hold { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
|
||||
|
||||
/* Confidence Bar */
|
||||
.confidence-bar-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 100px;
|
||||
}
|
||||
.confidence-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.conf-high { background: var(--success); }
|
||||
.conf-mid { background: var(--warning); }
|
||||
.conf-low { background: var(--error); }
|
||||
.confidence-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
min-width: 32px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Trades Table */
|
||||
.trader-trades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.trader-trades-table th {
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.trader-trades-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.trade-side-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.trade-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||
.trade-sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
[data-theme="light"] .sidebar-logo img,
|
||||
[data-theme="light"] .message-avatar img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -87,6 +87,10 @@ function toolIcon(toolName) {
|
||||
|
||||
// Alpine.js global store
|
||||
document.addEventListener('alpine:init', function() {
|
||||
// Restore saved API key on load
|
||||
var savedKey = localStorage.getItem('openfang-api-key');
|
||||
if (savedKey) OpenFangAPI.setAuthToken(savedKey);
|
||||
|
||||
Alpine.store('app', {
|
||||
agents: [],
|
||||
connected: false,
|
||||
@@ -99,6 +103,9 @@ document.addEventListener('alpine:init', function() {
|
||||
pendingAgent: null,
|
||||
focusMode: localStorage.getItem('openfang-focus') === 'true',
|
||||
showOnboarding: false,
|
||||
showAuthPrompt: false,
|
||||
authMode: 'apikey',
|
||||
sessionUser: null,
|
||||
|
||||
toggleFocusMode() {
|
||||
this.focusMode = !this.focusMode;
|
||||
@@ -146,6 +153,79 @@ document.addEventListener('alpine:init', function() {
|
||||
dismissOnboarding() {
|
||||
this.showOnboarding = false;
|
||||
localStorage.setItem('openfang-onboarded', 'true');
|
||||
},
|
||||
|
||||
async checkAuth() {
|
||||
try {
|
||||
// First check if session-based auth is configured
|
||||
var authInfo = await OpenFangAPI.get('/api/auth/check');
|
||||
if (authInfo.mode === 'none') {
|
||||
// No session auth — fall back to API key detection
|
||||
this.authMode = 'apikey';
|
||||
this.sessionUser = null;
|
||||
} else if (authInfo.mode === 'session') {
|
||||
this.authMode = 'session';
|
||||
if (authInfo.authenticated) {
|
||||
this.sessionUser = authInfo.username;
|
||||
this.showAuthPrompt = false;
|
||||
return;
|
||||
}
|
||||
// Session auth enabled but not authenticated — show login prompt
|
||||
this.showAuthPrompt = true;
|
||||
return;
|
||||
}
|
||||
} catch(e) { /* ignore — fall through to API key check */ }
|
||||
|
||||
// API key mode detection
|
||||
try {
|
||||
await OpenFangAPI.get('/api/tools');
|
||||
this.showAuthPrompt = false;
|
||||
} catch(e) {
|
||||
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
|
||||
var saved = localStorage.getItem('openfang-api-key');
|
||||
if (saved) {
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
}
|
||||
this.showAuthPrompt = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
submitApiKey(key) {
|
||||
if (!key || !key.trim()) return;
|
||||
OpenFangAPI.setAuthToken(key.trim());
|
||||
localStorage.setItem('openfang-api-key', key.trim());
|
||||
this.showAuthPrompt = false;
|
||||
this.refreshAgents();
|
||||
},
|
||||
|
||||
async sessionLogin(username, password) {
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/auth/login', { username: username, password: password });
|
||||
if (result.status === 'ok') {
|
||||
this.sessionUser = result.username;
|
||||
this.showAuthPrompt = false;
|
||||
this.refreshAgents();
|
||||
} else {
|
||||
OpenFangToast.error(result.error || 'Login failed');
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Login failed');
|
||||
}
|
||||
},
|
||||
|
||||
async sessionLogout() {
|
||||
try {
|
||||
await OpenFangAPI.post('/api/auth/logout');
|
||||
} catch(e) { /* ignore */ }
|
||||
this.sessionUser = null;
|
||||
this.showAuthPrompt = true;
|
||||
},
|
||||
|
||||
clearApiKey() {
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -154,7 +234,12 @@ document.addEventListener('alpine:init', function() {
|
||||
function app() {
|
||||
return {
|
||||
page: 'agents',
|
||||
theme: localStorage.getItem('openfang-theme') || 'light',
|
||||
themeMode: localStorage.getItem('openfang-theme-mode') || 'system',
|
||||
theme: (() => {
|
||||
var mode = localStorage.getItem('openfang-theme-mode') || 'system';
|
||||
if (mode === 'system') return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
return mode;
|
||||
})(),
|
||||
sidebarCollapsed: localStorage.getItem('openfang-sidebar') === 'collapsed',
|
||||
mobileMenuOpen: false,
|
||||
connected: false,
|
||||
@@ -167,8 +252,15 @@ function app() {
|
||||
init() {
|
||||
var self = this;
|
||||
|
||||
// Listen for OS theme changes (only matters when mode is 'system')
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||
if (self.themeMode === 'system') {
|
||||
self.theme = e.matches ? 'dark' : 'light';
|
||||
}
|
||||
});
|
||||
|
||||
// Hash routing
|
||||
var validPages = ['overview','agents','sessions','approvals','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
|
||||
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','runtime','settings','wizard'];
|
||||
var pageRedirects = {
|
||||
'chat': 'agents',
|
||||
'templates': 'agents',
|
||||
@@ -225,6 +317,7 @@ function app() {
|
||||
// Initial data load
|
||||
this.pollStatus();
|
||||
Alpine.store('app').checkOnboarding();
|
||||
Alpine.store('app').checkAuth();
|
||||
setInterval(function() { self.pollStatus(); }, 5000);
|
||||
},
|
||||
|
||||
@@ -234,9 +327,20 @@ function app() {
|
||||
this.mobileMenuOpen = false;
|
||||
},
|
||||
|
||||
setTheme(mode) {
|
||||
this.themeMode = mode;
|
||||
localStorage.setItem('openfang-theme-mode', mode);
|
||||
if (mode === 'system') {
|
||||
this.theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
} else {
|
||||
this.theme = mode;
|
||||
}
|
||||
},
|
||||
|
||||
toggleTheme() {
|
||||
this.theme = this.theme === 'dark' ? 'light' : 'dark';
|
||||
localStorage.setItem('openfang-theme', this.theme);
|
||||
var modes = ['light', 'system', 'dark'];
|
||||
var next = modes[(modes.indexOf(this.themeMode) + 1) % modes.length];
|
||||
this.setTheme(next);
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
// OpenFang Agents Page — Multi-step spawn wizard, detail view with tabs, file editor, personality presets
|
||||
'use strict';
|
||||
|
||||
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n""").
|
||||
* Backslashes are escaped, and runs of 3+ consecutive double-quotes are
|
||||
* broken up so the TOML parser never sees an unintended closing delimiter.
|
||||
*/
|
||||
function tomlMultilineEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
|
||||
}
|
||||
|
||||
/** Escape a string for use inside a TOML basic (single-line) string ("...").
|
||||
* Backslashes, double-quotes, and common control chars are escaped.
|
||||
*/
|
||||
function tomlBasicEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
function agentsPage() {
|
||||
return {
|
||||
tab: 'agents',
|
||||
@@ -54,6 +69,18 @@ function agentsPage() {
|
||||
filesLoading: false,
|
||||
configForm: {},
|
||||
configSaving: false,
|
||||
// -- Tool filters --
|
||||
toolFilters: { tool_allowlist: [], tool_blocklist: [] },
|
||||
toolFiltersLoading: false,
|
||||
newAllowTool: '',
|
||||
newBlockTool: '',
|
||||
// -- Model switch --
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
modelSaving: false,
|
||||
// -- Fallback chain --
|
||||
editingFallback: false,
|
||||
newFallbackValue: '',
|
||||
|
||||
// -- Templates state --
|
||||
tplTemplates: [],
|
||||
@@ -307,12 +334,15 @@ function agentsPage() {
|
||||
OpenFangAPI.wsDisconnect();
|
||||
},
|
||||
|
||||
showDetail(agent) {
|
||||
async showDetail(agent) {
|
||||
this.detailAgent = agent;
|
||||
this.detailAgent._fallbacks = [];
|
||||
this.detailTab = 'info';
|
||||
this.agentFiles = [];
|
||||
this.editingFile = null;
|
||||
this.fileContent = '';
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
this.configForm = {
|
||||
name: agent.name || '',
|
||||
system_prompt: agent.system_prompt || '',
|
||||
@@ -322,6 +352,11 @@ function agentsPage() {
|
||||
vibe: (agent.identity && agent.identity.vibe) || ''
|
||||
};
|
||||
this.showDetailModal = true;
|
||||
// Fetch full agent detail to get fallback_models
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
|
||||
this.detailAgent._fallbacks = full.fallback_models || [];
|
||||
} catch(e) { /* ignore */ }
|
||||
},
|
||||
|
||||
killAgent(agent) {
|
||||
@@ -358,7 +393,7 @@ function agentsPage() {
|
||||
},
|
||||
|
||||
// ── Multi-step wizard navigation ──
|
||||
openSpawnWizard() {
|
||||
async openSpawnWizard() {
|
||||
this.showSpawnModal = true;
|
||||
this.spawnStep = 1;
|
||||
this.spawnMode = 'wizard';
|
||||
@@ -366,8 +401,18 @@ function agentsPage() {
|
||||
this.selectedPreset = '';
|
||||
this.soulContent = '';
|
||||
this.spawnForm.name = '';
|
||||
this.spawnForm.provider = 'groq';
|
||||
this.spawnForm.model = 'llama-3.3-70b-versatile';
|
||||
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
|
||||
this.spawnForm.profile = 'full';
|
||||
try {
|
||||
var res = await fetch('/api/status');
|
||||
if (res.ok) {
|
||||
var status = await res.json();
|
||||
if (status.default_provider) this.spawnForm.provider = status.default_provider;
|
||||
if (status.default_model) this.spawnForm.model = status.default_model;
|
||||
}
|
||||
} catch(e) { /* keep hardcoded defaults */ }
|
||||
},
|
||||
|
||||
nextStep() {
|
||||
@@ -391,7 +436,7 @@ function agentsPage() {
|
||||
var f = this.spawnForm;
|
||||
var si = this.spawnIdentity;
|
||||
var lines = [
|
||||
'name = "' + f.name + '"',
|
||||
'name = "' + tomlBasicEscape(f.name) + '"',
|
||||
'module = "builtin:chat"'
|
||||
];
|
||||
if (f.profile && f.profile !== 'custom') {
|
||||
@@ -400,7 +445,7 @@ function agentsPage() {
|
||||
lines.push('', '[model]');
|
||||
lines.push('provider = "' + f.provider + '"');
|
||||
lines.push('model = "' + f.model + '"');
|
||||
lines.push('system_prompt = "' + f.systemPrompt.replace(/"/g, '\\"') + '"');
|
||||
lines.push('system_prompt = """\n' + tomlMultilineEscape(f.systemPrompt) + '\n"""');
|
||||
if (f.profile === 'custom') {
|
||||
lines.push('', '[capabilities]');
|
||||
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
|
||||
@@ -559,13 +604,131 @@ function agentsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Clear agent history ──
|
||||
async clearHistory(agent) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Clear History', 'Clear all conversation history for "' + agent.name + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/agents/' + agent.id + '/history');
|
||||
OpenFangToast.success('History cleared for "' + agent.name + '"');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to clear history: ' + e.message);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ── Model switch ──
|
||||
async changeModel() {
|
||||
if (!this.detailAgent || !this.newModelValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
var providerInfo = (resp && resp.provider) ? ' (provider: ' + resp.provider + ')' : '';
|
||||
OpenFangToast.success('Model changed' + providerInfo + ' (memory reset)');
|
||||
this.editingModel = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
// Refresh detailAgent
|
||||
var agents = Alpine.store('app').agents;
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to change model: ' + e.message);
|
||||
}
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Fallback model chain ──
|
||||
async addFallback() {
|
||||
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
|
||||
var parts = this.newFallbackValue.trim().split('/');
|
||||
var provider = parts.length > 1 ? parts[0] : this.detailAgent.model_provider;
|
||||
var model = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
|
||||
if (!this.detailAgent._fallbacks) this.detailAgent._fallbacks = [];
|
||||
this.detailAgent._fallbacks.push({ provider: provider, model: model });
|
||||
try {
|
||||
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
|
||||
fallback_models: this.detailAgent._fallbacks
|
||||
});
|
||||
OpenFangToast.success('Fallback added: ' + provider + '/' + model);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
|
||||
this.detailAgent._fallbacks.pop();
|
||||
}
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
},
|
||||
|
||||
async removeFallback(idx) {
|
||||
if (!this.detailAgent || !this.detailAgent._fallbacks) return;
|
||||
var removed = this.detailAgent._fallbacks.splice(idx, 1);
|
||||
try {
|
||||
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
|
||||
fallback_models: this.detailAgent._fallbacks
|
||||
});
|
||||
OpenFangToast.success('Fallback removed');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
|
||||
this.detailAgent._fallbacks.splice(idx, 0, removed[0]);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tool filters ──
|
||||
async loadToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
this.toolFiltersLoading = true;
|
||||
try {
|
||||
this.toolFilters = await OpenFangAPI.get('/api/agents/' + this.detailAgent.id + '/tools');
|
||||
} catch(e) {
|
||||
this.toolFilters = { tool_allowlist: [], tool_blocklist: [] };
|
||||
}
|
||||
this.toolFiltersLoading = false;
|
||||
},
|
||||
|
||||
addAllowTool() {
|
||||
var t = this.newAllowTool.trim();
|
||||
if (t && this.toolFilters.tool_allowlist.indexOf(t) === -1) {
|
||||
this.toolFilters.tool_allowlist.push(t);
|
||||
this.newAllowTool = '';
|
||||
this.saveToolFilters();
|
||||
}
|
||||
},
|
||||
|
||||
removeAllowTool(tool) {
|
||||
this.toolFilters.tool_allowlist = this.toolFilters.tool_allowlist.filter(function(t) { return t !== tool; });
|
||||
this.saveToolFilters();
|
||||
},
|
||||
|
||||
addBlockTool() {
|
||||
var t = this.newBlockTool.trim();
|
||||
if (t && this.toolFilters.tool_blocklist.indexOf(t) === -1) {
|
||||
this.toolFilters.tool_blocklist.push(t);
|
||||
this.newBlockTool = '';
|
||||
this.saveToolFilters();
|
||||
}
|
||||
},
|
||||
|
||||
removeBlockTool(tool) {
|
||||
this.toolFilters.tool_blocklist = this.toolFilters.tool_blocklist.filter(function(t) { return t !== tool; });
|
||||
this.saveToolFilters();
|
||||
},
|
||||
|
||||
async saveToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/tools', this.toolFilters);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update tool filters: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async spawnBuiltin(t) {
|
||||
var toml = 'name = "' + t.name + '"\n';
|
||||
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
|
||||
var toml = 'name = "' + tomlBasicEscape(t.name) + '"\n';
|
||||
toml += 'description = "' + tomlBasicEscape(t.description) + '"\n';
|
||||
toml += 'module = "builtin:chat"\n';
|
||||
toml += 'profile = "' + t.profile + '"\n\n';
|
||||
toml += '[model]\nprovider = "' + t.provider + '"\nmodel = "' + t.model + '"\n';
|
||||
toml += 'system_prompt = """\n' + t.system_prompt + '\n"""\n';
|
||||
toml += 'system_prompt = """\n' + tomlMultilineEscape(t.system_prompt) + '\n"""\n';
|
||||
|
||||
try {
|
||||
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
|
||||
|
||||
@@ -141,7 +141,16 @@ function channelsPage() {
|
||||
|
||||
openSetup(ch) {
|
||||
this.setupModal = ch;
|
||||
this.formValues = {};
|
||||
// Pre-populate form values from saved config (non-secret fields).
|
||||
var vals = {};
|
||||
if (ch.fields) {
|
||||
ch.fields.forEach(function(f) {
|
||||
if (f.value !== undefined && f.value !== null && f.type !== 'secret') {
|
||||
vals[f.key] = String(f.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.formValues = vals;
|
||||
this.showAdvanced = false;
|
||||
this.showBusinessApi = false;
|
||||
this.setupStep = ch.configured ? 3 : 1;
|
||||
|
||||
@@ -29,6 +29,19 @@ function chatPage() {
|
||||
_audioChunks: [],
|
||||
recordingTime: 0,
|
||||
_recordingTimer: null,
|
||||
// Model autocomplete state
|
||||
showModelPicker: false,
|
||||
modelPickerList: [],
|
||||
modelPickerFilter: '',
|
||||
modelPickerIdx: 0,
|
||||
// Model switcher dropdown
|
||||
showModelSwitcher: false,
|
||||
modelSwitcherFilter: '',
|
||||
modelSwitcherProviderFilter: '',
|
||||
modelSwitcherIdx: 0,
|
||||
modelSwitching: false,
|
||||
_modelCache: null,
|
||||
_modelCacheTime: 0,
|
||||
slashCommands: [
|
||||
{ cmd: '/help', desc: 'Show available commands' },
|
||||
{ cmd: '/agents', desc: 'Switch to Agents page' },
|
||||
@@ -80,6 +93,47 @@ function chatPage() {
|
||||
}
|
||||
},
|
||||
|
||||
get modelDisplayName() {
|
||||
if (!this.currentAgent) return '';
|
||||
var name = this.currentAgent.model_name || '';
|
||||
var short = name.replace(/-\d{8}$/, '');
|
||||
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
|
||||
},
|
||||
|
||||
get switcherProviders() {
|
||||
var seen = {};
|
||||
(this._modelCache || []).forEach(function(m) { seen[m.provider] = true; });
|
||||
return Object.keys(seen).sort();
|
||||
},
|
||||
|
||||
get filteredSwitcherModels() {
|
||||
var models = this._modelCache || [];
|
||||
var provFilter = this.modelSwitcherProviderFilter;
|
||||
var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : '';
|
||||
if (!provFilter && !textFilter) return models;
|
||||
return models.filter(function(m) {
|
||||
if (provFilter && m.provider !== provFilter) return false;
|
||||
if (textFilter) {
|
||||
return m.id.toLowerCase().indexOf(textFilter) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(textFilter) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(textFilter) !== -1;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
get groupedSwitcherModels() {
|
||||
var filtered = this.filteredSwitcherModels;
|
||||
var groups = {}, order = [];
|
||||
filtered.forEach(function(m) {
|
||||
if (!groups[m.provider]) { groups[m.provider] = []; order.push(m.provider); }
|
||||
groups[m.provider].push(m);
|
||||
});
|
||||
return order.map(function(p) {
|
||||
return { provider: p.charAt(0).toUpperCase() + p.slice(1), models: groups[p] };
|
||||
});
|
||||
},
|
||||
|
||||
init() {
|
||||
var self = this;
|
||||
|
||||
@@ -96,6 +150,11 @@ function chatPage() {
|
||||
var input = document.getElementById('msg-input');
|
||||
if (input) { input.focus(); self.inputText = '/'; }
|
||||
}
|
||||
// Ctrl+M for model switcher
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'm' && self.currentAgent) {
|
||||
e.preventDefault();
|
||||
self.toggleModelSwitcher();
|
||||
}
|
||||
// Ctrl+F for chat search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && self.currentAgent) {
|
||||
e.preventDefault();
|
||||
@@ -126,18 +185,98 @@ function chatPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for slash commands
|
||||
// Watch for slash commands + model autocomplete
|
||||
this.$watch('inputText', function(val) {
|
||||
if (val.startsWith('/')) {
|
||||
var modelMatch = val.match(/^\/model\s+(.*)$/i);
|
||||
if (modelMatch) {
|
||||
self.showSlashMenu = false;
|
||||
self.modelPickerFilter = modelMatch[1].toLowerCase();
|
||||
if (!self.modelPickerList.length) {
|
||||
OpenFangAPI.get('/api/models').then(function(data) {
|
||||
self.modelPickerList = (data.models || []).filter(function(m) { return m.available; });
|
||||
self.showModelPicker = true;
|
||||
self.modelPickerIdx = 0;
|
||||
}).catch(function() {});
|
||||
} else {
|
||||
self.showModelPicker = true;
|
||||
}
|
||||
} else if (val.startsWith('/')) {
|
||||
self.showModelPicker = false;
|
||||
self.slashFilter = val.slice(1).toLowerCase();
|
||||
self.showSlashMenu = true;
|
||||
self.slashIdx = 0;
|
||||
} else {
|
||||
self.showSlashMenu = false;
|
||||
self.showModelPicker = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
get filteredModelPicker() {
|
||||
if (!this.modelPickerFilter) return this.modelPickerList.slice(0, 15);
|
||||
var f = this.modelPickerFilter;
|
||||
return this.modelPickerList.filter(function(m) {
|
||||
return m.id.toLowerCase().indexOf(f) !== -1 || (m.display_name || '').toLowerCase().indexOf(f) !== -1 || m.provider.toLowerCase().indexOf(f) !== -1;
|
||||
}).slice(0, 15);
|
||||
},
|
||||
|
||||
pickModel(modelId) {
|
||||
this.showModelPicker = false;
|
||||
this.inputText = '/model ' + modelId;
|
||||
this.sendMessage();
|
||||
},
|
||||
|
||||
toggleModelSwitcher() {
|
||||
if (this.showModelSwitcher) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
var now = Date.now();
|
||||
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
|
||||
this.modelSwitcherFilter = '';
|
||||
this.modelSwitcherProviderFilter = '';
|
||||
this.modelSwitcherIdx = 0;
|
||||
this.showModelSwitcher = true;
|
||||
this.$nextTick(function() {
|
||||
var el = document.getElementById('model-switcher-search');
|
||||
if (el) el.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
OpenFangAPI.get('/api/models').then(function(data) {
|
||||
var models = (data.models || []).filter(function(m) { return m.available; });
|
||||
self._modelCache = models;
|
||||
self._modelCacheTime = Date.now();
|
||||
self.modelPickerList = models;
|
||||
self.modelSwitcherFilter = '';
|
||||
self.modelSwitcherProviderFilter = '';
|
||||
self.modelSwitcherIdx = 0;
|
||||
self.showModelSwitcher = true;
|
||||
self.$nextTick(function() {
|
||||
var el = document.getElementById('model-switcher-search');
|
||||
if (el) el.focus();
|
||||
});
|
||||
}).catch(function(e) {
|
||||
OpenFangToast.error('Failed to load models: ' + e.message);
|
||||
});
|
||||
},
|
||||
|
||||
switchModel(model) {
|
||||
if (!this.currentAgent) return;
|
||||
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
this.modelSwitching = true;
|
||||
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function(resp) {
|
||||
// Use server-resolved model/provider to stay in sync (fixes #387/#466)
|
||||
self.currentAgent.model_name = (resp && resp.model) || model.id;
|
||||
self.currentAgent.model_provider = (resp && resp.provider) || model.provider;
|
||||
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
|
||||
self.showModelSwitcher = false;
|
||||
self.modelSwitching = false;
|
||||
}).catch(function(e) {
|
||||
OpenFangToast.error('Switch failed: ' + e.message);
|
||||
self.modelSwitching = false;
|
||||
});
|
||||
},
|
||||
|
||||
// Fetch dynamic slash commands from server
|
||||
fetchCommands: function() {
|
||||
var self = this;
|
||||
@@ -282,9 +421,13 @@ function chatPage() {
|
||||
case '/model':
|
||||
if (self.currentAgent) {
|
||||
if (cmdArgs) {
|
||||
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function() {
|
||||
self.currentAgent.model_name = cmdArgs;
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`', meta: '', tools: [] });
|
||||
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) {
|
||||
// Use server-resolved model/provider (fixes #387/#466)
|
||||
var resolvedModel = (resp && resp.model) || cmdArgs;
|
||||
var resolvedProvider = (resp && resp.provider) || '';
|
||||
self.currentAgent.model_name = resolvedModel;
|
||||
if (resolvedProvider) { self.currentAgent.model_provider = resolvedProvider; }
|
||||
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + resolvedModel + '`' + (resolvedProvider ? ' (provider: `' + resolvedProvider + '`)' : ''), meta: '', tools: [] });
|
||||
self.scrollToBottom();
|
||||
}).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); });
|
||||
} else {
|
||||
@@ -393,7 +536,10 @@ function chatPage() {
|
||||
is_error: !!t.is_error
|
||||
};
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools };
|
||||
var images = (m.images || []).map(function(img) {
|
||||
return { file_id: img.file_id, filename: img.filename || 'image' };
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools, images: images };
|
||||
});
|
||||
self.$nextTick(function() { self.scrollToBottom(); });
|
||||
}
|
||||
@@ -504,17 +650,32 @@ function chatPage() {
|
||||
// Show tool/phase progress so the user sees the agent is working
|
||||
var phaseMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
|
||||
if (phaseMsg && (phaseMsg.thinking || phaseMsg.streaming)) {
|
||||
var detail = data.detail || data.phase || 'Working...';
|
||||
// Context warning: show prominently
|
||||
// Skip phases that have no user-meaningful display text — "streaming"
|
||||
// and "done" are lifecycle signals, not status to show in the chat bubble.
|
||||
if (data.phase === 'streaming' || data.phase === 'done') {
|
||||
break;
|
||||
}
|
||||
// Context warning: show prominently as a separate system message
|
||||
if (data.phase === 'context_warning') {
|
||||
this.messages.push({ id: ++msgId, role: 'system', text: detail, meta: '', tools: [] });
|
||||
var cwDetail = data.detail || 'Context limit reached.';
|
||||
this.messages.push({ id: ++msgId, role: 'system', text: cwDetail, meta: '', tools: [] });
|
||||
} else if (data.phase === 'thinking' && this.thinkingMode === 'stream') {
|
||||
// Stream reasoning tokens to a collapsible panel
|
||||
if (!phaseMsg._reasoning) phaseMsg._reasoning = '';
|
||||
phaseMsg._reasoning += (detail || '') + '\n';
|
||||
phaseMsg._reasoning += (data.detail || '') + '\n';
|
||||
phaseMsg.text = '<details><summary>Reasoning...</summary>\n\n' + phaseMsg._reasoning + '</details>';
|
||||
} else {
|
||||
phaseMsg.text = detail;
|
||||
} else if (phaseMsg.thinking) {
|
||||
// Only update text on messages still in thinking state (not yet
|
||||
// receiving streamed content) to avoid overwriting accumulated text.
|
||||
var phaseDetail;
|
||||
if (data.phase === 'tool_use') {
|
||||
phaseDetail = 'Using ' + (data.detail || 'tool') + '...';
|
||||
} else if (data.phase === 'thinking') {
|
||||
phaseDetail = 'Thinking...';
|
||||
} else {
|
||||
phaseDetail = data.detail || 'Working...';
|
||||
}
|
||||
phaseMsg.text = phaseDetail;
|
||||
}
|
||||
}
|
||||
this.scrollToBottom();
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// OpenFang Comms Page — Agent topology & inter-agent communication feed
|
||||
'use strict';
|
||||
|
||||
function commsPage() {
|
||||
return {
|
||||
topology: { nodes: [], edges: [] },
|
||||
events: [],
|
||||
loading: true,
|
||||
loadError: '',
|
||||
sseSource: null,
|
||||
showSendModal: false,
|
||||
showTaskModal: false,
|
||||
sendFrom: '',
|
||||
sendTo: '',
|
||||
sendMsg: '',
|
||||
sendLoading: false,
|
||||
taskTitle: '',
|
||||
taskDesc: '',
|
||||
taskAssign: '',
|
||||
taskLoading: false,
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/comms/topology'),
|
||||
OpenFangAPI.get('/api/comms/events?limit=200')
|
||||
]);
|
||||
this.topology = results[0] || { nodes: [], edges: [] };
|
||||
this.events = results[1] || [];
|
||||
this.startSSE();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load comms data.';
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
startSSE() {
|
||||
if (this.sseSource) this.sseSource.close();
|
||||
var self = this;
|
||||
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
|
||||
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
|
||||
this.sseSource = new EventSource(url);
|
||||
this.sseSource.onmessage = function(ev) {
|
||||
if (ev.data === 'ping') return;
|
||||
try {
|
||||
var event = JSON.parse(ev.data);
|
||||
self.events.unshift(event);
|
||||
if (self.events.length > 200) self.events.length = 200;
|
||||
// Refresh topology on spawn/terminate events
|
||||
if (event.kind === 'agent_spawned' || event.kind === 'agent_terminated') {
|
||||
self.refreshTopology();
|
||||
}
|
||||
} catch(e) { /* ignore parse errors */ }
|
||||
};
|
||||
},
|
||||
|
||||
stopSSE() {
|
||||
if (this.sseSource) {
|
||||
this.sseSource.close();
|
||||
this.sseSource = null;
|
||||
}
|
||||
},
|
||||
|
||||
async refreshTopology() {
|
||||
try {
|
||||
this.topology = await OpenFangAPI.get('/api/comms/topology');
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
rootNodes() {
|
||||
var childIds = {};
|
||||
var self = this;
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'parent_child') childIds[e.to] = true;
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return !childIds[n.id]; });
|
||||
},
|
||||
|
||||
childrenOf(id) {
|
||||
var childIds = {};
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'parent_child' && e.from === id) childIds[e.to] = true;
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return childIds[n.id]; });
|
||||
},
|
||||
|
||||
peersOf(id) {
|
||||
var peerIds = {};
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'peer') {
|
||||
if (e.from === id) peerIds[e.to] = true;
|
||||
if (e.to === id) peerIds[e.from] = true;
|
||||
}
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return peerIds[n.id]; });
|
||||
},
|
||||
|
||||
stateBadgeClass(state) {
|
||||
switch(state) {
|
||||
case 'Running': return 'badge badge-success';
|
||||
case 'Suspended': return 'badge badge-warning';
|
||||
case 'Terminated': case 'Crashed': return 'badge badge-danger';
|
||||
default: return 'badge badge-dim';
|
||||
}
|
||||
},
|
||||
|
||||
eventBadgeClass(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return 'badge badge-info';
|
||||
case 'agent_spawned': return 'badge badge-success';
|
||||
case 'agent_terminated': return 'badge badge-danger';
|
||||
case 'task_posted': return 'badge badge-warning';
|
||||
case 'task_claimed': return 'badge badge-info';
|
||||
case 'task_completed': return 'badge badge-success';
|
||||
default: return 'badge badge-dim';
|
||||
}
|
||||
},
|
||||
|
||||
eventIcon(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return '\u2709';
|
||||
case 'agent_spawned': return '+';
|
||||
case 'agent_terminated': return '\u2715';
|
||||
case 'task_posted': return '\u2691';
|
||||
case 'task_claimed': return '\u2690';
|
||||
case 'task_completed': return '\u2713';
|
||||
default: return '\u2022';
|
||||
}
|
||||
},
|
||||
|
||||
eventLabel(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return 'Message';
|
||||
case 'agent_spawned': return 'Spawned';
|
||||
case 'agent_terminated': return 'Terminated';
|
||||
case 'task_posted': return 'Task Posted';
|
||||
case 'task_claimed': return 'Task Claimed';
|
||||
case 'task_completed': return 'Task Done';
|
||||
default: return kind;
|
||||
}
|
||||
},
|
||||
|
||||
timeAgo(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
var d = new Date(dateStr);
|
||||
var secs = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (secs < 60) return secs + 's ago';
|
||||
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
|
||||
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
|
||||
return Math.floor(secs / 86400) + 'd ago';
|
||||
},
|
||||
|
||||
openSendModal() {
|
||||
this.sendFrom = '';
|
||||
this.sendTo = '';
|
||||
this.sendMsg = '';
|
||||
this.showSendModal = true;
|
||||
},
|
||||
|
||||
async submitSend() {
|
||||
if (!this.sendFrom || !this.sendTo || !this.sendMsg.trim()) return;
|
||||
this.sendLoading = true;
|
||||
try {
|
||||
await OpenFangAPI.post('/api/comms/send', {
|
||||
from_agent_id: this.sendFrom,
|
||||
to_agent_id: this.sendTo,
|
||||
message: this.sendMsg
|
||||
});
|
||||
OpenFangToast.success('Message sent');
|
||||
this.showSendModal = false;
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Send failed');
|
||||
}
|
||||
this.sendLoading = false;
|
||||
},
|
||||
|
||||
openTaskModal() {
|
||||
this.taskTitle = '';
|
||||
this.taskDesc = '';
|
||||
this.taskAssign = '';
|
||||
this.showTaskModal = true;
|
||||
},
|
||||
|
||||
async submitTask() {
|
||||
if (!this.taskTitle.trim()) return;
|
||||
this.taskLoading = true;
|
||||
try {
|
||||
var body = { title: this.taskTitle, description: this.taskDesc };
|
||||
if (this.taskAssign) body.assigned_to = this.taskAssign;
|
||||
await OpenFangAPI.post('/api/comms/task', body);
|
||||
OpenFangToast.success('Task posted');
|
||||
this.showTaskModal = false;
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Task failed');
|
||||
}
|
||||
this.taskLoading = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,15 @@ function handsPage() {
|
||||
browserViewerOpen: false,
|
||||
_browserPollTimer: null,
|
||||
|
||||
// ── Trader Dashboard State ────────────────────────────────────────────
|
||||
dashboardOpen: false,
|
||||
dashboardLoading: false,
|
||||
dashboardData: null,
|
||||
_dashboardInst: null,
|
||||
_chartEquity: null,
|
||||
_chartPnl: null,
|
||||
_chartRadar: null,
|
||||
|
||||
// ── Setup Wizard State ──────────────────────────────────────────────
|
||||
setupWizard: null,
|
||||
setupStep: 1,
|
||||
@@ -27,6 +36,7 @@ function handsPage() {
|
||||
_clipboardTimer: null,
|
||||
detectedPlatform: 'linux',
|
||||
installPlatforms: {},
|
||||
apiKeyInputs: {},
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
@@ -101,11 +111,15 @@ function handsPage() {
|
||||
} else {
|
||||
this._detectClientPlatform();
|
||||
}
|
||||
// Initialize per-requirement platform selections
|
||||
// Initialize per-requirement platform selections and API key inputs
|
||||
this.installPlatforms = {};
|
||||
this.apiKeyInputs = {};
|
||||
if (data.requirements) {
|
||||
for (var j = 0; j < data.requirements.length; j++) {
|
||||
this.installPlatforms[data.requirements[j].key] = this.detectedPlatform;
|
||||
if (data.requirements[j].type === 'ApiKey') {
|
||||
this.apiKeyInputs[data.requirements[j].key] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
this.setupWizard = data;
|
||||
@@ -274,7 +288,10 @@ function handsPage() {
|
||||
if (!this.setupWizard || !this.setupWizard.requirements) return 0;
|
||||
var count = 0;
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
if (this.setupWizard.requirements[i].satisfied) count++;
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.satisfied) { count++; continue; }
|
||||
// Count API key reqs as met if user entered a value
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') count++;
|
||||
}
|
||||
return count;
|
||||
},
|
||||
@@ -285,7 +302,34 @@ function handsPage() {
|
||||
},
|
||||
|
||||
get setupAllReqsMet() {
|
||||
return this.setupReqsTotal > 0 && this.setupReqsMet === this.setupReqsTotal;
|
||||
if (!this.setupWizard || !this.setupWizard.requirements) return false;
|
||||
if (this.setupReqsTotal === 0) return false;
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.satisfied) continue;
|
||||
// API key reqs are satisfied if the user entered a value in the input
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') continue;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
getSettingKeyForReq(req) {
|
||||
// Find the matching setting key for an API key requirement.
|
||||
// Convention: setting key is the lowercase version of the requirement key.
|
||||
if (!this.setupWizard || !this.setupWizard.settings) return null;
|
||||
var lowerKey = req.key.toLowerCase();
|
||||
for (var i = 0; i < this.setupWizard.settings.length; i++) {
|
||||
if (this.setupWizard.settings[i].key === lowerKey) return lowerKey;
|
||||
}
|
||||
// Fallback: try matching by check_value lowercased
|
||||
if (req.check_value) {
|
||||
var lowerCheck = req.check_value.toLowerCase();
|
||||
for (var j = 0; j < this.setupWizard.settings.length; j++) {
|
||||
if (this.setupWizard.settings[j].key === lowerCheck) return lowerCheck;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
get setupHasReqs() {
|
||||
@@ -297,6 +341,10 @@ function handsPage() {
|
||||
},
|
||||
|
||||
setupNextStep() {
|
||||
// When leaving step 1, sync API key inputs into settings values
|
||||
if (this.setupStep === 1) {
|
||||
this._syncApiKeysToSettings();
|
||||
}
|
||||
if (this.setupStep === 1 && this.setupHasSettings) {
|
||||
this.setupStep = 2;
|
||||
} else if (this.setupStep === 1) {
|
||||
@@ -306,6 +354,19 @@ function handsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
_syncApiKeysToSettings() {
|
||||
if (!this.setupWizard || !this.setupWizard.requirements) return;
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
|
||||
var settingKey = this.getSettingKeyForReq(req);
|
||||
if (settingKey) {
|
||||
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setupPrevStep() {
|
||||
if (this.setupStep === 3 && this.setupHasSettings) {
|
||||
this.setupStep = 2;
|
||||
@@ -323,11 +384,24 @@ function handsPage() {
|
||||
this.setupChecking = false;
|
||||
this.clipboardMsg = null;
|
||||
this.installPlatforms = {};
|
||||
this.apiKeyInputs = {};
|
||||
},
|
||||
|
||||
async launchHand() {
|
||||
if (!this.setupWizard) return;
|
||||
var handId = this.setupWizard.id;
|
||||
// Sync API key inputs from step 1 into settings values
|
||||
if (this.setupWizard.requirements) {
|
||||
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
|
||||
var req = this.setupWizard.requirements[i];
|
||||
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
|
||||
var settingKey = this.getSettingKeyForReq(req);
|
||||
if (settingKey) {
|
||||
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var config = {};
|
||||
for (var key in this.settingsValues) {
|
||||
config[key] = this.settingsValues[key];
|
||||
@@ -499,6 +573,382 @@ function handsPage() {
|
||||
this.stopBrowserPolling();
|
||||
this.browserViewerOpen = false;
|
||||
this.browserViewer = null;
|
||||
},
|
||||
|
||||
// ── Trader Dashboard ──────────────────────────────────────────────────
|
||||
|
||||
isTraderHand(inst) {
|
||||
return inst.hand_id === 'trader';
|
||||
},
|
||||
|
||||
async openDashboard(inst) {
|
||||
this._dashboardInst = inst;
|
||||
this.dashboardOpen = true;
|
||||
this.dashboardLoading = true;
|
||||
this.dashboardData = null;
|
||||
await this._fetchDashboardData(inst);
|
||||
this.dashboardLoading = false;
|
||||
// Render charts after DOM update
|
||||
var self = this;
|
||||
setTimeout(function() { self._renderCharts(); }, 60);
|
||||
},
|
||||
|
||||
async refreshDashboard() {
|
||||
if (!this._dashboardInst) return;
|
||||
this.dashboardLoading = true;
|
||||
await this._fetchDashboardData(this._dashboardInst);
|
||||
this.dashboardLoading = false;
|
||||
var self = this;
|
||||
setTimeout(function() { self._renderCharts(); }, 60);
|
||||
},
|
||||
|
||||
closeDashboard() {
|
||||
this.dashboardOpen = false;
|
||||
this._destroyCharts();
|
||||
this.dashboardData = null;
|
||||
this._dashboardInst = null;
|
||||
},
|
||||
|
||||
async _fetchDashboardData(inst) {
|
||||
var data = {
|
||||
agent_name: inst.agent_name || inst.hand_id,
|
||||
portfolio_value: null,
|
||||
total_pnl: null,
|
||||
win_rate: null,
|
||||
sharpe_ratio: null,
|
||||
max_drawdown: null,
|
||||
trades_count: null,
|
||||
equity_curve: [],
|
||||
daily_pnl: [],
|
||||
watchlist_heatmap: [],
|
||||
signal_radar: null,
|
||||
recent_trades: []
|
||||
};
|
||||
|
||||
// Fetch basic stats from the hand stats endpoint
|
||||
try {
|
||||
var stats = await OpenFangAPI.get('/api/hands/instances/' + inst.instance_id + '/stats');
|
||||
var m = stats.metrics || {};
|
||||
if (m['Portfolio Value']) data.portfolio_value = this._metricVal(m['Portfolio Value']);
|
||||
if (m['Total P&L']) data.total_pnl = this._metricVal(m['Total P&L']);
|
||||
if (m['Win Rate']) data.win_rate = this._metricVal(m['Win Rate']);
|
||||
if (m['Sharpe Ratio']) data.sharpe_ratio = this._metricVal(m['Sharpe Ratio']);
|
||||
if (m['Max Drawdown']) data.max_drawdown = this._metricVal(m['Max Drawdown']);
|
||||
if (m['Trades Executed']) data.trades_count = this._metricVal(m['Trades Executed']);
|
||||
} catch(e) {
|
||||
// Stats endpoint might fail — continue with KV data
|
||||
}
|
||||
|
||||
// Fetch rich chart data from agent memory KV
|
||||
var agentId = inst.agent_id || 'shared';
|
||||
var kvKeys = [
|
||||
'trader_hand_equity_curve',
|
||||
'trader_hand_daily_pnl',
|
||||
'trader_hand_watchlist_heatmap',
|
||||
'trader_hand_signal_radar',
|
||||
'trader_hand_recent_trades',
|
||||
'trader_hand_portfolio_value',
|
||||
'trader_hand_total_pnl',
|
||||
'trader_hand_win_rate',
|
||||
'trader_hand_sharpe_ratio',
|
||||
'trader_hand_max_drawdown',
|
||||
'trader_hand_trades_count'
|
||||
];
|
||||
|
||||
for (var i = 0; i < kvKeys.length; i++) {
|
||||
try {
|
||||
var resp = await OpenFangAPI.get('/api/memory/agents/' + agentId + '/kv/' + kvKeys[i]);
|
||||
if (resp && resp.value !== null && resp.value !== undefined) {
|
||||
var val = resp.value;
|
||||
this._applyKvToData(data, kvKeys[i], val);
|
||||
}
|
||||
} catch(e) {
|
||||
// Key might not exist yet — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
this.dashboardData = data;
|
||||
},
|
||||
|
||||
_metricVal(metric) {
|
||||
if (!metric) return null;
|
||||
var v = metric.value;
|
||||
if (v === null || v === undefined) return null;
|
||||
// Values come as JSON values — could be string, number, etc.
|
||||
if (typeof v === 'string') return v;
|
||||
return String(v);
|
||||
},
|
||||
|
||||
_applyKvToData(data, key, val) {
|
||||
// Values from KV can be strings (JSON-encoded) or already parsed
|
||||
var parsed = val;
|
||||
if (typeof val === 'string') {
|
||||
try { parsed = JSON.parse(val); } catch(e) { parsed = val; }
|
||||
}
|
||||
|
||||
switch(key) {
|
||||
case 'trader_hand_portfolio_value':
|
||||
if (!data.portfolio_value) data.portfolio_value = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_total_pnl':
|
||||
if (!data.total_pnl) data.total_pnl = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_win_rate':
|
||||
if (!data.win_rate) data.win_rate = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_sharpe_ratio':
|
||||
if (!data.sharpe_ratio) data.sharpe_ratio = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_max_drawdown':
|
||||
if (!data.max_drawdown) data.max_drawdown = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_trades_count':
|
||||
if (!data.trades_count) data.trades_count = String(parsed);
|
||||
break;
|
||||
case 'trader_hand_equity_curve':
|
||||
if (Array.isArray(parsed)) data.equity_curve = parsed;
|
||||
break;
|
||||
case 'trader_hand_daily_pnl':
|
||||
if (Array.isArray(parsed)) data.daily_pnl = parsed;
|
||||
break;
|
||||
case 'trader_hand_watchlist_heatmap':
|
||||
if (Array.isArray(parsed)) data.watchlist_heatmap = parsed;
|
||||
break;
|
||||
case 'trader_hand_signal_radar':
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data.signal_radar = parsed;
|
||||
break;
|
||||
case 'trader_hand_recent_trades':
|
||||
if (Array.isArray(parsed)) data.recent_trades = parsed;
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
_destroyCharts() {
|
||||
if (this._chartEquity) { this._chartEquity.destroy(); this._chartEquity = null; }
|
||||
if (this._chartPnl) { this._chartPnl.destroy(); this._chartPnl = null; }
|
||||
if (this._chartRadar) { this._chartRadar.destroy(); this._chartRadar = null; }
|
||||
},
|
||||
|
||||
_renderCharts() {
|
||||
if (typeof Chart === 'undefined') return;
|
||||
this._destroyCharts();
|
||||
if (!this.dashboardData) return;
|
||||
|
||||
var d = this.dashboardData;
|
||||
|
||||
// Detect theme
|
||||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
var gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
||||
var textColor = isDark ? '#8A8380' : '#6B6560';
|
||||
var accentColor = '#FF5C00';
|
||||
var successColor = isDark ? '#4ADE80' : '#22C55E';
|
||||
var errorColor = '#EF4444';
|
||||
|
||||
// ── Equity Curve ──
|
||||
if (d.equity_curve && d.equity_curve.length > 0) {
|
||||
var eqCanvas = document.getElementById('traderEquityChart');
|
||||
if (eqCanvas) {
|
||||
var labels = [];
|
||||
var values = [];
|
||||
for (var i = 0; i < d.equity_curve.length; i++) {
|
||||
labels.push(d.equity_curve[i].date || '');
|
||||
values.push(parseFloat(d.equity_curve[i].value) || 0);
|
||||
}
|
||||
// Determine gradient
|
||||
var eqCtx = eqCanvas.getContext('2d');
|
||||
var gradient = eqCtx.createLinearGradient(0, 0, 0, eqCanvas.parentElement.clientHeight || 180);
|
||||
gradient.addColorStop(0, isDark ? 'rgba(255, 92, 0, 0.25)' : 'rgba(255, 92, 0, 0.15)');
|
||||
gradient.addColorStop(1, 'rgba(255, 92, 0, 0)');
|
||||
|
||||
this._chartEquity = new Chart(eqCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: values,
|
||||
borderColor: accentColor,
|
||||
backgroundColor: gradient,
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: d.equity_curve.length > 20 ? 0 : 3,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: accentColor
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
||||
titleColor: textColor,
|
||||
bodyColor: isDark ? '#e0e0e0' : '#333',
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
return '$' + ctx.parsed.y.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: gridColor },
|
||||
ticks: { color: textColor, maxTicksLimit: 8, font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 10 },
|
||||
callback: function(v) { return '$' + v.toLocaleString(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Daily P&L Bar Chart ──
|
||||
if (d.daily_pnl && d.daily_pnl.length > 0) {
|
||||
var pnlCanvas = document.getElementById('traderPnlChart');
|
||||
if (pnlCanvas) {
|
||||
var pnlLabels = [];
|
||||
var pnlValues = [];
|
||||
var pnlColors = [];
|
||||
for (var j = 0; j < d.daily_pnl.length; j++) {
|
||||
pnlLabels.push(d.daily_pnl[j].date || '');
|
||||
var pnlVal = parseFloat(d.daily_pnl[j].pnl) || 0;
|
||||
pnlValues.push(pnlVal);
|
||||
pnlColors.push(pnlVal >= 0 ? successColor : errorColor);
|
||||
}
|
||||
|
||||
this._chartPnl = new Chart(pnlCanvas.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: pnlLabels,
|
||||
datasets: [{
|
||||
data: pnlValues,
|
||||
backgroundColor: pnlColors,
|
||||
borderRadius: 3,
|
||||
borderSkipped: false
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
||||
titleColor: textColor,
|
||||
bodyColor: isDark ? '#e0e0e0' : '#333',
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
var v = ctx.parsed.y;
|
||||
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: textColor, maxTicksLimit: 7, font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
grid: { color: gridColor },
|
||||
ticks: {
|
||||
color: textColor,
|
||||
font: { size: 10 },
|
||||
callback: function(v) {
|
||||
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signal Radar Chart ──
|
||||
if (d.signal_radar) {
|
||||
var radarCanvas = document.getElementById('traderRadarChart');
|
||||
if (radarCanvas) {
|
||||
var radarLabels = [];
|
||||
var radarValues = [];
|
||||
var keys = ['technical', 'fundamental', 'sentiment', 'macro'];
|
||||
var displayLabels = ['Technical', 'Fundamental', 'Sentiment', 'Macro'];
|
||||
for (var k = 0; k < keys.length; k++) {
|
||||
radarLabels.push(displayLabels[k]);
|
||||
radarValues.push(parseFloat(d.signal_radar[keys[k]]) || 0);
|
||||
}
|
||||
|
||||
this._chartRadar = new Chart(radarCanvas.getContext('2d'), {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: radarLabels,
|
||||
datasets: [{
|
||||
data: radarValues,
|
||||
borderColor: accentColor,
|
||||
backgroundColor: isDark ? 'rgba(255, 92, 0, 0.2)' : 'rgba(255, 92, 0, 0.12)',
|
||||
borderWidth: 2,
|
||||
pointBackgroundColor: accentColor,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#1a1a1a' : '#fff',
|
||||
titleColor: textColor,
|
||||
bodyColor: isDark ? '#e0e0e0' : '#333',
|
||||
borderColor: gridColor,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
callbacks: {
|
||||
label: function(ctx) { return ctx.parsed.r + '/100'; }
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
beginAtZero: true,
|
||||
grid: { color: gridColor },
|
||||
angleLines: { color: gridColor },
|
||||
pointLabels: {
|
||||
color: textColor,
|
||||
font: { size: 11, weight: '600' }
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
backdropColor: 'transparent',
|
||||
stepSize: 25,
|
||||
font: { size: 9 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Runtime page — system overview and provider status
|
||||
document.addEventListener('alpine:init', function() {
|
||||
Alpine.data('runtimePage', function() {
|
||||
return {
|
||||
loading: true,
|
||||
uptime: '-',
|
||||
agentCount: 0,
|
||||
version: '-',
|
||||
defaultModel: '-',
|
||||
platform: '-',
|
||||
arch: '-',
|
||||
apiListen: '-',
|
||||
homeDir: '-',
|
||||
logLevel: '-',
|
||||
networkEnabled: false,
|
||||
providers: [],
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/status'),
|
||||
OpenFangAPI.get('/api/version'),
|
||||
OpenFangAPI.get('/api/providers'),
|
||||
OpenFangAPI.get('/api/agents')
|
||||
]);
|
||||
var status = results[0];
|
||||
var ver = results[1];
|
||||
var prov = results[2];
|
||||
var agents = results[3];
|
||||
|
||||
this.version = ver.version || '-';
|
||||
this.platform = ver.platform || '-';
|
||||
this.arch = ver.arch || '-';
|
||||
this.agentCount = Array.isArray(agents) ? agents.length : 0;
|
||||
this.defaultModel = status.default_model || '-';
|
||||
this.apiListen = status.api_listen || status.listen || '-';
|
||||
this.homeDir = status.home_dir || '-';
|
||||
this.logLevel = status.log_level || '-';
|
||||
this.networkEnabled = !!status.network_enabled;
|
||||
|
||||
// Compute uptime from uptime_seconds
|
||||
var diff = status.uptime_seconds || 0;
|
||||
if (diff < 60) this.uptime = diff + 's';
|
||||
else if (diff < 3600) this.uptime = Math.floor(diff / 60) + 'm ' + (diff % 60) + 's';
|
||||
else if (diff < 86400) this.uptime = Math.floor(diff / 3600) + 'h ' + Math.floor((diff % 3600) / 60) + 'm';
|
||||
else this.uptime = Math.floor(diff / 86400) + 'd ' + Math.floor((diff % 86400) / 3600) + 'h';
|
||||
|
||||
this.providers = (prov.providers || []).filter(function(p) {
|
||||
return p.auth_status === 'Configured' || p.reachable || p.is_local;
|
||||
});
|
||||
} catch(e) {
|
||||
console.error('Runtime load error:', e);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -62,8 +62,29 @@ function schedulerPage() {
|
||||
},
|
||||
|
||||
async loadJobs() {
|
||||
var data = await OpenFangAPI.get('/api/schedules');
|
||||
this.jobs = data.schedules || [];
|
||||
var data = await OpenFangAPI.get('/api/cron/jobs');
|
||||
var raw = data.jobs || [];
|
||||
// Normalize cron API response to flat fields the UI expects
|
||||
this.jobs = raw.map(function(j) {
|
||||
var cron = '';
|
||||
if (j.schedule) {
|
||||
if (j.schedule.kind === 'cron') cron = j.schedule.expr || '';
|
||||
else if (j.schedule.kind === 'every') cron = 'every ' + j.schedule.every_secs + 's';
|
||||
else if (j.schedule.kind === 'at') cron = 'at ' + (j.schedule.at || '');
|
||||
}
|
||||
return {
|
||||
id: j.id,
|
||||
name: j.name,
|
||||
cron: cron,
|
||||
agent_id: j.agent_id,
|
||||
message: j.action ? j.action.message || '' : '',
|
||||
enabled: j.enabled,
|
||||
last_run: j.last_run,
|
||||
next_run: j.next_run,
|
||||
delivery: j.delivery ? j.delivery.kind || '' : '',
|
||||
created_at: j.created_at
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async loadTriggers() {
|
||||
@@ -82,25 +103,20 @@ function schedulerPage() {
|
||||
async loadHistory() {
|
||||
this.historyLoading = true;
|
||||
try {
|
||||
// Build history from jobs with run data + recent audit entries
|
||||
var historyItems = [];
|
||||
|
||||
// Add job run info from schedule data
|
||||
var jobs = this.jobs || [];
|
||||
for (var i = 0; i < jobs.length; i++) {
|
||||
var job = jobs[i];
|
||||
if (job.last_run) {
|
||||
historyItems.push({
|
||||
timestamp: job.last_run,
|
||||
name: job.name || job.description || '(unnamed)',
|
||||
name: job.name || '(unnamed)',
|
||||
type: 'schedule',
|
||||
status: 'completed',
|
||||
run_count: job.run_count || 0
|
||||
run_count: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also load trigger fire counts
|
||||
var triggers = this.triggers || [];
|
||||
for (var j = 0; j < triggers.length; j++) {
|
||||
var t = triggers[j];
|
||||
@@ -114,12 +130,9 @@ function schedulerPage() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp descending
|
||||
historyItems.sort(function(a, b) {
|
||||
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
||||
});
|
||||
|
||||
this.history = historyItems;
|
||||
} catch(e) {
|
||||
this.history = [];
|
||||
@@ -141,13 +154,15 @@ function schedulerPage() {
|
||||
this.creating = true;
|
||||
try {
|
||||
var jobName = this.newJob.name;
|
||||
await OpenFangAPI.post('/api/schedules', {
|
||||
name: this.newJob.name,
|
||||
cron: this.newJob.cron,
|
||||
var body = {
|
||||
agent_id: this.newJob.agent_id,
|
||||
message: this.newJob.message,
|
||||
name: this.newJob.name,
|
||||
schedule: { kind: 'cron', expr: this.newJob.cron },
|
||||
action: { kind: 'agent_turn', message: this.newJob.message || 'Scheduled task: ' + this.newJob.name },
|
||||
delivery: { kind: 'last_channel' },
|
||||
enabled: this.newJob.enabled
|
||||
});
|
||||
};
|
||||
await OpenFangAPI.post('/api/cron/jobs', body);
|
||||
this.showCreateForm = false;
|
||||
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
|
||||
OpenFangToast.success('Schedule "' + jobName + '" created');
|
||||
@@ -161,7 +176,7 @@ function schedulerPage() {
|
||||
async toggleJob(job) {
|
||||
try {
|
||||
var newState = !job.enabled;
|
||||
await OpenFangAPI.put('/api/schedules/' + job.id, { enabled: newState });
|
||||
await OpenFangAPI.put('/api/cron/jobs/' + job.id + '/enable', { enabled: newState });
|
||||
job.enabled = newState;
|
||||
OpenFangToast.success('Schedule ' + (newState ? 'enabled' : 'paused'));
|
||||
} catch(e) {
|
||||
@@ -174,7 +189,7 @@ function schedulerPage() {
|
||||
var jobName = job.name || job.id;
|
||||
OpenFangToast.confirm('Delete Schedule', 'Delete "' + jobName + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/schedules/' + job.id);
|
||||
await OpenFangAPI.del('/api/cron/jobs/' + job.id);
|
||||
self.jobs = self.jobs.filter(function(j) { return j.id !== job.id; });
|
||||
OpenFangToast.success('Schedule "' + jobName + '" deleted');
|
||||
} catch(e) {
|
||||
@@ -189,19 +204,17 @@ function schedulerPage() {
|
||||
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
|
||||
if (result.status === 'completed') {
|
||||
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
|
||||
// Update the job's last_run locally
|
||||
job.last_run = new Date().toISOString();
|
||||
job.run_count = (job.run_count || 0) + 1;
|
||||
} else {
|
||||
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to run schedule: ' + (e.message || e));
|
||||
OpenFangToast.error('Run Now is not yet available for cron jobs');
|
||||
}
|
||||
this.runningJobId = '';
|
||||
},
|
||||
|
||||
// ── Trigger helpers (reused from workflows page) ──
|
||||
// ── Trigger helpers ──
|
||||
|
||||
triggerType(pattern) {
|
||||
if (!pattern) return 'unknown';
|
||||
@@ -259,13 +272,16 @@ function schedulerPage() {
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i].id === agentId) return agents[i].name;
|
||||
}
|
||||
// Truncate UUID
|
||||
if (agentId.length > 12) return agentId.substring(0, 8) + '...';
|
||||
return agentId;
|
||||
},
|
||||
|
||||
describeCron(expr) {
|
||||
if (!expr) return '';
|
||||
// Handle non-cron schedule descriptions
|
||||
if (expr.indexOf('every ') === 0) return expr;
|
||||
if (expr.indexOf('at ') === 0) return 'One-time: ' + expr.substring(3);
|
||||
|
||||
var map = {
|
||||
'* * * * *': 'Every minute',
|
||||
'*/2 * * * *': 'Every 2 minutes',
|
||||
@@ -291,7 +307,6 @@ function schedulerPage() {
|
||||
};
|
||||
if (map[expr]) return map[expr];
|
||||
|
||||
// Try to parse common patterns
|
||||
var parts = expr.split(' ');
|
||||
if (parts.length !== 5) return expr;
|
||||
|
||||
@@ -301,22 +316,26 @@ function schedulerPage() {
|
||||
var mon = parts[3];
|
||||
var dow = parts[4];
|
||||
|
||||
// "*/N * * * *" patterns
|
||||
if (min.indexOf('*/') === 0 && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
|
||||
return 'Every ' + min.substring(2) + ' minutes';
|
||||
}
|
||||
// "0 */N * * *" patterns
|
||||
if (min === '0' && hour.indexOf('*/') === 0 && dom === '*' && mon === '*' && dow === '*') {
|
||||
return 'Every ' + hour.substring(2) + ' hours';
|
||||
}
|
||||
// "M H * * *" — daily at specific time
|
||||
if (dom === '*' && mon === '*' && dow === '*' && min.match(/^\d+$/) && hour.match(/^\d+$/)) {
|
||||
|
||||
var dowNames = { '0': 'Sun', '1': 'Mon', '2': 'Tue', '3': 'Wed', '4': 'Thu', '5': 'Fri', '6': 'Sat', '7': 'Sun',
|
||||
'1-5': 'Weekdays', '0,6': 'Weekends', '6,0': 'Weekends' };
|
||||
|
||||
if (dom === '*' && mon === '*' && min.match(/^\d+$/) && hour.match(/^\d+$/)) {
|
||||
var h = parseInt(hour, 10);
|
||||
var m = parseInt(min, 10);
|
||||
var ampm = h >= 12 ? 'PM' : 'AM';
|
||||
var h12 = h === 0 ? 12 : (h > 12 ? h - 12 : h);
|
||||
var mStr = m < 10 ? '0' + m : '' + m;
|
||||
return 'Daily at ' + h12 + ':' + mStr + ' ' + ampm;
|
||||
var timeStr = h12 + ':' + mStr + ' ' + ampm;
|
||||
if (dow === '*') return 'Daily at ' + timeStr;
|
||||
var dowLabel = dowNames[dow] || ('DoW ' + dow);
|
||||
return dowLabel + ' at ' + timeStr;
|
||||
}
|
||||
|
||||
return expr;
|
||||
@@ -340,7 +359,14 @@ function schedulerPage() {
|
||||
try {
|
||||
var diff = Date.now() - new Date(ts).getTime();
|
||||
if (isNaN(diff)) return 'never';
|
||||
if (diff < 0) return 'just now';
|
||||
if (diff < 0) {
|
||||
// Future time
|
||||
var absDiff = Math.abs(diff);
|
||||
if (absDiff < 60000) return 'in <1m';
|
||||
if (absDiff < 3600000) return 'in ' + Math.floor(absDiff / 60000) + 'm';
|
||||
if (absDiff < 86400000) return 'in ' + Math.floor(absDiff / 3600000) + 'h';
|
||||
return 'in ' + Math.floor(absDiff / 86400000) + 'd';
|
||||
}
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
||||
|
||||
@@ -14,9 +14,23 @@ function settingsPage() {
|
||||
modelSearch: '',
|
||||
modelProviderFilter: '',
|
||||
modelTierFilter: '',
|
||||
showCustomModelForm: false,
|
||||
customModelId: '',
|
||||
customModelProvider: 'openrouter',
|
||||
customModelContext: 128000,
|
||||
customModelMaxOutput: 8192,
|
||||
customModelStatus: '',
|
||||
providerKeyInputs: {},
|
||||
providerUrlInputs: {},
|
||||
providerUrlSaving: {},
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
customProviderName: '',
|
||||
customProviderUrl: '',
|
||||
customProviderKey: '',
|
||||
customProviderStatus: '',
|
||||
addingCustomProvider: false,
|
||||
loading: true,
|
||||
loadError: '',
|
||||
|
||||
@@ -208,6 +222,17 @@ function settingsPage() {
|
||||
try {
|
||||
var data = await OpenFangAPI.get('/api/providers');
|
||||
this.providers = data.providers || [];
|
||||
for (var i = 0; i < this.providers.length; i++) {
|
||||
var p = this.providers[i];
|
||||
if (p.is_local) {
|
||||
if (!this.providerUrlInputs[p.id]) {
|
||||
this.providerUrlInputs[p.id] = p.base_url || '';
|
||||
}
|
||||
if (this.providerUrlSaving[p.id] === undefined) {
|
||||
this.providerUrlSaving[p.id] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) { this.providers = []; }
|
||||
},
|
||||
|
||||
@@ -218,6 +243,37 @@ function settingsPage() {
|
||||
} catch(e) { this.models = []; }
|
||||
},
|
||||
|
||||
async addCustomModel() {
|
||||
var id = this.customModelId.trim();
|
||||
if (!id) return;
|
||||
this.customModelStatus = 'Adding...';
|
||||
try {
|
||||
await OpenFangAPI.post('/api/models/custom', {
|
||||
id: id,
|
||||
provider: this.customModelProvider || 'openrouter',
|
||||
context_window: this.customModelContext || 128000,
|
||||
max_output_tokens: this.customModelMaxOutput || 8192,
|
||||
});
|
||||
this.customModelStatus = 'Added!';
|
||||
this.customModelId = '';
|
||||
this.showCustomModelForm = false;
|
||||
await this.loadModels();
|
||||
} catch(e) {
|
||||
this.customModelStatus = 'Error: ' + (e.message || 'Failed');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCustomModel(modelId) {
|
||||
if (!confirm('Delete custom model "' + modelId + '"?')) return;
|
||||
try {
|
||||
await OpenFangAPI.del('/api/models/custom/' + encodeURIComponent(modelId));
|
||||
OpenFangToast.success('Model deleted');
|
||||
await this.loadModels();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete: ' + (e.message || 'Unknown error'));
|
||||
}
|
||||
},
|
||||
|
||||
async loadConfigSchema() {
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
@@ -239,11 +295,14 @@ function settingsPage() {
|
||||
|
||||
async saveConfigField(section, field, value) {
|
||||
var key = section + '.' + field;
|
||||
// Root-level fields (api_key, api_listen, log_level) use just the field name
|
||||
var sectionMeta = this.configSchema && this.configSchema[section];
|
||||
var path = (sectionMeta && sectionMeta.root_level) ? field : key;
|
||||
this.configSaving[key] = true;
|
||||
try {
|
||||
await OpenFangAPI.post('/api/config/set', { path: key, value: value });
|
||||
await OpenFangAPI.post('/api/config/set', { path: path, value: value });
|
||||
this.configDirty[key] = false;
|
||||
OpenFangToast.success('Saved ' + key);
|
||||
OpenFangToast.success('Saved ' + field);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save: ' + e.message);
|
||||
}
|
||||
@@ -293,7 +352,10 @@ function settingsPage() {
|
||||
|
||||
providerAuthText(p) {
|
||||
if (p.auth_status === 'configured') return 'Configured';
|
||||
if (p.auth_status === 'not_set' || p.auth_status === 'missing') return 'Not Set';
|
||||
if (p.auth_status === 'not_set' || p.auth_status === 'missing') {
|
||||
if (p.id === 'claude-code') return 'Not Installed';
|
||||
return 'Not Set';
|
||||
}
|
||||
return 'No Key Needed';
|
||||
},
|
||||
|
||||
@@ -360,6 +422,54 @@ function settingsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
async startCopilotOAuth() {
|
||||
this.copilotOAuth.polling = true;
|
||||
this.copilotOAuth.userCode = '';
|
||||
try {
|
||||
var resp = await OpenFangAPI.post('/api/providers/github-copilot/oauth/start', {});
|
||||
this.copilotOAuth.userCode = resp.user_code;
|
||||
this.copilotOAuth.verificationUri = resp.verification_uri;
|
||||
this.copilotOAuth.pollId = resp.poll_id;
|
||||
this.copilotOAuth.interval = resp.interval || 5;
|
||||
window.open(resp.verification_uri, '_blank');
|
||||
this.pollCopilotOAuth();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to start Copilot login: ' + e.message);
|
||||
this.copilotOAuth.polling = false;
|
||||
}
|
||||
},
|
||||
|
||||
pollCopilotOAuth() {
|
||||
var self = this;
|
||||
setTimeout(async function() {
|
||||
if (!self.copilotOAuth.pollId) return;
|
||||
try {
|
||||
var resp = await OpenFangAPI.get('/api/providers/github-copilot/oauth/poll/' + self.copilotOAuth.pollId);
|
||||
if (resp.status === 'complete') {
|
||||
OpenFangToast.success('GitHub Copilot authenticated successfully!');
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
await self.loadProviders();
|
||||
await self.loadModels();
|
||||
} else if (resp.status === 'pending') {
|
||||
if (resp.interval) self.copilotOAuth.interval = resp.interval;
|
||||
self.pollCopilotOAuth();
|
||||
} else if (resp.status === 'expired') {
|
||||
OpenFangToast.error('Device code expired. Please try again.');
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
} else if (resp.status === 'denied') {
|
||||
OpenFangToast.error('Access denied by user.');
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
} else {
|
||||
OpenFangToast.error('OAuth error: ' + (resp.error || resp.status));
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Poll error: ' + e.message);
|
||||
self.copilotOAuth = { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 };
|
||||
}
|
||||
}, self.copilotOAuth.interval * 1000);
|
||||
},
|
||||
|
||||
async testProvider(provider) {
|
||||
this.providerTesting[provider.id] = true;
|
||||
this.providerTestResults[provider.id] = null;
|
||||
@@ -378,6 +488,56 @@ function settingsPage() {
|
||||
this.providerTesting[provider.id] = false;
|
||||
},
|
||||
|
||||
async saveProviderUrl(provider) {
|
||||
var url = this.providerUrlInputs[provider.id];
|
||||
if (!url || !url.trim()) { OpenFangToast.error('Please enter a base URL'); return; }
|
||||
url = url.trim();
|
||||
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
||||
OpenFangToast.error('URL must start with http:// or https://'); return;
|
||||
}
|
||||
this.providerUrlSaving[provider.id] = true;
|
||||
try {
|
||||
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(provider.id) + '/url', { base_url: url });
|
||||
if (result.reachable) {
|
||||
OpenFangToast.success(provider.display_name + ' URL saved — reachable (' + (result.latency_ms || '?') + 'ms)');
|
||||
} else {
|
||||
OpenFangToast.warning(provider.display_name + ' URL saved but not reachable');
|
||||
}
|
||||
await this.loadProviders();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save URL: ' + e.message);
|
||||
}
|
||||
this.providerUrlSaving[provider.id] = false;
|
||||
},
|
||||
|
||||
async addCustomProvider() {
|
||||
var name = this.customProviderName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
||||
if (!name) { OpenFangToast.error('Please enter a provider name'); return; }
|
||||
var url = this.customProviderUrl.trim();
|
||||
if (!url) { OpenFangToast.error('Please enter a base URL'); return; }
|
||||
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
||||
OpenFangToast.error('URL must start with http:// or https://'); return;
|
||||
}
|
||||
this.addingCustomProvider = true;
|
||||
this.customProviderStatus = '';
|
||||
try {
|
||||
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(name) + '/url', { base_url: url });
|
||||
if (this.customProviderKey.trim()) {
|
||||
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(name) + '/key', { key: this.customProviderKey.trim() });
|
||||
}
|
||||
this.customProviderName = '';
|
||||
this.customProviderUrl = '';
|
||||
this.customProviderKey = '';
|
||||
this.customProviderStatus = '';
|
||||
OpenFangToast.success('Provider "' + name + '" added' + (result.reachable ? ' (reachable)' : ' (not reachable yet)'));
|
||||
await this.loadProviders();
|
||||
} catch(e) {
|
||||
this.customProviderStatus = 'Error: ' + (e.message || 'Failed');
|
||||
OpenFangToast.error('Failed to add provider: ' + e.message);
|
||||
}
|
||||
this.addingCustomProvider = false;
|
||||
},
|
||||
|
||||
// -- Security methods --
|
||||
async loadSecurity() {
|
||||
this.secLoading = true;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
// OpenFang Setup Wizard — First-run guided setup (Provider + Agent + Channel)
|
||||
'use strict';
|
||||
|
||||
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n"""). */
|
||||
function wizardTomlMultilineEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
|
||||
}
|
||||
|
||||
/** Escape a string for use inside a TOML basic (single-line) string ("..."). */
|
||||
function wizardTomlBasicEscape(s) {
|
||||
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
function wizardPage() {
|
||||
return {
|
||||
step: 1,
|
||||
@@ -283,11 +293,13 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get canGoNext() {
|
||||
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider;
|
||||
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider || this.claudeCodeDetected;
|
||||
if (this.step === 3) return this.agentName.trim().length > 0;
|
||||
return true;
|
||||
},
|
||||
|
||||
claudeCodeDetected: false,
|
||||
|
||||
get hasConfiguredProvider() {
|
||||
var self = this;
|
||||
return this.providers.some(function(p) {
|
||||
@@ -320,7 +332,7 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get popularProviders() {
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
|
||||
return this.providers.filter(function(p) {
|
||||
return popular.indexOf(p.id) >= 0;
|
||||
}).sort(function(a, b) {
|
||||
@@ -329,7 +341,7 @@ function wizardPage() {
|
||||
},
|
||||
|
||||
get otherProviders() {
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
|
||||
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
|
||||
return this.providers.filter(function(p) {
|
||||
return popular.indexOf(p.id) < 0;
|
||||
});
|
||||
@@ -355,7 +367,8 @@ function wizardPage() {
|
||||
fireworks: { url: 'https://fireworks.ai/account/api-keys', text: 'Get your key from Fireworks AI' },
|
||||
perplexity: { url: 'https://www.perplexity.ai/settings/api', text: 'Get your key from Perplexity Settings' },
|
||||
cohere: { url: 'https://dashboard.cohere.com/api-keys', text: 'Get your key from the Cohere Dashboard' },
|
||||
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' }
|
||||
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' },
|
||||
'claude-code': { url: 'https://docs.anthropic.com/en/docs/claude-code', text: 'Install: npm install -g @anthropic-ai/claude-code && claude auth (no API key needed)' }
|
||||
};
|
||||
return help[id] || null;
|
||||
},
|
||||
@@ -408,6 +421,28 @@ function wizardPage() {
|
||||
this.testingProvider = false;
|
||||
},
|
||||
|
||||
async detectClaudeCode() {
|
||||
this.testingProvider = true;
|
||||
this.testResult = null;
|
||||
try {
|
||||
var result = await OpenFangAPI.post('/api/providers/claude-code/test', {});
|
||||
this.testResult = result;
|
||||
if (result.status === 'ok') {
|
||||
this.claudeCodeDetected = true;
|
||||
this.keySaved = true;
|
||||
this.setupSummary.provider = 'Claude Code';
|
||||
OpenFangToast.success('Claude Code detected (' + (result.latency_ms || '?') + 'ms)');
|
||||
} else {
|
||||
this.testResult = { status: 'error', error: 'Claude Code CLI not detected' };
|
||||
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
|
||||
}
|
||||
} catch(e) {
|
||||
this.testResult = { status: 'error', error: e.message };
|
||||
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
|
||||
}
|
||||
this.testingProvider = false;
|
||||
},
|
||||
|
||||
// ── Step 3: Agent creation ──
|
||||
|
||||
selectTemplate(index) {
|
||||
@@ -437,12 +472,12 @@ function wizardPage() {
|
||||
}
|
||||
|
||||
var toml = '[agent]\n';
|
||||
toml += 'name = "' + name.replace(/"/g, '\\"') + '"\n';
|
||||
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
|
||||
toml += 'name = "' + wizardTomlBasicEscape(name) + '"\n';
|
||||
toml += 'description = "' + wizardTomlBasicEscape(tpl.description) + '"\n';
|
||||
toml += 'profile = "' + tpl.profile + '"\n\n';
|
||||
toml += '[model]\nprovider = "' + provider + '"\n';
|
||||
toml += 'name = "' + model + '"\n\n';
|
||||
toml += '[prompt]\nsystem = """\n' + tpl.system_prompt + '\n"""\n';
|
||||
toml += 'model = "' + model + '"\n';
|
||||
toml += 'system_prompt = """\n' + wizardTomlMultilineEscape(tpl.system_prompt) + '\n"""\n';
|
||||
|
||||
this.creatingAgent = true;
|
||||
try {
|
||||
@@ -468,13 +503,14 @@ function wizardPage() {
|
||||
gemini: 'gemini-2.5-flash',
|
||||
groq: 'llama-3.3-70b-versatile',
|
||||
deepseek: 'deepseek-chat',
|
||||
openrouter: 'openrouter/auto',
|
||||
openrouter: 'openrouter/google/gemini-2.5-flash',
|
||||
mistral: 'mistral-large-latest',
|
||||
together: 'meta-llama/Llama-3-70b-chat-hf',
|
||||
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
||||
perplexity: 'llama-3.1-sonar-large-128k-online',
|
||||
cohere: 'command-r-plus',
|
||||
xai: 'grok-2'
|
||||
xai: 'grok-2',
|
||||
'claude-code': 'claude-code/sonnet'
|
||||
};
|
||||
return defaults[providerId] || '';
|
||||
},
|
||||
|
||||
@@ -37,6 +37,13 @@ function workflowBuilder() {
|
||||
{ type: 'end', label: 'End', color: '#ef4444', icon: 'E', ports: { in: 1, out: 0 } }
|
||||
],
|
||||
|
||||
_renderScheduled: false,
|
||||
_lastClickNodeId: null,
|
||||
_lastClickTime: 0,
|
||||
_didDrag: false,
|
||||
_didConnect: false,
|
||||
_didPan: false,
|
||||
|
||||
async init() {
|
||||
var self = this;
|
||||
// Load agents for the agent step dropdown
|
||||
@@ -50,6 +57,157 @@ function workflowBuilder() {
|
||||
self.addNode('start', 60, 200);
|
||||
},
|
||||
|
||||
// ── SVG Manual Rendering ────────────────────────────
|
||||
// Alpine.js x-for inside <svg> breaks because document.importNode
|
||||
// doesn't handle SVG namespace correctly. We render nodes/connections
|
||||
// manually via createElementNS and schedule re-renders reactively.
|
||||
|
||||
scheduleRender: function() {
|
||||
if (this._renderScheduled) return;
|
||||
this._renderScheduled = true;
|
||||
var self = this;
|
||||
requestAnimationFrame(function() {
|
||||
self._renderScheduled = false;
|
||||
self.renderCanvas();
|
||||
});
|
||||
},
|
||||
|
||||
renderCanvas: function() {
|
||||
var container = document.getElementById('wf-render-group');
|
||||
if (!container) return;
|
||||
var SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
var self = this;
|
||||
|
||||
// Clear previous rendered content
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
// ── Connections ──
|
||||
for (var ci = 0; ci < this.connections.length; ci++) {
|
||||
var conn = this.connections[ci];
|
||||
var d = this.getConnectionPath(conn);
|
||||
if (!d) continue;
|
||||
var path = document.createElementNS(SVG_NS, 'path');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('fill', 'none');
|
||||
path.setAttribute('stroke', (this.selectedConnection && this.selectedConnection.id === conn.id) ? 'var(--accent)' : 'var(--text-dim)');
|
||||
path.setAttribute('stroke-width', (this.selectedConnection && this.selectedConnection.id === conn.id) ? '3' : '2');
|
||||
path.style.cursor = 'pointer';
|
||||
(function(c) {
|
||||
path.addEventListener('click', function(e) { e.stopPropagation(); self.selectedConnection = c; self.scheduleRender(); });
|
||||
})(conn);
|
||||
container.appendChild(path);
|
||||
}
|
||||
|
||||
// ── Connection preview ──
|
||||
if (this.connecting && this.connectPreview) {
|
||||
var pd = this.getPreviewPath();
|
||||
if (pd) {
|
||||
var preview = document.createElementNS(SVG_NS, 'path');
|
||||
preview.setAttribute('d', pd);
|
||||
preview.setAttribute('fill', 'none');
|
||||
preview.setAttribute('stroke', 'var(--accent)');
|
||||
preview.setAttribute('stroke-width', '2');
|
||||
preview.setAttribute('stroke-dasharray', '6,3');
|
||||
container.appendChild(preview);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nodes ──
|
||||
for (var ni = 0; ni < this.nodes.length; ni++) {
|
||||
var node = this.nodes[ni];
|
||||
var g = document.createElementNS(SVG_NS, 'g');
|
||||
g.classList.add('wf-node');
|
||||
g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')');
|
||||
(function(n) {
|
||||
g.addEventListener('mousedown', function(e) { self.onNodeMouseDown(n, e); });
|
||||
g.addEventListener('dblclick', function() { self.editNode(n); });
|
||||
})(node);
|
||||
|
||||
// Node body rect
|
||||
var rect = document.createElementNS(SVG_NS, 'rect');
|
||||
rect.setAttribute('x', '0'); rect.setAttribute('y', '0');
|
||||
rect.setAttribute('width', node.width); rect.setAttribute('height', node.height);
|
||||
rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8');
|
||||
rect.setAttribute('fill', (self.selectedNode && self.selectedNode.id === node.id) ? 'var(--card-bg)' : 'var(--bg-secondary)');
|
||||
rect.setAttribute('stroke', (self.selectedNode && self.selectedNode.id === node.id) ? node.color : 'var(--border)');
|
||||
rect.setAttribute('stroke-width', '2');
|
||||
rect.style.cursor = 'grab';
|
||||
g.appendChild(rect);
|
||||
|
||||
// Color accent bar
|
||||
var bar = document.createElementNS(SVG_NS, 'rect');
|
||||
bar.setAttribute('x', '0'); bar.setAttribute('y', '0');
|
||||
bar.setAttribute('width', '6'); bar.setAttribute('height', node.height);
|
||||
bar.setAttribute('rx', '3'); bar.setAttribute('ry', '0');
|
||||
bar.setAttribute('fill', node.color);
|
||||
g.appendChild(bar);
|
||||
|
||||
// Icon circle + text
|
||||
var circle = document.createElementNS(SVG_NS, 'circle');
|
||||
circle.setAttribute('cx', '28'); circle.setAttribute('cy', node.height / 2);
|
||||
circle.setAttribute('r', '14'); circle.setAttribute('fill', node.color);
|
||||
circle.setAttribute('opacity', '0.15');
|
||||
g.appendChild(circle);
|
||||
|
||||
var iconText = document.createElementNS(SVG_NS, 'text');
|
||||
iconText.setAttribute('x', '28'); iconText.setAttribute('y', node.height / 2 + 4);
|
||||
iconText.setAttribute('text-anchor', 'middle'); iconText.setAttribute('fill', node.color);
|
||||
iconText.setAttribute('style', 'font-size:12px;font-weight:700;pointer-events:none');
|
||||
iconText.textContent = node.icon;
|
||||
g.appendChild(iconText);
|
||||
|
||||
// Label
|
||||
var label = document.createElementNS(SVG_NS, 'text');
|
||||
label.setAttribute('x', '50'); label.setAttribute('y', node.height / 2 - 4);
|
||||
label.setAttribute('fill', 'var(--text)');
|
||||
label.setAttribute('style', 'font-size:12px;font-weight:600;pointer-events:none');
|
||||
label.textContent = node.label;
|
||||
g.appendChild(label);
|
||||
|
||||
// Sub-label
|
||||
var subLabel = document.createElementNS(SVG_NS, 'text');
|
||||
subLabel.setAttribute('x', '50'); subLabel.setAttribute('y', node.height / 2 + 12);
|
||||
subLabel.setAttribute('fill', 'var(--text-dim)');
|
||||
subLabel.setAttribute('style', 'font-size:10px;pointer-events:none');
|
||||
if (node.type === 'agent') subLabel.textContent = node.config.agent_name || 'No agent';
|
||||
else if (node.type === 'condition') subLabel.textContent = node.config.expression || 'No condition';
|
||||
else if (node.type === 'loop') subLabel.textContent = 'max ' + (node.config.max_iterations || 5) + ' iters';
|
||||
else if (node.type === 'parallel') subLabel.textContent = (node.config.fan_count || 3) + ' branches';
|
||||
else if (node.type === 'collect') subLabel.textContent = node.config.strategy || 'all';
|
||||
g.appendChild(subLabel);
|
||||
|
||||
// Input ports
|
||||
for (var pi = 0; pi < node.ports.in; pi++) {
|
||||
var inp = document.createElementNS(SVG_NS, 'circle');
|
||||
inp.classList.add('wf-port', 'wf-port-in');
|
||||
inp.setAttribute('cx', node.width / (node.ports.in + 1) * (pi + 1));
|
||||
inp.setAttribute('cy', '0'); inp.setAttribute('r', '6');
|
||||
inp.setAttribute('fill', 'var(--bg-secondary)');
|
||||
inp.setAttribute('stroke', 'var(--text-dim)'); inp.setAttribute('stroke-width', '2');
|
||||
(function(nid, idx) {
|
||||
inp.addEventListener('mouseup', function(e) { e.stopPropagation(); self.endConnect(nid, idx, e); });
|
||||
})(node.id, pi);
|
||||
g.appendChild(inp);
|
||||
}
|
||||
|
||||
// Output ports
|
||||
for (var po = 0; po < node.ports.out; po++) {
|
||||
var outp = document.createElementNS(SVG_NS, 'circle');
|
||||
outp.classList.add('wf-port', 'wf-port-out');
|
||||
outp.setAttribute('cx', node.width / (node.ports.out + 1) * (po + 1));
|
||||
outp.setAttribute('cy', node.height); outp.setAttribute('r', '6');
|
||||
outp.setAttribute('fill', 'var(--bg-secondary)');
|
||||
outp.setAttribute('stroke', node.color); outp.setAttribute('stroke-width', '2');
|
||||
(function(nid, idx) {
|
||||
outp.addEventListener('mousedown', function(e) { e.stopPropagation(); self.startConnect(nid, idx, e); });
|
||||
})(node.id, po);
|
||||
g.appendChild(outp);
|
||||
}
|
||||
|
||||
container.appendChild(g);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Node Management ──────────────────────────────────
|
||||
|
||||
addNode: function(type, x, y) {
|
||||
@@ -83,6 +241,7 @@ function workflowBuilder() {
|
||||
node.config = { strategy: 'all' };
|
||||
}
|
||||
this.nodes.push(node);
|
||||
this.scheduleRender();
|
||||
return node;
|
||||
},
|
||||
|
||||
@@ -95,6 +254,7 @@ function workflowBuilder() {
|
||||
this.selectedNode = null;
|
||||
this.showNodeEditor = false;
|
||||
}
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
duplicateNode: function(node) {
|
||||
@@ -166,19 +326,36 @@ function workflowBuilder() {
|
||||
}
|
||||
this.connecting = null;
|
||||
this.connectPreview = null;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
deleteConnection: function(connId) {
|
||||
this.connections = this.connections.filter(function(c) { return c.id !== connId; });
|
||||
this.selectedConnection = null;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── Drag Handling ────────────────────────────────────
|
||||
|
||||
onNodeMouseDown: function(node, e) {
|
||||
e.stopPropagation();
|
||||
// Detect double-click manually — the native dblclick event never fires
|
||||
// because scheduleRender() destroys and recreates all SVG elements between
|
||||
// the first and second click, so the browser loses the DOM target for dblclick.
|
||||
var now = Date.now();
|
||||
if (this._lastClickNodeId === node.id && (now - this._lastClickTime) < 350) {
|
||||
// Double-click detected — open editor instead of starting drag
|
||||
this._lastClickNodeId = null;
|
||||
this._lastClickTime = 0;
|
||||
this.editNode(node);
|
||||
return;
|
||||
}
|
||||
this._lastClickNodeId = node.id;
|
||||
this._lastClickTime = now;
|
||||
|
||||
this.selectedNode = node;
|
||||
this.selectedConnection = null;
|
||||
this._didDrag = false;
|
||||
this.dragging = node.id;
|
||||
var rect = this._getCanvasRect();
|
||||
this.dragOffset = {
|
||||
@@ -193,6 +370,7 @@ function workflowBuilder() {
|
||||
this.selectedConnection = null;
|
||||
this.showNodeEditor = false;
|
||||
// Start canvas pan
|
||||
this._didPan = false;
|
||||
this.canvasDragging = true;
|
||||
this.canvasDragStart = { x: e.clientX - this.canvasOffset.x * this.zoom, y: e.clientY - this.canvasOffset.y * this.zoom };
|
||||
},
|
||||
@@ -200,17 +378,22 @@ function workflowBuilder() {
|
||||
onCanvasMouseMove: function(e) {
|
||||
var rect = this._getCanvasRect();
|
||||
if (this.dragging) {
|
||||
this._didDrag = true;
|
||||
var node = this.getNode(this.dragging);
|
||||
if (node) {
|
||||
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
|
||||
node.y = Math.max(0, (e.clientY - rect.top) / this.zoom - this.canvasOffset.y - this.dragOffset.y);
|
||||
}
|
||||
this.scheduleRender();
|
||||
} else if (this.connecting) {
|
||||
this._didConnect = true;
|
||||
this.connectPreview = {
|
||||
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
|
||||
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
|
||||
};
|
||||
this.scheduleRender();
|
||||
} else if (this.canvasDragging) {
|
||||
this._didPan = true;
|
||||
this.canvasOffset = {
|
||||
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
|
||||
y: (e.clientY - this.canvasDragStart.y) / this.zoom
|
||||
@@ -219,10 +402,19 @@ function workflowBuilder() {
|
||||
},
|
||||
|
||||
onCanvasMouseUp: function() {
|
||||
// Only re-render if something actually moved. Rendering on every mouseup
|
||||
// destroys SVG elements between clicks, which prevents dblclick detection.
|
||||
var needsRender = this._didDrag || this._didConnect || this._didPan;
|
||||
this.dragging = null;
|
||||
this.connecting = null;
|
||||
this.connectPreview = null;
|
||||
this.canvasDragging = false;
|
||||
this._didDrag = false;
|
||||
this._didConnect = false;
|
||||
this._didPan = false;
|
||||
if (needsRender) {
|
||||
this.scheduleRender();
|
||||
}
|
||||
},
|
||||
|
||||
onCanvasWheel: function(e) {
|
||||
@@ -267,6 +459,12 @@ function workflowBuilder() {
|
||||
editNode: function(node) {
|
||||
this.selectedNode = node;
|
||||
this.showNodeEditor = true;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// Called from editor panel inputs to reflect changes on the canvas SVG
|
||||
applyNodeEdit: function() {
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── TOML Generation ──────────────────────────────────
|
||||
@@ -386,7 +584,7 @@ function workflowBuilder() {
|
||||
var rect = this._getCanvasRect();
|
||||
var x = (e.clientX - rect.left) / this.zoom - this.canvasOffset.x;
|
||||
var y = (e.clientY - rect.top) / this.zoom - this.canvasOffset.y;
|
||||
this.addNode(type, x - 90, y - 35);
|
||||
this.addNode(type, x - 90, y - 35); // addNode already calls scheduleRender
|
||||
},
|
||||
|
||||
onCanvasDragOver: function(e) {
|
||||
@@ -405,6 +603,7 @@ function workflowBuilder() {
|
||||
this.nodes[i].y = y;
|
||||
y += 120;
|
||||
}
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── Clear ────────────────────────────────────────────
|
||||
@@ -414,7 +613,7 @@ function workflowBuilder() {
|
||||
this.connections = [];
|
||||
this.selectedNode = null;
|
||||
this.nextId = 1;
|
||||
this.addNode('start', 60, 200);
|
||||
this.addNode('start', 60, 200); // addNode already calls scheduleRender
|
||||
},
|
||||
|
||||
// ── Zoom controls ────────────────────────────────────
|
||||
|
||||
@@ -13,6 +13,8 @@ function workflowsPage() {
|
||||
loading: true,
|
||||
loadError: '',
|
||||
newWf: { name: '', description: '', steps: [{ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' }] },
|
||||
editModal: null,
|
||||
editWf: { name: '', description: '', steps: [] },
|
||||
|
||||
// -- Workflows methods --
|
||||
async loadWorkflows() {
|
||||
@@ -74,6 +76,57 @@ function workflowsPage() {
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to load run history: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWorkflow(wf) {
|
||||
if (!confirm('Delete workflow "' + wf.name + '"? This cannot be undone.')) return;
|
||||
try {
|
||||
await OpenFangAPI.delete('/api/workflows/' + wf.id);
|
||||
OpenFangToast.success('Workflow "' + wf.name + '" deleted');
|
||||
await this.loadWorkflows();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete workflow: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async showEditModal(wf) {
|
||||
try {
|
||||
var full = await OpenFangAPI.get('/api/workflows/' + wf.id);
|
||||
this.editWf = {
|
||||
name: full.name || '',
|
||||
description: full.description || '',
|
||||
steps: (full.steps || []).map(function(s) {
|
||||
return {
|
||||
name: s.name || '',
|
||||
agent_name: (s.agent && s.agent.name) || '',
|
||||
mode: s.mode || 'sequential',
|
||||
prompt: s.prompt_template || '{{input}}'
|
||||
};
|
||||
})
|
||||
};
|
||||
if (this.editWf.steps.length === 0) {
|
||||
this.editWf.steps.push({ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' });
|
||||
}
|
||||
this.editModal = wf;
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to load workflow: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async saveWorkflow() {
|
||||
if (!this.editModal) return;
|
||||
var steps = this.editWf.steps.map(function(s) {
|
||||
return { name: s.name || 'step', agent_name: s.agent_name, mode: s.mode, prompt: s.prompt || '{{input}}' };
|
||||
});
|
||||
try {
|
||||
var wfName = this.editWf.name;
|
||||
await OpenFangAPI.put('/api/workflows/' + this.editModal.id, { name: wfName, description: this.editWf.description, steps: steps });
|
||||
this.editModal = null;
|
||||
OpenFangToast.success('Workflow "' + wfName + '" updated');
|
||||
await this.loadWorkflows();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update workflow: ' + e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -75,6 +75,9 @@ async fn start_test_server_with_provider(
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -220,10 +223,10 @@ async fn test_status_endpoint() {
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["status"], "running");
|
||||
assert_eq!(body["agent_count"], 0);
|
||||
assert_eq!(body["agent_count"], 1); // default assistant auto-spawned
|
||||
assert!(body["uptime_seconds"].is_number());
|
||||
assert_eq!(body["default_provider"], "ollama");
|
||||
assert_eq!(body["agents"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(body["agents"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -245,7 +248,7 @@ async fn test_spawn_list_kill_agent() {
|
||||
let agent_id = body["agent_id"].as_str().unwrap().to_string();
|
||||
assert!(!agent_id.is_empty());
|
||||
|
||||
// --- List (1 agent) ---
|
||||
// --- List (2 agents: default assistant + test-agent) ---
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
@@ -253,10 +256,10 @@ async fn test_spawn_list_kill_agent() {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 1);
|
||||
assert_eq!(agents[0]["name"], "test-agent");
|
||||
assert_eq!(agents[0]["id"], agent_id);
|
||||
assert_eq!(agents[0]["model_provider"], "ollama");
|
||||
assert_eq!(agents.len(), 2);
|
||||
let test_agent = agents.iter().find(|a| a["name"] == "test-agent").unwrap();
|
||||
assert_eq!(test_agent["id"], agent_id);
|
||||
assert_eq!(test_agent["model_provider"], "ollama");
|
||||
|
||||
// --- Kill ---
|
||||
let resp = client
|
||||
@@ -268,7 +271,7 @@ async fn test_spawn_list_kill_agent() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["status"], "killed");
|
||||
|
||||
// --- List (empty) ---
|
||||
// --- List (only default assistant remains) ---
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
@@ -276,7 +279,8 @@ async fn test_spawn_list_kill_agent() {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 0);
|
||||
assert_eq!(agents.len(), 1);
|
||||
assert_eq!(agents[0]["name"], "assistant");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -615,14 +619,14 @@ memory_write = ["self.*"]
|
||||
ids.push(body["agent_id"].as_str().unwrap().to_string());
|
||||
}
|
||||
|
||||
// List should show 3
|
||||
// List should show 4 (3 spawned + default assistant)
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 3);
|
||||
assert_eq!(agents.len(), 4);
|
||||
|
||||
// Status should agree
|
||||
let resp = client
|
||||
@@ -631,7 +635,7 @@ memory_write = ["self.*"]
|
||||
.await
|
||||
.unwrap();
|
||||
let status: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(status["agent_count"], 3);
|
||||
assert_eq!(status["agent_count"], 4);
|
||||
|
||||
// Kill one
|
||||
let resp = client
|
||||
@@ -641,14 +645,14 @@ memory_write = ["self.*"]
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// List should show 2
|
||||
// List should show 3 (2 spawned + default assistant)
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 2);
|
||||
assert_eq!(agents.len(), 3);
|
||||
|
||||
// Kill the rest
|
||||
for id in [&ids[0], &ids[2]] {
|
||||
@@ -659,14 +663,14 @@ memory_write = ["self.*"]
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// List should be empty
|
||||
// List should have only default assistant
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 0);
|
||||
assert_eq!(agents.len(), 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -700,9 +704,23 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let api_key_state = state.kernel.config.api_key.clone();
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
let auth_state = middleware::AuthState {
|
||||
api_key: api_key.clone(),
|
||||
auth_enabled: state.kernel.config.auth.enabled,
|
||||
session_secret: if !api_key.is_empty() {
|
||||
api_key.clone()
|
||||
} else if state.kernel.config.auth.enabled {
|
||||
state.kernel.config.auth.password_hash.clone()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/health", axum::routing::get(routes::health))
|
||||
@@ -746,7 +764,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
)
|
||||
.route("/api/shutdown", axum::routing::post(routes::shutdown))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
api_key_state,
|
||||
auth_state,
|
||||
middleware::auth,
|
||||
))
|
||||
.layer(axum::middleware::from_fn(middleware::request_logging))
|
||||
|
||||
@@ -112,6 +112,9 @@ async fn test_full_daemon_lifecycle() {
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -234,6 +237,9 @@ async fn test_server_immediate_responsiveness() {
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -56,6 +56,9 @@ async fn start_test_server() -> TestServer {
|
||||
peer_registry: None,
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -539,7 +542,7 @@ async fn load_spawn_kill_cycle() {
|
||||
.await
|
||||
.unwrap();
|
||||
let remaining = agents.as_array().map(|a| a.len()).unwrap_or(0);
|
||||
assert_eq!(remaining, 0, "All agents should be killed");
|
||||
assert_eq!(remaining, 1, "Only default assistant should remain");
|
||||
}
|
||||
|
||||
/// Test: Prometheus metrics endpoint under sustained load.
|
||||
|
||||
@@ -26,6 +26,12 @@ hmac = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
html-escape = { workspace = true }
|
||||
|
||||
lettre = { workspace = true }
|
||||
imap = { workspace = true }
|
||||
native-tls = { workspace = true }
|
||||
mailparse = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
|
||||
use crate::formatter;
|
||||
use crate::router::AgentRouter;
|
||||
use crate::types::{ChannelAdapter, ChannelContent, ChannelMessage, ChannelUser};
|
||||
use crate::types::{
|
||||
default_phase_emoji, AgentPhase, ChannelAdapter, ChannelContent, ChannelMessage, ChannelUser,
|
||||
LifecycleReaction,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use openfang_types::message::ContentBlock;
|
||||
use futures::StreamExt;
|
||||
use openfang_types::agent::AgentId;
|
||||
use openfang_types::config::{ChannelOverrides, DmPolicy, GroupPolicy, OutputFormat};
|
||||
@@ -25,6 +29,26 @@ pub trait ChannelBridgeHandle: Send + Sync {
|
||||
/// Send a message to an agent and get the text response.
|
||||
async fn send_message(&self, agent_id: AgentId, message: &str) -> Result<String, String>;
|
||||
|
||||
/// Send a message with structured content blocks (text + images) to an agent.
|
||||
///
|
||||
/// Default implementation extracts text from blocks and falls back to `send_message()`.
|
||||
async fn send_message_with_blocks(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
blocks: Vec<ContentBlock>,
|
||||
) -> Result<String, String> {
|
||||
// Default: extract text from blocks and send as plain text
|
||||
let text: String = blocks
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
ContentBlock::Text { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
self.send_message(agent_id, &text).await
|
||||
}
|
||||
|
||||
/// Find an agent by name, returning its ID.
|
||||
async fn find_agent_by_name(&self, name: &str) -> Result<Option<AgentId>, String>;
|
||||
|
||||
@@ -111,6 +135,9 @@ pub trait ChannelBridgeHandle: Send + Sync {
|
||||
}
|
||||
|
||||
/// Record a delivery result for tracking (optional — default no-op).
|
||||
///
|
||||
/// `thread_id` preserves Telegram forum-topic context so cron/workflow
|
||||
/// delivery can target the same topic later.
|
||||
async fn record_delivery(
|
||||
&self,
|
||||
_agent_id: AgentId,
|
||||
@@ -118,6 +145,7 @@ pub trait ChannelBridgeHandle: Send + Sync {
|
||||
_recipient: &str,
|
||||
_success: bool,
|
||||
_error: Option<&str>,
|
||||
_thread_id: Option<&str>,
|
||||
) {
|
||||
// Default: no tracking
|
||||
}
|
||||
@@ -264,6 +292,15 @@ impl BridgeManager {
|
||||
}
|
||||
|
||||
/// Start an adapter: subscribe to its message stream and spawn a dispatch task.
|
||||
///
|
||||
/// Each incoming message is dispatched as a concurrent task so that slow LLM
|
||||
/// calls (10-30s) don't block subsequent messages. This prevents voice/media
|
||||
/// messages sent in quick succession from appearing "lost" — all messages
|
||||
/// begin processing immediately. Per-agent serialization (to prevent session
|
||||
/// corruption) is handled by the kernel's `agent_msg_locks`.
|
||||
///
|
||||
/// A semaphore limits concurrent dispatch tasks to prevent unbounded memory
|
||||
/// growth under burst traffic.
|
||||
pub async fn start_adapter(
|
||||
&mut self,
|
||||
adapter: Arc<dyn ChannelAdapter>,
|
||||
@@ -275,6 +312,10 @@ impl BridgeManager {
|
||||
let adapter_clone = adapter.clone();
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
|
||||
// Limit concurrent dispatch tasks to prevent unbounded growth.
|
||||
// 32 is generous — most setups have 1-5 concurrent users.
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(32));
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
loop {
|
||||
@@ -282,13 +323,28 @@ impl BridgeManager {
|
||||
msg = stream.next() => {
|
||||
match msg {
|
||||
Some(message) => {
|
||||
dispatch_message(
|
||||
&message,
|
||||
&handle,
|
||||
&router,
|
||||
adapter_clone.as_ref(),
|
||||
&rate_limiter,
|
||||
).await;
|
||||
// Spawn each dispatch as a concurrent task so the stream
|
||||
// loop is never blocked by slow LLM calls. The kernel's
|
||||
// per-agent lock ensures session integrity.
|
||||
let handle = handle.clone();
|
||||
let router = router.clone();
|
||||
let adapter = adapter_clone.clone();
|
||||
let rate_limiter = rate_limiter.clone();
|
||||
let sem = semaphore.clone();
|
||||
tokio::spawn(async move {
|
||||
// Acquire semaphore permit (blocks if 32 tasks are in flight).
|
||||
let _permit = match sem.acquire().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return, // semaphore closed — shutting down
|
||||
};
|
||||
dispatch_message(
|
||||
&message,
|
||||
&handle,
|
||||
&router,
|
||||
adapter.as_ref(),
|
||||
&rate_limiter,
|
||||
).await;
|
||||
});
|
||||
}
|
||||
None => {
|
||||
info!("Channel adapter {} stream ended", adapter_clone.name());
|
||||
@@ -359,6 +415,25 @@ async fn send_response(
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a lifecycle reaction (best-effort, non-blocking for supported adapters).
|
||||
///
|
||||
/// Silently ignores errors — reactions are non-critical UX polish.
|
||||
/// For Telegram, the underlying HTTP call is already fire-and-forget (spawned internally),
|
||||
/// so this await returns almost immediately.
|
||||
async fn send_lifecycle_reaction(
|
||||
adapter: &dyn ChannelAdapter,
|
||||
user: &ChannelUser,
|
||||
message_id: &str,
|
||||
phase: AgentPhase,
|
||||
) {
|
||||
let reaction = LifecycleReaction {
|
||||
emoji: default_phase_emoji(&phase).to_string(),
|
||||
phase,
|
||||
remove_previous: true,
|
||||
};
|
||||
let _ = adapter.send_reaction(user, message_id, &reaction).await;
|
||||
}
|
||||
|
||||
/// Dispatch a single incoming message — handles bot commands or routes to an agent.
|
||||
///
|
||||
/// Applies per-channel policies (DM/group filtering, rate limiting, formatting, threading).
|
||||
@@ -373,11 +448,17 @@ async fn dispatch_message(
|
||||
|
||||
// Fetch per-channel overrides (if configured)
|
||||
let overrides = handle.channel_overrides(ct_str).await;
|
||||
let channel_default_format = match ct_str {
|
||||
"telegram" => OutputFormat::TelegramHtml,
|
||||
"slack" => OutputFormat::SlackMrkdwn,
|
||||
_ => OutputFormat::Markdown,
|
||||
};
|
||||
let output_format = overrides
|
||||
.as_ref()
|
||||
.and_then(|o| o.output_format)
|
||||
.unwrap_or(OutputFormat::Markdown);
|
||||
.unwrap_or(channel_default_format);
|
||||
let threading_enabled = overrides.as_ref().map(|o| o.threading).unwrap_or(false);
|
||||
let lifecycle_reactions = overrides.as_ref().map(|o| o.lifecycle_reactions).unwrap_or(true);
|
||||
let thread_id = if threading_enabled {
|
||||
message.thread_id.as_deref()
|
||||
} else {
|
||||
@@ -402,8 +483,15 @@ async fn dispatch_message(
|
||||
}
|
||||
}
|
||||
GroupPolicy::MentionOnly => {
|
||||
// Pass through — adapters should only forward mentioned messages.
|
||||
// This is a hint for adapters, not enforced here.
|
||||
// Only allow messages where the bot was @mentioned or commands.
|
||||
let was_mentioned = message.metadata.get("was_mentioned")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_command = matches!(&message.content, ChannelContent::Command { .. });
|
||||
if !was_mentioned && !is_command {
|
||||
debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
GroupPolicy::All => {}
|
||||
}
|
||||
@@ -434,24 +522,57 @@ async fn dispatch_message(
|
||||
}
|
||||
}
|
||||
|
||||
let text = match &message.content {
|
||||
ChannelContent::Text(t) => t.clone(),
|
||||
ChannelContent::Command { name, args } => {
|
||||
let result = handle_command(name, args, handle, router, &message.sender).await;
|
||||
send_response(adapter, &message.sender, result, thread_id, output_format).await;
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
send_response(
|
||||
// Handle commands first (early return)
|
||||
if let ChannelContent::Command { ref name, ref args } = message.content {
|
||||
let result = handle_command(name, args, handle, router, &message.sender).await;
|
||||
send_response(adapter, &message.sender, result, thread_id, output_format).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// For images: download, base64 encode, and send as multimodal content blocks
|
||||
if let ChannelContent::Image { ref url, ref caption } = message.content {
|
||||
let blocks = download_image_to_blocks(url, caption.as_deref()).await;
|
||||
if blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. })) {
|
||||
// We have actual image data — send as structured blocks for vision
|
||||
dispatch_with_blocks(
|
||||
blocks,
|
||||
message,
|
||||
handle,
|
||||
router,
|
||||
adapter,
|
||||
&message.sender,
|
||||
"I can only handle text messages for now.".to_string(),
|
||||
ct_str,
|
||||
thread_id,
|
||||
output_format,
|
||||
lifecycle_reactions,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
// Image download failed — fall through to text description below
|
||||
}
|
||||
|
||||
let text = match &message.content {
|
||||
ChannelContent::Text(t) => t.clone(),
|
||||
ChannelContent::Command { .. } => unreachable!(), // handled above
|
||||
ChannelContent::Image { ref url, ref caption } => {
|
||||
// Fallback when image download failed
|
||||
match caption {
|
||||
Some(c) => format!("[User sent a photo: {url}]\nCaption: {c}"),
|
||||
None => format!("[User sent a photo: {url}]"),
|
||||
}
|
||||
}
|
||||
ChannelContent::File { ref url, ref filename } => {
|
||||
format!("[User sent a file ({filename}): {url}]")
|
||||
}
|
||||
ChannelContent::Voice { ref url, duration_seconds } => {
|
||||
format!("[User sent a voice message ({duration_seconds}s): {url}]")
|
||||
}
|
||||
ChannelContent::Location { lat, lon } => {
|
||||
format!("[User shared location: {lat}, {lon}]")
|
||||
}
|
||||
ChannelContent::FileData { ref filename, .. } => {
|
||||
format!("[User sent a local file: {filename}]")
|
||||
}
|
||||
};
|
||||
|
||||
// Check if it's a slash command embedded in text (e.g. "/agents")
|
||||
@@ -577,14 +698,33 @@ async fn dispatch_message(
|
||||
let agent_id = match agent_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
"No agent assigned. Use /agents to list available agents, then /agent <name> to select one.".to_string(),
|
||||
thread_id,
|
||||
output_format,
|
||||
).await;
|
||||
return;
|
||||
// Fallback: try "assistant" agent, then first available agent
|
||||
let fallback = handle.find_agent_by_name("assistant").await.ok().flatten();
|
||||
let fallback = match fallback {
|
||||
Some(id) => Some(id),
|
||||
None => handle
|
||||
.list_agents()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|agents| agents.first().map(|(id, _)| *id)),
|
||||
};
|
||||
match fallback {
|
||||
Some(id) => {
|
||||
// Auto-set this as the user's default so future messages route directly
|
||||
router.set_user_default(message.sender.platform_id.clone(), id);
|
||||
id
|
||||
}
|
||||
None => {
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
"No agents available. Start the dashboard at http://127.0.0.1:4200 to create one.".to_string(),
|
||||
thread_id,
|
||||
output_format,
|
||||
).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -609,7 +749,7 @@ async fn dispatch_message(
|
||||
if let Some(reply) = handle.check_auto_reply(agent_id, &text).await {
|
||||
send_response(adapter, &message.sender, reply, thread_id, output_format).await;
|
||||
handle
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None)
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
@@ -617,17 +757,30 @@ async fn dispatch_message(
|
||||
// Send typing indicator (best-effort)
|
||||
let _ = adapter.send_typing(&message.sender).await;
|
||||
|
||||
// Lifecycle reaction: ⏳ Queued → 🤔 Thinking → ✅ Done / ❌ Error
|
||||
let msg_id = &message.platform_message_id;
|
||||
if lifecycle_reactions {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Queued).await;
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Thinking).await;
|
||||
}
|
||||
|
||||
// Send to agent and relay response
|
||||
match handle.send_message(agent_id, &text).await {
|
||||
Ok(response) => {
|
||||
if lifecycle_reactions {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
|
||||
}
|
||||
send_response(adapter, &message.sender, response, thread_id, output_format).await;
|
||||
handle
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None)
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
if lifecycle_reactions {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
|
||||
}
|
||||
warn!("Agent error for {agent_id}: {e}");
|
||||
let err_msg = format!("Agent error: {e}");
|
||||
let err_msg = sanitize_agent_error(&e.to_string());
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
@@ -643,6 +796,309 @@ async fn dispatch_message(
|
||||
&message.sender.platform_id,
|
||||
false,
|
||||
Some(&err_msg),
|
||||
thread_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_agent_error(raw: &str) -> String {
|
||||
let lower = raw.to_lowercase();
|
||||
|
||||
if lower.contains("rate limit")
|
||||
|| lower.contains("rate_limit")
|
||||
|| lower.contains("429")
|
||||
|| lower.contains("too many requests")
|
||||
|| lower.contains("resource_exhausted")
|
||||
{
|
||||
return "Rate limit reached, please try again later.".to_string();
|
||||
}
|
||||
|
||||
if lower.contains("authentication")
|
||||
|| lower.contains("unauthorized")
|
||||
|| lower.contains("invalid api key")
|
||||
|| lower.contains("invalid x-goog-api-key")
|
||||
|| lower.contains("incorrect api key")
|
||||
|| lower.contains("permission denied")
|
||||
|| lower.contains("billing")
|
||||
|| lower.contains("quota exceeded")
|
||||
{
|
||||
return "Service temporarily unavailable.".to_string();
|
||||
}
|
||||
|
||||
if lower.contains("context length")
|
||||
|| lower.contains("token limit")
|
||||
|| lower.contains("too many tokens")
|
||||
|| lower.contains("maximum context")
|
||||
|| lower.contains("max_tokens")
|
||||
|| lower.contains("context window")
|
||||
{
|
||||
return "Message too long, try a shorter request.".to_string();
|
||||
}
|
||||
|
||||
if lower.contains("overloaded")
|
||||
|| lower.contains("503")
|
||||
|| lower.contains("502")
|
||||
|| lower.contains("server error")
|
||||
|| lower.contains("internal error")
|
||||
{
|
||||
return "The AI service is temporarily overloaded, please try again shortly.".to_string();
|
||||
}
|
||||
|
||||
if lower.contains("timeout") || lower.contains("timed out") || lower.contains("deadline") {
|
||||
return "Request timed out, please try again.".to_string();
|
||||
}
|
||||
|
||||
if lower.contains("model not found") || lower.contains("model_not_found") {
|
||||
return "The requested model is currently unavailable.".to_string();
|
||||
}
|
||||
|
||||
let cleaned = raw
|
||||
.strip_prefix("LLM driver error: ")
|
||||
.or_else(|| raw.strip_prefix("Agent error: "))
|
||||
.unwrap_or(raw);
|
||||
|
||||
if let Some(first_sentence_end) = cleaned.find(". ") {
|
||||
let first = &cleaned[..=first_sentence_end];
|
||||
if first.len() < cleaned.len() / 2 {
|
||||
return format!("Agent error: {first}");
|
||||
}
|
||||
}
|
||||
|
||||
if cleaned.contains('{') || cleaned.len() > 200 {
|
||||
return "Something went wrong processing your request. Please try again.".to_string();
|
||||
}
|
||||
|
||||
format!("Agent error: {cleaned}")
|
||||
}
|
||||
|
||||
/// Detect image format from the first few magic bytes.
|
||||
///
|
||||
/// Returns `Some("image/...")` for JPEG, PNG, GIF, and WebP.
|
||||
fn detect_image_magic(bytes: &[u8]) -> Option<String> {
|
||||
if bytes.len() >= 3 && bytes[..3] == [0xFF, 0xD8, 0xFF] {
|
||||
return Some("image/jpeg".to_string());
|
||||
}
|
||||
if bytes.len() >= 4 && bytes[..4] == [0x89, 0x50, 0x4E, 0x47] {
|
||||
return Some("image/png".to_string());
|
||||
}
|
||||
if bytes.len() >= 4 && bytes[..4] == [0x47, 0x49, 0x46, 0x38] {
|
||||
return Some("image/gif".to_string());
|
||||
}
|
||||
if bytes.len() >= 12 && bytes[..4] == [0x52, 0x49, 0x46, 0x46] && bytes[8..12] == [0x57, 0x45, 0x42, 0x50]
|
||||
{
|
||||
return Some("image/webp".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Guess image media type from the URL file extension.
|
||||
fn media_type_from_url(url: &str) -> String {
|
||||
if url.contains(".png") {
|
||||
"image/png".to_string()
|
||||
} else if url.contains(".gif") {
|
||||
"image/gif".to_string()
|
||||
} else if url.contains(".webp") {
|
||||
"image/webp".to_string()
|
||||
} else {
|
||||
// JPEG is the most common image format — safe default
|
||||
"image/jpeg".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Download an image from a URL and build content blocks for multimodal LLM input.
|
||||
///
|
||||
/// Returns a `Vec<ContentBlock>` containing an image block (base64-encoded) and
|
||||
/// optionally a text block for the caption. If the download fails, returns a
|
||||
/// text-only block describing the failure.
|
||||
async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec<ContentBlock> {
|
||||
use base64::Engine;
|
||||
|
||||
// 5 MB limit to prevent memory abuse from oversized images
|
||||
const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = match client.get(url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("Failed to download image from channel: {e}");
|
||||
return vec![ContentBlock::Text {
|
||||
text: format!("[Image download failed: {e}]"),
|
||||
provider_metadata: None,
|
||||
}];
|
||||
}
|
||||
};
|
||||
|
||||
// Detect media type from Content-Type header — but only trust it if it's
|
||||
// actually an image/* type. Many APIs (Telegram, S3 pre-signed URLs) return
|
||||
// `application/octet-stream` for all files, which breaks vision.
|
||||
let header_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string())
|
||||
.filter(|ct| ct.starts_with("image/"));
|
||||
|
||||
let bytes = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("Failed to read image bytes: {e}");
|
||||
return vec![ContentBlock::Text {
|
||||
text: format!("[Image read failed: {e}]"),
|
||||
provider_metadata: None,
|
||||
}];
|
||||
}
|
||||
};
|
||||
|
||||
// Three-tier media type detection:
|
||||
// 1. Trusted Content-Type header (only if image/*)
|
||||
// 2. Magic byte sniffing (most reliable for binary data)
|
||||
// 3. URL extension fallback
|
||||
let media_type = header_type.unwrap_or_else(|| {
|
||||
detect_image_magic(&bytes).unwrap_or_else(|| media_type_from_url(url))
|
||||
});
|
||||
|
||||
if bytes.len() > MAX_IMAGE_BYTES {
|
||||
warn!(
|
||||
"Image too large ({} bytes), skipping vision — sending as text",
|
||||
bytes.len()
|
||||
);
|
||||
let desc = match caption {
|
||||
Some(c) => format!("[Image too large for vision ({} KB)]\nCaption: {c}", bytes.len() / 1024),
|
||||
None => format!("[Image too large for vision ({} KB)]", bytes.len() / 1024),
|
||||
};
|
||||
return vec![ContentBlock::Text { text: desc, provider_metadata: None }];
|
||||
}
|
||||
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
let mut blocks = Vec::new();
|
||||
|
||||
// Caption as text block first (gives the LLM context about the image)
|
||||
if let Some(cap) = caption {
|
||||
if !cap.is_empty() {
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: cap.to_string(),
|
||||
provider_metadata: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
blocks.push(ContentBlock::Image { media_type, data });
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Dispatch a multimodal message (content blocks) to an agent, handling routing
|
||||
/// and RBAC the same way as the text path.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dispatch_with_blocks(
|
||||
blocks: Vec<ContentBlock>,
|
||||
message: &ChannelMessage,
|
||||
handle: &Arc<dyn ChannelBridgeHandle>,
|
||||
router: &Arc<AgentRouter>,
|
||||
adapter: &dyn ChannelAdapter,
|
||||
ct_str: &str,
|
||||
thread_id: Option<&str>,
|
||||
output_format: OutputFormat,
|
||||
lifecycle_reactions: bool,
|
||||
) {
|
||||
// Route to agent (same logic as text path)
|
||||
let agent_id = router.resolve(
|
||||
&message.channel,
|
||||
&message.sender.platform_id,
|
||||
message.sender.openfang_user.as_deref(),
|
||||
);
|
||||
|
||||
let agent_id = match agent_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let fallback = handle.find_agent_by_name("assistant").await.ok().flatten();
|
||||
let fallback = match fallback {
|
||||
Some(id) => Some(id),
|
||||
None => handle
|
||||
.list_agents()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|agents| agents.first().map(|(id, _)| *id)),
|
||||
};
|
||||
match fallback {
|
||||
Some(id) => {
|
||||
router.set_user_default(message.sender.platform_id.clone(), id);
|
||||
id
|
||||
}
|
||||
None => {
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
"No agents available. Start the dashboard at http://127.0.0.1:4200 to create one.".to_string(),
|
||||
thread_id,
|
||||
output_format,
|
||||
).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// RBAC check
|
||||
if let Err(denied) = handle
|
||||
.authorize_channel_user(ct_str, &message.sender.platform_id, "chat")
|
||||
.await
|
||||
{
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
format!("Access denied: {denied}"),
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = adapter.send_typing(&message.sender).await;
|
||||
|
||||
// Lifecycle reaction: ⏳ Queued → 🤔 Thinking → ✅ Done / ❌ Error
|
||||
let msg_id = &message.platform_message_id;
|
||||
if lifecycle_reactions {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Queued).await;
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Thinking).await;
|
||||
}
|
||||
|
||||
match handle.send_message_with_blocks(agent_id, blocks).await {
|
||||
Ok(response) => {
|
||||
if lifecycle_reactions {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
|
||||
}
|
||||
send_response(adapter, &message.sender, response, thread_id, output_format).await;
|
||||
handle
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
if lifecycle_reactions {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
|
||||
}
|
||||
warn!("Agent error for {agent_id}: {e}");
|
||||
let err_msg = sanitize_agent_error(&e.to_string());
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
err_msg.clone(),
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
handle
|
||||
.record_delivery(
|
||||
agent_id,
|
||||
ct_str,
|
||||
&message.sender.platform_id,
|
||||
false,
|
||||
Some(&err_msg),
|
||||
thread_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1088,4 +1544,102 @@ mod tests {
|
||||
"irc"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_message_with_blocks_default_fallback() {
|
||||
// The default implementation of send_message_with_blocks extracts text
|
||||
// from blocks and calls send_message
|
||||
let agent_id = AgentId::new();
|
||||
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
|
||||
agents: Mutex::new(vec![(agent_id, "vision-agent".to_string())]),
|
||||
});
|
||||
|
||||
let blocks = vec![
|
||||
ContentBlock::Text {
|
||||
text: "What is in this photo?".to_string(),
|
||||
provider_metadata: None,
|
||||
},
|
||||
ContentBlock::Image {
|
||||
media_type: "image/jpeg".to_string(),
|
||||
data: "base64data".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
// Default impl should extract text and call send_message
|
||||
let result = handle
|
||||
.send_message_with_blocks(agent_id, blocks)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "Echo: What is in this photo?");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_message_with_blocks_image_only() {
|
||||
// When there's no text block, the default should still work
|
||||
let agent_id = AgentId::new();
|
||||
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
|
||||
agents: Mutex::new(vec![(agent_id, "vision-agent".to_string())]),
|
||||
});
|
||||
|
||||
let blocks = vec![ContentBlock::Image {
|
||||
media_type: "image/png".to_string(),
|
||||
data: "base64data".to_string(),
|
||||
}];
|
||||
|
||||
// Default impl sends empty text when no text blocks
|
||||
let result = handle
|
||||
.send_message_with_blocks(agent_id, blocks)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "Echo: ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_image_magic_jpeg() {
|
||||
let bytes = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
|
||||
assert_eq!(detect_image_magic(&bytes), Some("image/jpeg".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_image_magic_png() {
|
||||
let bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
assert_eq!(detect_image_magic(&bytes), Some("image/png".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_image_magic_gif() {
|
||||
let bytes = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
|
||||
assert_eq!(detect_image_magic(&bytes), Some("image/gif".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_image_magic_webp() {
|
||||
let bytes = [
|
||||
0x52, 0x49, 0x46, 0x46, // RIFF
|
||||
0x00, 0x00, 0x00, 0x00, // size (don't care)
|
||||
0x57, 0x45, 0x42, 0x50, // WEBP
|
||||
];
|
||||
assert_eq!(detect_image_magic(&bytes), Some("image/webp".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_image_magic_unknown() {
|
||||
let bytes = [0x00, 0x01, 0x02, 0x03];
|
||||
assert_eq!(detect_image_magic(&bytes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_image_magic_empty() {
|
||||
assert_eq!(detect_image_magic(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_type_from_url() {
|
||||
assert_eq!(media_type_from_url("https://example.com/photo.png"), "image/png");
|
||||
assert_eq!(media_type_from_url("https://example.com/anim.gif"), "image/gif");
|
||||
assert_eq!(media_type_from_url("https://example.com/img.webp"), "image/webp");
|
||||
assert_eq!(media_type_from_url("https://example.com/photo.jpg"), "image/jpeg");
|
||||
// No extension — defaults to JPEG
|
||||
assert_eq!(media_type_from_url("https://api.telegram.org/file/bot123/photos/file_42"), "image/jpeg");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,47 @@
|
||||
//! Email channel adapter (IMAP + SMTP).
|
||||
//!
|
||||
//! Polls IMAP for new emails and sends responses via SMTP.
|
||||
//! Polls IMAP for new emails and sends responses via SMTP using `lettre`.
|
||||
//! Uses the subject line for agent routing (e.g., "\[coder\] Fix this bug").
|
||||
|
||||
use crate::types::{ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use futures::Stream;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::AsyncSmtpTransport;
|
||||
use lettre::AsyncTransport;
|
||||
use lettre::Tokio1Executor;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, info};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// SASL PLAIN authenticator for IMAP servers that reject LOGIN
|
||||
/// (e.g., Lark/Larksuite which only advertise AUTH=PLAIN).
|
||||
struct PlainAuthenticator {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl imap::Authenticator for PlainAuthenticator {
|
||||
type Response = String;
|
||||
fn process(&self, _data: &[u8]) -> Self::Response {
|
||||
// SASL PLAIN: \0<username>\0<password>
|
||||
format!("\x00{}\x00{}", self.username, self.password)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reply context for email threading (In-Reply-To / Subject continuity).
|
||||
#[derive(Debug, Clone)]
|
||||
struct ReplyCtx {
|
||||
subject: String,
|
||||
message_id: String,
|
||||
}
|
||||
|
||||
/// Email channel adapter using IMAP for receiving and SMTP for sending.
|
||||
pub struct EmailAdapter {
|
||||
/// IMAP server host.
|
||||
@@ -21,7 +50,7 @@ pub struct EmailAdapter {
|
||||
imap_port: u16,
|
||||
/// SMTP server host.
|
||||
smtp_host: String,
|
||||
/// SMTP port (587 for STARTTLS).
|
||||
/// SMTP port (587 for STARTTLS, 465 for implicit TLS).
|
||||
smtp_port: u16,
|
||||
/// Email address (used for both IMAP and SMTP).
|
||||
username: String,
|
||||
@@ -36,6 +65,8 @@ pub struct EmailAdapter {
|
||||
/// Shutdown signal.
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Tracks reply context per sender for email threading.
|
||||
reply_ctx: Arc<DashMap<String, ReplyCtx>>,
|
||||
}
|
||||
|
||||
impl EmailAdapter {
|
||||
@@ -69,16 +100,17 @@ impl EmailAdapter {
|
||||
allowed_senders,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
reply_ctx: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a sender is in the allowlist (empty = allow all). Used in tests.
|
||||
#[allow(dead_code)]
|
||||
fn is_allowed_sender(&self, sender: &str) -> bool {
|
||||
self.allowed_senders.is_empty() || self.allowed_senders.iter().any(|s| sender.contains(s))
|
||||
}
|
||||
|
||||
/// Extract agent name from subject line brackets, e.g., "[coder] Fix the bug" -> Some("coder")
|
||||
#[allow(dead_code)]
|
||||
fn extract_agent_from_subject(subject: &str) -> Option<String> {
|
||||
let subject = subject.trim();
|
||||
if subject.starts_with('[') {
|
||||
@@ -93,7 +125,6 @@ impl EmailAdapter {
|
||||
}
|
||||
|
||||
/// Strip the agent tag from a subject line.
|
||||
#[allow(dead_code)]
|
||||
fn strip_agent_tag(subject: &str) -> String {
|
||||
let subject = subject.trim();
|
||||
if subject.starts_with('[') {
|
||||
@@ -103,6 +134,175 @@ impl EmailAdapter {
|
||||
}
|
||||
subject.to_string()
|
||||
}
|
||||
|
||||
/// Build an async SMTP transport for sending emails.
|
||||
async fn build_smtp_transport(
|
||||
&self,
|
||||
) -> Result<AsyncSmtpTransport<Tokio1Executor>, Box<dyn std::error::Error>> {
|
||||
let creds =
|
||||
Credentials::new(self.username.clone(), self.password.as_str().to_string());
|
||||
|
||||
let transport = if self.smtp_port == 465 {
|
||||
// Implicit TLS (port 465)
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&self.smtp_host)?
|
||||
.port(self.smtp_port)
|
||||
.credentials(creds)
|
||||
.build()
|
||||
} else {
|
||||
// STARTTLS (port 587 or other)
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.smtp_host)?
|
||||
.port(self.smtp_port)
|
||||
.credentials(creds)
|
||||
.build()
|
||||
};
|
||||
|
||||
Ok(transport)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `user@domain` from a potentially formatted email string like `"Name <user@domain>"`.
|
||||
fn extract_email_addr(raw: &str) -> String {
|
||||
let raw = raw.trim();
|
||||
if let Some(start) = raw.find('<') {
|
||||
if let Some(end) = raw.find('>') {
|
||||
if end > start {
|
||||
return raw[start + 1..end].trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
/// Get a specific header value from a parsed email.
|
||||
fn get_header(parsed: &mailparse::ParsedMail<'_>, name: &str) -> Option<String> {
|
||||
parsed
|
||||
.headers
|
||||
.iter()
|
||||
.find(|h| h.get_key().eq_ignore_ascii_case(name))
|
||||
.map(|h| h.get_value())
|
||||
}
|
||||
|
||||
/// Extract the text/plain body from a parsed email (handles multipart).
|
||||
fn extract_text_body(parsed: &mailparse::ParsedMail<'_>) -> String {
|
||||
if parsed.subparts.is_empty() {
|
||||
return parsed.get_body().unwrap_or_default();
|
||||
}
|
||||
// Walk subparts looking for text/plain
|
||||
for part in &parsed.subparts {
|
||||
let ct = part.ctype.mimetype.to_lowercase();
|
||||
if ct == "text/plain" {
|
||||
return part.get_body().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
// Fallback: first subpart body
|
||||
parsed
|
||||
.subparts
|
||||
.first()
|
||||
.and_then(|p| p.get_body().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fetch unseen emails from IMAP using blocking I/O.
|
||||
/// Returns a Vec of (from_addr, subject, message_id, body).
|
||||
fn fetch_unseen_emails(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: &str,
|
||||
folders: &[String],
|
||||
) -> Result<Vec<(String, String, String, String)>, String> {
|
||||
let tls = native_tls::TlsConnector::builder()
|
||||
.build()
|
||||
.map_err(|e| format!("TLS connector error: {e}"))?;
|
||||
|
||||
let client = imap::connect((host, port), host, &tls)
|
||||
.map_err(|e| format!("IMAP connect failed: {e}"))?;
|
||||
|
||||
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
|
||||
// that reject LOGIN and only support AUTH=PLAIN (SASL).
|
||||
let mut session = match client.login(username, password) {
|
||||
Ok(s) => s,
|
||||
Err((login_err, client)) => {
|
||||
let authenticator = PlainAuthenticator {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
client
|
||||
.authenticate("PLAIN", &authenticator)
|
||||
.map_err(|(e, _)| {
|
||||
format!("IMAP login failed: {login_err}; AUTH=PLAIN also failed: {e}")
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for folder in folders {
|
||||
if let Err(e) = session.select(folder) {
|
||||
warn!(folder, error = %e, "IMAP SELECT failed, skipping folder");
|
||||
continue;
|
||||
}
|
||||
|
||||
let uids = match session.uid_search("UNSEEN") {
|
||||
Ok(uids) => uids,
|
||||
Err(e) => {
|
||||
warn!(folder, error = %e, "IMAP SEARCH UNSEEN failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if uids.is_empty() {
|
||||
debug!(folder, "No unseen emails");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch in batches of up to 50 to avoid huge responses
|
||||
let uid_list: Vec<u32> = uids.into_iter().take(50).collect();
|
||||
let uid_set: String = uid_list
|
||||
.iter()
|
||||
.map(|u| u.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
let fetches = match session.uid_fetch(&uid_set, "RFC822") {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!(folder, error = %e, "IMAP FETCH failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for fetch in fetches.iter() {
|
||||
let body_bytes = match fetch.body() {
|
||||
Some(b) => b,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let parsed = match mailparse::parse_mail(body_bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Failed to parse email");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let from = get_header(&parsed, "From").unwrap_or_default();
|
||||
let subject = get_header(&parsed, "Subject").unwrap_or_default();
|
||||
let message_id = get_header(&parsed, "Message-ID").unwrap_or_default();
|
||||
let text_body = extract_text_body(&parsed);
|
||||
|
||||
let from_addr = extract_email_addr(&from);
|
||||
results.push((from_addr, subject, message_id, text_body));
|
||||
}
|
||||
|
||||
// Mark fetched messages as Seen
|
||||
if let Err(e) = session.uid_store(&uid_set, "+FLAGS (\\Seen)") {
|
||||
warn!(error = %e, "Failed to mark emails as Seen");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = session.logout();
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -119,25 +319,23 @@ impl ChannelAdapter for EmailAdapter {
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
let (_tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let poll_interval = self.poll_interval;
|
||||
let _allowed_senders = self.allowed_senders.clone();
|
||||
let imap_host = self.imap_host.clone();
|
||||
let imap_port = self.imap_port;
|
||||
let _username = self.username.clone();
|
||||
let _password = self.password.clone();
|
||||
let _folders = self.folders.clone();
|
||||
let username = self.username.clone();
|
||||
let password = self.password.clone();
|
||||
let folders = self.folders.clone();
|
||||
let allowed_senders = self.allowed_senders.clone();
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
let reply_ctx = self.reply_ctx.clone();
|
||||
|
||||
info!(
|
||||
"Starting email adapter (IMAP: {}:{}, polling every {:?})",
|
||||
imap_host, imap_port, poll_interval
|
||||
"Starting email adapter (IMAP: {}:{}, SMTP: {}:{}, polling every {:?})",
|
||||
imap_host, imap_port, self.smtp_host, self.smtp_port, poll_interval
|
||||
);
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Email polling is blocking I/O, so we'll use spawn_blocking
|
||||
// For now, implement as a polling loop with placeholder
|
||||
// Full IMAP implementation requires the `imap` crate
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
@@ -147,14 +345,83 @@ impl ChannelAdapter for EmailAdapter {
|
||||
_ = tokio::time::sleep(poll_interval) => {}
|
||||
}
|
||||
|
||||
// Placeholder: In a full implementation, this would:
|
||||
// 1. Connect to IMAP server via TLS
|
||||
// 2. Select each folder
|
||||
// 3. Search for UNSEEN messages
|
||||
// 4. Fetch and parse each message (From, Subject, Body)
|
||||
// 5. Convert to ChannelMessage
|
||||
// 6. Mark as seen
|
||||
debug!("Email poll cycle (IMAP {}:{})", imap_host, imap_port);
|
||||
// IMAP operations are blocking I/O — run in spawn_blocking
|
||||
let host = imap_host.clone();
|
||||
let port = imap_port;
|
||||
let user = username.clone();
|
||||
let pass = password.clone();
|
||||
let fldrs = folders.clone();
|
||||
|
||||
let emails = tokio::task::spawn_blocking(move || {
|
||||
fetch_unseen_emails(&host, port, &user, pass.as_str(), &fldrs)
|
||||
})
|
||||
.await;
|
||||
|
||||
let emails = match emails {
|
||||
Ok(Ok(emails)) => emails,
|
||||
Ok(Err(e)) => {
|
||||
error!("IMAP poll error: {e}");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("IMAP spawn_blocking panic: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for (from_addr, subject, message_id, body) in emails {
|
||||
// Check allowed senders
|
||||
if !allowed_senders.is_empty()
|
||||
&& !allowed_senders.iter().any(|s| from_addr.contains(s))
|
||||
{
|
||||
debug!(from = %from_addr, "Email from non-allowed sender, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store reply context for threading
|
||||
if !message_id.is_empty() {
|
||||
reply_ctx.insert(
|
||||
from_addr.clone(),
|
||||
ReplyCtx {
|
||||
subject: subject.clone(),
|
||||
message_id: message_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Extract target agent from subject brackets (stored in metadata for router)
|
||||
let _target_agent =
|
||||
EmailAdapter::extract_agent_from_subject(&subject);
|
||||
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
|
||||
|
||||
// Build the message body: prepend subject context
|
||||
let text = if clean_subject.is_empty() {
|
||||
body.trim().to_string()
|
||||
} else {
|
||||
format!("Subject: {clean_subject}\n\n{}", body.trim())
|
||||
};
|
||||
|
||||
let msg = ChannelMessage {
|
||||
channel: ChannelType::Email,
|
||||
platform_message_id: message_id.clone(),
|
||||
sender: ChannelUser {
|
||||
platform_id: from_addr.clone(),
|
||||
display_name: from_addr.clone(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content: ChannelContent::Text(text),
|
||||
target_agent: None, // Routing handled by bridge AgentRouter
|
||||
timestamp: Utc::now(),
|
||||
is_group: false,
|
||||
thread_id: None,
|
||||
metadata: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
info!("Email channel receiver dropped, stopping poll");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -168,22 +435,71 @@ impl ChannelAdapter for EmailAdapter {
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
// Placeholder: In a full implementation, this would:
|
||||
// 1. Build email (From, To, Subject, Body) using lettre
|
||||
// 2. Connect to SMTP server via STARTTLS
|
||||
// 3. Send the email
|
||||
// Parse recipient address
|
||||
let to_addr = extract_email_addr(&user.platform_id);
|
||||
let to_mailbox: Mailbox = to_addr
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid recipient email '{}': {}", to_addr, e))?;
|
||||
|
||||
let from_mailbox: Mailbox = self
|
||||
.username
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid sender email '{}': {}", self.username, e))?;
|
||||
|
||||
// Extract subject from text body convention: "Subject: ...\n\n..."
|
||||
let (subject, body) = if text.starts_with("Subject: ") {
|
||||
if let Some(pos) = text.find("\n\n") {
|
||||
let subj = text[9..pos].trim().to_string();
|
||||
let body = text[pos + 2..].to_string();
|
||||
(subj, body)
|
||||
} else {
|
||||
("OpenFang Reply".to_string(), text)
|
||||
}
|
||||
} else {
|
||||
// Check reply context for subject continuity
|
||||
let subj = self
|
||||
.reply_ctx
|
||||
.get(&to_addr)
|
||||
.map(|ctx| format!("Re: {}", ctx.subject))
|
||||
.unwrap_or_else(|| "OpenFang Reply".to_string());
|
||||
(subj, text)
|
||||
};
|
||||
|
||||
// Build email message
|
||||
let mut builder = lettre::Message::builder()
|
||||
.from(from_mailbox)
|
||||
.to(to_mailbox)
|
||||
.subject(&subject);
|
||||
|
||||
// Add In-Reply-To header for threading
|
||||
if let Some(ctx) = self.reply_ctx.get(&to_addr) {
|
||||
if !ctx.message_id.is_empty() {
|
||||
builder = builder.in_reply_to(ctx.message_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let email = builder
|
||||
.body(body)
|
||||
.map_err(|e| format!("Failed to build email: {e}"))?;
|
||||
|
||||
// Send via SMTP
|
||||
let transport = self.build_smtp_transport().await?;
|
||||
transport
|
||||
.send(email)
|
||||
.await
|
||||
.map_err(|e| format!("SMTP send failed: {e}"))?;
|
||||
|
||||
info!(
|
||||
"Would send email to {}: {} chars",
|
||||
user.platform_id,
|
||||
text.len()
|
||||
);
|
||||
debug!(
|
||||
"SMTP: {}:{} -> {}",
|
||||
self.smtp_host, self.smtp_port, user.platform_id
|
||||
to = %to_addr,
|
||||
subject = %subject,
|
||||
"Email sent successfully via SMTP"
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
info!("Unsupported email content type for {}", user.platform_id);
|
||||
warn!(
|
||||
"Unsupported email content type for {}, only text is supported",
|
||||
user.platform_id
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -274,4 +590,40 @@ mod tests {
|
||||
);
|
||||
assert_eq!(EmailAdapter::strip_agent_tag("No brackets"), "No brackets");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_email_addr() {
|
||||
assert_eq!(
|
||||
extract_email_addr("John Doe <john@example.com>"),
|
||||
"john@example.com"
|
||||
);
|
||||
assert_eq!(extract_email_addr("user@example.com"), "user@example.com");
|
||||
assert_eq!(extract_email_addr("<user@test.com>"), "user@test.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subject_extraction_from_body() {
|
||||
let text = "Subject: Test Subject\n\nThis is the body.";
|
||||
assert!(text.starts_with("Subject: "));
|
||||
let pos = text.find("\n\n").unwrap();
|
||||
let subject = &text[9..pos];
|
||||
let body = &text[pos + 2..];
|
||||
assert_eq!(subject, "Test Subject");
|
||||
assert_eq!(body, "This is the body.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reply_ctx_threading() {
|
||||
let ctx_map: DashMap<String, ReplyCtx> = DashMap::new();
|
||||
ctx_map.insert(
|
||||
"user@test.com".to_string(),
|
||||
ReplyCtx {
|
||||
subject: "Original Subject".to_string(),
|
||||
message_id: "<msg-123@test.com>".to_string(),
|
||||
},
|
||||
);
|
||||
let ctx = ctx_map.get("user@test.com").unwrap();
|
||||
assert_eq!(ctx.subject, "Original Subject");
|
||||
assert_eq!(ctx.message_id, "<msg-123@test.com>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">");
|
||||
|
||||
// Bold: **text** → <b>text</b>
|
||||
while let Some(start) = result.find("**") {
|
||||
|
||||
@@ -268,38 +268,39 @@ fn parse_mastodon_notification(
|
||||
fn strip_html_tags(html: &str) -> String {
|
||||
let mut result = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
let mut tag_buf = String::new();
|
||||
|
||||
for ch in html.chars() {
|
||||
match ch {
|
||||
'<' => {
|
||||
in_tag = true;
|
||||
// Check if this is a <br> or </p> — insert newline
|
||||
if html[result.len()..].starts_with("<br")
|
||||
|| html[result.len()..].starts_with("</p")
|
||||
tag_buf.clear();
|
||||
}
|
||||
'>' if in_tag => {
|
||||
in_tag = false;
|
||||
// Insert newline for block-level closing tags
|
||||
let tag_lower = tag_buf.to_lowercase();
|
||||
if tag_lower.starts_with("br")
|
||||
|| tag_lower.starts_with("/p")
|
||||
|| tag_lower.starts_with("/div")
|
||||
|| tag_lower.starts_with("/li")
|
||||
{
|
||||
result.push('\n');
|
||||
}
|
||||
tag_buf.clear();
|
||||
}
|
||||
'>' => {
|
||||
in_tag = false;
|
||||
_ if in_tag => {
|
||||
tag_buf.push(ch);
|
||||
}
|
||||
_ if !in_tag => {
|
||||
_ => {
|
||||
result.push(ch);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Decode common HTML entities
|
||||
result
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.trim()
|
||||
.to_string()
|
||||
// Decode HTML entities (handles named, decimal, and hex entities)
|
||||
let decoded = html_escape::decode_html_entities(&result);
|
||||
decoded.trim().to_string()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -577,6 +578,35 @@ mod tests {
|
||||
assert_eq!(strip_html_tags("plain text"), "plain text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_tags_emoji() {
|
||||
assert_eq!(
|
||||
strip_html_tags("<p>Hello 🦀🔥 world</p>"),
|
||||
"Hello 🦀🔥 world"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_tags_cjk() {
|
||||
assert_eq!(
|
||||
strip_html_tags("<p>你好 <strong>世界</strong></p>"),
|
||||
"你好 世界"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_tags_numeric_entities() {
|
||||
assert_eq!(strip_html_tags("'hello'"), "'hello'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_tags_div_newline() {
|
||||
assert_eq!(
|
||||
strip_html_tags("<div>one</div><div>two</div>").trim(),
|
||||
"one\ntwo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mastodon_notification_mention() {
|
||||
let notif = serde_json::json!({
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
const SYNC_TIMEOUT_MS: u64 = 30000;
|
||||
@@ -35,6 +35,8 @@ pub struct MatrixAdapter {
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Sync token for resuming /sync.
|
||||
since_token: Arc<RwLock<Option<String>>>,
|
||||
/// Whether to auto-accept room invites.
|
||||
auto_accept_invites: bool,
|
||||
}
|
||||
|
||||
impl MatrixAdapter {
|
||||
@@ -55,6 +57,7 @@ impl MatrixAdapter {
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
since_token: Arc::new(RwLock::new(None)),
|
||||
auto_accept_invites: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,12 +119,86 @@ impl MatrixAdapter {
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg(test)]
|
||||
fn is_allowed_room(&self, room_id: &str) -> bool {
|
||||
self.allowed_rooms.is_empty() || self.allowed_rooms.iter().any(|r| r == room_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
|
||||
async fn accept_invite(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
access_token: &str,
|
||||
room_id: &str,
|
||||
) {
|
||||
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/join");
|
||||
match client
|
||||
.post(&url)
|
||||
.bearer_auth(access_token)
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
info!("Matrix: auto-accepted invite to {room_id}");
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
warn!("Matrix: failed to accept invite to {room_id}: {status}");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Matrix: error accepting invite to {room_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the number of joined members in a room.
|
||||
async fn get_room_member_count(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
access_token: &str,
|
||||
room_id: &str,
|
||||
) -> Option<usize> {
|
||||
let url = format!(
|
||||
"{homeserver}/_matrix/client/v3/rooms/{room_id}/joined_members"
|
||||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body["joined"].as_object().map(|m| m.len())
|
||||
}
|
||||
|
||||
/// Do an initial /sync with timeout=0 to get the since token without processing events.
|
||||
/// This prevents replaying old messages when the adapter first connects.
|
||||
async fn initial_sync(
|
||||
client: &reqwest::Client,
|
||||
homeserver: &str,
|
||||
access_token: &str,
|
||||
) -> Option<String> {
|
||||
let url = format!(
|
||||
"{homeserver}/_matrix/client/v3/sync?timeout=0&filter={{\"room\":{{\"timeline\":{{\"limit\":0}}}}}}"
|
||||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
body["next_batch"].as_str().map(String::from)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChannelAdapter for MatrixAdapter {
|
||||
fn name(&self) -> &str {
|
||||
@@ -148,6 +225,15 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
let client = self.client.clone();
|
||||
let since_token = Arc::clone(&self.since_token);
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
let auto_accept = self.auto_accept_invites;
|
||||
|
||||
// FIX #4: Do an initial sync to get the since token, skipping old messages.
|
||||
if since_token.read().await.is_none() {
|
||||
if let Some(token) = initial_sync(&client, &homeserver, access_token.as_str()).await {
|
||||
info!("Matrix: initial sync complete, skipping old messages");
|
||||
*since_token.write().await = Some(token);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
@@ -168,7 +254,7 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
info!("Matrix adapter shutting down");
|
||||
break;
|
||||
}
|
||||
result = client.get(&url).bearer_auth(&*access_token).send() => {
|
||||
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
|
||||
match result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -203,6 +289,21 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
*since_token.write().await = Some(next.to_string());
|
||||
}
|
||||
|
||||
// FIX #1: Auto-accept room invites.
|
||||
if auto_accept {
|
||||
if let Some(invites) = body["rooms"]["invite"].as_object() {
|
||||
for (room_id, _invite_data) in invites {
|
||||
if !allowed_rooms.is_empty()
|
||||
&& !allowed_rooms.iter().any(|r| r == room_id)
|
||||
{
|
||||
debug!("Matrix: ignoring invite to {room_id} (not in allowed_rooms)");
|
||||
continue;
|
||||
}
|
||||
accept_invite(&client, &homeserver, access_token.as_str(), room_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process room events
|
||||
if let Some(rooms) = body["rooms"]["join"].as_object() {
|
||||
for (room_id, room_data) in rooms {
|
||||
@@ -245,6 +346,38 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
|
||||
let event_id = event["event_id"].as_str().unwrap_or("").to_string();
|
||||
|
||||
// FIX #2: Detect @mentions in message text.
|
||||
let mut metadata = HashMap::new();
|
||||
if content.contains(&user_id) {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
}
|
||||
|
||||
// FIX #3: Determine if room is a DM (2 members) or group.
|
||||
let is_group = get_room_member_count(
|
||||
&client,
|
||||
&homeserver,
|
||||
access_token.as_str(),
|
||||
room_id,
|
||||
)
|
||||
.await
|
||||
.map(|count| count > 2)
|
||||
.unwrap_or(true);
|
||||
|
||||
// For DMs, auto-set was_mentioned so dm_policy works.
|
||||
if !is_group {
|
||||
metadata.insert(
|
||||
"was_mentioned".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
metadata.insert(
|
||||
"is_dm".to_string(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
}
|
||||
|
||||
let channel_msg = ChannelMessage {
|
||||
channel: ChannelType::Matrix,
|
||||
platform_message_id: event_id,
|
||||
@@ -256,9 +389,9 @@ impl ChannelAdapter for MatrixAdapter {
|
||||
content: msg_content,
|
||||
target_agent: None,
|
||||
timestamp: Utc::now(),
|
||||
is_group: true,
|
||||
is_group,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
metadata,
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -7,11 +7,12 @@ use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use futures::{SinkExt, Stream, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::{mpsc, watch, RwLock};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
@@ -32,10 +33,22 @@ pub struct SlackAdapter {
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Bot's own user ID (populated after auth.test).
|
||||
bot_user_id: Arc<RwLock<Option<String>>>,
|
||||
/// Threads where the bot was @-mentioned. Maps thread_ts -> last interaction time.
|
||||
active_threads: Arc<DashMap<String, Instant>>,
|
||||
/// How long to track a thread after last interaction.
|
||||
thread_ttl: Duration,
|
||||
/// Whether auto-thread-reply is enabled.
|
||||
auto_thread_reply: bool,
|
||||
}
|
||||
|
||||
impl SlackAdapter {
|
||||
pub fn new(app_token: String, bot_token: String, allowed_channels: Vec<String>) -> Self {
|
||||
pub fn new(
|
||||
app_token: String,
|
||||
bot_token: String,
|
||||
allowed_channels: Vec<String>,
|
||||
auto_thread_reply: bool,
|
||||
thread_ttl_hours: u64,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
app_token: Zeroizing::new(app_token),
|
||||
@@ -45,6 +58,9 @@ impl SlackAdapter {
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
bot_user_id: Arc::new(RwLock::new(None)),
|
||||
active_threads: Arc::new(DashMap::new()),
|
||||
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
|
||||
auto_thread_reply,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,14 +92,18 @@ impl SlackAdapter {
|
||||
&self,
|
||||
channel_id: &str,
|
||||
text: &str,
|
||||
thread_ts: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let chunks = split_message(text, SLACK_MSG_LIMIT);
|
||||
|
||||
for chunk in chunks {
|
||||
let body = serde_json::json!({
|
||||
let mut body = serde_json::json!({
|
||||
"channel": channel_id,
|
||||
"text": chunk,
|
||||
});
|
||||
if let Some(ts) = thread_ts {
|
||||
body["thread_ts"] = serde_json::json!(ts);
|
||||
}
|
||||
|
||||
let resp: serde_json::Value = self
|
||||
.client
|
||||
@@ -133,6 +153,30 @@ impl ChannelAdapter for SlackAdapter {
|
||||
let allowed_channels = self.allowed_channels.clone();
|
||||
let client = self.client.clone();
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
let active_threads = self.active_threads.clone();
|
||||
let auto_thread_reply = self.auto_thread_reply;
|
||||
|
||||
// Spawn periodic cleanup of expired thread entries.
|
||||
{
|
||||
let active_threads = self.active_threads.clone();
|
||||
let thread_ttl = self.thread_ttl;
|
||||
let mut cleanup_shutdown = self.shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(300));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
active_threads.retain(|_, last| last.elapsed() < thread_ttl);
|
||||
}
|
||||
_ = cleanup_shutdown.changed() => {
|
||||
if *cleanup_shutdown.borrow() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = INITIAL_BACKOFF;
|
||||
@@ -241,7 +285,14 @@ impl ChannelAdapter for SlackAdapter {
|
||||
// Extract the event
|
||||
let event = &payload["payload"]["event"];
|
||||
if let Some(msg) =
|
||||
parse_slack_event(event, &bot_user_id, &allowed_channels).await
|
||||
parse_slack_event(
|
||||
event,
|
||||
&bot_user_id,
|
||||
&allowed_channels,
|
||||
&active_threads,
|
||||
auto_thread_reply,
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
"Slack message from {}: {:?}",
|
||||
@@ -289,16 +340,40 @@ impl ChannelAdapter for SlackAdapter {
|
||||
let channel_id = &user.platform_id;
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(channel_id, &text).await?;
|
||||
self.api_send_message(channel_id, &text, None).await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(channel_id, "(Unsupported content type)")
|
||||
self.api_send_message(channel_id, "(Unsupported content type)", None)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_in_thread(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
thread_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let channel_id = &user.platform_id;
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(channel_id, &text, Some(thread_id))
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(
|
||||
channel_id,
|
||||
"(Unsupported content type)",
|
||||
Some(thread_id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = self.shutdown_tx.send(true);
|
||||
Ok(())
|
||||
@@ -335,6 +410,8 @@ async fn parse_slack_event(
|
||||
event: &serde_json::Value,
|
||||
bot_user_id: &Arc<RwLock<Option<String>>>,
|
||||
allowed_channels: &[String],
|
||||
active_threads: &Arc<DashMap<String, Instant>>,
|
||||
auto_thread_reply: bool,
|
||||
) -> Option<ChannelMessage> {
|
||||
let event_type = event["type"].as_str()?;
|
||||
if event_type != "message" {
|
||||
@@ -413,6 +490,47 @@ async fn parse_slack_event(
|
||||
ChannelContent::Text(text.to_string())
|
||||
};
|
||||
|
||||
// Extract thread_id: threaded replies have `thread_ts`, top-level messages
|
||||
// use their own `ts` so the reply will start a thread under the original.
|
||||
let thread_id = msg_data["thread_ts"]
|
||||
.as_str()
|
||||
.or_else(|| event["thread_ts"].as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| Some(ts.to_string()));
|
||||
|
||||
// Check if the bot was @-mentioned (for group_policy = "mention_only")
|
||||
let mut metadata = HashMap::new();
|
||||
|
||||
// Determine the real thread_ts from the event (None for top-level messages).
|
||||
let real_thread_ts = msg_data["thread_ts"]
|
||||
.as_str()
|
||||
.or_else(|| event["thread_ts"].as_str());
|
||||
|
||||
let mut explicitly_mentioned = false;
|
||||
if let Some(ref bid) = *bot_user_id.read().await {
|
||||
let mention_tag = format!("<@{bid}>");
|
||||
if text.contains(&mention_tag) {
|
||||
explicitly_mentioned = true;
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
|
||||
|
||||
// Track thread for auto-reply on subsequent messages.
|
||||
if let Some(tts) = real_thread_ts {
|
||||
active_threads.insert(tts.to_string(), Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-reply to follow-up messages in tracked threads.
|
||||
if !explicitly_mentioned && auto_thread_reply {
|
||||
if let Some(tts) = real_thread_ts {
|
||||
if let Some(mut entry) = active_threads.get_mut(tts) {
|
||||
// Refresh TTL and mark as mentioned so dispatch proceeds.
|
||||
*entry = Instant::now();
|
||||
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Slack,
|
||||
platform_message_id: ts.to_string(),
|
||||
@@ -425,8 +543,8 @@ async fn parse_slack_event(
|
||||
target_agent: None,
|
||||
timestamp,
|
||||
is_group: true,
|
||||
thread_id: None,
|
||||
metadata: HashMap::new(),
|
||||
thread_id,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -445,7 +563,7 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Slack);
|
||||
assert_eq!(msg.sender.platform_id, "C789");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello agent!"));
|
||||
@@ -463,7 +581,7 @@ mod tests {
|
||||
"bot_id": "B999"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -478,7 +596,7 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -495,11 +613,11 @@ mod tests {
|
||||
|
||||
// Not in allowed channels
|
||||
let msg =
|
||||
parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()]).await;
|
||||
parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed channels
|
||||
let msg = parse_slack_event(&event, &bot_id, &["C789".to_string()]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &["C789".to_string()], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -516,7 +634,7 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await;
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -531,7 +649,7 @@ mod tests {
|
||||
"ts": "1700000000.000100"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -556,7 +674,7 @@ mod tests {
|
||||
"ts": "1700000001.000200"
|
||||
});
|
||||
|
||||
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
|
||||
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Slack);
|
||||
assert_eq!(msg.sender.platform_id, "C789");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message text"));
|
||||
@@ -568,6 +686,8 @@ mod tests {
|
||||
"xapp-test".to_string(),
|
||||
"xoxb-test".to_string(),
|
||||
vec!["C123".to_string()],
|
||||
true,
|
||||
24,
|
||||
);
|
||||
assert_eq!(adapter.name(), "slack");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Slack);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,13 @@ pub enum ChannelContent {
|
||||
url: String,
|
||||
filename: String,
|
||||
},
|
||||
/// Local file data (bytes read from disk). Used by the proactive `channel_send`
|
||||
/// tool when `file_path` is provided instead of `file_url`.
|
||||
FileData {
|
||||
data: Vec<u8>,
|
||||
filename: String,
|
||||
mime_type: String,
|
||||
},
|
||||
Voice {
|
||||
url: String,
|
||||
duration_seconds: u32,
|
||||
@@ -278,8 +285,9 @@ pub fn split_message(text: &str, max_len: usize) -> Vec<&str> {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
// Try to split at a newline near the boundary
|
||||
let split_at = remaining[..max_len].rfind('\n').unwrap_or(max_len);
|
||||
// Try to split at a newline near the boundary (UTF-8 safe)
|
||||
let safe_end = openfang_types::truncate_str(remaining, max_len).len();
|
||||
let split_at = remaining[..safe_end].rfind('\n').unwrap_or(safe_end);
|
||||
let (chunk, rest) = remaining.split_at(split_at);
|
||||
chunks.push(chunk);
|
||||
// Skip the newline (and optional \r) we split on
|
||||
|
||||
@@ -379,8 +379,8 @@ async fn test_bridge_dispatch_no_agent_assigned() {
|
||||
let sent = adapter_ref.get_sent();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert!(
|
||||
sent[0].1.contains("No agent assigned"),
|
||||
"Expected 'No agent assigned' message, got: {}",
|
||||
sent[0].1.contains("No agents available"),
|
||||
"Expected 'No agents available' message, got: {}",
|
||||
sent[0].1
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Compile-time embedded agent templates.
|
||||
//!
|
||||
//! All 30 bundled agent templates are embedded into the binary via `include_str!`.
|
||||
//! This ensures `openfang agent new` works immediately after install — no filesystem
|
||||
//! discovery needed.
|
||||
|
||||
/// Returns all bundled agent templates as `(name, toml_content)` pairs.
|
||||
pub fn bundled_agents() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
("analyst", include_str!("../../../agents/analyst/agent.toml")),
|
||||
("architect", include_str!("../../../agents/architect/agent.toml")),
|
||||
("assistant", include_str!("../../../agents/assistant/agent.toml")),
|
||||
("coder", include_str!("../../../agents/coder/agent.toml")),
|
||||
("code-reviewer", include_str!("../../../agents/code-reviewer/agent.toml")),
|
||||
("customer-support", include_str!("../../../agents/customer-support/agent.toml")),
|
||||
("data-scientist", include_str!("../../../agents/data-scientist/agent.toml")),
|
||||
("debugger", include_str!("../../../agents/debugger/agent.toml")),
|
||||
("devops-lead", include_str!("../../../agents/devops-lead/agent.toml")),
|
||||
("doc-writer", include_str!("../../../agents/doc-writer/agent.toml")),
|
||||
("email-assistant", include_str!("../../../agents/email-assistant/agent.toml")),
|
||||
("health-tracker", include_str!("../../../agents/health-tracker/agent.toml")),
|
||||
("hello-world", include_str!("../../../agents/hello-world/agent.toml")),
|
||||
("home-automation", include_str!("../../../agents/home-automation/agent.toml")),
|
||||
("legal-assistant", include_str!("../../../agents/legal-assistant/agent.toml")),
|
||||
("meeting-assistant", include_str!("../../../agents/meeting-assistant/agent.toml")),
|
||||
("ops", include_str!("../../../agents/ops/agent.toml")),
|
||||
("orchestrator", include_str!("../../../agents/orchestrator/agent.toml")),
|
||||
("personal-finance", include_str!("../../../agents/personal-finance/agent.toml")),
|
||||
("planner", include_str!("../../../agents/planner/agent.toml")),
|
||||
("recruiter", include_str!("../../../agents/recruiter/agent.toml")),
|
||||
("researcher", include_str!("../../../agents/researcher/agent.toml")),
|
||||
("sales-assistant", include_str!("../../../agents/sales-assistant/agent.toml")),
|
||||
("security-auditor", include_str!("../../../agents/security-auditor/agent.toml")),
|
||||
("social-media", include_str!("../../../agents/social-media/agent.toml")),
|
||||
("test-engineer", include_str!("../../../agents/test-engineer/agent.toml")),
|
||||
("translator", include_str!("../../../agents/translator/agent.toml")),
|
||||
("travel-planner", include_str!("../../../agents/travel-planner/agent.toml")),
|
||||
("tutor", include_str!("../../../agents/tutor/agent.toml")),
|
||||
("writer", include_str!("../../../agents/writer/agent.toml")),
|
||||
]
|
||||
}
|
||||
|
||||
/// Install bundled agent templates to `~/.openfang/agents/`.
|
||||
/// Skips any template that already exists on disk (user customization preserved).
|
||||
pub fn install_bundled_agents(agents_dir: &std::path::Path) {
|
||||
for (name, content) in bundled_agents() {
|
||||
let dest_dir = agents_dir.join(name);
|
||||
let dest_file = dest_dir.join("agent.toml");
|
||||
if dest_file.exists() {
|
||||
continue; // Preserve user customization
|
||||
}
|
||||
if std::fs::create_dir_all(&dest_dir).is_ok() {
|
||||
let _ = std::fs::write(&dest_file, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,24 +6,45 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Return the path to `~/.openfang/.env`.
|
||||
pub fn env_file_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".openfang").join(".env"))
|
||||
/// Get the OpenFang home directory, respecting OPENFANG_HOME env var.
|
||||
fn dotenv_openfang_home() -> Option<PathBuf> {
|
||||
if let Ok(home) = std::env::var("OPENFANG_HOME") {
|
||||
return Some(PathBuf::from(home));
|
||||
}
|
||||
dirs::home_dir().map(|h| h.join(".openfang"))
|
||||
}
|
||||
|
||||
/// Load `~/.openfang/.env` into `std::env`.
|
||||
/// Return the path to `~/.openfang/.env`.
|
||||
pub fn env_file_path() -> Option<PathBuf> {
|
||||
dotenv_openfang_home().map(|h| h.join(".env"))
|
||||
}
|
||||
|
||||
/// Load `~/.openfang/.env` and `~/.openfang/secrets.env` into `std::env`.
|
||||
///
|
||||
/// System env vars take priority — existing vars are NOT overridden.
|
||||
/// Silently does nothing if the file doesn't exist.
|
||||
/// `secrets.env` is loaded second so `.env` values take priority over secrets
|
||||
/// (but both yield to system env vars).
|
||||
/// Silently does nothing if the files don't exist.
|
||||
pub fn load_dotenv() {
|
||||
let path = match env_file_path() {
|
||||
load_env_file(env_file_path());
|
||||
// Also load secrets.env (written by dashboard "Set API Key" button)
|
||||
load_env_file(secrets_env_path());
|
||||
}
|
||||
|
||||
/// Return the path to `~/.openfang/secrets.env`.
|
||||
pub fn secrets_env_path() -> Option<PathBuf> {
|
||||
dotenv_openfang_home().map(|h| h.join("secrets.env"))
|
||||
}
|
||||
|
||||
fn load_env_file(path: Option<PathBuf>) {
|
||||
let path = match path {
|
||||
Some(p) => p,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return, // file doesn't exist or unreadable — that's fine
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for line in content.lines() {
|
||||
@@ -33,7 +54,6 @@ pub fn load_dotenv() {
|
||||
}
|
||||
|
||||
if let Some((key, value)) = parse_env_line(trimmed) {
|
||||
// Only set if not already in environment (system env takes priority)
|
||||
if std::env::var(&key).is_err() {
|
||||
std::env::set_var(&key, &value);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1244
-74
File diff suppressed because it is too large
Load Diff
@@ -216,8 +216,13 @@ fn read_message(reader: &mut impl BufRead) -> io::Result<Option<Value>> {
|
||||
/// Write a Content-Length framed JSON-RPC response to the writer.
|
||||
fn write_message(writer: &mut impl Write, msg: &Value) {
|
||||
let body = serde_json::to_string(msg).unwrap_or_default();
|
||||
let _ = write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body);
|
||||
let _ = writer.flush();
|
||||
if let Err(e) = write!(writer, "Content-Length: {}\r\n\r\n{}", body.len(), body) {
|
||||
eprintln!("MCP write error: {e}");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = writer.flush() {
|
||||
eprintln!("MCP flush error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a JSON-RPC message and return an optional response.
|
||||
@@ -225,6 +230,10 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
let method = msg["method"].as_str().unwrap_or("");
|
||||
let id = msg.get("id").cloned();
|
||||
|
||||
// Per JSON-RPC 2.0 spec: requests MUST have an id field.
|
||||
// Use null if missing so we always send a response.
|
||||
let rid = id.unwrap_or(Value::Null);
|
||||
|
||||
match method {
|
||||
"initialize" => {
|
||||
let result = json!({
|
||||
@@ -234,10 +243,10 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "openfang",
|
||||
"version": "0.1.0"
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
});
|
||||
Some(jsonrpc_response(id?, result))
|
||||
Some(jsonrpc_response(rid, result))
|
||||
}
|
||||
|
||||
"notifications/initialized" => None, // Notification, no response
|
||||
@@ -269,7 +278,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(jsonrpc_response(id?, json!({ "tools": tools })))
|
||||
Some(jsonrpc_response(rid, json!({ "tools": tools })))
|
||||
}
|
||||
|
||||
"tools/call" => {
|
||||
@@ -281,14 +290,14 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
.to_string();
|
||||
|
||||
if message.is_empty() {
|
||||
return Some(jsonrpc_error(id?, -32602, "Missing 'message' argument"));
|
||||
return Some(jsonrpc_error(rid, -32602, "Missing 'message' argument"));
|
||||
}
|
||||
|
||||
let agent_id = match backend.resolve_tool_agent(tool_name) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return Some(jsonrpc_error(
|
||||
id?,
|
||||
rid,
|
||||
-32602,
|
||||
&format!("Unknown tool: {tool_name}"),
|
||||
));
|
||||
@@ -297,7 +306,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
|
||||
match backend.send_message(&agent_id, &message) {
|
||||
Ok(response) => Some(jsonrpc_response(
|
||||
id?,
|
||||
rid,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
@@ -306,7 +315,7 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
|
||||
}),
|
||||
)),
|
||||
Err(e) => Some(jsonrpc_response(
|
||||
id?,
|
||||
rid,
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
@@ -319,8 +328,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}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,16 @@ pub fn discover_template_dirs() -> Vec<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
// Installed templates
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let agents = home.join(".openfang").join("agents");
|
||||
// Installed templates (respects OPENFANG_HOME)
|
||||
let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
PathBuf::from(h)
|
||||
} else if let Some(home) = dirs::home_dir() {
|
||||
home.join(".openfang")
|
||||
} else {
|
||||
std::env::temp_dir().join(".openfang")
|
||||
};
|
||||
{
|
||||
let agents = of_home.join("agents");
|
||||
if agents.is_dir() && !dirs.contains(&agents) {
|
||||
dirs.push(agents);
|
||||
}
|
||||
@@ -54,11 +61,12 @@ pub fn discover_template_dirs() -> Vec<PathBuf> {
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Load all templates from discovered directories.
|
||||
/// Load all templates from discovered directories, falling back to bundled templates.
|
||||
pub fn load_all_templates() -> Vec<AgentTemplate> {
|
||||
let mut templates = Vec::new();
|
||||
let mut seen_names = std::collections::HashSet::new();
|
||||
|
||||
// First: load from filesystem (user-installed or dev repo)
|
||||
for dir in discover_template_dirs() {
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
@@ -86,6 +94,18 @@ pub fn load_all_templates() -> Vec<AgentTemplate> {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: load bundled templates for any not found on disk
|
||||
for (name, content) in crate::bundled_agents::bundled_agents() {
|
||||
if seen_names.insert(name.to_string()) {
|
||||
let description = extract_description(content);
|
||||
templates.push(AgentTemplate {
|
||||
name: name.to_string(),
|
||||
description,
|
||||
content: content.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
templates.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
templates
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ impl StandaloneChat {
|
||||
self.chat.last_tokens =
|
||||
Some((r.total_usage.input_tokens, r.total_usage.output_tokens));
|
||||
}
|
||||
self.chat.last_cost_usd = r.cost_usd;
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.status_msg = Some(format!("Error: {e}"));
|
||||
@@ -219,6 +220,8 @@ impl StandaloneChat {
|
||||
}
|
||||
ChatAction::SendMessage(msg) => self.send_message(msg),
|
||||
ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
|
||||
ChatAction::OpenModelPicker => self.open_model_picker(),
|
||||
ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +230,7 @@ impl StandaloneChat {
|
||||
self.chat.thinking = true;
|
||||
self.chat.streaming_chars = 0;
|
||||
self.chat.last_tokens = None;
|
||||
self.chat.last_cost_usd = None;
|
||||
self.chat.status_msg = None;
|
||||
|
||||
match &self.backend {
|
||||
@@ -265,12 +269,13 @@ impl StandaloneChat {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/model \u{2014} show current model",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent & quit",
|
||||
"/exit \u{2014} end chat session",
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent & quit",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
@@ -292,8 +297,14 @@ impl StandaloneChat {
|
||||
self.chat.push_message(Role::System, s.join("\n"));
|
||||
}
|
||||
"/model" => {
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Model: {}", self.chat.model_label));
|
||||
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
||||
if args.is_empty() {
|
||||
// No argument: open the model picker
|
||||
self.open_model_picker();
|
||||
} else {
|
||||
// With argument: switch directly
|
||||
self.switch_model(args);
|
||||
}
|
||||
}
|
||||
"/clear" => {
|
||||
let name = self.chat.agent_name.clone();
|
||||
@@ -362,6 +373,160 @@ impl StandaloneChat {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model picker helpers ──────────────────────────────────────────────────
|
||||
|
||||
fn open_model_picker(&mut self) {
|
||||
use super::screens::chat::ModelEntry;
|
||||
|
||||
let models = match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
let client = crate::daemon_client();
|
||||
match client.get(format!("{base_url}/api/models")).send() {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>() {
|
||||
Ok(body) => body["models"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter(|m| m["available"].as_bool().unwrap_or(false))
|
||||
.map(|m| ModelEntry {
|
||||
id: m["id"].as_str().unwrap_or("").to_string(),
|
||||
display_name: m["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
let catalog = kernel.model_catalog.read().unwrap();
|
||||
catalog
|
||||
.available_models()
|
||||
.into_iter()
|
||||
.map(|e| ModelEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
provider: e.provider.clone(),
|
||||
tier: format!("{:?}", e.tier),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Backend::None => Vec::new(),
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
self.chat
|
||||
.push_message(Role::System, "No models available.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.chat.model_picker_models = models;
|
||||
self.chat.model_picker_filter.clear();
|
||||
self.chat.model_picker_idx = 0;
|
||||
self.chat.show_model_picker = true;
|
||||
}
|
||||
|
||||
fn switch_model(&mut self, model_id: &str) {
|
||||
// Skip if already on this model
|
||||
if self.chat.model_label.ends_with(model_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
if let Some(ref agent_id) = self.agent_id_daemon {
|
||||
let client = crate::daemon_client();
|
||||
let url = format!("{base_url}/api/agents/{agent_id}/model");
|
||||
match client
|
||||
.put(&url)
|
||||
.json(&serde_json::json!({"model": model_id}))
|
||||
.send()
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
// Re-fetch agent to get updated provider/model
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/agents/{agent_id}"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Failed to switch to {model_id}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
if let Some(id) = self.agent_id_inprocess {
|
||||
let provider = kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap()
|
||||
.find_model(model_id)
|
||||
.map(|e| e.provider.clone());
|
||||
let result = if let Some(ref prov) = provider {
|
||||
kernel.registry.update_model_and_provider(
|
||||
id,
|
||||
model_id.to_string(),
|
||||
prov.clone(),
|
||||
)
|
||||
} else {
|
||||
kernel.registry.update_model(id, model_id.to_string())
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let prov_label = provider.unwrap_or_else(|| {
|
||||
kernel
|
||||
.registry
|
||||
.get(id)
|
||||
.map(|e| e.manifest.model.provider.clone())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
});
|
||||
self.chat.model_label = format!("{prov_label}/{model_id}");
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend::None => {
|
||||
self.chat
|
||||
.push_message(Role::System, "No backend connected.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent resolution helpers ─────────────────────────────────────────────
|
||||
|
||||
fn enter_chat_daemon(&mut self, id: String, name: String) {
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::llm_driver::StreamEvent;
|
||||
use openfang_types::agent::AgentId;
|
||||
use screens::{
|
||||
agents, audit, channels, chat, dashboard, extensions, hands, logs, memory, peers, security,
|
||||
sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
|
||||
agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers,
|
||||
security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{mpsc, Arc};
|
||||
@@ -53,6 +53,7 @@ enum Tab {
|
||||
Extensions,
|
||||
Templates,
|
||||
Peers,
|
||||
Comms,
|
||||
Security,
|
||||
Audit,
|
||||
Usage,
|
||||
@@ -74,6 +75,7 @@ const TABS: &[Tab] = &[
|
||||
Tab::Extensions,
|
||||
Tab::Templates,
|
||||
Tab::Peers,
|
||||
Tab::Comms,
|
||||
Tab::Security,
|
||||
Tab::Audit,
|
||||
Tab::Usage,
|
||||
@@ -97,6 +99,7 @@ impl Tab {
|
||||
Tab::Extensions => "Extensions",
|
||||
Tab::Templates => "Templates",
|
||||
Tab::Peers => "Peers",
|
||||
Tab::Comms => "Comms",
|
||||
Tab::Security => "Security",
|
||||
Tab::Audit => "Audit",
|
||||
Tab::Usage => "Usage",
|
||||
@@ -169,6 +172,7 @@ struct App {
|
||||
usage: usage::UsageState,
|
||||
settings: settings::SettingsState,
|
||||
peers: peers::PeersState,
|
||||
comms: comms::CommsState,
|
||||
logs: logs::LogsState,
|
||||
|
||||
kernel_booting: bool,
|
||||
@@ -207,6 +211,7 @@ impl App {
|
||||
usage: usage::UsageState::new(),
|
||||
settings: settings::SettingsState::new(),
|
||||
peers: peers::PeersState::new(),
|
||||
comms: comms::CommsState::new(),
|
||||
logs: logs::LogsState::new(),
|
||||
kernel_booting: false,
|
||||
kernel_boot_error: None,
|
||||
@@ -504,6 +509,26 @@ impl App {
|
||||
}
|
||||
self.peers.loading = false;
|
||||
}
|
||||
AppEvent::CommsTopologyLoaded { nodes, edges } => {
|
||||
self.comms.nodes = nodes;
|
||||
self.comms.edges = edges;
|
||||
self.comms.loading = false;
|
||||
}
|
||||
AppEvent::CommsEventsLoaded(events) => {
|
||||
self.comms.events = events;
|
||||
if !self.comms.events.is_empty()
|
||||
&& self.comms.event_list_state.selected().is_none()
|
||||
{
|
||||
self.comms.event_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
AppEvent::CommsSendResult(msg) => {
|
||||
self.comms.status_msg = msg;
|
||||
self.refresh_comms();
|
||||
}
|
||||
AppEvent::CommsTaskResult(msg) => {
|
||||
self.comms.status_msg = msg;
|
||||
}
|
||||
AppEvent::LogsLoaded(entries) => {
|
||||
self.logs.entries = entries;
|
||||
self.logs.refilter();
|
||||
@@ -845,6 +870,10 @@ impl App {
|
||||
let action = self.peers.handle_key(key);
|
||||
self.handle_peers_action(action);
|
||||
}
|
||||
Tab::Comms => {
|
||||
let action = self.comms.handle_key(key);
|
||||
self.handle_comms_action(action);
|
||||
}
|
||||
Tab::Logs => {
|
||||
let action = self.logs.handle_key(key);
|
||||
self.handle_logs_action(action);
|
||||
@@ -876,6 +905,7 @@ impl App {
|
||||
self.usage.tick();
|
||||
self.settings.tick();
|
||||
self.peers.tick();
|
||||
self.comms.tick();
|
||||
self.logs.tick();
|
||||
|
||||
// Auto-poll for active tabs
|
||||
@@ -883,6 +913,7 @@ impl App {
|
||||
match self.active_tab {
|
||||
Tab::Logs if self.logs.should_poll() => self.refresh_logs(),
|
||||
Tab::Peers if self.peers.should_poll() => self.refresh_peers(),
|
||||
Tab::Comms if self.comms.should_poll() => self.refresh_comms(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -932,6 +963,7 @@ impl App {
|
||||
Tab::Usage => self.refresh_usage(),
|
||||
Tab::Settings => self.refresh_settings_providers(),
|
||||
Tab::Peers => self.refresh_peers(),
|
||||
Tab::Comms => self.refresh_comms(),
|
||||
Tab::Logs => self.refresh_logs(),
|
||||
Tab::Chat => {} // Chat doesn't need refresh on enter
|
||||
}
|
||||
@@ -1089,6 +1121,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_comms(&mut self) {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
self.comms.loading = true;
|
||||
event::spawn_fetch_comms(backend, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_logs(&mut self) {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
self.logs.loading = true;
|
||||
@@ -1325,6 +1364,8 @@ impl App {
|
||||
}
|
||||
chat::ChatAction::SendMessage(msg) => self.send_message(msg),
|
||||
chat::ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
|
||||
chat::ChatAction::OpenModelPicker => self.open_model_picker(),
|
||||
chat::ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1660,6 +1701,27 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_comms_action(&mut self, action: comms::CommsAction) {
|
||||
match action {
|
||||
comms::CommsAction::Continue => {}
|
||||
comms::CommsAction::Refresh => self.refresh_comms(),
|
||||
comms::CommsAction::SendMessage { from, to, msg } => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_comms_send(backend, from, to, msg, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
comms::CommsAction::PostTask {
|
||||
title,
|
||||
desc,
|
||||
assign,
|
||||
} => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_comms_task(backend, title, desc, assign, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_logs_action(&mut self, action: logs::LogsAction) {
|
||||
match action {
|
||||
logs::LogsAction::Continue => {}
|
||||
@@ -1788,6 +1850,159 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Model picker ────────────────────────────────────────────────────────
|
||||
|
||||
fn open_model_picker(&mut self) {
|
||||
let models = match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
let client = crate::daemon_client();
|
||||
match client.get(format!("{base_url}/api/models")).send() {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>() {
|
||||
Ok(body) => body["models"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter(|m| m["available"].as_bool().unwrap_or(false))
|
||||
.map(|m| chat::ModelEntry {
|
||||
id: m["id"].as_str().unwrap_or("").to_string(),
|
||||
display_name: m["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tier: m["tier"]
|
||||
.as_str()
|
||||
.unwrap_or("Balanced")
|
||||
.to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
let catalog = kernel.model_catalog.read().unwrap();
|
||||
catalog
|
||||
.available_models()
|
||||
.into_iter()
|
||||
.map(|e| chat::ModelEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
provider: e.provider.clone(),
|
||||
tier: format!("{:?}", e.tier),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Backend::None => Vec::new(),
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
self.chat
|
||||
.push_message(chat::Role::System, "No models available.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.chat.model_picker_models = models;
|
||||
self.chat.model_picker_filter.clear();
|
||||
self.chat.model_picker_idx = 0;
|
||||
self.chat.show_model_picker = true;
|
||||
}
|
||||
|
||||
fn switch_model(&mut self, model_id: &str) {
|
||||
if self.chat.model_label.ends_with(model_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match (&self.backend, &self.chat_target) {
|
||||
(Backend::Daemon { base_url }, Some(target)) => {
|
||||
if let Some(ref agent_id) = target.agent_id_daemon {
|
||||
let client = crate::daemon_client();
|
||||
let url = format!("{base_url}/api/agents/{agent_id}/model");
|
||||
match client
|
||||
.put(&url)
|
||||
.json(&serde_json::json!({"model": model_id}))
|
||||
.send()
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/agents/{agent_id}"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Failed to switch to {model_id}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(Backend::InProcess { kernel }, Some(target)) => {
|
||||
if let Some(id) = target.agent_id_inprocess {
|
||||
let provider = kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap()
|
||||
.find_model(model_id)
|
||||
.map(|e| e.provider.clone());
|
||||
let result = if let Some(ref prov) = provider {
|
||||
kernel.registry.update_model_and_provider(
|
||||
id,
|
||||
model_id.to_string(),
|
||||
prov.clone(),
|
||||
)
|
||||
} else {
|
||||
kernel.registry.update_model(id, model_id.to_string())
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let prov_label = provider.unwrap_or_else(|| {
|
||||
kernel
|
||||
.registry
|
||||
.get(id)
|
||||
.map(|e| e.manifest.model.provider.clone())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
});
|
||||
self.chat.model_label = format!("{prov_label}/{model_id}");
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.chat
|
||||
.push_message(chat::Role::System, "No backend connected.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Slash commands ──────────────────────────────────────────────────────
|
||||
|
||||
fn handle_slash_command(&mut self, cmd: &str) {
|
||||
@@ -1798,13 +2013,14 @@ impl App {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/model \u{2014} show current model",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
@@ -1929,10 +2145,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
"/model" => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Model: {}", self.chat.model_label),
|
||||
);
|
||||
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
||||
if args.is_empty() {
|
||||
self.open_model_picker();
|
||||
} else {
|
||||
self.switch_model(args);
|
||||
}
|
||||
}
|
||||
"/hands" => match &self.backend {
|
||||
Backend::InProcess { kernel } => {
|
||||
@@ -2029,6 +2247,7 @@ impl App {
|
||||
Tab::Usage => usage::draw(frame, chunks[1], &mut self.usage),
|
||||
Tab::Settings => settings::draw(frame, chunks[1], &mut self.settings),
|
||||
Tab::Peers => peers::draw(frame, chunks[1], &mut self.peers),
|
||||
Tab::Comms => comms::draw(frame, chunks[1], &mut self.comms),
|
||||
Tab::Logs => logs::draw(frame, chunks[1], &mut self.logs),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1524,6 +1524,6 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", &s[..max - 1])
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +341,6 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", &s[..max - 1])
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,18 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Padding, Paragraph};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Model entry for the picker.
|
||||
#[derive(Clone)]
|
||||
pub struct ModelEntry {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub provider: String,
|
||||
pub tier: String,
|
||||
}
|
||||
|
||||
/// Tool call metadata for rich rendering.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolInfo {
|
||||
@@ -58,6 +67,8 @@ pub struct ChatState {
|
||||
pub scroll_offset: u16,
|
||||
/// Token usage from last response.
|
||||
pub last_tokens: Option<(u64, u64)>,
|
||||
/// Cost in USD from last response.
|
||||
pub last_cost_usd: Option<f64>,
|
||||
/// Characters received during current stream (~4 chars ≈ 1 token).
|
||||
pub streaming_chars: usize,
|
||||
/// Status message (errors, etc.)
|
||||
@@ -66,6 +77,14 @@ pub struct ChatState {
|
||||
pub staged_messages: Vec<String>,
|
||||
/// Accumulates ToolInputDelta text for the current tool call.
|
||||
pub tool_input_buf: String,
|
||||
/// Model picker overlay state.
|
||||
pub show_model_picker: bool,
|
||||
/// Available models for the picker.
|
||||
pub model_picker_models: Vec<ModelEntry>,
|
||||
/// Filter text for model search.
|
||||
pub model_picker_filter: String,
|
||||
/// Selected index in the filtered model list.
|
||||
pub model_picker_idx: usize,
|
||||
}
|
||||
|
||||
pub enum ChatAction {
|
||||
@@ -73,6 +92,10 @@ pub enum ChatAction {
|
||||
SendMessage(String),
|
||||
Back,
|
||||
SlashCommand(String),
|
||||
/// Open the model picker (fetch models first).
|
||||
OpenModelPicker,
|
||||
/// Switch to a specific model by id.
|
||||
SwitchModel(String),
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
@@ -90,10 +113,15 @@ impl ChatState {
|
||||
input: String::new(),
|
||||
scroll_offset: 0,
|
||||
last_tokens: None,
|
||||
last_cost_usd: None,
|
||||
streaming_chars: 0,
|
||||
status_msg: None,
|
||||
staged_messages: Vec::new(),
|
||||
tool_input_buf: String::new(),
|
||||
show_model_picker: false,
|
||||
model_picker_models: Vec::new(),
|
||||
model_picker_filter: String::new(),
|
||||
model_picker_idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +135,14 @@ impl ChatState {
|
||||
self.input.clear();
|
||||
self.scroll_offset = 0;
|
||||
self.last_tokens = None;
|
||||
self.last_cost_usd = None;
|
||||
self.streaming_chars = 0;
|
||||
self.status_msg = None;
|
||||
self.staged_messages.clear();
|
||||
self.tool_input_buf.clear();
|
||||
self.show_model_picker = false;
|
||||
self.model_picker_filter.clear();
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
|
||||
/// Push a completed message into history.
|
||||
@@ -201,11 +233,81 @@ impl ChatState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return filtered models based on the current picker filter.
|
||||
pub fn filtered_models(&self) -> Vec<&ModelEntry> {
|
||||
if self.model_picker_filter.is_empty() {
|
||||
return self.model_picker_models.iter().collect();
|
||||
}
|
||||
let f = self.model_picker_filter.to_lowercase();
|
||||
self.model_picker_models
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.id.to_lowercase().contains(&f)
|
||||
|| m.display_name.to_lowercase().contains(&f)
|
||||
|| m.provider.to_lowercase().contains(&f)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> ChatAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.show_model_picker {
|
||||
self.show_model_picker = false;
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
return ChatAction::Back;
|
||||
}
|
||||
|
||||
// Ctrl+M: toggle model picker
|
||||
if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.is_streaming {
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
if self.show_model_picker {
|
||||
self.show_model_picker = false;
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
return ChatAction::OpenModelPicker;
|
||||
}
|
||||
|
||||
// Model picker mode: intercept all keys
|
||||
if self.show_model_picker {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_model_picker = false;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
self.model_picker_idx = self.model_picker_idx.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let max = self.filtered_models().len().saturating_sub(1);
|
||||
if self.model_picker_idx < max {
|
||||
self.model_picker_idx += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let filtered = self.filtered_models();
|
||||
if let Some(entry) = filtered.get(self.model_picker_idx) {
|
||||
let model_id = entry.id.clone();
|
||||
self.show_model_picker = false;
|
||||
self.model_picker_filter.clear();
|
||||
self.model_picker_idx = 0;
|
||||
return ChatAction::SwitchModel(model_id);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.model_picker_filter.pop();
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.model_picker_filter.push(c);
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
|
||||
// When streaming, allow typing + staging messages, scrolling, and Esc
|
||||
if self.is_streaming {
|
||||
match key.code {
|
||||
@@ -357,13 +459,136 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut ChatState) {
|
||||
f.render_widget(Paragraph::new(input_line), chunks[2]);
|
||||
|
||||
// ── Hints ────────────────────────────────────────────────────────────────
|
||||
let hints = if state.is_streaming {
|
||||
let hints = if state.show_model_picker {
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] Select [Esc] Close [type] Filter"
|
||||
} else if state.is_streaming {
|
||||
" [Enter] Stage [\u{2191}\u{2193}] Scroll [Esc] Stop"
|
||||
} else {
|
||||
" [Enter] Send [\u{2191}\u{2193}/PgUp/PgDn] Scroll [Esc] Back"
|
||||
" [Enter] Send [Ctrl+M] Models [\u{2191}\u{2193}] Scroll [Esc] Back"
|
||||
};
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(hints, theme::hint_style())]));
|
||||
f.render_widget(hints, chunks[3]);
|
||||
|
||||
// ── Model picker overlay ────────────────────────────────────────────────
|
||||
if state.show_model_picker {
|
||||
draw_model_picker(f, inner, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
let filtered = state.filtered_models();
|
||||
|
||||
// Center a popup — width ~50 cols, height capped at area
|
||||
if area.height < 6 || area.width < 20 {
|
||||
return; // Too small to show picker
|
||||
}
|
||||
let popup_w = area.width.clamp(30, 54);
|
||||
let popup_h = (filtered.len() as u16 + 4)
|
||||
.clamp(5, area.height.saturating_sub(2));
|
||||
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
|
||||
let popup_area = Rect::new(x, y, popup_w, popup_h);
|
||||
|
||||
// Clear background
|
||||
f.render_widget(Clear, popup_area);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Switch Model ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
f.render_widget(block, popup_area);
|
||||
|
||||
if inner.height < 2 || inner.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout: search bar | model list
|
||||
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
|
||||
|
||||
// Search bar
|
||||
let search_line = Line::from(vec![
|
||||
Span::styled("/ ", theme::dim_style()),
|
||||
Span::raw(&state.model_picker_filter),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(search_line), chunks[0]);
|
||||
|
||||
// Model list
|
||||
let visible_h = chunks[1].height as usize;
|
||||
let total = filtered.len();
|
||||
|
||||
if total == 0 {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" No models match",
|
||||
theme::dim_style(),
|
||||
)])),
|
||||
chunks[1],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll window: keep selected item visible
|
||||
let scroll_start = if state.model_picker_idx >= visible_h {
|
||||
state.model_picker_idx - visible_h + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let max_name = (chunks[1].width as usize).saturating_sub(14);
|
||||
for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) {
|
||||
let selected = i == state.model_picker_idx;
|
||||
let indicator = if selected { "\u{25b6} " } else { " " };
|
||||
|
||||
let name = if entry.display_name.is_empty() {
|
||||
&entry.id
|
||||
} else {
|
||||
&entry.display_name
|
||||
};
|
||||
let name_display = if name.len() > max_name && max_name > 1 {
|
||||
let truncated = openfang_types::truncate_str(name, max_name.saturating_sub(1));
|
||||
format!("{truncated}\u{2026}")
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
let tier_style = match entry.tier.to_lowercase().as_str() {
|
||||
"frontier" => Style::default().fg(theme::PURPLE),
|
||||
"smart" => Style::default().fg(theme::BLUE),
|
||||
"balanced" => Style::default().fg(theme::GREEN),
|
||||
"fast" => Style::default().fg(theme::YELLOW),
|
||||
_ => theme::dim_style(),
|
||||
};
|
||||
|
||||
let bg = if selected {
|
||||
Style::default()
|
||||
.fg(theme::TEXT_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme::TEXT_SECONDARY)
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(indicator, Style::default().fg(theme::ACCENT)),
|
||||
Span::styled(name_display, bg),
|
||||
Span::raw(" "),
|
||||
Span::styled(entry.tier.to_lowercase(), tier_style),
|
||||
]));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), chunks[1]);
|
||||
}
|
||||
|
||||
fn draw_messages(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
@@ -547,11 +772,15 @@ fn draw_messages(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
)]));
|
||||
}
|
||||
|
||||
// Add token usage if available
|
||||
// Add token usage and cost if available
|
||||
if let Some((input, output)) = state.last_tokens {
|
||||
if input > 0 || output > 0 {
|
||||
let cost_str = match state.last_cost_usd {
|
||||
Some(c) if c > 0.0 => format!(" | ${:.4}", c),
|
||||
_ => String::new(),
|
||||
};
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
format!(" [tokens: {} in / {} out]", input, output),
|
||||
format!(" [tokens: {} in / {} out{}]", input, output, cost_str),
|
||||
theme::dim_style(),
|
||||
)]));
|
||||
}
|
||||
@@ -653,6 +882,6 @@ fn truncate_line(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}\u{2026}", &s[..max_len.saturating_sub(1)])
|
||||
format!("{}\u{2026}", openfang_types::truncate_str(s, max_len.saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
//! Comms screen: Agent communication topology + live event feed.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommsNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub state: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommsEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub kind: String, // "parent_child" or "peer"
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommsEventItem {
|
||||
/// Event ID — used by the dashboard for dedup, kept for wire compat.
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
pub timestamp: String,
|
||||
pub kind: String,
|
||||
pub source_name: String,
|
||||
pub target_name: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommsFocus {
|
||||
Topology,
|
||||
EventList,
|
||||
}
|
||||
|
||||
pub struct CommsState {
|
||||
pub nodes: Vec<CommsNode>,
|
||||
pub edges: Vec<CommsEdge>,
|
||||
pub events: Vec<CommsEventItem>,
|
||||
pub event_list_state: ListState,
|
||||
pub focus: CommsFocus,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub poll_tick: usize,
|
||||
// Send modal
|
||||
pub show_send_modal: bool,
|
||||
pub send_from: String,
|
||||
pub send_to: String,
|
||||
pub send_msg: String,
|
||||
pub send_field: usize,
|
||||
// Task modal
|
||||
pub show_task_modal: bool,
|
||||
pub task_title: String,
|
||||
pub task_desc: String,
|
||||
pub task_assign: String,
|
||||
pub task_field: usize,
|
||||
// Status
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum CommsAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
SendMessage {
|
||||
from: String,
|
||||
to: String,
|
||||
msg: String,
|
||||
},
|
||||
PostTask {
|
||||
title: String,
|
||||
desc: String,
|
||||
assign: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CommsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nodes: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
events: Vec::new(),
|
||||
event_list_state: ListState::default(),
|
||||
focus: CommsFocus::Topology,
|
||||
loading: false,
|
||||
tick: 0,
|
||||
poll_tick: 0,
|
||||
show_send_modal: false,
|
||||
send_from: String::new(),
|
||||
send_to: String::new(),
|
||||
send_msg: String::new(),
|
||||
send_field: 0,
|
||||
show_task_modal: false,
|
||||
task_title: String::new(),
|
||||
task_desc: String::new(),
|
||||
task_assign: String::new(),
|
||||
task_field: 0,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
self.poll_tick = self.poll_tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Auto-refresh every ~5s at 20fps tick rate.
|
||||
pub fn should_poll(&self) -> bool {
|
||||
self.poll_tick > 0 && self.poll_tick.is_multiple_of(100)
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> CommsAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return CommsAction::Continue;
|
||||
}
|
||||
|
||||
// Modal key handling
|
||||
if self.show_send_modal {
|
||||
return self.handle_send_modal_key(key);
|
||||
}
|
||||
if self.show_task_modal {
|
||||
return self.handle_task_modal_key(key);
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Tab => {
|
||||
self.focus = match self.focus {
|
||||
CommsFocus::Topology => CommsFocus::EventList,
|
||||
CommsFocus::EventList => CommsFocus::Topology,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
self.show_send_modal = true;
|
||||
self.send_from.clear();
|
||||
self.send_to.clear();
|
||||
self.send_msg.clear();
|
||||
self.send_field = 0;
|
||||
}
|
||||
KeyCode::Char('t') => {
|
||||
self.show_task_modal = true;
|
||||
self.task_title.clear();
|
||||
self.task_desc.clear();
|
||||
self.task_assign.clear();
|
||||
self.task_field = 0;
|
||||
}
|
||||
KeyCode::Char('r') => return CommsAction::Refresh,
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 {
|
||||
self.events.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
};
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % self.events.len();
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
CommsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_send_modal_key(&mut self, key: KeyEvent) -> CommsAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_send_modal = false;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.send_field = (self.send_field + 1) % 3;
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
self.send_field = if self.send_field == 0 {
|
||||
2
|
||||
} else {
|
||||
self.send_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.send_from.is_empty()
|
||||
&& !self.send_to.is_empty()
|
||||
&& !self.send_msg.is_empty()
|
||||
{
|
||||
self.show_send_modal = false;
|
||||
return CommsAction::SendMessage {
|
||||
from: self.send_from.clone(),
|
||||
to: self.send_to.clone(),
|
||||
msg: self.send_msg.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => match self.send_field {
|
||||
0 => self.send_from.push(c),
|
||||
1 => self.send_to.push(c),
|
||||
_ => self.send_msg.push(c),
|
||||
},
|
||||
KeyCode::Backspace => match self.send_field {
|
||||
0 => {
|
||||
self.send_from.pop();
|
||||
}
|
||||
1 => {
|
||||
self.send_to.pop();
|
||||
}
|
||||
_ => {
|
||||
self.send_msg.pop();
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
CommsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_task_modal_key(&mut self, key: KeyEvent) -> CommsAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_task_modal = false;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.task_field = (self.task_field + 1) % 3;
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
self.task_field = if self.task_field == 0 {
|
||||
2
|
||||
} else {
|
||||
self.task_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.task_title.is_empty() {
|
||||
self.show_task_modal = false;
|
||||
return CommsAction::PostTask {
|
||||
title: self.task_title.clone(),
|
||||
desc: self.task_desc.clone(),
|
||||
assign: self.task_assign.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => match self.task_field {
|
||||
0 => self.task_title.push(c),
|
||||
1 => self.task_desc.push(c),
|
||||
_ => self.task_assign.push(c),
|
||||
},
|
||||
KeyCode::Backspace => match self.task_field {
|
||||
0 => {
|
||||
self.task_title.pop();
|
||||
}
|
||||
1 => {
|
||||
self.task_desc.pop();
|
||||
}
|
||||
_ => {
|
||||
self.task_assign.pop();
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
CommsAction::Continue
|
||||
}
|
||||
|
||||
// ── Topology helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn root_nodes(&self) -> Vec<&CommsNode> {
|
||||
let child_ids: std::collections::HashSet<&str> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == "parent_child")
|
||||
.map(|e| e.to.as_str())
|
||||
.collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| !child_ids.contains(n.id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn children_of(&self, id: &str) -> Vec<&CommsNode> {
|
||||
let child_ids: Vec<&str> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == "parent_child" && e.from == id)
|
||||
.map(|e| e.to.as_str())
|
||||
.collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| child_ids.contains(&n.id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn peers_of(&self, id: &str) -> Vec<&CommsNode> {
|
||||
let peer_ids: std::collections::HashSet<&str> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == "peer")
|
||||
.filter_map(|e| {
|
||||
if e.from == id {
|
||||
Some(e.to.as_str())
|
||||
} else if e.to == id {
|
||||
Some(e.from.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| peer_ids.contains(n.id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Comms ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Percentage(35), // topology
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(4), // event list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Header
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" Agent Topology ({} agents, {} edges)",
|
||||
state.nodes.len(),
|
||||
state.edges.len()
|
||||
),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(""),
|
||||
]),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
// Separator
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
"\u{2500}".repeat(inner.width as usize),
|
||||
theme::dim_style(),
|
||||
))),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
// Topology tree
|
||||
draw_topology(f, chunks[2], state);
|
||||
|
||||
// Separator
|
||||
let event_label = if state.focus == CommsFocus::EventList {
|
||||
" \u{25b6} Live Event Feed"
|
||||
} else {
|
||||
" Live Event Feed"
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
event_label,
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ({} events)", state.events.len()),
|
||||
theme::dim_style(),
|
||||
),
|
||||
])),
|
||||
chunks[3],
|
||||
);
|
||||
|
||||
// Event list
|
||||
draw_event_list(f, chunks[4], state);
|
||||
|
||||
// Status message or hints
|
||||
let hint_text = if !state.status_msg.is_empty() {
|
||||
format!(
|
||||
" {} | [s]end [t]ask [r]efresh [Tab] focus [\u{2191}\u{2193}] scroll",
|
||||
state.status_msg
|
||||
)
|
||||
} else {
|
||||
" [s]end [t]ask [r]efresh [Tab] focus [\u{2191}\u{2193}] scroll".to_string()
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(hint_text, theme::hint_style()))),
|
||||
chunks[5],
|
||||
);
|
||||
|
||||
// Modal overlays
|
||||
if state.show_send_modal {
|
||||
draw_send_modal(f, area, state);
|
||||
}
|
||||
if state.show_task_modal {
|
||||
draw_task_modal(f, area, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
if state.loading && state.nodes.is_empty() {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading topology\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if state.nodes.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No agents running.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let focus_highlight = state.focus == CommsFocus::Topology;
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for root in state.root_nodes() {
|
||||
let state_style = state_color(&root.state);
|
||||
let mut spans = vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(format!("[{}]", &root.state), state_style),
|
||||
Span::styled(
|
||||
format!(" {} ", root.name),
|
||||
Style::default()
|
||||
.fg(if focus_highlight {
|
||||
theme::CYAN
|
||||
} else {
|
||||
theme::TEXT
|
||||
})
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!("({})", root.model), theme::dim_style()),
|
||||
];
|
||||
// Peer annotations
|
||||
for peer in state.peers_of(&root.id) {
|
||||
spans.push(Span::styled(
|
||||
format!(" \u{2194} {}", peer.name),
|
||||
Style::default().fg(theme::PURPLE),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
|
||||
// Children
|
||||
let children = state.children_of(&root.id);
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
let branch = if i < children.len() - 1 {
|
||||
"\u{251c}\u{2500}\u{2500} "
|
||||
} else {
|
||||
"\u{2514}\u{2500}\u{2500} "
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(branch, theme::dim_style()),
|
||||
Span::styled(format!("[{}]", child.state), state_color(&child.state)),
|
||||
Span::styled(format!(" {} ", child.name), Style::default().fg(theme::TEXT)),
|
||||
Span::styled(format!("({})", child.model), theme::dim_style()),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
fn draw_event_list(f: &mut Frame, area: Rect, state: &mut CommsState) {
|
||||
if state.events.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No inter-agent events yet.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = state
|
||||
.events
|
||||
.iter()
|
||||
.map(|ev| {
|
||||
let kind_style = kind_color(&ev.kind);
|
||||
let kind_label = kind_short(&ev.kind);
|
||||
let target_part = if ev.target_name.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" \u{2192} {}", ev.target_name)
|
||||
};
|
||||
let detail = truncate(&ev.detail, 50);
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<8}", short_time(&ev.timestamp)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
Span::styled(format!(" {:<10}", kind_label), kind_style),
|
||||
Span::styled(
|
||||
format!(" {}", ev.source_name),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(target_part, Style::default().fg(theme::PURPLE)),
|
||||
Span::styled(format!(" {detail}"), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, area, &mut state.event_list_state);
|
||||
}
|
||||
|
||||
fn draw_send_modal(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
let modal = centered_rect(50, 12, area);
|
||||
f.render_widget(Clear, modal);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(" Send Message ", theme::title_style()))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::uniform(1));
|
||||
let inner = block.inner(modal);
|
||||
f.render_widget(block, modal);
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let field_style = |idx: usize| {
|
||||
if state.send_field == idx {
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
theme::dim_style()
|
||||
}
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("From (agent ID):", field_style(0))),
|
||||
rows[0],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.send_from),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("To (agent ID):", field_style(1))),
|
||||
rows[2],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.send_to),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[3],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Message:", field_style(2))),
|
||||
rows[4],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.send_msg),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[5],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
"[Tab] field [Enter] send [Esc] cancel",
|
||||
theme::hint_style(),
|
||||
)),
|
||||
rows[6],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
let modal = centered_rect(50, 12, area);
|
||||
f.render_widget(Clear, modal);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(" Post Task ", theme::title_style()))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::uniform(1));
|
||||
let inner = block.inner(modal);
|
||||
f.render_widget(block, modal);
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let field_style = |idx: usize| {
|
||||
if state.task_field == idx {
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
theme::dim_style()
|
||||
}
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Title:", field_style(0))),
|
||||
rows[0],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.task_title),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Description:", field_style(1))),
|
||||
rows[2],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.task_desc),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[3],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))),
|
||||
rows[4],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.task_assign),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[5],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
"[Tab] field [Enter] post [Esc] cancel",
|
||||
theme::hint_style(),
|
||||
)),
|
||||
rows[6],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn state_color(state: &str) -> Style {
|
||||
match state {
|
||||
"Running" => Style::default().fg(theme::GREEN),
|
||||
"Suspended" => Style::default().fg(theme::YELLOW),
|
||||
"Terminated" | "Crashed" => Style::default().fg(theme::RED),
|
||||
_ => theme::dim_style(),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_color(kind: &str) -> Style {
|
||||
match kind {
|
||||
"agent_message" => Style::default().fg(theme::CYAN),
|
||||
"agent_spawned" => Style::default().fg(theme::GREEN),
|
||||
"agent_terminated" => Style::default().fg(theme::RED),
|
||||
"task_posted" => Style::default().fg(theme::YELLOW),
|
||||
"task_claimed" => Style::default().fg(theme::CYAN),
|
||||
"task_completed" => Style::default().fg(theme::GREEN),
|
||||
_ => theme::dim_style(),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_short(kind: &str) -> &str {
|
||||
match kind {
|
||||
"agent_message" => "MSG",
|
||||
"agent_spawned" => "SPAWNED",
|
||||
"agent_terminated" => "KILLED",
|
||||
"task_posted" => "TASK+",
|
||||
"task_claimed" => "CLAIM",
|
||||
"task_completed" => "DONE",
|
||||
_ => kind,
|
||||
}
|
||||
}
|
||||
|
||||
fn short_time(ts: &str) -> String {
|
||||
// Extract HH:MM:SS from ISO-8601
|
||||
if let Some(t_pos) = ts.find('T') {
|
||||
let time_part = &ts[t_pos + 1..];
|
||||
if time_part.len() >= 8 {
|
||||
return time_part[..8].to_string();
|
||||
}
|
||||
}
|
||||
ts.chars().take(8).collect()
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect {
|
||||
let w = area.width * percent_x / 100;
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||
Rect::new(x, y, w, height.min(area.height))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user