mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-17 10:22:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
@@ -181,6 +181,11 @@ jobs:
|
||||
- name: Build CLI
|
||||
if: matrix.target != 'aarch64-unknown-linux-gnu'
|
||||
run: cargo build --release --target ${{ matrix.target }} --bin openfang
|
||||
- name: Ad-hoc codesign CLI binary (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
xattr -cr target/${{ matrix.target }}/release/openfang || true
|
||||
codesign --force --sign - target/${{ matrix.target }}/release/openfang
|
||||
- name: Package (Unix)
|
||||
if: matrix.archive == 'tar.gz'
|
||||
run: |
|
||||
|
||||
Generated
+338
-326
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.7"
|
||||
version = "0.3.44"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -49,6 +49,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
|
||||
# IDs
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
@@ -61,7 +62,7 @@ clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
|
||||
# HTTP client (for LLM drivers)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
|
||||
|
||||
# Async trait
|
||||
async-trait = "0.1"
|
||||
@@ -119,9 +120,18 @@ colored = "3"
|
||||
aes-gcm = "0.10"
|
||||
argon2 = "0.5"
|
||||
|
||||
# HTML entity decoding
|
||||
html-escape = "0.2"
|
||||
|
||||
# Lightweight regex
|
||||
regex-lite = "0.1"
|
||||
|
||||
# Socket options (SO_REUSEADDR)
|
||||
socket2 = "0.5"
|
||||
|
||||
# Zip archive extraction
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Email (SMTP + IMAP)
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
|
||||
imap = "2"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
|
||||
<img src="https://img.shields.io/badge/version-0.1.0-green?style=flat-square" alt="v0.1.0" />
|
||||
<img src="https://img.shields.io/badge/version-0.3.30-green?style=flat-square" alt="v0.3.30" />
|
||||
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
|
||||
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
|
||||
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
|
||||
@@ -27,9 +27,9 @@
|
||||
|
||||
---
|
||||
|
||||
> **v0.1.0 — First Release (February 2026)**
|
||||
> **v0.3.30 — Security Hardening Release (March 2026)**
|
||||
>
|
||||
> OpenFang is feature-complete but this is the first public release. You may encounter instability, rough edges, or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
> OpenFang is feature-complete but still pre-1.0. You may encounter rough edges or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
|
||||
|
||||
---
|
||||
|
||||
@@ -371,7 +371,7 @@ cargo fmt --all -- --check
|
||||
|
||||
## Stability Notice
|
||||
|
||||
OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
OpenFang v0.3.30 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
|
||||
|
||||
- **Breaking changes** may occur between minor versions until v1.0
|
||||
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
|
||||
@@ -382,6 +382,12 @@ We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
To report a security vulnerability, email **jaber@rightnowai.co**. We take all reports seriously and will respond within 48 hours.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — use it however you want.
|
||||
|
||||
+2
-2
@@ -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,10 @@ governor = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -73,6 +73,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))
|
||||
}
|
||||
@@ -648,7 +675,16 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
self.kernel
|
||||
.set_agent_model(agent_id, model)
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
Ok(format!("Model switched to: {model}"))
|
||||
// Read back resolved model+provider from registry
|
||||
let entry = self
|
||||
.kernel
|
||||
.registry
|
||||
.get(agent_id)
|
||||
.ok_or_else(|| "Agent not found after model switch".to_string())?;
|
||||
Ok(format!(
|
||||
"Model switched to: {} (provider: {})",
|
||||
entry.manifest.model.model, entry.manifest.model.provider
|
||||
))
|
||||
}
|
||||
|
||||
async fn stop_run(&self, agent_id: AgentId) -> Result<String, String> {
|
||||
@@ -774,6 +810,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
recipient: &str,
|
||||
success: bool,
|
||||
error: Option<&str>,
|
||||
thread_id: Option<&str>,
|
||||
) {
|
||||
let receipt = if success {
|
||||
openfang_kernel::DeliveryTracker::sent_receipt(channel, recipient)
|
||||
@@ -786,9 +823,13 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
|
||||
};
|
||||
self.kernel.delivery_tracker.record(agent_id, receipt);
|
||||
|
||||
// Persist last channel for cron CronDelivery::LastChannel
|
||||
// Persist last channel for cron CronDelivery::LastChannel.
|
||||
// Include thread_id when present so forum-topic context survives restarts.
|
||||
if success {
|
||||
let kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
|
||||
let mut kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
|
||||
if let Some(tid) = thread_id {
|
||||
kv_val["thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let _ = self
|
||||
.kernel
|
||||
.memory
|
||||
@@ -934,30 +975,6 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a default agent by name — find running or spawn from manifest.
|
||||
async fn resolve_default_agent(
|
||||
handle: &KernelBridgeAdapter,
|
||||
name: &str,
|
||||
router: &mut AgentRouter,
|
||||
adapter_name: &str,
|
||||
) {
|
||||
match handle.find_agent_by_name(name).await {
|
||||
Ok(Some(agent_id)) => {
|
||||
router.set_default(agent_id);
|
||||
info!("{adapter_name} default agent: {name} ({agent_id})");
|
||||
}
|
||||
_ => match handle.spawn_agent_by_name(name).await {
|
||||
Ok(agent_id) => {
|
||||
router.set_default(agent_id);
|
||||
info!("{adapter_name}: spawned default agent {name} ({agent_id})");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{adapter_name}: could not find or spawn default agent '{name}': {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a token from an env var, returning None with a warning if missing/empty.
|
||||
fn read_token(env_var: &str, adapter_name: &str) -> Option<String> {
|
||||
match std::env::var(env_var) {
|
||||
@@ -1054,6 +1071,7 @@ pub async fn start_channel_bridge_with_config(
|
||||
token,
|
||||
tg_config.allowed_users.clone(),
|
||||
poll_interval,
|
||||
tg_config.api_url.clone(),
|
||||
));
|
||||
adapters.push((adapter, tg_config.default_agent.clone()));
|
||||
}
|
||||
@@ -1065,6 +1083,8 @@ pub async fn start_channel_bridge_with_config(
|
||||
let adapter = Arc::new(DiscordAdapter::new(
|
||||
token,
|
||||
dc_config.allowed_guilds.clone(),
|
||||
dc_config.allowed_users.clone(),
|
||||
dc_config.ignore_bots,
|
||||
dc_config.intents,
|
||||
));
|
||||
adapters.push((adapter, dc_config.default_agent.clone()));
|
||||
@@ -1545,12 +1565,40 @@ pub async fn start_channel_bridge_with_config(
|
||||
return (None, Vec::new());
|
||||
}
|
||||
|
||||
// Resolve default agent from first adapter that has one configured
|
||||
// Resolve per-channel default agents AND set the first one as system-wide fallback
|
||||
let mut router = AgentRouter::new();
|
||||
for (_, default_agent) in &adapters {
|
||||
let mut system_default_set = false;
|
||||
for (adapter, default_agent) in &adapters {
|
||||
if let Some(ref name) = default_agent {
|
||||
resolve_default_agent(&handle, name, &mut router, "Channel bridge").await;
|
||||
break; // Only need one default
|
||||
// Resolve agent name to ID
|
||||
let agent_id = match handle.find_agent_by_name(name).await {
|
||||
Ok(Some(id)) => Some(id),
|
||||
_ => match handle.spawn_agent_by_name(name).await {
|
||||
Ok(id) => Some(id),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"{}: could not find or spawn default agent '{}': {e}",
|
||||
adapter.name(),
|
||||
name
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Some(agent_id) = agent_id {
|
||||
// Register per-channel default
|
||||
let channel_key = format!("{:?}", adapter.channel_type());
|
||||
info!(
|
||||
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
|
||||
adapter.name()
|
||||
);
|
||||
router.set_channel_default(channel_key, agent_id);
|
||||
// First configured default also becomes system-wide fallback
|
||||
if !system_default_set {
|
||||
router.set_default(agent_id);
|
||||
system_default_set = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1616,6 +1664,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));
|
||||
|
||||
@@ -45,77 +45,86 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
|
||||
|
||||
/// Bearer token authentication middleware.
|
||||
///
|
||||
/// When `api_key` is non-empty, all requests must include
|
||||
/// `Authorization: Bearer <api_key>`. If the key is empty, auth is bypassed.
|
||||
/// When `api_key` is non-empty (after trimming), requests to non-public
|
||||
/// endpoints must include `Authorization: Bearer <api_key>`.
|
||||
/// If the key is empty or whitespace-only, auth is disabled entirely
|
||||
/// (public/local development mode).
|
||||
pub async fn auth(
|
||||
axum::extract::State(api_key): axum::extract::State<String>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
// If no API key configured, restrict to loopback addresses only.
|
||||
if api_key.is_empty() {
|
||||
// SECURITY: Capture method early for method-aware public endpoint checks.
|
||||
let method = request.method().clone();
|
||||
|
||||
// Shutdown is loopback-only (CLI on same machine) — skip token auth
|
||||
let path = request.uri().path();
|
||||
if path == "/api/shutdown" {
|
||||
let is_loopback = request
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().is_loopback())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_loopback {
|
||||
tracing::warn!(
|
||||
"Rejected non-localhost request: no API key configured. \
|
||||
Set api_key in config.toml for remote access."
|
||||
);
|
||||
return Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"error": "No API key configured. Remote access denied. Configure api_key in ~/.openfang/config.toml"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap_or_default();
|
||||
.unwrap_or(false); // SECURITY: default-deny — unknown origin is NOT loopback
|
||||
if is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth (dashboard needs these)
|
||||
let path = request.uri().path();
|
||||
if path == "/"
|
||||
// Public endpoints that don't require auth (dashboard needs these).
|
||||
// SECURITY: /api/agents is GET-only (listing). POST (spawn) requires auth.
|
||||
// SECURITY: Public endpoints are GET-only unless explicitly noted.
|
||||
// POST/PUT/DELETE to any endpoint ALWAYS requires auth to prevent
|
||||
// unauthenticated writes (cron job creation, skill install, etc.).
|
||||
let is_get = method == axum::http::Method::GET;
|
||||
let is_public = path == "/"
|
||||
|| path == "/logo.png"
|
||||
|| path == "/favicon.ico"
|
||||
|| (path == "/.well-known/agent.json" && is_get)
|
||||
|| (path.starts_with("/a2a/") && is_get)
|
||||
|| path == "/api/health"
|
||||
|| path == "/api/health/detail"
|
||||
|| path == "/api/status"
|
||||
|| path == "/api/version"
|
||||
|| path == "/api/agents"
|
||||
|| path == "/api/profiles"
|
||||
|| path == "/api/config"
|
||||
|| path.starts_with("/api/uploads/")
|
||||
|| (path == "/api/agents" && is_get)
|
||||
|| (path == "/api/profiles" && is_get)
|
||||
|| (path == "/api/config" && is_get)
|
||||
|| (path == "/api/config/schema" && is_get)
|
||||
|| (path.starts_with("/api/uploads/") && is_get)
|
||||
// Dashboard read endpoints — allow unauthenticated so the SPA can
|
||||
// render before the user enters their API key.
|
||||
|| path == "/api/models"
|
||||
|| path == "/api/models/aliases"
|
||||
|| path == "/api/providers"
|
||||
|| path == "/api/budget"
|
||||
|| path == "/api/budget/agents"
|
||||
|| path.starts_with("/api/budget/agents/")
|
||||
|| path == "/api/network/status"
|
||||
|| path == "/api/a2a/agents"
|
||||
|| path == "/api/approvals"
|
||||
|| path.starts_with("/api/approvals/")
|
||||
|| path == "/api/channels"
|
||||
|| path == "/api/hands"
|
||||
|| path == "/api/hands/active"
|
||||
|| path.starts_with("/api/hands/")
|
||||
|| path == "/api/skills"
|
||||
|| path == "/api/sessions"
|
||||
|| path == "/api/integrations"
|
||||
|| path == "/api/integrations/available"
|
||||
|| path == "/api/integrations/health"
|
||||
|| path == "/api/workflows"
|
||||
|| path == "/api/logs/stream"
|
||||
|| path.starts_with("/api/cron/")
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/")
|
||||
{
|
||||
|| (path == "/api/models" && is_get)
|
||||
|| (path == "/api/models/aliases" && is_get)
|
||||
|| (path == "/api/providers" && is_get)
|
||||
|| (path == "/api/budget" && is_get)
|
||||
|| (path == "/api/budget/agents" && is_get)
|
||||
|| (path.starts_with("/api/budget/agents/") && is_get)
|
||||
|| (path == "/api/network/status" && is_get)
|
||||
|| (path == "/api/a2a/agents" && is_get)
|
||||
|| (path == "/api/approvals" && is_get)
|
||||
|| (path.starts_with("/api/approvals/") && is_get)
|
||||
|| (path == "/api/channels" && is_get)
|
||||
|| (path == "/api/hands" && is_get)
|
||||
|| (path == "/api/hands/active" && is_get)
|
||||
|| (path.starts_with("/api/hands/") && is_get)
|
||||
|| (path == "/api/skills" && is_get)
|
||||
|| (path == "/api/sessions" && is_get)
|
||||
|| (path == "/api/integrations" && is_get)
|
||||
|| (path == "/api/integrations/available" && is_get)
|
||||
|| (path == "/api/integrations/health" && is_get)
|
||||
|| (path == "/api/workflows" && is_get)
|
||||
|| path == "/api/logs/stream" // SSE stream, read-only
|
||||
|| (path.starts_with("/api/cron/") && is_get)
|
||||
|| path.starts_with("/api/providers/github-copilot/oauth/");
|
||||
|
||||
if is_public {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// If no API key configured (empty, whitespace-only, or missing), skip auth
|
||||
// entirely. Users who don't set api_key accept that all endpoints are open.
|
||||
// To secure the dashboard, set a non-empty api_key in config.toml.
|
||||
let api_key = api_key.trim();
|
||||
if api_key.is_empty() {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
@@ -202,6 +211,10 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
|
||||
"cache-control",
|
||||
"no-store, no-cache, must-revalidate".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"strict-transport-security",
|
||||
"max-age=63072000; includeSubDomains".parse().unwrap(),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
@@ -179,9 +179,8 @@ fn resolve_agent(state: &AppState, model: &str) -> Option<(AgentId, String)> {
|
||||
return Some((entry.id, entry.name.clone()));
|
||||
}
|
||||
|
||||
// 4. Fallback → first registered agent
|
||||
let agents = state.kernel.registry.list();
|
||||
agents.first().map(|e| (e.id, e.name.clone()))
|
||||
// No match — return None so the caller returns a proper 404
|
||||
None
|
||||
}
|
||||
|
||||
// ── Message conversion ──────────────────────────────────────────────────────
|
||||
@@ -336,7 +335,7 @@ pub async fn chat_completions(
|
||||
index: 0,
|
||||
message: ChoiceMessage {
|
||||
role: "assistant",
|
||||
content: Some(result.response),
|
||||
content: Some(crate::ws::strip_think_tags(&result.response)),
|
||||
tool_calls: None,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
|
||||
+1122
-269
File diff suppressed because it is too large
Load Diff
@@ -49,11 +49,13 @@ pub async fn build_router(
|
||||
bridge_manager: tokio::sync::Mutex::new(bridge),
|
||||
channels_config: tokio::sync::RwLock::new(channels_config),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
// CORS: allow localhost origins by default. If API key is set, the API
|
||||
// is protected anyway. For development, permissive CORS is convenient.
|
||||
let cors = if state.kernel.config.api_key.is_empty() {
|
||||
let cors = if state.kernel.config.api_key.trim().is_empty() {
|
||||
// No auth → restrict CORS to localhost origins (include both 127.0.0.1 and localhost)
|
||||
let port = listen_addr.port();
|
||||
let mut origins: Vec<axum::http::HeaderValue> = vec![
|
||||
@@ -101,7 +103,8 @@ pub async fn build_router(
|
||||
.allow_headers(tower_http::cors::Any)
|
||||
};
|
||||
|
||||
let api_key = state.kernel.config.api_key.clone();
|
||||
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
|
||||
let api_key = state.kernel.config.api_key.trim().to_string();
|
||||
let gcra_limiter = rate_limiter::create_rate_limiter();
|
||||
|
||||
let app = Router::new()
|
||||
@@ -125,7 +128,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}",
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent),
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/mode",
|
||||
@@ -320,12 +323,20 @@ pub async fn build_router(
|
||||
"/api/clawhub/skill/{slug}",
|
||||
axum::routing::get(routes::clawhub_skill_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/clawhub/skill/{slug}/code",
|
||||
axum::routing::get(routes::clawhub_skill_code),
|
||||
)
|
||||
.route(
|
||||
"/api/clawhub/install",
|
||||
axum::routing::post(routes::clawhub_install),
|
||||
)
|
||||
// Hands endpoints
|
||||
.route("/api/hands", axum::routing::get(routes::list_hands))
|
||||
.route(
|
||||
"/api/hands/install",
|
||||
axum::routing::post(routes::install_hand),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/active",
|
||||
axum::routing::get(routes::list_active_hands),
|
||||
@@ -731,7 +742,8 @@ pub async fn run_daemon(
|
||||
if info_path.exists() {
|
||||
if let Ok(existing) = std::fs::read_to_string(info_path) {
|
||||
if let Ok(info) = serde_json::from_str::<DaemonInfo>(&existing) {
|
||||
if is_process_alive(info.pid) {
|
||||
// PID alive AND the health endpoint responds → truly running
|
||||
if is_process_alive(info.pid) && is_daemon_responding(&info.listen_addr) {
|
||||
return Err(format!(
|
||||
"Another daemon (PID {}) is already running at {}",
|
||||
info.pid, info.listen_addr
|
||||
@@ -740,7 +752,8 @@ pub async fn run_daemon(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stale PID file, remove it
|
||||
// Stale PID file (process dead or different process reused PID), remove it
|
||||
info!("Removing stale daemon info file");
|
||||
let _ = std::fs::remove_file(info_path);
|
||||
}
|
||||
|
||||
@@ -762,7 +775,22 @@ pub async fn run_daemon(
|
||||
info!("WebChat UI available at http://{addr}/",);
|
||||
info!("WebSocket endpoint: ws://{addr}/api/agents/{{id}}/ws",);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
// Use SO_REUSEADDR to allow binding immediately after reboot (avoids TIME_WAIT).
|
||||
let socket = socket2::Socket::new(
|
||||
if addr.is_ipv4() {
|
||||
socket2::Domain::IPV4
|
||||
} else {
|
||||
socket2::Domain::IPV6
|
||||
},
|
||||
socket2::Type::STREAM,
|
||||
None,
|
||||
)?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(1024)?;
|
||||
let listener =
|
||||
tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
|
||||
|
||||
// Run server with graceful shutdown.
|
||||
// SECURITY: `into_make_service_with_connect_info` injects the peer
|
||||
@@ -882,3 +910,26 @@ fn is_process_alive(pid: u32) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an OpenFang daemon is actually responding at the given address.
|
||||
/// This avoids false positives where a different process reused the same PID
|
||||
/// after a system reboot.
|
||||
fn is_daemon_responding(addr: &str) -> bool {
|
||||
// Quick TCP connect check — don't make a full HTTP request to avoid delays
|
||||
let addr_only = addr
|
||||
.strip_prefix("http://")
|
||||
.or_else(|| addr.strip_prefix("https://"))
|
||||
.unwrap_or(addr);
|
||||
if let Ok(sock_addr) = addr_only.parse::<std::net::SocketAddr>() {
|
||||
std::net::TcpStream::connect_timeout(
|
||||
&sock_addr,
|
||||
std::time::Duration::from_millis(500),
|
||||
)
|
||||
.is_ok()
|
||||
} else {
|
||||
// Fallback: try connecting to hostname
|
||||
std::net::TcpStream::connect(addr_only)
|
||||
.map(|_| true)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,9 +140,23 @@ impl StreamChunker {
|
||||
}
|
||||
|
||||
/// Find the last occurrence of a pattern within a byte range.
|
||||
///
|
||||
/// Both `range.start` and `range.end` are clamped to the nearest valid UTF-8
|
||||
/// char boundary so that slicing never panics on multi-byte content.
|
||||
fn find_last_in_range(text: &str, pattern: &str, range: &std::ops::Range<usize>) -> Option<usize> {
|
||||
let search_text = &text[range.start..range.end.min(text.len())];
|
||||
search_text.rfind(pattern).map(|pos| range.start + pos)
|
||||
let len = text.len();
|
||||
// Clamp end to text length and walk back to a char boundary
|
||||
let mut end = range.end.min(len);
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
// Walk start forward to the nearest char boundary (never past end)
|
||||
let mut start = range.start.min(end);
|
||||
while start < end && !text.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
let search_text = &text[start..end];
|
||||
search_text.rfind(pattern).map(|pos| start + pos)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -125,6 +125,8 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/js/pages/approvals.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/comms.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/runtime.js"),
|
||||
"\n</script>\n",
|
||||
// Alpine.js MUST be last — it processes x-data and fires alpine:init
|
||||
"<script>\n",
|
||||
|
||||
+107
-12
@@ -146,19 +146,30 @@ pub async fn agent_ws(
|
||||
uri: axum::http::Uri,
|
||||
) -> impl IntoResponse {
|
||||
// SECURITY: Authenticate WebSocket upgrades (bypasses middleware).
|
||||
let api_key = &state.kernel.config.api_key;
|
||||
// Trim whitespace so empty/whitespace-only api_key disables auth.
|
||||
let api_key_raw = &state.kernel.config.api_key;
|
||||
let api_key = api_key_raw.trim();
|
||||
if !api_key.is_empty() {
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks on API key
|
||||
let ct_eq = |token: &str, key: &str| -> bool {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != key.len() {
|
||||
return false;
|
||||
}
|
||||
token.as_bytes().ct_eq(key.as_bytes()).into()
|
||||
};
|
||||
|
||||
let header_auth = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|token| token == api_key)
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
let query_auth = uri
|
||||
.query()
|
||||
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
|
||||
.map(|token| token == api_key)
|
||||
.map(|token| ct_eq(token, api_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !header_auth && !query_auth {
|
||||
@@ -621,8 +632,12 @@ async fn handle_text_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip <think>...</think> blocks from model output
|
||||
// (e.g. MiniMax, DeepSeek reasoning tokens)
|
||||
let cleaned_response = strip_think_tags(&result.response);
|
||||
|
||||
// Guard: ensure we never send an empty response
|
||||
let content = if result.response.trim().is_empty() {
|
||||
let content = if cleaned_response.trim().is_empty() {
|
||||
format!(
|
||||
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
|
||||
result.total_usage.input_tokens,
|
||||
@@ -630,7 +645,7 @@ async fn handle_text_message(
|
||||
result.iterations,
|
||||
)
|
||||
} else {
|
||||
result.response
|
||||
cleaned_response
|
||||
};
|
||||
|
||||
// Estimate context pressure from last call
|
||||
@@ -796,7 +811,19 @@ async fn handle_command(
|
||||
} else {
|
||||
match state.kernel.set_agent_model(agent_id, args) {
|
||||
Ok(()) => {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
let model = &entry.manifest.model.model;
|
||||
let provider = &entry.manifest.model.provider;
|
||||
serde_json::json!({
|
||||
"type": "command_result",
|
||||
"command": cmd,
|
||||
"message": format!("Model switched to: {model} (provider: {provider})"),
|
||||
"model": model,
|
||||
"provider": provider
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": format!("Model switched to: {args}")})
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"type": "error", "content": format!("Model switch failed: {e}")})
|
||||
@@ -1092,6 +1119,9 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
let status = extract_status_code(&inner);
|
||||
let classified = llm_errors::classify_error(&inner, status);
|
||||
|
||||
// Build a user-facing message. The classified.sanitized_message now
|
||||
// includes a redacted excerpt of the raw error (issue #493 fix), so we
|
||||
// use it as the base and only override for cases that need extra context.
|
||||
match classified.category {
|
||||
llm_errors::LlmErrorCategory::ContextOverflow => {
|
||||
"Context is full. Try /compact or /new.".to_string()
|
||||
@@ -1099,20 +1129,33 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
llm_errors::LlmErrorCategory::RateLimit => {
|
||||
if let Some(delay_ms) = classified.suggested_delay_ms {
|
||||
let secs = (delay_ms / 1000).max(1);
|
||||
format!("Provider rate limited. Wait ~{secs}s and try again.")
|
||||
format!("Rate limited. Wait ~{secs}s and try again.")
|
||||
} else {
|
||||
"Provider rate limited. Wait a moment and try again.".to_string()
|
||||
"Rate limited. Wait a moment and try again.".to_string()
|
||||
}
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Billing => {
|
||||
"Check provider account status (billing issue detected).".to_string()
|
||||
format!("Billing issue. {}", classified.sanitized_message)
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Auth => {
|
||||
// Show the actual error detail so users can diagnose (issue #493).
|
||||
// The sanitized_message already redacts secrets.
|
||||
classified.sanitized_message.clone()
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Auth => "Verify your API key in config.".to_string(),
|
||||
llm_errors::LlmErrorCategory::ModelNotFound => {
|
||||
"Model unavailable. Use /model to see options.".to_string()
|
||||
if inner.contains("localhost:11434") || inner.contains("ollama") {
|
||||
"Model not found on Ollama. Run `ollama pull <model>` first. Use /model to see options.".to_string()
|
||||
} else {
|
||||
format!("{}. Use /model to see options.", classified.sanitized_message)
|
||||
}
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Format => {
|
||||
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
|
||||
// Claude Code CLI errors have actionable messages — pass them through
|
||||
if inner.contains("Claude Code CLI") || inner.contains("claude auth") {
|
||||
classified.raw_message.clone()
|
||||
} else {
|
||||
classified.sanitized_message.clone()
|
||||
}
|
||||
}
|
||||
_ => classified.sanitized_message,
|
||||
}
|
||||
@@ -1120,6 +1163,14 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
|
||||
/// Try to extract an HTTP status code from an error string.
|
||||
fn extract_status_code(s: &str) -> Option<u16> {
|
||||
// "API error (NNN):" — the format produced by LlmError::Api Display impl
|
||||
if let Some(idx) = s.find("API error (") {
|
||||
let after = &s[idx + 11..];
|
||||
let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(code) = num.parse::<u16>() {
|
||||
return Some(code);
|
||||
}
|
||||
}
|
||||
// "status: NNN"
|
||||
if let Some(idx) = s.find("status: ") {
|
||||
let after = &s[idx + 8..];
|
||||
@@ -1147,6 +1198,27 @@ fn extract_status_code(s: &str) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip `<think>...</think>` blocks from model output.
|
||||
///
|
||||
/// Some models (MiniMax, DeepSeek, etc.) wrap their reasoning in `<think>` tags.
|
||||
/// These are internal chain-of-thought and shouldn't be shown to the user.
|
||||
pub fn strip_think_tags(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut remaining = text;
|
||||
while let Some(start) = remaining.find("<think>") {
|
||||
result.push_str(&remaining[..start]);
|
||||
if let Some(end) = remaining[start..].find("</think>") {
|
||||
remaining = &remaining[(start + end + 8)..]; // 8 = "</think>".len()
|
||||
} else {
|
||||
// Unclosed <think> tag — strip to end
|
||||
remaining = "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1217,10 +1289,33 @@ mod tests {
|
||||
);
|
||||
assert_eq!(extract_status_code("StatusCode(401)"), Some(401));
|
||||
assert_eq!(extract_status_code("some random error"), None);
|
||||
// LlmError::Api Display format (issue #493 fix)
|
||||
assert_eq!(
|
||||
extract_status_code("LLM driver error: API error (403): quota exceeded"),
|
||||
Some(403)
|
||||
);
|
||||
assert_eq!(
|
||||
extract_status_code("API error (401): invalid api key"),
|
||||
Some(401)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_trims_whitespace() {
|
||||
assert_eq!(sanitize_user_input(" hello "), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_think_tags() {
|
||||
assert_eq!(
|
||||
strip_think_tags("<think>reasoning here</think>The answer is 42."),
|
||||
"The answer is 42."
|
||||
);
|
||||
assert_eq!(
|
||||
strip_think_tags("Hello <think>\nsome thinking\n</think> world"),
|
||||
"Hello world"
|
||||
);
|
||||
assert_eq!(strip_think_tags("No thinking here"), "No thinking here");
|
||||
assert_eq!(strip_think_tags("<think>all thinking</think>"), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '' }">
|
||||
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
|
||||
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
|
||||
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
|
||||
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
|
||||
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
|
||||
</div>
|
||||
@@ -156,6 +157,10 @@
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'runtime' }" @click="navigate('runtime')" :aria-current="page === 'runtime' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></span>
|
||||
<span class="nav-label">Runtime</span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'settings' }" @click="navigate('settings')" :aria-current="page === 'settings' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/></svg></span>
|
||||
<span class="nav-label">Settings</span>
|
||||
@@ -585,6 +590,14 @@
|
||||
|
||||
<!-- Messages area -->
|
||||
<div class="messages" id="messages" @dragover.prevent="dragOver = true" @dragleave="dragOver = false" @drop.prevent="handleDrop($event); dragOver = false">
|
||||
<!-- Empty state: no agent selected -->
|
||||
<template x-if="!currentAgent">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center;padding:32px;opacity:0.8">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:16px;opacity:0.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<h3 style="margin:0 0 8px;font-size:16px;font-weight:600">Select an agent to start chatting</h3>
|
||||
<p class="text-dim" style="font-size:13px;max-width:320px">Choose an agent from the sidebar or go to the Agents tab to create a new one.</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Message list -->
|
||||
<template x-if="currentAgent">
|
||||
<div>
|
||||
@@ -723,7 +736,7 @@
|
||||
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
|
||||
</div>
|
||||
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
|
||||
@keydown.enter.prevent="if(!$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.enter.prevent="if(!$event.isComposing && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false; showModelPicker = false"
|
||||
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
@@ -742,9 +755,61 @@
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Footer: tokens + queue + tips -->
|
||||
<!-- Footer: model switcher + tokens + queue + tips -->
|
||||
<div class="input-footer">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Model Switcher -->
|
||||
<div style="position:relative" x-show="currentAgent" @click.outside="showModelSwitcher = false" @keydown.escape.window="showModelSwitcher = false">
|
||||
<button class="model-switcher-btn" @click="toggleModelSwitcher()" :disabled="sending" title="Switch model (Ctrl+M)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
<span class="model-switcher-label" x-text="modelDisplayName || 'Model'"></span>
|
||||
<svg class="model-switcher-chevron" :class="{'open': showModelSwitcher}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<!-- Dropdown -->
|
||||
<div class="model-switcher-dropdown" x-show="showModelSwitcher" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform translate-y-1" x-transition:enter-end="opacity-100 transform translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
||||
<div class="model-switcher-search">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
|
||||
<select x-model="modelSwitcherProviderFilter" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;padding:2px 6px;cursor:pointer;font-family:var(--font-mono);flex-shrink:0">
|
||||
<option value="">All</option>
|
||||
<template x-for="pn in switcherProviders" :key="pn">
|
||||
<option :value="pn" x-text="pn"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="modelSwitching" style="display:flex;align-items:center;justify-content:center;padding:12px;gap:8px">
|
||||
<div class="tool-card-spinner"></div>
|
||||
<span class="text-xs text-dim">Switching...</span>
|
||||
</div>
|
||||
<div class="model-switcher-list" x-show="!modelSwitching">
|
||||
<template x-if="groupedSwitcherModels.length === 0">
|
||||
<div style="padding:16px;text-align:center" class="text-xs text-dim">No models found</div>
|
||||
</template>
|
||||
<template x-for="group in groupedSwitcherModels" :key="group.provider">
|
||||
<div>
|
||||
<div class="model-switcher-group-header" x-text="group.provider"></div>
|
||||
<template x-for="(m, mi) in group.models" :key="m.id">
|
||||
<div class="model-switcher-item" :class="{'active': currentAgent && m.id === currentAgent.model_name}" @click="switchModel(m)" @mouseenter="modelSwitcherIdx = filteredSwitcherModels.indexOf(m)">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span class="model-switcher-item-name" x-text="m.display_name || m.id"></span>
|
||||
<span class="model-switcher-tier" :class="'tier-' + (m.tier || 'balanced').toLowerCase()" x-text="m.tier || 'Balanced'"></span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
|
||||
<span class="text-xs text-dim" x-text="m.id" style="font-family:var(--font-mono)"></span>
|
||||
<span class="text-xs text-dim" x-show="m.context_window" x-text="m.context_window >= 1000000 ? (m.context_window/1000000).toFixed(1)+'M' : Math.round(m.context_window/1000)+'K'"></span>
|
||||
<svg x-show="m.supports_vision" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.5" title="Vision"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<svg x-show="m.supports_tools" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.5" title="Tools"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<svg x-show="currentAgent && m.id === currentAgent.model_name" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-dim" x-text="tokenCount > 0 ? '~' + tokenCount + ' tokens' : (attachments.length ? attachments.length + ' file(s)' : '')"></span>
|
||||
<span class="queue-badge" x-show="messageQueue.length > 0" x-text="messageQueue.length + ' queued'"></span>
|
||||
</div>
|
||||
@@ -783,6 +848,9 @@
|
||||
<div class="text-xs text-dim font-mono" style="font-size:11px" x-text="agent.model_name"></div>
|
||||
</div>
|
||||
<span class="badge" :class="'badge-' + agent.state.toLowerCase()" x-text="agent.state" style="font-size:10px"></span>
|
||||
<button class="agent-chip-config-btn" @click.stop="showDetail(agent)" title="Agent settings" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--text-dim);transition:all 0.15s;flex-shrink:0" @mouseenter="$el.style.borderColor='var(--accent)';$el.style.color='var(--accent)';$el.style.background='var(--surface2)'" @mouseleave="$el.style.borderColor='var(--border)';$el.style.color='var(--text-dim)';$el.style.background='transparent'">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -854,6 +922,36 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value" x-text="detailAgent.created_at ? new Date(detailAgent.created_at).toLocaleString() : '-'"></span></div>
|
||||
|
||||
<!-- Fallback Model Chain -->
|
||||
<div class="detail-row" style="align-items:flex-start">
|
||||
<span class="detail-label">Fallbacks</span>
|
||||
<div style="flex:1">
|
||||
<template x-if="detailAgent._fallbacks && detailAgent._fallbacks.length > 0">
|
||||
<div>
|
||||
<template x-for="(fb, idx) in detailAgent._fallbacks" :key="idx">
|
||||
<div class="flex gap-1 items-center" style="margin-bottom:4px">
|
||||
<span class="badge" style="font-size:11px;font-family:var(--font-mono)" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger)" @click="removeFallback(idx)">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!detailAgent._fallbacks || detailAgent._fallbacks.length === 0">
|
||||
<span class="text-dim" style="font-size:12px">None — add a fallback chain</span>
|
||||
</template>
|
||||
<template x-if="!editingFallback">
|
||||
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px;margin-top:4px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
|
||||
</template>
|
||||
<template x-if="editingFallback">
|
||||
<div class="flex gap-1 mt-1" style="align-items:center">
|
||||
<input class="form-input" style="width:220px;font-size:12px" x-model="newFallbackValue" placeholder="provider/model" @keydown.enter="addFallback()" @keydown.escape="editingFallback = false">
|
||||
<button class="btn btn-primary btn-sm" @click="addFallback()" style="padding:2px 10px;font-size:11px">Add</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingFallback = false" style="padding:2px 8px;font-size:11px">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" @click="chatWithAgent(detailAgent); showDetailModal = false">Chat</button>
|
||||
@@ -1349,63 +1447,8 @@
|
||||
</defs>
|
||||
<rect x="-2000" y="-2000" width="6000" height="6000" fill="url(#wf-grid)"/>
|
||||
|
||||
<!-- Connections -->
|
||||
<template x-for="conn in connections" :key="conn.id">
|
||||
<g>
|
||||
<path :d="getConnectionPath(conn)" fill="none" stroke="var(--text-dim)" stroke-width="2"
|
||||
style="cursor:pointer" @click.stop="selectedConnection = conn"
|
||||
:stroke="selectedConnection && selectedConnection.id === conn.id ? 'var(--accent)' : 'var(--text-dim)'"
|
||||
:stroke-width="selectedConnection && selectedConnection.id === conn.id ? 3 : 2"/>
|
||||
<!-- Arrow at midpoint -->
|
||||
</g>
|
||||
</template>
|
||||
|
||||
<!-- Connection preview line -->
|
||||
<template x-if="connecting && connectPreview">
|
||||
<path :d="getPreviewPath()" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="6,3"/>
|
||||
</template>
|
||||
|
||||
<!-- Nodes -->
|
||||
<template x-for="node in nodes" :key="node.id">
|
||||
<g class="wf-node" :transform="'translate(' + node.x + ',' + node.y + ')'"
|
||||
@mousedown="onNodeMouseDown(node, $event)" @dblclick="editNode(node)">
|
||||
<!-- Node body -->
|
||||
<rect x="0" y="0" :width="node.width" :height="node.height" rx="8" ry="8"
|
||||
:fill="selectedNode && selectedNode.id === node.id ? 'var(--card-bg)' : 'var(--bg-secondary)'"
|
||||
:stroke="selectedNode && selectedNode.id === node.id ? node.color : 'var(--border)'"
|
||||
stroke-width="2" style="cursor:grab"/>
|
||||
<!-- Color accent bar -->
|
||||
<rect x="0" y="0" width="6" :height="node.height" rx="3" ry="0" :fill="node.color"/>
|
||||
<!-- Icon -->
|
||||
<circle cx="28" :cy="node.height/2" r="14" :fill="node.color" opacity="0.15"/>
|
||||
<text x="28" :y="node.height/2 + 4" text-anchor="middle" :fill="node.color"
|
||||
style="font-size:12px;font-weight:700;pointer-events:none" x-text="node.icon"></text>
|
||||
<!-- Label -->
|
||||
<text x="50" :y="node.height/2 - 4" fill="var(--text)" style="font-size:12px;font-weight:600;pointer-events:none"
|
||||
x-text="node.label"></text>
|
||||
<!-- Sub-label (config hint) -->
|
||||
<text x="50" :y="node.height/2 + 12" fill="var(--text-dim)" style="font-size:10px;pointer-events:none"
|
||||
x-text="node.type === 'agent' ? (node.config.agent_name || 'No agent') :
|
||||
node.type === 'condition' ? (node.config.expression || 'No condition') :
|
||||
node.type === 'loop' ? ('max ' + (node.config.max_iterations || 5) + ' iters') :
|
||||
node.type === 'parallel' ? (node.config.fan_count + ' branches') :
|
||||
node.type === 'collect' ? node.config.strategy : ''"></text>
|
||||
|
||||
<!-- Input ports -->
|
||||
<template x-for="pi in Array.from({length: node.ports.in}, function(_,i){return i})" :key="'in-'+pi">
|
||||
<circle class="wf-port wf-port-in" :cx="node.width / (node.ports.in + 1) * (pi + 1)" cy="0" r="6"
|
||||
fill="var(--bg-secondary)" stroke="var(--text-dim)" stroke-width="2"
|
||||
@mouseup.stop="endConnect(node.id, pi, $event)"/>
|
||||
</template>
|
||||
|
||||
<!-- Output ports -->
|
||||
<template x-for="po in Array.from({length: node.ports.out}, function(_,i){return i})" :key="'out-'+po">
|
||||
<circle class="wf-port wf-port-out" :cx="node.width / (node.ports.out + 1) * (po + 1)" :cy="node.height" r="6"
|
||||
fill="var(--bg-secondary)" :stroke="node.color" stroke-width="2"
|
||||
@mousedown.stop="startConnect(node.id, po, $event)"/>
|
||||
</template>
|
||||
</g>
|
||||
</template>
|
||||
<!-- Manually rendered nodes & connections (Alpine x-for breaks inside SVG) -->
|
||||
<g id="wf-render-group" x-effect="nodes.length; connections.length; selectedNode; selectedConnection; connecting; connectPreview; scheduleRender()"></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
@@ -1425,7 +1468,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Label</label>
|
||||
<input class="form-input" x-model="selectedNode.label" style="font-size:11px">
|
||||
<input class="form-input" x-model="selectedNode.label" @input="applyNodeEdit()" style="font-size:11px">
|
||||
</div>
|
||||
|
||||
<!-- Agent config -->
|
||||
@@ -1433,7 +1476,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Agent</label>
|
||||
<select class="form-select" x-model="selectedNode.config.agent_name" style="font-size:11px">
|
||||
<select class="form-select" x-model="selectedNode.config.agent_name" @change="applyNodeEdit()" style="font-size:11px">
|
||||
<option value="">Select agent...</option>
|
||||
<template x-for="a in agents" :key="a.id || a.name">
|
||||
<option :value="a.name" x-text="a.name"></option>
|
||||
@@ -1442,11 +1485,11 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Prompt Template</label>
|
||||
<textarea class="form-textarea" x-model="selectedNode.config.prompt" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
|
||||
<textarea class="form-textarea" x-model="selectedNode.config.prompt" @input="applyNodeEdit()" style="font-size:11px;min-height:60px" placeholder="{{input}}"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Model (optional)</label>
|
||||
<input class="form-input" x-model="selectedNode.config.model" style="font-size:11px" placeholder="Default model">
|
||||
<input class="form-input" x-model="selectedNode.config.model" @input="applyNodeEdit()" style="font-size:11px" placeholder="Default model">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1456,7 +1499,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Expression</label>
|
||||
<input class="form-input" x-model="selectedNode.config.expression" style="font-size:11px" placeholder="output.contains('yes')">
|
||||
<input class="form-input" x-model="selectedNode.config.expression" @input="applyNodeEdit()" style="font-size:11px" placeholder="output.contains('yes')">
|
||||
</div>
|
||||
<div class="text-xs text-dim">Top port = true, bottom port = false</div>
|
||||
</div>
|
||||
@@ -1467,11 +1510,11 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Max Iterations</label>
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" style="font-size:11px" min="1" max="100">
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.max_iterations" @input="applyNodeEdit()" style="font-size:11px" min="1" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Until (stop condition)</label>
|
||||
<input class="form-input" x-model="selectedNode.config.until" style="font-size:11px" placeholder="output === 'done'">
|
||||
<input class="form-input" x-model="selectedNode.config.until" @input="applyNodeEdit()" style="font-size:11px" placeholder="output === 'done'">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1481,7 +1524,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Fan-out Count</label>
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" style="font-size:11px" min="2" max="10">
|
||||
<input type="number" class="form-input" x-model.number="selectedNode.config.fan_count" @input="applyNodeEdit()" style="font-size:11px" min="2" max="10">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1491,7 +1534,7 @@
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label class="text-xs">Strategy</label>
|
||||
<select class="form-select" x-model="selectedNode.config.strategy" style="font-size:11px">
|
||||
<select class="form-select" x-model="selectedNode.config.strategy" @change="applyNodeEdit()" style="font-size:11px">
|
||||
<option value="all">Wait for all</option>
|
||||
<option value="first">First to finish</option>
|
||||
<option value="majority">Majority vote</option>
|
||||
@@ -2360,7 +2403,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary btn-block" @click="installFromClawHub(skillDetail.slug)" :disabled="installingSlug === skillDetail.slug || skillDetail.installed || isSkillInstalled(skillDetail.slug)" x-text="skillDetail.installed || isSkillInstalled(skillDetail.slug) ? 'Already Installed' : installingSlug === skillDetail.slug ? 'Installing...' : 'Install from ClawHub'"></button>
|
||||
<button class="btn btn-primary" style="flex:1" @click="installFromClawHub(skillDetail.slug)" :disabled="installingSlug === skillDetail.slug || skillDetail.installed || isSkillInstalled(skillDetail.slug)" x-text="skillDetail.installed || isSkillInstalled(skillDetail.slug) ? 'Already Installed' : installingSlug === skillDetail.slug ? 'Installing...' : 'Install from ClawHub'"></button>
|
||||
<button class="btn btn-ghost" @click="viewSkillCode(skillDetail.slug)" :disabled="skillCodeLoading" x-text="skillCodeLoading ? 'Loading...' : showSkillCode ? 'Hide Code' : 'View Code'"></button>
|
||||
</div>
|
||||
<div x-show="showSkillCode && skillCode" x-transition class="mt-3" style="max-height:300px;overflow:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-inset)">
|
||||
<div class="flex justify-between items-center" style="padding:6px 12px;border-bottom:1px solid var(--border)">
|
||||
<span class="text-xs text-dim" x-text="skillCodeFilename"></span>
|
||||
</div>
|
||||
<pre style="margin:0;padding:12px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all" x-text="skillCode"></pre>
|
||||
</div>
|
||||
<div class="text-xs text-dim mt-2 text-center">Skills are security-scanned before installation. Prompt injection and malware patterns are blocked.</div>
|
||||
</div>
|
||||
@@ -2777,7 +2827,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
|
||||
<!-- Text type -->
|
||||
<template x-if="setting.setting_type === 'text'">
|
||||
<input type="text" class="input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
|
||||
<input type="text" class="form-input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2993,6 +3043,30 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Add Custom Provider -->
|
||||
<div class="info-card mt-4" style="border:1px solid var(--border)">
|
||||
<h4 style="margin-top:0">Add Custom Provider</h4>
|
||||
<p class="text-xs text-dim mb-2">Connect any OpenAI-compatible API (vLLM, LiteLLM, LocalAI, etc.)</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
|
||||
<div>
|
||||
<label class="text-xs text-dim">Provider Name</label>
|
||||
<input class="form-input" x-model="customProviderName" placeholder="e.g. my-local-llm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Base URL (required)</label>
|
||||
<input class="form-input" x-model="customProviderUrl" placeholder="http://localhost:8080/v1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="text-xs text-dim">API Key (optional)</label>
|
||||
<input class="form-input" type="password" x-model="customProviderKey" placeholder="sk-... (leave blank if not needed)">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="addCustomProvider()" :disabled="!customProviderName.trim() || !customProviderUrl.trim() || addingCustomProvider">
|
||||
<span x-show="!addingCustomProvider">Add Provider</span>
|
||||
<span x-show="addingCustomProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
||||
</button>
|
||||
<span class="text-xs text-dim ml-2" x-text="customProviderStatus"></span>
|
||||
</div>
|
||||
<div class="empty-state" x-show="!providers.length">
|
||||
<h4>No providers found</h4>
|
||||
<p class="hint">Provider information could not be loaded. Check that the API is running.</p>
|
||||
@@ -3049,9 +3123,15 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
|
||||
<div x-show="!filteredModels.length && !settingsLoading" style="text-align:center;padding:32px 16px">
|
||||
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">🤖</div>
|
||||
<h3 style="margin:0 0 4px;font-size:14px" x-text="models.length ? 'No models match your search' : 'No models available'"></h3>
|
||||
<p class="text-xs text-dim" x-text="models.length ? 'Try a different search term or clear filters.' : 'Configure an LLM provider to see available models.'"></p>
|
||||
<button class="btn btn-ghost btn-sm mt-2" x-show="models.length && (modelSearch || modelProviderFilter)" @click="modelSearch=''; modelProviderFilter=''">Clear Filters</button>
|
||||
</div>
|
||||
<div class="table-wrap" x-show="filteredModels.length">
|
||||
<table>
|
||||
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th></tr></thead>
|
||||
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="m in filteredModels" :key="m.id">
|
||||
<tr>
|
||||
@@ -3062,6 +3142,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<td class="text-xs" x-text="formatCost(m.input_cost_per_m)"></td>
|
||||
<td class="text-xs" x-text="formatCost(m.output_cost_per_m)"></td>
|
||||
<td><span class="badge" :class="m.available ? 'badge-success' : 'badge-muted'" x-text="m.available ? 'Available' : 'Needs Key'"></span></td>
|
||||
<td><button x-show="m.tier === 'custom'" class="btn btn-ghost btn-sm" @click="deleteCustomModel(m.id)" title="Delete custom model" style="padding:2px 6px;font-size:11px;color:var(--text-muted)">✕</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
@@ -3076,6 +3157,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<input placeholder="Search tools..." x-model="toolSearch">
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredTools.length + ' of ' + tools.length + ' tools'"></div>
|
||||
<div x-show="!filteredTools.length && !settingsLoading" style="text-align:center;padding:32px 16px">
|
||||
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">🔧</div>
|
||||
<h3 style="margin:0 0 4px;font-size:14px" x-text="tools.length ? 'No tools match your search' : 'No tools available'"></h3>
|
||||
<p class="text-xs text-dim" x-text="tools.length ? 'Try a different search term.' : 'Tools will appear once agents are configured.'"></p>
|
||||
<button class="btn btn-ghost btn-sm mt-2" x-show="tools.length && toolSearch" @click="toolSearch=''">Clear Search</button>
|
||||
</div>
|
||||
<div class="table-wrap" x-show="filteredTools.length">
|
||||
<table>
|
||||
<thead><tr><th>Tool</th><th>Description</th></tr></thead>
|
||||
@@ -3285,11 +3372,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<!-- Network tab -->
|
||||
<div x-show="tab === 'network'" x-data="{
|
||||
netStatus: null, a2aAgents: [], a2aDiscoverUrl: '', a2aDiscovering: false,
|
||||
async loadNetStatus() { try { this.netStatus = await (await fetch('/api/network/status')).json(); } catch(e) {} },
|
||||
async loadA2aAgents() { try { let r = await (await fetch('/api/a2a/agents')).json(); this.a2aAgents = r.agents || []; } catch(e) {} },
|
||||
async loadNetStatus() { try { this.netStatus = await OpenFangAPI.get('/api/network/status'); } catch(e) {} },
|
||||
async loadA2aAgents() { try { let r = await OpenFangAPI.get('/api/a2a/agents'); this.a2aAgents = r.agents || []; } catch(e) {} },
|
||||
async discoverA2a() {
|
||||
if (!this.a2aDiscoverUrl) return; this.a2aDiscovering = true;
|
||||
try { await fetch('/api/a2a/discover', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:this.a2aDiscoverUrl})}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
|
||||
try { await OpenFangAPI.post('/api/a2a/discover', {url:this.a2aDiscoverUrl}); this.a2aDiscoverUrl=''; await this.loadA2aAgents(); } catch(e) {}
|
||||
this.a2aDiscovering = false;
|
||||
}
|
||||
}" x-init="loadNetStatus(); loadA2aAgents()">
|
||||
@@ -3371,14 +3458,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<div x-show="tab === 'budget'" x-data="{
|
||||
budgetData: null, agentRanking: [], budgetLoading: true,
|
||||
editMode: false,
|
||||
editHourly: '', editDaily: '', editMonthly: '', editAlert: '',
|
||||
editHourly: '', editDaily: '', editMonthly: '', editAlert: '', editTokenLimit: '',
|
||||
saving: false,
|
||||
async loadBudget() {
|
||||
this.budgetLoading = true;
|
||||
try {
|
||||
let [b, a] = await Promise.all([
|
||||
fetch('/api/budget').then(r => r.json()),
|
||||
fetch('/api/budget/agents').then(r => r.json())
|
||||
OpenFangAPI.get('/api/budget'),
|
||||
OpenFangAPI.get('/api/budget/agents')
|
||||
]);
|
||||
this.budgetData = b;
|
||||
this.agentRanking = a.agents || [];
|
||||
@@ -3390,6 +3477,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
this.editDaily = this.budgetData.daily_limit || 0;
|
||||
this.editMonthly = this.budgetData.monthly_limit || 0;
|
||||
this.editAlert = ((this.budgetData.alert_threshold || 0.8) * 100).toFixed(0);
|
||||
this.editTokenLimit = this.budgetData.default_max_llm_tokens_per_hour || 0;
|
||||
this.editMode = true;
|
||||
},
|
||||
async saveBudget() {
|
||||
@@ -3401,12 +3489,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
if (+this.editMonthly !== this.budgetData.monthly_limit) body.max_monthly_usd = +this.editMonthly;
|
||||
let alertVal = (+this.editAlert) / 100;
|
||||
if (Math.abs(alertVal - this.budgetData.alert_threshold) > 0.001) body.alert_threshold = alertVal;
|
||||
await fetch('/api/budget', { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
if (+this.editTokenLimit !== (this.budgetData.default_max_llm_tokens_per_hour || 0)) body.default_max_llm_tokens_per_hour = +this.editTokenLimit;
|
||||
await OpenFangAPI.put('/api/budget', body);
|
||||
this.editMode = false;
|
||||
await this.loadBudget();
|
||||
} catch(e) { alert('Failed to save: ' + e); }
|
||||
} catch(e) { OpenFangToast.error('Failed to save: ' + (e.message || e)); }
|
||||
this.saving = false;
|
||||
},
|
||||
fmtTokens(v) { return v > 0 ? (v >= 1000000 ? (v/1000000).toFixed(1)+'M' : v >= 1000 ? (v/1000).toFixed(0)+'K' : v) : 'per-agent'; },
|
||||
pctColor(pct) { return pct >= 0.8 ? '#ef4444' : pct >= 0.5 ? '#eab308' : '#22c55e'; },
|
||||
fmtUsd(v) { return v > 0 ? '$' + v.toFixed(4) : 'unlimited'; }
|
||||
}" x-init="loadBudget()">
|
||||
@@ -3446,9 +3536,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-3" x-show="budgetData.alert_threshold > 0 && !editMode">
|
||||
<div class="text-xs text-dim mb-1" x-show="budgetData.alert_threshold > 0 && !editMode">
|
||||
Alert threshold: <span x-text="(budgetData.alert_threshold * 100).toFixed(0) + '%'"></span> of any limit
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-3" x-show="!editMode">
|
||||
Hourly token limit (per agent): <span x-text="fmtTokens(budgetData.default_max_llm_tokens_per_hour || 0)"></span>
|
||||
</div>
|
||||
|
||||
<!-- Edit limits form -->
|
||||
<div x-show="editMode" class="card" style="margin:12px 0;padding:12px;border:1px solid var(--accent);border-radius:6px">
|
||||
@@ -3470,7 +3563,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<input type="number" step="5" min="0" max="100" x-model="editAlert" class="input" style="width:100%;margin-top:2px" placeholder="80">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-dim">Set to 0 for unlimited. Changes apply immediately (in-memory, not persisted to config.toml).</div>
|
||||
<div style="margin-bottom:8px">
|
||||
<label class="text-xs text-dim">Hourly Token Limit (per agent, 0 = use per-agent values)</label>
|
||||
<input type="number" step="100000" min="0" x-model="editTokenLimit" class="input" style="width:260px;margin-top:2px" placeholder="0 = per-agent default">
|
||||
</div>
|
||||
<div class="text-xs text-dim">Set to 0 for unlimited/per-agent default. Changes apply immediately (in-memory, not persisted to config.toml).</div>
|
||||
<button class="btn btn-sm mt-2" @click="editMode = false" style="margin-right:8px">Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -3478,7 +3575,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
|
||||
<div class="table-wrap" x-show="agentRanking.length">
|
||||
<table>
|
||||
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th></tr></thead>
|
||||
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th><th>Token Limit/hr</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="a in agentRanking" :key="a.agent_id">
|
||||
<tr>
|
||||
@@ -3487,6 +3584,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<td class="text-dim" x-text="fmtUsd(a.hourly_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtUsd(a.daily_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtUsd(a.monthly_limit)"></td>
|
||||
<td class="text-dim" x-text="fmtTokens(a.max_llm_tokens_per_hour || 0)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
@@ -4366,7 +4464,29 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj)">
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider === 'claude-code'">
|
||||
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
|
||||
<div class="card-header">Configure Claude Code</div>
|
||||
<div class="text-xs text-dim mb-2" style="line-height:1.8">
|
||||
Claude Code uses its own CLI authentication — no API key needed.
|
||||
</div>
|
||||
<div style="background:var(--bg);border-radius:4px;padding:10px 12px;margin-bottom:12px;font-size:12px;line-height:1.8">
|
||||
<div><span style="color:var(--accent)">1.</span> Install: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">npm install -g @anthropic-ai/claude-code</code></div>
|
||||
<div><span style="color:var(--accent)">2.</span> Authenticate: <code style="color:var(--accent-light);background:var(--bg-secondary);padding:1px 4px;border-radius:2px">claude auth</code></div>
|
||||
<div><span style="color:var(--accent)">3.</span> Click <strong>Detect</strong> below to verify</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click="detectClaudeCode()" :disabled="testingProvider">
|
||||
<span x-show="!testingProvider">Detect Claude Code</span>
|
||||
<span x-show="testingProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
||||
</button>
|
||||
<div x-show="testResult" class="mt-2">
|
||||
<div x-show="testResult && testResult.status === 'ok'" class="badge badge-success" style="padding:6px 12px">Claude Code detected<span x-show="testResult && testResult.latency_ms" x-text="' (' + (testResult ? testResult.latency_ms : '') + 'ms)'"></span></div>
|
||||
<div x-show="testResult && testResult.status !== 'ok'" class="badge badge-error" style="padding:6px 12px">Claude Code CLI not detected. Make sure you’ve run: <code>npm install -g @anthropic-ai/claude-code && claude auth</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="selectedProviderObj && !providerIsConfigured(selectedProviderObj) && selectedProvider !== 'claude-code'">
|
||||
<div class="card" style="border-left:3px solid var(--accent);margin-top:16px">
|
||||
<div class="card-header" x-text="'Configure ' + selectedProviderObj.display_name"></div>
|
||||
<div class="text-xs text-dim mb-2" x-show="selectedProviderObj.api_key_env">
|
||||
@@ -4618,6 +4738,71 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ─── Runtime page ─── -->
|
||||
<template x-if="page === 'runtime'">
|
||||
<div x-data="runtimePage">
|
||||
<div class="page-header"><h2>Runtime</h2></div>
|
||||
<div class="page-body" x-init="loadData()">
|
||||
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading runtime info...</span></div>
|
||||
<div x-show="!loading">
|
||||
<div class="grid grid-cols-4" style="gap:16px;margin-bottom:24px">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Uptime</div>
|
||||
<div class="stat-value" x-text="uptime"></div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Agents</div>
|
||||
<div class="stat-value" x-text="agentCount"></div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Version</div>
|
||||
<div class="stat-value" x-text="version"></div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">Default Model</div>
|
||||
<div class="stat-value" style="font-size:13px" x-text="defaultModel"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-header">System</div>
|
||||
<table class="table" style="margin-top:8px">
|
||||
<tbody>
|
||||
<tr><td style="width:180px;font-weight:500">Platform</td><td x-text="platform"></td></tr>
|
||||
<tr><td style="font-weight:500">Architecture</td><td x-text="arch"></td></tr>
|
||||
<tr><td style="font-weight:500">API Listen</td><td x-text="apiListen"></td></tr>
|
||||
<tr><td style="font-weight:500">Home Directory</td><td x-text="homeDir"></td></tr>
|
||||
<tr><td style="font-weight:500">Log Level</td><td x-text="logLevel"></td></tr>
|
||||
<tr><td style="font-weight:500">Network</td><td x-text="networkEnabled ? 'Enabled' : 'Disabled'"></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<div class="card-header">Providers</div>
|
||||
<table class="table" style="margin-top:8px">
|
||||
<thead><tr><th>Provider</th><th>Status</th><th>Models</th><th>Latency</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="p in providers" :key="p.id">
|
||||
<tr>
|
||||
<td x-text="p.display_name || p.id"></td>
|
||||
<td><span class="badge" :class="p.reachable ? 'badge-success' : (p.auth_status === 'Configured' ? 'badge-success' : 'badge-dim')" x-text="p.reachable ? 'Online' : (p.auth_status === 'Configured' ? 'Ready' : 'Not configured')"></span></td>
|
||||
<td x-text="p.model_count"></td>
|
||||
<td x-text="p.latency_ms ? p.latency_ms + 'ms' : '-'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-ghost btn-sm" @click="loadData()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -218,7 +218,7 @@ function app() {
|
||||
});
|
||||
|
||||
// Hash routing
|
||||
var validPages = ['overview','agents','sessions','approvals','comms','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',
|
||||
|
||||
@@ -63,6 +63,9 @@ function agentsPage() {
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
modelSaving: false,
|
||||
// -- Fallback chain --
|
||||
editingFallback: false,
|
||||
newFallbackValue: '',
|
||||
|
||||
// -- Templates state --
|
||||
tplTemplates: [],
|
||||
@@ -316,12 +319,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 || '',
|
||||
@@ -331,6 +337,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) {
|
||||
@@ -400,7 +411,7 @@ function agentsPage() {
|
||||
var f = this.spawnForm;
|
||||
var si = this.spawnIdentity;
|
||||
var lines = [
|
||||
'name = "' + f.name + '"',
|
||||
'name = "' + f.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"',
|
||||
'module = "builtin:chat"'
|
||||
];
|
||||
if (f.profile && f.profile !== 'custom') {
|
||||
@@ -409,7 +420,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' + f.systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"') + '\n"""');
|
||||
if (f.profile === 'custom') {
|
||||
lines.push('', '[capabilities]');
|
||||
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
|
||||
@@ -586,8 +597,9 @@ function agentsPage() {
|
||||
if (!this.detailAgent || !this.newModelValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
OpenFangToast.success('Model changed (memory reset)');
|
||||
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
var providerInfo = (resp && resp.provider) ? ' (provider: ' + resp.provider + ')' : '';
|
||||
OpenFangToast.success('Model changed' + providerInfo + ' (memory reset)');
|
||||
this.editingModel = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
// Refresh detailAgent
|
||||
@@ -601,6 +613,41 @@ function agentsPage() {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -34,6 +34,14 @@ function chatPage() {
|
||||
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' },
|
||||
@@ -85,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;
|
||||
|
||||
@@ -101,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();
|
||||
@@ -172,6 +226,57 @@ function chatPage() {
|
||||
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;
|
||||
@@ -316,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 {
|
||||
@@ -427,7 +536,10 @@ function chatPage() {
|
||||
is_error: !!t.is_error
|
||||
};
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools };
|
||||
var images = (m.images || []).map(function(img) {
|
||||
return { file_id: img.file_id, filename: img.filename || 'image' };
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools, images: images };
|
||||
});
|
||||
self.$nextTick(function() { self.scrollToBottom(); });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,11 @@ function settingsPage() {
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
customProviderName: '',
|
||||
customProviderUrl: '',
|
||||
customProviderKey: '',
|
||||
customProviderStatus: '',
|
||||
addingCustomProvider: false,
|
||||
loading: true,
|
||||
loadError: '',
|
||||
|
||||
@@ -258,6 +263,17 @@ function settingsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
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([
|
||||
@@ -279,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);
|
||||
}
|
||||
@@ -333,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';
|
||||
},
|
||||
|
||||
@@ -488,6 +510,34 @@ function settingsPage() {
|
||||
this.providerUrlSaving[provider.id] = false;
|
||||
},
|
||||
|
||||
async addCustomProvider() {
|
||||
var name = this.customProviderName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
||||
if (!name) { OpenFangToast.error('Please enter a provider name'); return; }
|
||||
var url = this.customProviderUrl.trim();
|
||||
if (!url) { OpenFangToast.error('Please enter a base URL'); return; }
|
||||
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
||||
OpenFangToast.error('URL must start with http:// or https://'); return;
|
||||
}
|
||||
this.addingCustomProvider = true;
|
||||
this.customProviderStatus = '';
|
||||
try {
|
||||
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(name) + '/url', { base_url: url });
|
||||
if (this.customProviderKey.trim()) {
|
||||
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(name) + '/key', { key: this.customProviderKey.trim() });
|
||||
}
|
||||
this.customProviderName = '';
|
||||
this.customProviderUrl = '';
|
||||
this.customProviderKey = '';
|
||||
this.customProviderStatus = '';
|
||||
OpenFangToast.success('Provider "' + name + '" added' + (result.reachable ? ' (reachable)' : ' (not reachable yet)'));
|
||||
await this.loadProviders();
|
||||
} catch(e) {
|
||||
this.customProviderStatus = 'Error: ' + (e.message || 'Failed');
|
||||
OpenFangToast.error('Failed to add provider: ' + e.message);
|
||||
}
|
||||
this.addingCustomProvider = false;
|
||||
},
|
||||
|
||||
// -- Security methods --
|
||||
async loadSecurity() {
|
||||
this.secLoading = true;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -283,11 +283,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 +322,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 +331,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 +357,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 +411,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) {
|
||||
@@ -441,8 +466,8 @@ function wizardPage() {
|
||||
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\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' + tpl.system_prompt + '\n"""\n';
|
||||
|
||||
this.creatingAgent = true;
|
||||
try {
|
||||
@@ -468,13 +493,14 @@ function wizardPage() {
|
||||
gemini: 'gemini-2.5-flash',
|
||||
groq: 'llama-3.3-70b-versatile',
|
||||
deepseek: 'deepseek-chat',
|
||||
openrouter: 'openrouter/auto',
|
||||
openrouter: 'openrouter/google/gemini-2.5-flash',
|
||||
mistral: 'mistral-large-latest',
|
||||
together: 'meta-llama/Llama-3-70b-chat-hf',
|
||||
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
|
||||
perplexity: 'llama-3.1-sonar-large-128k-online',
|
||||
cohere: 'command-r-plus',
|
||||
xai: 'grok-2'
|
||||
xai: 'grok-2',
|
||||
'claude-code': 'claude-code/sonnet'
|
||||
};
|
||||
return defaults[providerId] || '';
|
||||
},
|
||||
|
||||
@@ -37,6 +37,13 @@ function workflowBuilder() {
|
||||
{ type: 'end', label: 'End', color: '#ef4444', icon: 'E', ports: { in: 1, out: 0 } }
|
||||
],
|
||||
|
||||
_renderScheduled: false,
|
||||
_lastClickNodeId: null,
|
||||
_lastClickTime: 0,
|
||||
_didDrag: false,
|
||||
_didConnect: false,
|
||||
_didPan: false,
|
||||
|
||||
async init() {
|
||||
var self = this;
|
||||
// Load agents for the agent step dropdown
|
||||
@@ -50,6 +57,157 @@ function workflowBuilder() {
|
||||
self.addNode('start', 60, 200);
|
||||
},
|
||||
|
||||
// ── SVG Manual Rendering ────────────────────────────
|
||||
// Alpine.js x-for inside <svg> breaks because document.importNode
|
||||
// doesn't handle SVG namespace correctly. We render nodes/connections
|
||||
// manually via createElementNS and schedule re-renders reactively.
|
||||
|
||||
scheduleRender: function() {
|
||||
if (this._renderScheduled) return;
|
||||
this._renderScheduled = true;
|
||||
var self = this;
|
||||
requestAnimationFrame(function() {
|
||||
self._renderScheduled = false;
|
||||
self.renderCanvas();
|
||||
});
|
||||
},
|
||||
|
||||
renderCanvas: function() {
|
||||
var container = document.getElementById('wf-render-group');
|
||||
if (!container) return;
|
||||
var SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
var self = this;
|
||||
|
||||
// Clear previous rendered content
|
||||
while (container.firstChild) container.removeChild(container.firstChild);
|
||||
|
||||
// ── Connections ──
|
||||
for (var ci = 0; ci < this.connections.length; ci++) {
|
||||
var conn = this.connections[ci];
|
||||
var d = this.getConnectionPath(conn);
|
||||
if (!d) continue;
|
||||
var path = document.createElementNS(SVG_NS, 'path');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('fill', 'none');
|
||||
path.setAttribute('stroke', (this.selectedConnection && this.selectedConnection.id === conn.id) ? 'var(--accent)' : 'var(--text-dim)');
|
||||
path.setAttribute('stroke-width', (this.selectedConnection && this.selectedConnection.id === conn.id) ? '3' : '2');
|
||||
path.style.cursor = 'pointer';
|
||||
(function(c) {
|
||||
path.addEventListener('click', function(e) { e.stopPropagation(); self.selectedConnection = c; self.scheduleRender(); });
|
||||
})(conn);
|
||||
container.appendChild(path);
|
||||
}
|
||||
|
||||
// ── Connection preview ──
|
||||
if (this.connecting && this.connectPreview) {
|
||||
var pd = this.getPreviewPath();
|
||||
if (pd) {
|
||||
var preview = document.createElementNS(SVG_NS, 'path');
|
||||
preview.setAttribute('d', pd);
|
||||
preview.setAttribute('fill', 'none');
|
||||
preview.setAttribute('stroke', 'var(--accent)');
|
||||
preview.setAttribute('stroke-width', '2');
|
||||
preview.setAttribute('stroke-dasharray', '6,3');
|
||||
container.appendChild(preview);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nodes ──
|
||||
for (var ni = 0; ni < this.nodes.length; ni++) {
|
||||
var node = this.nodes[ni];
|
||||
var g = document.createElementNS(SVG_NS, 'g');
|
||||
g.classList.add('wf-node');
|
||||
g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')');
|
||||
(function(n) {
|
||||
g.addEventListener('mousedown', function(e) { self.onNodeMouseDown(n, e); });
|
||||
g.addEventListener('dblclick', function() { self.editNode(n); });
|
||||
})(node);
|
||||
|
||||
// Node body rect
|
||||
var rect = document.createElementNS(SVG_NS, 'rect');
|
||||
rect.setAttribute('x', '0'); rect.setAttribute('y', '0');
|
||||
rect.setAttribute('width', node.width); rect.setAttribute('height', node.height);
|
||||
rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8');
|
||||
rect.setAttribute('fill', (self.selectedNode && self.selectedNode.id === node.id) ? 'var(--card-bg)' : 'var(--bg-secondary)');
|
||||
rect.setAttribute('stroke', (self.selectedNode && self.selectedNode.id === node.id) ? node.color : 'var(--border)');
|
||||
rect.setAttribute('stroke-width', '2');
|
||||
rect.style.cursor = 'grab';
|
||||
g.appendChild(rect);
|
||||
|
||||
// Color accent bar
|
||||
var bar = document.createElementNS(SVG_NS, 'rect');
|
||||
bar.setAttribute('x', '0'); bar.setAttribute('y', '0');
|
||||
bar.setAttribute('width', '6'); bar.setAttribute('height', node.height);
|
||||
bar.setAttribute('rx', '3'); bar.setAttribute('ry', '0');
|
||||
bar.setAttribute('fill', node.color);
|
||||
g.appendChild(bar);
|
||||
|
||||
// Icon circle + text
|
||||
var circle = document.createElementNS(SVG_NS, 'circle');
|
||||
circle.setAttribute('cx', '28'); circle.setAttribute('cy', node.height / 2);
|
||||
circle.setAttribute('r', '14'); circle.setAttribute('fill', node.color);
|
||||
circle.setAttribute('opacity', '0.15');
|
||||
g.appendChild(circle);
|
||||
|
||||
var iconText = document.createElementNS(SVG_NS, 'text');
|
||||
iconText.setAttribute('x', '28'); iconText.setAttribute('y', node.height / 2 + 4);
|
||||
iconText.setAttribute('text-anchor', 'middle'); iconText.setAttribute('fill', node.color);
|
||||
iconText.setAttribute('style', 'font-size:12px;font-weight:700;pointer-events:none');
|
||||
iconText.textContent = node.icon;
|
||||
g.appendChild(iconText);
|
||||
|
||||
// Label
|
||||
var label = document.createElementNS(SVG_NS, 'text');
|
||||
label.setAttribute('x', '50'); label.setAttribute('y', node.height / 2 - 4);
|
||||
label.setAttribute('fill', 'var(--text)');
|
||||
label.setAttribute('style', 'font-size:12px;font-weight:600;pointer-events:none');
|
||||
label.textContent = node.label;
|
||||
g.appendChild(label);
|
||||
|
||||
// Sub-label
|
||||
var subLabel = document.createElementNS(SVG_NS, 'text');
|
||||
subLabel.setAttribute('x', '50'); subLabel.setAttribute('y', node.height / 2 + 12);
|
||||
subLabel.setAttribute('fill', 'var(--text-dim)');
|
||||
subLabel.setAttribute('style', 'font-size:10px;pointer-events:none');
|
||||
if (node.type === 'agent') subLabel.textContent = node.config.agent_name || 'No agent';
|
||||
else if (node.type === 'condition') subLabel.textContent = node.config.expression || 'No condition';
|
||||
else if (node.type === 'loop') subLabel.textContent = 'max ' + (node.config.max_iterations || 5) + ' iters';
|
||||
else if (node.type === 'parallel') subLabel.textContent = (node.config.fan_count || 3) + ' branches';
|
||||
else if (node.type === 'collect') subLabel.textContent = node.config.strategy || 'all';
|
||||
g.appendChild(subLabel);
|
||||
|
||||
// Input ports
|
||||
for (var pi = 0; pi < node.ports.in; pi++) {
|
||||
var inp = document.createElementNS(SVG_NS, 'circle');
|
||||
inp.classList.add('wf-port', 'wf-port-in');
|
||||
inp.setAttribute('cx', node.width / (node.ports.in + 1) * (pi + 1));
|
||||
inp.setAttribute('cy', '0'); inp.setAttribute('r', '6');
|
||||
inp.setAttribute('fill', 'var(--bg-secondary)');
|
||||
inp.setAttribute('stroke', 'var(--text-dim)'); inp.setAttribute('stroke-width', '2');
|
||||
(function(nid, idx) {
|
||||
inp.addEventListener('mouseup', function(e) { e.stopPropagation(); self.endConnect(nid, idx, e); });
|
||||
})(node.id, pi);
|
||||
g.appendChild(inp);
|
||||
}
|
||||
|
||||
// Output ports
|
||||
for (var po = 0; po < node.ports.out; po++) {
|
||||
var outp = document.createElementNS(SVG_NS, 'circle');
|
||||
outp.classList.add('wf-port', 'wf-port-out');
|
||||
outp.setAttribute('cx', node.width / (node.ports.out + 1) * (po + 1));
|
||||
outp.setAttribute('cy', node.height); outp.setAttribute('r', '6');
|
||||
outp.setAttribute('fill', 'var(--bg-secondary)');
|
||||
outp.setAttribute('stroke', node.color); outp.setAttribute('stroke-width', '2');
|
||||
(function(nid, idx) {
|
||||
outp.addEventListener('mousedown', function(e) { e.stopPropagation(); self.startConnect(nid, idx, e); });
|
||||
})(node.id, po);
|
||||
g.appendChild(outp);
|
||||
}
|
||||
|
||||
container.appendChild(g);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Node Management ──────────────────────────────────
|
||||
|
||||
addNode: function(type, x, y) {
|
||||
@@ -83,6 +241,7 @@ function workflowBuilder() {
|
||||
node.config = { strategy: 'all' };
|
||||
}
|
||||
this.nodes.push(node);
|
||||
this.scheduleRender();
|
||||
return node;
|
||||
},
|
||||
|
||||
@@ -95,6 +254,7 @@ function workflowBuilder() {
|
||||
this.selectedNode = null;
|
||||
this.showNodeEditor = false;
|
||||
}
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
duplicateNode: function(node) {
|
||||
@@ -166,19 +326,36 @@ function workflowBuilder() {
|
||||
}
|
||||
this.connecting = null;
|
||||
this.connectPreview = null;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
deleteConnection: function(connId) {
|
||||
this.connections = this.connections.filter(function(c) { return c.id !== connId; });
|
||||
this.selectedConnection = null;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── Drag Handling ────────────────────────────────────
|
||||
|
||||
onNodeMouseDown: function(node, e) {
|
||||
e.stopPropagation();
|
||||
// Detect double-click manually — the native dblclick event never fires
|
||||
// because scheduleRender() destroys and recreates all SVG elements between
|
||||
// the first and second click, so the browser loses the DOM target for dblclick.
|
||||
var now = Date.now();
|
||||
if (this._lastClickNodeId === node.id && (now - this._lastClickTime) < 350) {
|
||||
// Double-click detected — open editor instead of starting drag
|
||||
this._lastClickNodeId = null;
|
||||
this._lastClickTime = 0;
|
||||
this.editNode(node);
|
||||
return;
|
||||
}
|
||||
this._lastClickNodeId = node.id;
|
||||
this._lastClickTime = now;
|
||||
|
||||
this.selectedNode = node;
|
||||
this.selectedConnection = null;
|
||||
this._didDrag = false;
|
||||
this.dragging = node.id;
|
||||
var rect = this._getCanvasRect();
|
||||
this.dragOffset = {
|
||||
@@ -193,6 +370,7 @@ function workflowBuilder() {
|
||||
this.selectedConnection = null;
|
||||
this.showNodeEditor = false;
|
||||
// Start canvas pan
|
||||
this._didPan = false;
|
||||
this.canvasDragging = true;
|
||||
this.canvasDragStart = { x: e.clientX - this.canvasOffset.x * this.zoom, y: e.clientY - this.canvasOffset.y * this.zoom };
|
||||
},
|
||||
@@ -200,17 +378,22 @@ function workflowBuilder() {
|
||||
onCanvasMouseMove: function(e) {
|
||||
var rect = this._getCanvasRect();
|
||||
if (this.dragging) {
|
||||
this._didDrag = true;
|
||||
var node = this.getNode(this.dragging);
|
||||
if (node) {
|
||||
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
|
||||
node.y = Math.max(0, (e.clientY - rect.top) / this.zoom - this.canvasOffset.y - this.dragOffset.y);
|
||||
}
|
||||
this.scheduleRender();
|
||||
} else if (this.connecting) {
|
||||
this._didConnect = true;
|
||||
this.connectPreview = {
|
||||
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
|
||||
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
|
||||
};
|
||||
this.scheduleRender();
|
||||
} else if (this.canvasDragging) {
|
||||
this._didPan = true;
|
||||
this.canvasOffset = {
|
||||
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
|
||||
y: (e.clientY - this.canvasDragStart.y) / this.zoom
|
||||
@@ -219,10 +402,19 @@ function workflowBuilder() {
|
||||
},
|
||||
|
||||
onCanvasMouseUp: function() {
|
||||
// Only re-render if something actually moved. Rendering on every mouseup
|
||||
// destroys SVG elements between clicks, which prevents dblclick detection.
|
||||
var needsRender = this._didDrag || this._didConnect || this._didPan;
|
||||
this.dragging = null;
|
||||
this.connecting = null;
|
||||
this.connectPreview = null;
|
||||
this.canvasDragging = false;
|
||||
this._didDrag = false;
|
||||
this._didConnect = false;
|
||||
this._didPan = false;
|
||||
if (needsRender) {
|
||||
this.scheduleRender();
|
||||
}
|
||||
},
|
||||
|
||||
onCanvasWheel: function(e) {
|
||||
@@ -267,6 +459,12 @@ function workflowBuilder() {
|
||||
editNode: function(node) {
|
||||
this.selectedNode = node;
|
||||
this.showNodeEditor = true;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// Called from editor panel inputs to reflect changes on the canvas SVG
|
||||
applyNodeEdit: function() {
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── TOML Generation ──────────────────────────────────
|
||||
@@ -386,7 +584,7 @@ function workflowBuilder() {
|
||||
var rect = this._getCanvasRect();
|
||||
var x = (e.clientX - rect.left) / this.zoom - this.canvasOffset.x;
|
||||
var y = (e.clientY - rect.top) / this.zoom - this.canvasOffset.y;
|
||||
this.addNode(type, x - 90, y - 35);
|
||||
this.addNode(type, x - 90, y - 35); // addNode already calls scheduleRender
|
||||
},
|
||||
|
||||
onCanvasDragOver: function(e) {
|
||||
@@ -405,6 +603,7 @@ function workflowBuilder() {
|
||||
this.nodes[i].y = y;
|
||||
y += 120;
|
||||
}
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── Clear ────────────────────────────────────────────
|
||||
@@ -414,7 +613,7 @@ function workflowBuilder() {
|
||||
this.connections = [];
|
||||
this.selectedNode = null;
|
||||
this.nextId = 1;
|
||||
this.addNode('start', 60, 200);
|
||||
this.addNode('start', 60, 200); // addNode already calls scheduleRender
|
||||
},
|
||||
|
||||
// ── Zoom controls ────────────────────────────────────
|
||||
|
||||
@@ -76,6 +76,8 @@ async fn start_test_server_with_provider(
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -221,10 +223,10 @@ async fn test_status_endpoint() {
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["status"], "running");
|
||||
assert_eq!(body["agent_count"], 0);
|
||||
assert_eq!(body["agent_count"], 1); // default assistant auto-spawned
|
||||
assert!(body["uptime_seconds"].is_number());
|
||||
assert_eq!(body["default_provider"], "ollama");
|
||||
assert_eq!(body["agents"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(body["agents"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -246,7 +248,7 @@ async fn test_spawn_list_kill_agent() {
|
||||
let agent_id = body["agent_id"].as_str().unwrap().to_string();
|
||||
assert!(!agent_id.is_empty());
|
||||
|
||||
// --- List (1 agent) ---
|
||||
// --- List (2 agents: default assistant + test-agent) ---
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
@@ -254,10 +256,10 @@ async fn test_spawn_list_kill_agent() {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 1);
|
||||
assert_eq!(agents[0]["name"], "test-agent");
|
||||
assert_eq!(agents[0]["id"], agent_id);
|
||||
assert_eq!(agents[0]["model_provider"], "ollama");
|
||||
assert_eq!(agents.len(), 2);
|
||||
let test_agent = agents.iter().find(|a| a["name"] == "test-agent").unwrap();
|
||||
assert_eq!(test_agent["id"], agent_id);
|
||||
assert_eq!(test_agent["model_provider"], "ollama");
|
||||
|
||||
// --- Kill ---
|
||||
let resp = client
|
||||
@@ -269,7 +271,7 @@ async fn test_spawn_list_kill_agent() {
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(body["status"], "killed");
|
||||
|
||||
// --- List (empty) ---
|
||||
// --- List (only default assistant remains) ---
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
@@ -277,7 +279,8 @@ async fn test_spawn_list_kill_agent() {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 0);
|
||||
assert_eq!(agents.len(), 1);
|
||||
assert_eq!(agents[0]["name"], "assistant");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -616,14 +619,14 @@ memory_write = ["self.*"]
|
||||
ids.push(body["agent_id"].as_str().unwrap().to_string());
|
||||
}
|
||||
|
||||
// List should show 3
|
||||
// List should show 4 (3 spawned + default assistant)
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 3);
|
||||
assert_eq!(agents.len(), 4);
|
||||
|
||||
// Status should agree
|
||||
let resp = client
|
||||
@@ -632,7 +635,7 @@ memory_write = ["self.*"]
|
||||
.await
|
||||
.unwrap();
|
||||
let status: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(status["agent_count"], 3);
|
||||
assert_eq!(status["agent_count"], 4);
|
||||
|
||||
// Kill one
|
||||
let resp = client
|
||||
@@ -642,14 +645,14 @@ memory_write = ["self.*"]
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// List should show 2
|
||||
// List should show 3 (2 spawned + default assistant)
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 2);
|
||||
assert_eq!(agents.len(), 3);
|
||||
|
||||
// Kill the rest
|
||||
for id in [&ids[0], &ids[2]] {
|
||||
@@ -660,14 +663,14 @@ memory_write = ["self.*"]
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// List should be empty
|
||||
// List should have only default assistant
|
||||
let resp = client
|
||||
.get(format!("{}/api/agents", server.base_url))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let agents: Vec<serde_json::Value> = resp.json().await.unwrap();
|
||||
assert_eq!(agents.len(), 0);
|
||||
assert_eq!(agents.len(), 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -702,6 +705,8 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let api_key_state = state.kernel.config.api_key.clone();
|
||||
|
||||
@@ -113,6 +113,8 @@ async fn test_full_daemon_lifecycle() {
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -236,6 +238,8 @@ async fn test_server_immediate_responsiveness() {
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -57,6 +57,8 @@ async fn start_test_server() -> TestServer {
|
||||
bridge_manager: tokio::sync::Mutex::new(None),
|
||||
channels_config: tokio::sync::RwLock::new(Default::default()),
|
||||
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
|
||||
clawhub_cache: dashmap::DashMap::new(),
|
||||
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -540,7 +542,7 @@ async fn load_spawn_kill_cycle() {
|
||||
.await
|
||||
.unwrap();
|
||||
let remaining = agents.as_array().map(|a| a.len()).unwrap_or(0);
|
||||
assert_eq!(remaining, 0, "All agents should be killed");
|
||||
assert_eq!(remaining, 1, "Only default assistant should remain");
|
||||
}
|
||||
|
||||
/// Test: Prometheus metrics endpoint under sustained load.
|
||||
|
||||
@@ -26,6 +26,7 @@ hmac = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
html-escape = { workspace = true }
|
||||
|
||||
lettre = { workspace = true }
|
||||
imap = { workspace = true }
|
||||
|
||||
@@ -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,10 +448,15 @@ async fn dispatch_message(
|
||||
|
||||
// Fetch per-channel overrides (if configured)
|
||||
let overrides = handle.channel_overrides(ct_str).await;
|
||||
let channel_default_format = match ct_str {
|
||||
"telegram" => OutputFormat::TelegramHtml,
|
||||
"slack" => OutputFormat::SlackMrkdwn,
|
||||
_ => OutputFormat::Markdown,
|
||||
};
|
||||
let output_format = overrides
|
||||
.as_ref()
|
||||
.and_then(|o| o.output_format)
|
||||
.unwrap_or(OutputFormat::Markdown);
|
||||
.unwrap_or(channel_default_format);
|
||||
let threading_enabled = overrides.as_ref().map(|o| o.threading).unwrap_or(false);
|
||||
let thread_id = if threading_enabled {
|
||||
message.thread_id.as_deref()
|
||||
@@ -402,8 +482,15 @@ async fn dispatch_message(
|
||||
}
|
||||
}
|
||||
GroupPolicy::MentionOnly => {
|
||||
// Pass through — adapters should only forward mentioned messages.
|
||||
// This is a hint for adapters, not enforced here.
|
||||
// Only allow messages where the bot was @mentioned or commands.
|
||||
let was_mentioned = message.metadata.get("was_mentioned")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_command = matches!(&message.content, ChannelContent::Command { .. });
|
||||
if !was_mentioned && !is_command {
|
||||
debug!("Ignoring group message on {ct_str} (group_policy=mention_only, not mentioned)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
GroupPolicy::All => {}
|
||||
}
|
||||
@@ -434,24 +521,53 @@ async fn dispatch_message(
|
||||
}
|
||||
}
|
||||
|
||||
let text = match &message.content {
|
||||
ChannelContent::Text(t) => t.clone(),
|
||||
ChannelContent::Command { name, args } => {
|
||||
let result = handle_command(name, args, handle, router, &message.sender).await;
|
||||
send_response(adapter, &message.sender, result, thread_id, output_format).await;
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
send_response(
|
||||
// Handle commands first (early return)
|
||||
if let ChannelContent::Command { ref name, ref args } = message.content {
|
||||
let result = handle_command(name, args, handle, router, &message.sender).await;
|
||||
send_response(adapter, &message.sender, result, thread_id, output_format).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// For images: download, base64 encode, and send as multimodal content blocks
|
||||
if let ChannelContent::Image { ref url, ref caption } = message.content {
|
||||
let blocks = download_image_to_blocks(url, caption.as_deref()).await;
|
||||
if blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. })) {
|
||||
// We have actual image data — send as structured blocks for vision
|
||||
dispatch_with_blocks(
|
||||
blocks,
|
||||
message,
|
||||
handle,
|
||||
router,
|
||||
adapter,
|
||||
&message.sender,
|
||||
"I can only handle text messages for now.".to_string(),
|
||||
ct_str,
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
// Image download failed — fall through to text description below
|
||||
}
|
||||
|
||||
let text = match &message.content {
|
||||
ChannelContent::Text(t) => t.clone(),
|
||||
ChannelContent::Command { .. } => unreachable!(), // handled above
|
||||
ChannelContent::Image { ref url, ref caption } => {
|
||||
// Fallback when image download failed
|
||||
match caption {
|
||||
Some(c) => format!("[User sent a photo: {url}]\nCaption: {c}"),
|
||||
None => format!("[User sent a photo: {url}]"),
|
||||
}
|
||||
}
|
||||
ChannelContent::File { ref url, ref filename } => {
|
||||
format!("[User sent a file ({filename}): {url}]")
|
||||
}
|
||||
ChannelContent::Voice { ref url, duration_seconds } => {
|
||||
format!("[User sent a voice message ({duration_seconds}s): {url}]")
|
||||
}
|
||||
ChannelContent::Location { lat, lon } => {
|
||||
format!("[User shared location: {lat}, {lon}]")
|
||||
}
|
||||
};
|
||||
|
||||
// Check if it's a slash command embedded in text (e.g. "/agents")
|
||||
@@ -577,14 +693,33 @@ async fn dispatch_message(
|
||||
let agent_id = match agent_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
"No agent assigned. Use /agents to list available agents, then /agent <name> to select one.".to_string(),
|
||||
thread_id,
|
||||
output_format,
|
||||
).await;
|
||||
return;
|
||||
// Fallback: try "assistant" agent, then first available agent
|
||||
let fallback = handle.find_agent_by_name("assistant").await.ok().flatten();
|
||||
let fallback = match fallback {
|
||||
Some(id) => Some(id),
|
||||
None => handle
|
||||
.list_agents()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|agents| agents.first().map(|(id, _)| *id)),
|
||||
};
|
||||
match fallback {
|
||||
Some(id) => {
|
||||
// Auto-set this as the user's default so future messages route directly
|
||||
router.set_user_default(message.sender.platform_id.clone(), id);
|
||||
id
|
||||
}
|
||||
None => {
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
"No agents available. Start the dashboard at http://127.0.0.1:4200 to create one.".to_string(),
|
||||
thread_id,
|
||||
output_format,
|
||||
).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -609,7 +744,7 @@ async fn dispatch_message(
|
||||
if let Some(reply) = handle.check_auto_reply(agent_id, &text).await {
|
||||
send_response(adapter, &message.sender, reply, thread_id, output_format).await;
|
||||
handle
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None)
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
@@ -617,15 +752,22 @@ async fn dispatch_message(
|
||||
// Send typing indicator (best-effort)
|
||||
let _ = adapter.send_typing(&message.sender).await;
|
||||
|
||||
// Lifecycle reaction: ⏳ Queued → 🤔 Thinking → ✅ Done / ❌ Error
|
||||
let msg_id = &message.platform_message_id;
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Queued).await;
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Thinking).await;
|
||||
|
||||
// Send to agent and relay response
|
||||
match handle.send_message(agent_id, &text).await {
|
||||
Ok(response) => {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
|
||||
send_response(adapter, &message.sender, response, thread_id, output_format).await;
|
||||
handle
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None)
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
|
||||
warn!("Agent error for {agent_id}: {e}");
|
||||
let err_msg = format!("Agent error: {e}");
|
||||
send_response(
|
||||
@@ -643,6 +785,196 @@ async fn dispatch_message(
|
||||
&message.sender.platform_id,
|
||||
false,
|
||||
Some(&err_msg),
|
||||
thread_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Download an image from a URL and build content blocks for multimodal LLM input.
|
||||
///
|
||||
/// Returns a `Vec<ContentBlock>` containing an image block (base64-encoded) and
|
||||
/// optionally a text block for the caption. If the download fails, returns a
|
||||
/// text-only block describing the failure.
|
||||
async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec<ContentBlock> {
|
||||
use base64::Engine;
|
||||
|
||||
// 5 MB limit to prevent memory abuse from oversized images
|
||||
const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = match client.get(url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("Failed to download image from channel: {e}");
|
||||
return vec![ContentBlock::Text {
|
||||
text: format!("[Image download failed: {e}]"),
|
||||
}];
|
||||
}
|
||||
};
|
||||
|
||||
// Detect media type from Content-Type header, fall back to URL extension
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|ct| ct.split(';').next().unwrap_or(ct).trim().to_string());
|
||||
|
||||
let media_type = content_type.unwrap_or_else(|| {
|
||||
if url.contains(".png") {
|
||||
"image/png".to_string()
|
||||
} else if url.contains(".gif") {
|
||||
"image/gif".to_string()
|
||||
} else if url.contains(".webp") {
|
||||
"image/webp".to_string()
|
||||
} else {
|
||||
"image/jpeg".to_string()
|
||||
}
|
||||
});
|
||||
|
||||
let bytes = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!("Failed to read image bytes: {e}");
|
||||
return vec![ContentBlock::Text {
|
||||
text: format!("[Image read failed: {e}]"),
|
||||
}];
|
||||
}
|
||||
};
|
||||
|
||||
if bytes.len() > MAX_IMAGE_BYTES {
|
||||
warn!(
|
||||
"Image too large ({} bytes), skipping vision — sending as text",
|
||||
bytes.len()
|
||||
);
|
||||
let desc = match caption {
|
||||
Some(c) => format!("[Image too large for vision ({} KB)]\nCaption: {c}", bytes.len() / 1024),
|
||||
None => format!("[Image too large for vision ({} KB)]", bytes.len() / 1024),
|
||||
};
|
||||
return vec![ContentBlock::Text { text: desc }];
|
||||
}
|
||||
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
let mut blocks = Vec::new();
|
||||
|
||||
// Caption as text block first (gives the LLM context about the image)
|
||||
if let Some(cap) = caption {
|
||||
if !cap.is_empty() {
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: cap.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
blocks.push(ContentBlock::Image { media_type, data });
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Dispatch a multimodal message (content blocks) to an agent, handling routing
|
||||
/// and RBAC the same way as the text path.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dispatch_with_blocks(
|
||||
blocks: Vec<ContentBlock>,
|
||||
message: &ChannelMessage,
|
||||
handle: &Arc<dyn ChannelBridgeHandle>,
|
||||
router: &Arc<AgentRouter>,
|
||||
adapter: &dyn ChannelAdapter,
|
||||
ct_str: &str,
|
||||
thread_id: Option<&str>,
|
||||
output_format: OutputFormat,
|
||||
) {
|
||||
// Route to agent (same logic as text path)
|
||||
let agent_id = router.resolve(
|
||||
&message.channel,
|
||||
&message.sender.platform_id,
|
||||
message.sender.openfang_user.as_deref(),
|
||||
);
|
||||
|
||||
let agent_id = match agent_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let fallback = handle.find_agent_by_name("assistant").await.ok().flatten();
|
||||
let fallback = match fallback {
|
||||
Some(id) => Some(id),
|
||||
None => handle
|
||||
.list_agents()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|agents| agents.first().map(|(id, _)| *id)),
|
||||
};
|
||||
match fallback {
|
||||
Some(id) => {
|
||||
router.set_user_default(message.sender.platform_id.clone(), id);
|
||||
id
|
||||
}
|
||||
None => {
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
"No agents available. Start the dashboard at http://127.0.0.1:4200 to create one.".to_string(),
|
||||
thread_id,
|
||||
output_format,
|
||||
).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// RBAC check
|
||||
if let Err(denied) = handle
|
||||
.authorize_channel_user(ct_str, &message.sender.platform_id, "chat")
|
||||
.await
|
||||
{
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
format!("Access denied: {denied}"),
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = adapter.send_typing(&message.sender).await;
|
||||
|
||||
// Lifecycle reaction: ⏳ Queued → 🤔 Thinking → ✅ Done / ❌ Error
|
||||
let msg_id = &message.platform_message_id;
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Queued).await;
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Thinking).await;
|
||||
|
||||
match handle.send_message_with_blocks(agent_id, blocks).await {
|
||||
Ok(response) => {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Done).await;
|
||||
send_response(adapter, &message.sender, response, thread_id, output_format).await;
|
||||
handle
|
||||
.record_delivery(agent_id, ct_str, &message.sender.platform_id, true, None, thread_id)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
send_lifecycle_reaction(adapter, &message.sender, msg_id, AgentPhase::Error).await;
|
||||
warn!("Agent error for {agent_id}: {e}");
|
||||
let err_msg = format!("Agent error: {e}");
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
err_msg.clone(),
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
handle
|
||||
.record_delivery(
|
||||
agent_id,
|
||||
ct_str,
|
||||
&message.sender.platform_id,
|
||||
false,
|
||||
Some(&err_msg),
|
||||
thread_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1088,4 +1420,52 @@ mod tests {
|
||||
"irc"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_message_with_blocks_default_fallback() {
|
||||
// The default implementation of send_message_with_blocks extracts text
|
||||
// from blocks and calls send_message
|
||||
let agent_id = AgentId::new();
|
||||
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
|
||||
agents: Mutex::new(vec![(agent_id, "vision-agent".to_string())]),
|
||||
});
|
||||
|
||||
let blocks = vec![
|
||||
ContentBlock::Text {
|
||||
text: "What is in this photo?".to_string(),
|
||||
},
|
||||
ContentBlock::Image {
|
||||
media_type: "image/jpeg".to_string(),
|
||||
data: "base64data".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
// Default impl should extract text and call send_message
|
||||
let result = handle
|
||||
.send_message_with_blocks(agent_id, blocks)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "Echo: What is in this photo?");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_message_with_blocks_image_only() {
|
||||
// When there's no text block, the default should still work
|
||||
let agent_id = AgentId::new();
|
||||
let handle: Arc<dyn ChannelBridgeHandle> = Arc::new(MockHandle {
|
||||
agents: Mutex::new(vec![(agent_id, "vision-agent".to_string())]),
|
||||
});
|
||||
|
||||
let blocks = vec![ContentBlock::Image {
|
||||
media_type: "image/png".to_string(),
|
||||
data: "base64data".to_string(),
|
||||
}];
|
||||
|
||||
// Default impl sends empty text when no text blocks
|
||||
let result = handle
|
||||
.send_message_with_blocks(agent_id, blocks)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "Echo: ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,21 @@ use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// SASL PLAIN authenticator for IMAP servers that reject LOGIN
|
||||
/// (e.g., Lark/Larksuite which only advertise AUTH=PLAIN).
|
||||
struct PlainAuthenticator {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl imap::Authenticator for PlainAuthenticator {
|
||||
type Response = String;
|
||||
fn process(&self, _data: &[u8]) -> Self::Response {
|
||||
// SASL PLAIN: \0<username>\0<password>
|
||||
format!("\x00{}\x00{}", self.username, self.password)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reply context for email threading (In-Reply-To / Subject continuity).
|
||||
#[derive(Debug, Clone)]
|
||||
struct ReplyCtx {
|
||||
@@ -203,9 +218,22 @@ fn fetch_unseen_emails(
|
||||
let client = imap::connect((host, port), host, &tls)
|
||||
.map_err(|e| format!("IMAP connect failed: {e}"))?;
|
||||
|
||||
let mut session = client
|
||||
.login(username, password)
|
||||
.map_err(|(e, _)| format!("IMAP login failed: {e}"))?;
|
||||
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
|
||||
// that reject LOGIN and only support AUTH=PLAIN (SASL).
|
||||
let mut session = match client.login(username, password) {
|
||||
Ok(s) => s,
|
||||
Err((login_err, client)) => {
|
||||
let authenticator = PlainAuthenticator {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
client
|
||||
.authenticate("PLAIN", &authenticator)
|
||||
.map_err(|(e, _)| {
|
||||
format!("IMAP login failed: {login_err}; AUTH=PLAIN also failed: {e}")
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
|
||||
@@ -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("**") {
|
||||
|
||||
@@ -298,17 +298,8 @@ fn strip_html_tags(html: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Decode HTML entities
|
||||
let decoded = result
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ");
|
||||
|
||||
// Decode HTML entities (handles named, decimal, and hex entities)
|
||||
let decoded = html_escape::decode_html_entities(&result);
|
||||
decoded.trim().to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use crate::types::{
|
||||
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
|
||||
LifecycleReaction,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
@@ -13,7 +14,7 @@ use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Maximum backoff duration on API failures.
|
||||
@@ -23,13 +24,18 @@ const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
/// Telegram long-polling timeout (seconds) — sent as the `timeout` parameter to getUpdates.
|
||||
const LONG_POLL_TIMEOUT: u64 = 30;
|
||||
|
||||
/// Default Telegram Bot API base URL.
|
||||
const DEFAULT_API_URL: &str = "https://api.telegram.org";
|
||||
|
||||
/// Telegram Bot API adapter using long-polling.
|
||||
pub struct TelegramAdapter {
|
||||
/// SECURITY: Bot token is zeroized on drop to prevent memory disclosure.
|
||||
token: Zeroizing<String>,
|
||||
client: reqwest::Client,
|
||||
allowed_users: Vec<i64>,
|
||||
allowed_users: Vec<String>,
|
||||
poll_interval: Duration,
|
||||
/// Base URL for Telegram Bot API (supports proxies/mirrors).
|
||||
api_base_url: String,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
}
|
||||
@@ -39,13 +45,24 @@ impl TelegramAdapter {
|
||||
///
|
||||
/// `token` is the raw bot token (read from env by the caller).
|
||||
/// `allowed_users` is the list of Telegram user IDs allowed to interact (empty = allow all).
|
||||
pub fn new(token: String, allowed_users: Vec<i64>, poll_interval: Duration) -> Self {
|
||||
/// `api_url` overrides the Telegram Bot API base URL (for proxies/mirrors).
|
||||
pub fn new(
|
||||
token: String,
|
||||
allowed_users: Vec<String>,
|
||||
poll_interval: Duration,
|
||||
api_url: Option<String>,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
let api_base_url = api_url
|
||||
.unwrap_or_else(|| DEFAULT_API_URL.to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
Self {
|
||||
token: Zeroizing::new(token),
|
||||
client: reqwest::Client::new(),
|
||||
allowed_users,
|
||||
poll_interval,
|
||||
api_base_url,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
}
|
||||
@@ -53,7 +70,7 @@ impl TelegramAdapter {
|
||||
|
||||
/// Validate the bot token by calling `getMe`.
|
||||
pub async fn validate_token(&self) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let url = format!("https://api.telegram.org/bot{}/getMe", self.token.as_str());
|
||||
let url = format!("{}/bot{}/getMe", self.api_base_url, self.token.as_str());
|
||||
let resp: serde_json::Value = self.client.get(&url).send().await?.json().await?;
|
||||
|
||||
if resp["ok"].as_bool() != Some(true) {
|
||||
@@ -69,23 +86,37 @@ impl TelegramAdapter {
|
||||
}
|
||||
|
||||
/// Call `sendMessage` on the Telegram API.
|
||||
///
|
||||
/// When `thread_id` is provided, includes `message_thread_id` in the request
|
||||
/// so the message lands in the correct forum topic.
|
||||
async fn api_send_message(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
text: &str,
|
||||
thread_id: Option<i64>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendMessage",
|
||||
"{}/bot{}/sendMessage",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
|
||||
// Sanitize: strip unsupported HTML tags so Telegram doesn't reject with 400.
|
||||
// Telegram only allows: b, i, u, s, tg-spoiler, a, code, pre, blockquote.
|
||||
// Any other tag (e.g. <name>, <thinking>) causes a 400 Bad Request.
|
||||
let sanitized = sanitize_telegram_html(text);
|
||||
|
||||
// Telegram has a 4096 character limit per message — split if needed
|
||||
let chunks = split_message(text, 4096);
|
||||
let chunks = split_message(&sanitized, 4096);
|
||||
for chunk in chunks {
|
||||
let body = serde_json::json!({
|
||||
let mut body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"text": chunk,
|
||||
"parse_mode": "HTML",
|
||||
});
|
||||
if let Some(tid) = thread_id {
|
||||
body["message_thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
let status = resp.status();
|
||||
@@ -97,17 +128,221 @@ impl TelegramAdapter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendChatAction` to show "typing..." indicator.
|
||||
async fn api_send_typing(&self, chat_id: i64) -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// Call `sendPhoto` on the Telegram API.
|
||||
async fn api_send_photo(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
photo_url: &str,
|
||||
caption: Option<&str>,
|
||||
thread_id: Option<i64>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendChatAction",
|
||||
"{}/bot{}/sendPhoto",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let mut body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"photo": photo_url,
|
||||
});
|
||||
if let Some(cap) = caption {
|
||||
body["caption"] = serde_json::Value::String(cap.to_string());
|
||||
body["parse_mode"] = serde_json::Value::String("HTML".to_string());
|
||||
}
|
||||
if let Some(tid) = thread_id {
|
||||
body["message_thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendPhoto failed: {body_text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendDocument` on the Telegram API.
|
||||
async fn api_send_document(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
document_url: &str,
|
||||
filename: &str,
|
||||
thread_id: Option<i64>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"{}/bot{}/sendDocument",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let mut body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"document": document_url,
|
||||
"caption": filename,
|
||||
});
|
||||
if let Some(tid) = thread_id {
|
||||
body["message_thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendDocument failed: {body_text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendVoice` on the Telegram API.
|
||||
async fn api_send_voice(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
voice_url: &str,
|
||||
thread_id: Option<i64>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"{}/bot{}/sendVoice",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let mut body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"voice": voice_url,
|
||||
});
|
||||
if let Some(tid) = thread_id {
|
||||
body["message_thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendVoice failed: {body_text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendLocation` on the Telegram API.
|
||||
async fn api_send_location(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
thread_id: Option<i64>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"{}/bot{}/sendLocation",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let mut body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
});
|
||||
if let Some(tid) = thread_id {
|
||||
body["message_thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
warn!("Telegram sendLocation failed: {body_text}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendChatAction` to show "typing..." indicator.
|
||||
///
|
||||
/// When `thread_id` is provided, the typing indicator appears in the forum topic.
|
||||
async fn api_send_typing(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
thread_id: Option<i64>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"{}/bot{}/sendChatAction",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let mut body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"action": "typing",
|
||||
});
|
||||
if let Some(tid) = thread_id {
|
||||
body["message_thread_id"] = serde_json::json!(tid);
|
||||
}
|
||||
let _ = self.client.post(&url).json(&body).send().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `setMessageReaction` on the Telegram API (fire-and-forget).
|
||||
///
|
||||
/// Sets or replaces the bot's emoji reaction on a message. Each new call
|
||||
/// automatically replaces the previous reaction, so there is no need to
|
||||
/// explicitly remove old ones.
|
||||
fn fire_reaction(&self, chat_id: i64, message_id: i64, emoji: &str) {
|
||||
let url = format!(
|
||||
"{}/bot{}/setMessageReaction",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"action": "typing",
|
||||
"message_id": message_id,
|
||||
"reaction": [{"type": "emoji", "emoji": emoji}],
|
||||
});
|
||||
let _ = self.client.post(&url).json(&body).send().await?;
|
||||
let client = self.client.clone();
|
||||
tokio::spawn(async move {
|
||||
match client.post(&url).json(&body).send().await {
|
||||
Ok(resp) if !resp.status().is_success() => {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
debug!("Telegram setMessageReaction failed: {body_text}");
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Telegram setMessageReaction error: {e}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl TelegramAdapter {
|
||||
/// Internal helper: send content with optional forum-topic thread_id.
|
||||
///
|
||||
/// Both `send()` and `send_in_thread()` delegate here. When `thread_id` is
|
||||
/// `Some(id)`, every outbound Telegram API call includes `message_thread_id`
|
||||
/// so the message lands in the correct forum topic.
|
||||
async fn send_content(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
thread_id: Option<i64>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let chat_id: i64 = user
|
||||
.platform_id
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid Telegram chat_id: {}", user.platform_id))?;
|
||||
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(chat_id, &text, thread_id).await?;
|
||||
}
|
||||
ChannelContent::Image { url, caption } => {
|
||||
self.api_send_photo(chat_id, &url, caption.as_deref(), thread_id)
|
||||
.await?;
|
||||
}
|
||||
ChannelContent::File { url, filename } => {
|
||||
self.api_send_document(chat_id, &url, &filename, thread_id)
|
||||
.await?;
|
||||
}
|
||||
ChannelContent::Voice { url, .. } => {
|
||||
self.api_send_voice(chat_id, &url, thread_id).await?;
|
||||
}
|
||||
ChannelContent::Location { lat, lon } => {
|
||||
self.api_send_location(chat_id, lat, lon, thread_id)
|
||||
.await?;
|
||||
}
|
||||
ChannelContent::Command { name, args } => {
|
||||
let text = format!("/{name} {}", args.join(" "));
|
||||
self.api_send_message(chat_id, text.trim(), thread_id)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -130,12 +365,34 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
let bot_name = self.validate_token().await?;
|
||||
info!("Telegram bot @{bot_name} connected");
|
||||
|
||||
// Clear any existing webhook to avoid 409 Conflict during getUpdates polling.
|
||||
// This is necessary when the daemon restarts — the old polling session may
|
||||
// still be active on Telegram's side for ~30s, causing 409 errors.
|
||||
{
|
||||
let delete_url = format!(
|
||||
"{}/bot{}/deleteWebhook",
|
||||
self.api_base_url,
|
||||
self.token.as_str()
|
||||
);
|
||||
match self
|
||||
.client
|
||||
.post(&delete_url)
|
||||
.json(&serde_json::json!({"drop_pending_updates": true}))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => info!("Telegram: cleared webhook, polling mode active"),
|
||||
Err(e) => tracing::warn!("Telegram: deleteWebhook failed (non-fatal): {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
|
||||
let token = self.token.clone();
|
||||
let client = self.client.clone();
|
||||
let allowed_users = self.allowed_users.clone();
|
||||
let poll_interval = self.poll_interval;
|
||||
let api_base_url = self.api_base_url.clone();
|
||||
let mut shutdown = self.shutdown_rx.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -149,7 +406,7 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
}
|
||||
|
||||
// Build getUpdates request
|
||||
let url = format!("https://api.telegram.org/bot{}/getUpdates", token.as_str());
|
||||
let url = format!("{}/bot{}/getUpdates", api_base_url, token.as_str());
|
||||
let mut params = serde_json::json!({
|
||||
"timeout": LONG_POLL_TIMEOUT,
|
||||
"allowed_updates": ["message", "edited_message"],
|
||||
@@ -195,10 +452,14 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle conflict (another bot instance polling)
|
||||
// Handle conflict (another bot instance or stale session polling).
|
||||
// On daemon restart, the old long-poll may still be active on Telegram's
|
||||
// side for up to 30s. Retry with backoff instead of stopping permanently.
|
||||
if status.as_u16() == 409 {
|
||||
error!("Telegram 409 Conflict — another bot instance is running. Stopping.");
|
||||
break;
|
||||
warn!("Telegram 409 Conflict — stale polling session, retrying in {backoff:?}");
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(MAX_BACKOFF);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !status.is_success() {
|
||||
@@ -244,7 +505,7 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
}
|
||||
|
||||
// Parse the message
|
||||
let msg = match parse_telegram_update(update, &allowed_users) {
|
||||
let msg = match parse_telegram_update(update, &allowed_users, token.as_str(), &client, &api_base_url).await {
|
||||
Some(m) => m,
|
||||
None => continue, // filtered out or unparseable
|
||||
};
|
||||
@@ -276,21 +537,7 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let chat_id: i64 = user
|
||||
.platform_id
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid Telegram chat_id: {}", user.platform_id))?;
|
||||
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(chat_id, &text).await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(chat_id, "(Unsupported content type)")
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
self.send_content(user, content, None).await
|
||||
}
|
||||
|
||||
async fn send_typing(&self, user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -298,7 +545,34 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
.platform_id
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid Telegram chat_id: {}", user.platform_id))?;
|
||||
self.api_send_typing(chat_id).await
|
||||
self.api_send_typing(chat_id, None).await
|
||||
}
|
||||
|
||||
async fn send_in_thread(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
content: ChannelContent,
|
||||
thread_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tid: Option<i64> = thread_id.parse().ok();
|
||||
self.send_content(user, content, tid).await
|
||||
}
|
||||
|
||||
async fn send_reaction(
|
||||
&self,
|
||||
user: &ChannelUser,
|
||||
message_id: &str,
|
||||
reaction: &LifecycleReaction,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let chat_id: i64 = user
|
||||
.platform_id
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid Telegram chat_id: {}", user.platform_id))?;
|
||||
let msg_id: i64 = message_id
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid Telegram message_id: {message_id}"))?;
|
||||
self.fire_reaction(chat_id, msg_id, &reaction.emoji);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -309,9 +583,36 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
|
||||
/// Parse a Telegram update JSON into a `ChannelMessage`, or `None` if filtered/unparseable.
|
||||
/// Handles both `message` and `edited_message` update types.
|
||||
fn parse_telegram_update(
|
||||
/// Resolve a Telegram file_id to a download URL via the Bot API.
|
||||
async fn telegram_get_file_url(
|
||||
token: &str,
|
||||
client: &reqwest::Client,
|
||||
file_id: &str,
|
||||
api_base_url: &str,
|
||||
) -> Option<String> {
|
||||
let url = format!("{api_base_url}/bot{token}/getFile");
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({"file_id": file_id}))
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
let body: serde_json::Value = resp.json().await.ok()?;
|
||||
if body["ok"].as_bool() != Some(true) {
|
||||
return None;
|
||||
}
|
||||
let file_path = body["result"]["file_path"].as_str()?;
|
||||
Some(format!(
|
||||
"{api_base_url}/file/bot{token}/{file_path}"
|
||||
))
|
||||
}
|
||||
|
||||
async fn parse_telegram_update(
|
||||
update: &serde_json::Value,
|
||||
allowed_users: &[i64],
|
||||
allowed_users: &[String],
|
||||
token: &str,
|
||||
client: &reqwest::Client,
|
||||
api_base_url: &str,
|
||||
) -> Option<ChannelMessage> {
|
||||
let message = update
|
||||
.get("message")
|
||||
@@ -319,8 +620,9 @@ fn parse_telegram_update(
|
||||
let from = message.get("from")?;
|
||||
let user_id = from["id"].as_i64()?;
|
||||
|
||||
// Security: check allowed_users
|
||||
if !allowed_users.is_empty() && !allowed_users.contains(&user_id) {
|
||||
// Security: check allowed_users (compare as strings for consistency)
|
||||
let user_id_str = user_id.to_string();
|
||||
if !allowed_users.is_empty() && !allowed_users.iter().any(|u| u == &user_id_str) {
|
||||
debug!("Telegram: ignoring message from unlisted user {user_id}");
|
||||
return None;
|
||||
}
|
||||
@@ -336,41 +638,87 @@ fn parse_telegram_update(
|
||||
|
||||
let chat_type = message["chat"]["type"].as_str().unwrap_or("private");
|
||||
let is_group = chat_type == "group" || chat_type == "supergroup";
|
||||
|
||||
let text = message["text"].as_str()?;
|
||||
let message_id = message["message_id"].as_i64().unwrap_or(0);
|
||||
let timestamp = message["date"]
|
||||
.as_i64()
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
|
||||
// Parse bot commands (Telegram sends entities for /commands)
|
||||
let content = if let Some(entities) = message["entities"].as_array() {
|
||||
let is_bot_command = entities
|
||||
.iter()
|
||||
.any(|e| e["type"].as_str() == Some("bot_command") && e["offset"].as_i64() == Some(0));
|
||||
if is_bot_command {
|
||||
let parts: Vec<&str> = text.splitn(2, ' ').collect();
|
||||
let cmd_name = parts[0].trim_start_matches('/');
|
||||
// Strip @botname from command (e.g. /agents@mybot -> agents)
|
||||
let cmd_name = cmd_name.split('@').next().unwrap_or(cmd_name);
|
||||
let args = if parts.len() > 1 {
|
||||
parts[1].split_whitespace().map(String::from).collect()
|
||||
// Determine content: text, photo, document, voice, or location
|
||||
let content = if let Some(text) = message["text"].as_str() {
|
||||
// Parse bot commands (Telegram sends entities for /commands)
|
||||
if let Some(entities) = message["entities"].as_array() {
|
||||
let is_bot_command = entities.iter().any(|e| {
|
||||
e["type"].as_str() == Some("bot_command") && e["offset"].as_i64() == Some(0)
|
||||
});
|
||||
if is_bot_command {
|
||||
let parts: Vec<&str> = text.splitn(2, ' ').collect();
|
||||
let cmd_name = parts[0].trim_start_matches('/');
|
||||
let cmd_name = cmd_name.split('@').next().unwrap_or(cmd_name);
|
||||
let args = if parts.len() > 1 {
|
||||
parts[1].split_whitespace().map(String::from).collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
ChannelContent::Command {
|
||||
name: cmd_name.to_string(),
|
||||
args,
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
ChannelContent::Command {
|
||||
name: cmd_name.to_string(),
|
||||
args,
|
||||
ChannelContent::Text(text.to_string())
|
||||
}
|
||||
} else {
|
||||
ChannelContent::Text(text.to_string())
|
||||
}
|
||||
} else if let Some(photos) = message["photo"].as_array() {
|
||||
// Photos come as array of sizes; pick the largest (last)
|
||||
let file_id = photos
|
||||
.last()
|
||||
.and_then(|p| p["file_id"].as_str())
|
||||
.unwrap_or("");
|
||||
let caption = message["caption"].as_str().map(String::from);
|
||||
match telegram_get_file_url(token, client, file_id, api_base_url).await {
|
||||
Some(url) => ChannelContent::Image { url, caption },
|
||||
None => ChannelContent::Text(format!(
|
||||
"[Photo received{}]",
|
||||
caption.as_deref().map(|c| format!(": {c}")).unwrap_or_default()
|
||||
)),
|
||||
}
|
||||
} else if message.get("document").is_some() {
|
||||
let file_id = message["document"]["file_id"].as_str().unwrap_or("");
|
||||
let filename = message["document"]["file_name"]
|
||||
.as_str()
|
||||
.unwrap_or("document")
|
||||
.to_string();
|
||||
match telegram_get_file_url(token, client, file_id, api_base_url).await {
|
||||
Some(url) => ChannelContent::File { url, filename },
|
||||
None => ChannelContent::Text(format!("[Document received: {filename}]")),
|
||||
}
|
||||
} else if message.get("voice").is_some() {
|
||||
let file_id = message["voice"]["file_id"].as_str().unwrap_or("");
|
||||
let duration = message["voice"]["duration"].as_u64().unwrap_or(0) as u32;
|
||||
match telegram_get_file_url(token, client, file_id, api_base_url).await {
|
||||
Some(url) => ChannelContent::Voice {
|
||||
url,
|
||||
duration_seconds: duration,
|
||||
},
|
||||
None => ChannelContent::Text(format!("[Voice message, {duration}s]")),
|
||||
}
|
||||
} else if message.get("location").is_some() {
|
||||
let lat = message["location"]["latitude"].as_f64().unwrap_or(0.0);
|
||||
let lon = message["location"]["longitude"].as_f64().unwrap_or(0.0);
|
||||
ChannelContent::Location { lat, lon }
|
||||
} else {
|
||||
ChannelContent::Text(text.to_string())
|
||||
// Unsupported message type (stickers, polls, etc.)
|
||||
return None;
|
||||
};
|
||||
|
||||
// Use chat_id as the platform_id (so responses go to the right chat)
|
||||
// Extract forum topic thread_id (Telegram sends this as `message_thread_id`
|
||||
// for messages inside forum topics / reply threads).
|
||||
let thread_id = message["message_thread_id"]
|
||||
.as_i64()
|
||||
.map(|tid| tid.to_string());
|
||||
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Telegram,
|
||||
platform_message_id: message_id.to_string(),
|
||||
@@ -383,7 +731,7 @@ fn parse_telegram_update(
|
||||
target_agent: None,
|
||||
timestamp,
|
||||
is_group,
|
||||
thread_id: None,
|
||||
thread_id,
|
||||
metadata: HashMap::new(),
|
||||
})
|
||||
}
|
||||
@@ -393,12 +741,73 @@ pub fn calculate_backoff(current: Duration) -> Duration {
|
||||
(current * 2).min(MAX_BACKOFF)
|
||||
}
|
||||
|
||||
/// Sanitize text for Telegram HTML parse mode.
|
||||
///
|
||||
/// Escapes angle brackets that are NOT part of Telegram-allowed HTML tags.
|
||||
/// Allowed tags: b, i, u, s, tg-spoiler, a, code, pre, blockquote.
|
||||
/// Everything else (e.g. `<name>`, `<thinking>`) gets escaped to `<...>`.
|
||||
fn sanitize_telegram_html(text: &str) -> String {
|
||||
const ALLOWED: &[&str] = &[
|
||||
"b", "i", "u", "s", "em", "strong", "a", "code", "pre", "blockquote", "tg-spoiler",
|
||||
"tg-emoji",
|
||||
];
|
||||
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut chars = text.char_indices().peekable();
|
||||
|
||||
while let Some(&(i, ch)) = chars.peek() {
|
||||
if ch == '<' {
|
||||
// Try to parse an HTML tag
|
||||
if let Some(end_offset) = text[i..].find('>') {
|
||||
let tag_end = i + end_offset;
|
||||
let tag_content = &text[i + 1..tag_end]; // content between < and >
|
||||
let tag_name = tag_content
|
||||
.trim_start_matches('/')
|
||||
.split(|c: char| c.is_whitespace() || c == '/' || c == '>')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
if !tag_name.is_empty() && ALLOWED.contains(&tag_name.as_str()) {
|
||||
// Allowed tag — keep as-is
|
||||
result.push_str(&text[i..tag_end + 1]);
|
||||
} else {
|
||||
// Unknown tag — escape both brackets
|
||||
result.push_str("<");
|
||||
result.push_str(tag_content);
|
||||
result.push_str(">");
|
||||
}
|
||||
// Advance past the whole tag
|
||||
while let Some(&(j, _)) = chars.peek() {
|
||||
chars.next();
|
||||
if j >= tag_end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No closing > — escape the lone <
|
||||
result.push_str("<");
|
||||
chars.next();
|
||||
}
|
||||
} else {
|
||||
result.push(ch);
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_telegram_update() {
|
||||
fn test_client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_update() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123456,
|
||||
"message": {
|
||||
@@ -417,15 +826,16 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Telegram);
|
||||
assert_eq!(msg.sender.display_name, "Alice Smith");
|
||||
assert_eq!(msg.sender.platform_id, "111222333");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello, agent!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_telegram_command() {
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_command() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123457,
|
||||
"message": {
|
||||
@@ -448,7 +858,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -458,8 +869,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allowed_users_filter() {
|
||||
#[tokio::test]
|
||||
async fn test_allowed_users_filter() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123458,
|
||||
"message": {
|
||||
@@ -477,21 +888,25 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg = parse_telegram_update(&update, &[]);
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Non-matching allowed_users = filter out
|
||||
let msg = parse_telegram_update(&update, &[111, 222]);
|
||||
let blocked: Vec<String> = vec!["111".to_string(), "222".to_string()];
|
||||
let msg = parse_telegram_update(&update, &blocked, "fake:token", &client, DEFAULT_API_URL).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// Matching allowed_users = allow
|
||||
let msg = parse_telegram_update(&update, &[999]);
|
||||
let allowed: Vec<String> = vec!["999".to_string()];
|
||||
let msg = parse_telegram_update(&update, &allowed, "fake:token", &client, DEFAULT_API_URL).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_telegram_edited_message() {
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_edited_message() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 123459,
|
||||
"edited_message": {
|
||||
@@ -511,7 +926,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Telegram);
|
||||
assert_eq!(msg.sender.display_name, "Alice Smith");
|
||||
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message!"));
|
||||
@@ -532,8 +948,8 @@ mod tests {
|
||||
assert_eq!(b4, Duration::from_secs(60)); // stays at cap
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_with_botname() {
|
||||
#[tokio::test]
|
||||
async fn test_parse_command_with_botname() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 100,
|
||||
"message": {
|
||||
@@ -546,7 +962,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agents");
|
||||
@@ -555,4 +972,192 @@ mod tests {
|
||||
other => panic!("Expected Command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_location() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 200,
|
||||
"message": {
|
||||
"message_id": 50,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": 123, "type": "private" },
|
||||
"date": 1700000000,
|
||||
"location": { "latitude": 51.5074, "longitude": -0.1278 }
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
assert!(matches!(msg.content, ChannelContent::Location { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_photo_fallback() {
|
||||
// When getFile fails (fake token), photo messages should fall back to
|
||||
// a text description rather than being silently dropped.
|
||||
let update = serde_json::json!({
|
||||
"update_id": 300,
|
||||
"message": {
|
||||
"message_id": 60,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": 123, "type": "private" },
|
||||
"date": 1700000000,
|
||||
"photo": [
|
||||
{ "file_id": "small_id", "file_unique_id": "a", "width": 90, "height": 90, "file_size": 1234 },
|
||||
{ "file_id": "large_id", "file_unique_id": "b", "width": 800, "height": 600, "file_size": 45678 }
|
||||
],
|
||||
"caption": "Check this out"
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
// With a fake token, getFile will fail, so we get a text fallback
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.contains("Photo received"));
|
||||
assert!(t.contains("Check this out"));
|
||||
}
|
||||
ChannelContent::Image { caption, .. } => {
|
||||
// If somehow the HTTP call succeeded (unlikely with fake token),
|
||||
// verify caption was extracted
|
||||
assert_eq!(caption.as_deref(), Some("Check this out"));
|
||||
}
|
||||
other => panic!("Expected Text or Image fallback for photo, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_document_fallback() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 301,
|
||||
"message": {
|
||||
"message_id": 61,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": 123, "type": "private" },
|
||||
"date": 1700000000,
|
||||
"document": {
|
||||
"file_id": "doc_id",
|
||||
"file_unique_id": "c",
|
||||
"file_name": "report.pdf",
|
||||
"file_size": 102400
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.contains("Document received"));
|
||||
assert!(t.contains("report.pdf"));
|
||||
}
|
||||
ChannelContent::File { filename, .. } => {
|
||||
assert_eq!(filename, "report.pdf");
|
||||
}
|
||||
other => panic!("Expected Text or File for document, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_voice_fallback() {
|
||||
let update = serde_json::json!({
|
||||
"update_id": 302,
|
||||
"message": {
|
||||
"message_id": 62,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": 123, "type": "private" },
|
||||
"date": 1700000000,
|
||||
"voice": {
|
||||
"file_id": "voice_id",
|
||||
"file_unique_id": "d",
|
||||
"duration": 15
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Text(t) => {
|
||||
assert!(t.contains("Voice message"));
|
||||
assert!(t.contains("15s"));
|
||||
}
|
||||
ChannelContent::Voice { duration_seconds, .. } => {
|
||||
assert_eq!(*duration_seconds, 15);
|
||||
}
|
||||
other => panic!("Expected Text or Voice for voice message, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_forum_topic_thread_id() {
|
||||
// Messages inside a Telegram forum topic include `message_thread_id`.
|
||||
let update = serde_json::json!({
|
||||
"update_id": 400,
|
||||
"message": {
|
||||
"message_id": 70,
|
||||
"message_thread_id": 42,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": -1001234567890_i64, "type": "supergroup" },
|
||||
"date": 1700000000,
|
||||
"text": "Hello from a forum topic"
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
assert_eq!(msg.thread_id, Some("42".to_string()));
|
||||
assert!(msg.is_group);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_no_thread_id_in_private_chat() {
|
||||
// Private chats should have thread_id = None.
|
||||
let update = serde_json::json!({
|
||||
"update_id": 401,
|
||||
"message": {
|
||||
"message_id": 71,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": 123, "type": "private" },
|
||||
"date": 1700000000,
|
||||
"text": "Hello from DM"
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
assert_eq!(msg.thread_id, None);
|
||||
assert!(!msg.is_group);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_parse_telegram_edited_message_in_forum() {
|
||||
// Edited messages in forum topics should also preserve thread_id.
|
||||
let update = serde_json::json!({
|
||||
"update_id": 402,
|
||||
"edited_message": {
|
||||
"message_id": 72,
|
||||
"message_thread_id": 99,
|
||||
"from": { "id": 123, "first_name": "Alice" },
|
||||
"chat": { "id": -1001234567890_i64, "type": "supergroup" },
|
||||
"date": 1700000000,
|
||||
"edit_date": 1700000060,
|
||||
"text": "Edited in forum"
|
||||
}
|
||||
});
|
||||
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL).await.unwrap();
|
||||
assert_eq!(msg.thread_id, Some("99".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_telegram_html_basic() {
|
||||
// Allowed tags preserved, unknown tags escaped
|
||||
let input = "<b>bold</b> <thinking>hmm</thinking>";
|
||||
let output = sanitize_telegram_html(input);
|
||||
assert!(output.contains("<b>bold</b>"));
|
||||
assert!(output.contains("<thinking>"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -6,9 +6,17 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Get the OpenFang home directory, respecting OPENFANG_HOME env var.
|
||||
fn dotenv_openfang_home() -> Option<PathBuf> {
|
||||
if let Ok(home) = std::env::var("OPENFANG_HOME") {
|
||||
return Some(PathBuf::from(home));
|
||||
}
|
||||
dirs::home_dir().map(|h| h.join(".openfang"))
|
||||
}
|
||||
|
||||
/// Return the path to `~/.openfang/.env`.
|
||||
pub fn env_file_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".openfang").join(".env"))
|
||||
dotenv_openfang_home().map(|h| h.join(".env"))
|
||||
}
|
||||
|
||||
/// Load `~/.openfang/.env` and `~/.openfang/secrets.env` into `std::env`.
|
||||
@@ -25,7 +33,7 @@ pub fn load_dotenv() {
|
||||
|
||||
/// Return the path to `~/.openfang/secrets.env`.
|
||||
pub fn secrets_env_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(".openfang").join("secrets.env"))
|
||||
dotenv_openfang_home().map(|h| h.join("secrets.env"))
|
||||
}
|
||||
|
||||
fn load_env_file(path: Option<PathBuf>) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+415
-86
@@ -390,6 +390,11 @@ enum HandCommands {
|
||||
List,
|
||||
/// Show currently active hand instances.
|
||||
Active,
|
||||
/// Install a hand from a local directory containing HAND.toml.
|
||||
Install {
|
||||
/// Path to the hand directory (must contain HAND.toml).
|
||||
path: String,
|
||||
},
|
||||
/// Activate a hand by ID.
|
||||
Activate {
|
||||
/// Hand ID (e.g. "clip", "lead", "researcher").
|
||||
@@ -405,6 +410,26 @@ enum HandCommands {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Check dependency status for a hand.
|
||||
CheckDeps {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Install missing dependencies for a hand.
|
||||
InstallDeps {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Pause a running hand instance.
|
||||
Pause {
|
||||
/// Instance ID (from `hand active`).
|
||||
id: String,
|
||||
},
|
||||
/// Resume a paused hand instance.
|
||||
Resume {
|
||||
/// Instance ID (from `hand active`).
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -612,6 +637,9 @@ enum CronCommands {
|
||||
spec: String,
|
||||
/// Prompt to send when the job fires.
|
||||
prompt: String,
|
||||
/// Optional job name (auto-generated if omitted).
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Delete a scheduled job.
|
||||
Delete {
|
||||
@@ -758,11 +786,19 @@ fn init_tracing_stderr() {
|
||||
.init();
|
||||
}
|
||||
|
||||
/// Get the OpenFang home directory, respecting OPENFANG_HOME env var.
|
||||
fn cli_openfang_home() -> std::path::PathBuf {
|
||||
if let Ok(home) = std::env::var("OPENFANG_HOME") {
|
||||
return std::path::PathBuf::from(home);
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join(".openfang")
|
||||
}
|
||||
|
||||
/// Redirect tracing to a log file so it doesn't corrupt the ratatui TUI.
|
||||
fn init_tracing_file() {
|
||||
let log_dir = dirs::home_dir()
|
||||
.map(|h| h.join(".openfang"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
let log_dir = cli_openfang_home();
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
let log_path = log_dir.join("tui.log");
|
||||
|
||||
@@ -887,9 +923,14 @@ fn main() {
|
||||
Some(Commands::Hand(sub)) => match sub {
|
||||
HandCommands::List => cmd_hand_list(),
|
||||
HandCommands::Active => cmd_hand_active(),
|
||||
HandCommands::Install { path } => cmd_hand_install(&path),
|
||||
HandCommands::Activate { id } => cmd_hand_activate(&id),
|
||||
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
|
||||
HandCommands::Info { id } => cmd_hand_info(&id),
|
||||
HandCommands::CheckDeps { id } => cmd_hand_check_deps(&id),
|
||||
HandCommands::InstallDeps { id } => cmd_hand_install_deps(&id),
|
||||
HandCommands::Pause { id } => cmd_hand_pause(&id),
|
||||
HandCommands::Resume { id } => cmd_hand_resume(&id),
|
||||
},
|
||||
Some(Commands::Config(sub)) => match sub {
|
||||
ConfigCommands::Show => cmd_config_show(),
|
||||
@@ -940,7 +981,8 @@ fn main() {
|
||||
agent,
|
||||
spec,
|
||||
prompt,
|
||||
} => cmd_cron_create(&agent, &spec, &prompt),
|
||||
name,
|
||||
} => cmd_cron_create(&agent, &spec, &prompt, name.as_deref()),
|
||||
CronCommands::Delete { id } => cmd_cron_delete(&id),
|
||||
CronCommands::Enable { id } => cmd_cron_toggle(&id, true),
|
||||
CronCommands::Disable { id } => cmd_cron_toggle(&id, false),
|
||||
@@ -1009,7 +1051,7 @@ pub(crate) fn restrict_dir_permissions(path: &std::path::Path) {
|
||||
pub(crate) fn restrict_dir_permissions(_path: &std::path::Path) {}
|
||||
|
||||
pub(crate) fn find_daemon() -> Option<String> {
|
||||
let home_dir = dirs::home_dir()?.join(".openfang");
|
||||
let home_dir = cli_openfang_home();
|
||||
let info = read_daemon_info(&home_dir)?;
|
||||
|
||||
// Normalize listen address: replace 0.0.0.0 with 127.0.0.1 to avoid
|
||||
@@ -1031,11 +1073,23 @@ pub(crate) fn find_daemon() -> Option<String> {
|
||||
}
|
||||
|
||||
/// Build an HTTP client for daemon calls.
|
||||
///
|
||||
/// When api_key is configured in config.toml, the client automatically
|
||||
/// includes a `Authorization: Bearer <key>` header on every request.
|
||||
/// When api_key is empty or missing, no auth header is sent.
|
||||
pub(crate) fn daemon_client() -> reqwest::blocking::Client {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client")
|
||||
let mut builder = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120));
|
||||
|
||||
if let Some(key) = read_api_key() {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {key}")) {
|
||||
headers.insert(reqwest::header::AUTHORIZATION, val);
|
||||
}
|
||||
builder = builder.default_headers(headers);
|
||||
}
|
||||
|
||||
builder.build().expect("Failed to build HTTP client")
|
||||
}
|
||||
|
||||
/// Helper: send a request to the daemon and parse the JSON body.
|
||||
@@ -1091,7 +1145,7 @@ fn cmd_init(quick: bool) {
|
||||
}
|
||||
};
|
||||
|
||||
let openfang_dir = home.join(".openfang");
|
||||
let openfang_dir = cli_openfang_home();
|
||||
|
||||
// --- Ensure directories exist ---
|
||||
if !openfang_dir.exists() {
|
||||
@@ -1121,6 +1175,12 @@ fn cmd_init(quick: bool) {
|
||||
|
||||
if quick {
|
||||
cmd_init_quick(&openfang_dir);
|
||||
} else if !std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
|| !std::io::IsTerminal::is_terminal(&std::io::stdout())
|
||||
{
|
||||
ui::hint("Non-interactive terminal detected — running in quick mode");
|
||||
ui::hint("For the interactive wizard, run: openfang init (in a terminal)");
|
||||
cmd_init_quick(&openfang_dir);
|
||||
} else {
|
||||
cmd_init_interactive(&openfang_dir);
|
||||
}
|
||||
@@ -1242,8 +1302,17 @@ fn launch_desktop_app(_openfang_dir: &std::path::Path) {
|
||||
if let Some(base) = find_daemon() {
|
||||
let url = format!("{base}/");
|
||||
if !open_in_browser(&url) {
|
||||
ui::hint(&format!("Visit: {url}"));
|
||||
// Browser launch failed entirely (e.g., sandbox EPERM,
|
||||
// no display server, container environment).
|
||||
ui::hint("Could not open a browser automatically.");
|
||||
}
|
||||
// Always print the URL so the user can open it manually,
|
||||
// even when open_in_browser reported success — the spawned
|
||||
// opener may still fail asynchronously.
|
||||
ui::hint(&format!("Dashboard: {url}"));
|
||||
} else {
|
||||
ui::hint("Daemon is not running. Start it with: openfang start");
|
||||
ui::hint("Then open: http://127.0.0.1:4200");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1264,8 +1333,14 @@ fn detect_best_provider() -> (&'static str, &'static str, &'static str) {
|
||||
ui::success("Detected Gemini (GOOGLE_API_KEY)");
|
||||
return ("gemini", "GOOGLE_API_KEY", "gemini-2.5-flash");
|
||||
}
|
||||
// Check if Ollama is running locally (no API key needed)
|
||||
if check_ollama_available() {
|
||||
ui::success("Detected Ollama running locally (no API key needed)");
|
||||
return ("ollama", "OLLAMA_API_KEY", "llama3.2");
|
||||
}
|
||||
ui::hint("No LLM provider API keys found");
|
||||
ui::hint("Groq offers a free tier: https://console.groq.com");
|
||||
ui::hint("Or install Ollama for local models: https://ollama.com");
|
||||
("groq", "GROQ_API_KEY", "llama-3.3-70b-versatile")
|
||||
}
|
||||
|
||||
@@ -1285,12 +1360,21 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
|
||||
(
|
||||
"openrouter",
|
||||
"OPENROUTER_API_KEY",
|
||||
"openrouter/auto",
|
||||
"openrouter/google/gemini-2.5-flash",
|
||||
"OpenRouter",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Quick probe to check if Ollama is running on localhost.
|
||||
fn check_ollama_available() -> bool {
|
||||
std::net::TcpStream::connect_timeout(
|
||||
&std::net::SocketAddr::from(([127, 0, 0, 1], 11434)),
|
||||
std::time::Duration::from_millis(500),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Write config.toml if it doesn't already exist.
|
||||
fn write_config_if_missing(
|
||||
openfang_dir: &std::path::Path,
|
||||
@@ -1391,6 +1475,22 @@ fn cmd_start(config: Option<PathBuf>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Read the api_key from ~/.openfang/config.toml (if any).
|
||||
///
|
||||
/// Returns `None` when the key is missing, empty, or whitespace-only —
|
||||
/// meaning the daemon is running in public (unauthenticated) mode.
|
||||
fn read_api_key() -> Option<String> {
|
||||
let config_path = cli_openfang_home().join("config.toml");
|
||||
let text = std::fs::read_to_string(config_path).ok()?;
|
||||
let table: toml::Value = text.parse().ok()?;
|
||||
let key = table.get("api_key")?.as_str()?.trim();
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(key.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_stop() {
|
||||
match find_daemon() {
|
||||
Some(base) => {
|
||||
@@ -1406,8 +1506,8 @@ fn cmd_stop() {
|
||||
}
|
||||
}
|
||||
// Still alive — force kill via PID
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let of_dir = home.join(".openfang");
|
||||
{
|
||||
let of_dir = cli_openfang_home();
|
||||
if let Some(info) = read_daemon_info(&of_dir) {
|
||||
force_kill_pid(info.pid);
|
||||
let _ = std::fs::remove_file(of_dir.join("daemon.json"));
|
||||
@@ -1882,8 +1982,8 @@ fn cmd_doctor(json: bool, repair: bool) {
|
||||
}
|
||||
|
||||
let home = dirs::home_dir();
|
||||
if let Some(h) = &home {
|
||||
let openfang_dir = h.join(".openfang");
|
||||
if let Some(_h) = &home {
|
||||
let openfang_dir = cli_openfang_home();
|
||||
|
||||
// --- Check 1: OpenFang directory ---
|
||||
if openfang_dir.exists() {
|
||||
@@ -2001,20 +2101,23 @@ fn cmd_doctor(json: bool, repair: bool) {
|
||||
}
|
||||
let answer = prompt_input(" Create default config? [Y/n] ");
|
||||
if answer.is_empty() || answer.starts_with('y') || answer.starts_with('Y') {
|
||||
let default_config = r#"# OpenFang Agent OS configuration
|
||||
let (provider, api_key_env, model) = detect_best_provider();
|
||||
let default_config = format!(
|
||||
r#"# OpenFang Agent OS configuration
|
||||
# See https://github.com/RightNow-AI/openfang for documentation
|
||||
|
||||
# For Docker, change to "0.0.0.0:4200" or set OPENFANG_LISTEN env var.
|
||||
api_listen = "127.0.0.1:4200"
|
||||
|
||||
[default_model]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
api_key_env = "GROQ_API_KEY"
|
||||
provider = "{provider}"
|
||||
model = "{model}"
|
||||
api_key_env = "{api_key_env}"
|
||||
|
||||
[memory]
|
||||
decay_rate = 0.05
|
||||
"#;
|
||||
"#
|
||||
);
|
||||
let _ = std::fs::create_dir_all(&openfang_dir);
|
||||
if std::fs::write(&config_path, default_config).is_ok() {
|
||||
restrict_file_permissions(&config_path);
|
||||
@@ -2040,7 +2143,20 @@ decay_rate = 0.05
|
||||
all_ok = false;
|
||||
}
|
||||
|
||||
// --- Check 4: Port 4200 availability ---
|
||||
// --- Check 4: Port availability ---
|
||||
// Read api_listen from config (default: 127.0.0.1:4200)
|
||||
let api_listen = {
|
||||
let cfg_path = openfang_dir.join("config.toml");
|
||||
if cfg_path.exists() {
|
||||
std::fs::read_to_string(&cfg_path)
|
||||
.ok()
|
||||
.and_then(|s| toml::from_str::<openfang_types::config::KernelConfig>(&s).ok())
|
||||
.map(|c| c.api_listen)
|
||||
.unwrap_or_else(|| "127.0.0.1:4200".to_string())
|
||||
} else {
|
||||
"127.0.0.1:4200".to_string()
|
||||
}
|
||||
};
|
||||
if !json {
|
||||
println!();
|
||||
}
|
||||
@@ -2056,19 +2172,24 @@ decay_rate = 0.05
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "daemon", "status": "warn"}));
|
||||
|
||||
// Check if port 4200 is available
|
||||
match std::net::TcpListener::bind("127.0.0.1:4200") {
|
||||
// Check if the configured port is available
|
||||
let bind_addr = if api_listen.starts_with("0.0.0.0") {
|
||||
api_listen.replacen("0.0.0.0", "127.0.0.1", 1)
|
||||
} else {
|
||||
api_listen.clone()
|
||||
};
|
||||
match std::net::TcpListener::bind(&bind_addr) {
|
||||
Ok(_) => {
|
||||
if !json {
|
||||
ui::check_ok("Port 4200 is available");
|
||||
ui::check_ok(&format!("Port {api_listen} is available"));
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "port_4200", "status": "ok"}));
|
||||
checks.push(serde_json::json!({"check": "port", "status": "ok", "address": api_listen}));
|
||||
}
|
||||
Err(_) => {
|
||||
if !json {
|
||||
ui::check_warn("Port 4200 is in use by another process");
|
||||
ui::check_warn(&format!("Port {api_listen} is in use by another process"));
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "port_4200", "status": "warn"}));
|
||||
checks.push(serde_json::json!({"check": "port", "status": "warn", "address": api_listen}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2288,8 +2409,8 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// --- Check 11: .env keys vs config api_key_env consistency ---
|
||||
if let Some(ref h) = home {
|
||||
let openfang_dir = h.join(".openfang");
|
||||
{
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let config_path = openfang_dir.join("config.toml");
|
||||
if config_path.exists() {
|
||||
let config_str = std::fs::read_to_string(&config_path).unwrap_or_default();
|
||||
@@ -2314,8 +2435,8 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// --- Check 12: Config deserialization into KernelConfig ---
|
||||
if let Some(ref h) = home {
|
||||
let openfang_dir = h.join(".openfang");
|
||||
{
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let config_path = openfang_dir.join("config.toml");
|
||||
if config_path.exists() {
|
||||
if !json {
|
||||
@@ -2419,10 +2540,7 @@ decay_rate = 0.05
|
||||
if !json {
|
||||
println!("\n Skills:");
|
||||
}
|
||||
let skills_dir = home
|
||||
.as_ref()
|
||||
.map(|h| h.join(".openfang").join("skills"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("skills"));
|
||||
let skills_dir = cli_openfang_home().join("skills");
|
||||
let mut skill_reg = openfang_skills::registry::SkillRegistry::new(skills_dir.clone());
|
||||
skill_reg.load_bundled();
|
||||
let bundled_count = skill_reg.count();
|
||||
@@ -2456,12 +2574,20 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// Check for prompt injection issues in skill definitions
|
||||
// Only flag Critical-severity warnings (Warning-level hits are expected
|
||||
// in bundled skills that mention shell commands in educational context).
|
||||
let skills = skill_reg.list();
|
||||
let mut injection_warnings = 0;
|
||||
for skill in &skills {
|
||||
if let Some(ref prompt) = skill.manifest.prompt_context {
|
||||
let warnings = openfang_skills::verify::SkillVerifier::scan_prompt_content(prompt);
|
||||
if !warnings.is_empty() {
|
||||
let has_critical = warnings.iter().any(|w| {
|
||||
matches!(
|
||||
w.severity,
|
||||
openfang_skills::verify::WarningSeverity::Critical
|
||||
)
|
||||
});
|
||||
if has_critical {
|
||||
injection_warnings += 1;
|
||||
if !json {
|
||||
ui::check_warn(&format!(
|
||||
@@ -2483,11 +2609,11 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// --- Check 14: Extension registry health ---
|
||||
if let Some(ref h) = home {
|
||||
{
|
||||
if !json {
|
||||
println!("\n Extensions:");
|
||||
}
|
||||
let openfang_dir = h.join(".openfang");
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let mut ext_registry =
|
||||
openfang_extensions::registry::IntegrationRegistry::new(&openfang_dir);
|
||||
ext_registry.load_bundled();
|
||||
@@ -2528,7 +2654,7 @@ decay_rate = 0.05
|
||||
checks.push(serde_json::json!({"check": "daemon_uptime", "status": "ok", "secs": uptime}));
|
||||
}
|
||||
if let Some(db_status) = body.get("database").and_then(|v| v.as_str()) {
|
||||
if db_status == "ok" {
|
||||
if db_status == "connected" || db_status == "ok" {
|
||||
if !json {
|
||||
ui::check_ok("Database connectivity: OK");
|
||||
}
|
||||
@@ -2602,12 +2728,18 @@ decay_rate = 0.05
|
||||
match client.get(format!("{base}/api/integrations/health")).send() {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
if let Some(obj) = body.as_object() {
|
||||
let healthy = obj
|
||||
.values()
|
||||
.filter(|v| v.get("healthy").and_then(|h| h.as_bool()).unwrap_or(false))
|
||||
let entries = body.get("health").and_then(|h| h.as_array());
|
||||
if let Some(arr) = entries {
|
||||
let healthy = arr
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
v.get("status")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.eq_ignore_ascii_case("ready"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
let total = obj.len();
|
||||
let total = arr.len();
|
||||
if healthy == total {
|
||||
if !json {
|
||||
ui::check_ok(&format!(
|
||||
@@ -2850,10 +2982,31 @@ pub(crate) fn open_in_browser(url: &str) -> bool {
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(url)
|
||||
.spawn()
|
||||
.is_ok()
|
||||
// Try multiple openers in order. xdg-open is the standard, but it
|
||||
// (or the browser it launches) can fail with EPERM in sandboxed
|
||||
// environments (containers, Snap, Flatpak, user-namespace
|
||||
// restrictions). Fall through to alternatives if any opener fails.
|
||||
let openers = [
|
||||
"xdg-open",
|
||||
"sensible-browser",
|
||||
"x-www-browser",
|
||||
"firefox",
|
||||
"google-chrome",
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
];
|
||||
for opener in &openers {
|
||||
let result = std::process::Command::new(opener)
|
||||
.arg(url)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
if result.is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
@@ -3098,12 +3251,7 @@ fn cmd_migrate(args: MigrateArgs) {
|
||||
}
|
||||
});
|
||||
|
||||
let target_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Error: Could not determine home directory");
|
||||
std::process::exit(1);
|
||||
})
|
||||
.join(".openfang");
|
||||
let target_dir = cli_openfang_home();
|
||||
|
||||
println!("Migrating from {} ({})...", source, source_dir.display());
|
||||
if args.dry_run {
|
||||
@@ -3794,6 +3942,52 @@ fn cmd_channel_toggle(channel: &str, enable: bool) {
|
||||
// Hand commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cmd_hand_install(path: &str) {
|
||||
let base = require_daemon("hand install");
|
||||
let dir = std::path::Path::new(path);
|
||||
let toml_path = dir.join("HAND.toml");
|
||||
let skill_path = dir.join("SKILL.md");
|
||||
|
||||
if !toml_path.exists() {
|
||||
eprintln!(
|
||||
"Error: No HAND.toml found in {}",
|
||||
dir.canonicalize()
|
||||
.unwrap_or_else(|_| dir.to_path_buf())
|
||||
.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let toml_content = std::fs::read_to_string(&toml_path).unwrap_or_else(|e| {
|
||||
eprintln!("Error reading {}: {e}", toml_path.display());
|
||||
std::process::exit(1);
|
||||
});
|
||||
let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default();
|
||||
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/install"))
|
||||
.json(&serde_json::json!({
|
||||
"toml_content": toml_content,
|
||||
"skill_content": skill_content,
|
||||
}))
|
||||
.send(),
|
||||
);
|
||||
|
||||
if let Some(err) = body.get("error").and_then(|v| v.as_str()) {
|
||||
eprintln!("Error: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Installed hand: {} ({})",
|
||||
body["name"].as_str().unwrap_or("?"),
|
||||
body["id"].as_str().unwrap_or("?"),
|
||||
);
|
||||
println!("Use `openfang hand activate {}` to start it.", body["id"].as_str().unwrap_or("?"));
|
||||
}
|
||||
|
||||
fn cmd_hand_list() {
|
||||
let base = require_daemon("hand list");
|
||||
let client = daemon_client();
|
||||
@@ -3952,6 +4146,87 @@ fn cmd_hand_info(id: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
fn cmd_hand_check_deps(id: &str) {
|
||||
let base = require_daemon("hand check-deps");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/{id}/check-deps"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&body).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_install_deps(id: &str) {
|
||||
let base = require_daemon("hand install-deps");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/{id}/install-deps"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
ui::success(&format!("Dependencies installed for hand '{id}'."));
|
||||
if let Some(results) = body.get("results") {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(results).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_pause(id: &str) {
|
||||
let base = require_daemon("hand pause");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/instances/{id}/pause"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
ui::success(&format!("Hand instance '{id}' paused."));
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_resume(id: &str) {
|
||||
let base = require_daemon("hand resume");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/instances/{id}/resume"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
ui::success(&format!("Hand instance '{id}' resumed."));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider / API key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -4220,6 +4495,31 @@ fn cmd_config_set(key: &str, value: &str) {
|
||||
}
|
||||
|
||||
let last_key = parts[parts.len() - 1];
|
||||
|
||||
// Validate: single-part keys must be known scalar fields, not sections.
|
||||
// Writing a section name as a scalar silently breaks config deserialization.
|
||||
if parts.len() == 1 {
|
||||
let known_scalars = [
|
||||
"home_dir",
|
||||
"data_dir",
|
||||
"log_level",
|
||||
"api_listen",
|
||||
"network_enabled",
|
||||
"api_key",
|
||||
"language",
|
||||
"max_cron_jobs",
|
||||
"usage_footer",
|
||||
"workspaces_dir",
|
||||
];
|
||||
if !known_scalars.contains(&last_key) {
|
||||
ui::error_with_fix(
|
||||
&format!("'{last_key}' is a section, not a scalar"),
|
||||
&format!("Use dotted notation: {last_key}.field_name"),
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let tbl = current.as_table_mut().unwrap_or_else(|| {
|
||||
ui::error(&format!("Parent of '{key}' is not a table"));
|
||||
std::process::exit(1);
|
||||
@@ -4229,8 +4529,9 @@ fn cmd_config_set(key: &str, value: &str) {
|
||||
let new_value = if let Some(existing) = tbl.get(last_key) {
|
||||
match existing {
|
||||
toml::Value::Integer(_) => value
|
||||
.parse::<i64>()
|
||||
.map(toml::Value::Integer)
|
||||
.parse::<u64>()
|
||||
.map(|v| toml::Value::Integer(v as i64))
|
||||
.or_else(|_| value.parse::<i64>().map(toml::Value::Integer))
|
||||
.unwrap_or_else(|_| toml::Value::String(value.to_string())),
|
||||
toml::Value::Float(_) => value
|
||||
.parse::<f64>()
|
||||
@@ -4243,7 +4544,18 @@ fn cmd_config_set(key: &str, value: &str) {
|
||||
_ => toml::Value::String(value.to_string()),
|
||||
}
|
||||
} else {
|
||||
toml::Value::String(value.to_string())
|
||||
// No existing value — infer type from the string content
|
||||
if let Ok(b) = value.parse::<bool>() {
|
||||
toml::Value::Boolean(b)
|
||||
} else if let Ok(i) = value.parse::<u64>() {
|
||||
toml::Value::Integer(i as i64)
|
||||
} else if let Ok(i) = value.parse::<i64>() {
|
||||
toml::Value::Integer(i)
|
||||
} else if let Ok(f) = value.parse::<f64>() {
|
||||
toml::Value::Float(f)
|
||||
} else {
|
||||
toml::Value::String(value.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
tbl.insert(last_key.to_string(), new_value);
|
||||
@@ -4402,6 +4714,9 @@ fn cmd_quick_chat(config: Option<PathBuf>, agent: Option<String>) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn openfang_home() -> PathBuf {
|
||||
if let Ok(home) = std::env::var("OPENFANG_HOME") {
|
||||
return PathBuf::from(home);
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Error: Could not determine home directory");
|
||||
@@ -5124,16 +5439,40 @@ fn cmd_cron_list(json: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str) {
|
||||
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<&str>) {
|
||||
let base = require_daemon("cron create");
|
||||
let client = daemon_client();
|
||||
|
||||
// Use explicit name if provided, otherwise derive from agent + prompt
|
||||
let name = if let Some(n) = explicit_name {
|
||||
n.to_string()
|
||||
} else {
|
||||
let short_prompt: String = prompt
|
||||
.split_whitespace()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||
.take(64)
|
||||
.collect();
|
||||
format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt })
|
||||
};
|
||||
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/cron/jobs"))
|
||||
.json(&serde_json::json!({
|
||||
"agent_id": agent,
|
||||
"cron_expr": spec,
|
||||
"prompt": prompt,
|
||||
"name": name,
|
||||
"schedule": {
|
||||
"kind": "cron",
|
||||
"expr": spec
|
||||
},
|
||||
"action": {
|
||||
"kind": "agent_turn",
|
||||
"message": prompt
|
||||
}
|
||||
}))
|
||||
.send(),
|
||||
);
|
||||
@@ -5220,9 +5559,7 @@ fn cmd_sessions(agent: Option<&str>, json: bool) {
|
||||
}
|
||||
|
||||
fn cmd_logs(lines: usize, follow: bool) {
|
||||
let log_path = dirs::home_dir()
|
||||
.map(|h| h.join(".openfang").join("tui.log"))
|
||||
.unwrap_or_else(|| PathBuf::from("tui.log"));
|
||||
let log_path = cli_openfang_home().join("tui.log");
|
||||
|
||||
if !log_path.exists() {
|
||||
ui::error_with_fix(
|
||||
@@ -5752,13 +6089,7 @@ fn cmd_system_version(json: bool) {
|
||||
}
|
||||
|
||||
fn cmd_reset(confirm: bool) {
|
||||
let openfang_dir = match dirs::home_dir() {
|
||||
Some(h) => h.join(".openfang"),
|
||||
None => {
|
||||
ui::error("Could not determine home directory");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let openfang_dir = cli_openfang_home();
|
||||
|
||||
if !openfang_dir.exists() {
|
||||
println!(
|
||||
@@ -5793,14 +6124,7 @@ fn cmd_reset(confirm: bool) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cmd_uninstall(confirm: bool, keep_config: bool) {
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
ui::error("Could not determine home directory");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let openfang_dir = home.join(".openfang");
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let exe_path = std::env::current_exe().ok();
|
||||
|
||||
// Step 1: Show what will be removed
|
||||
@@ -5826,11 +6150,15 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
|
||||
println!(" • Remove binary: {}", exe.display());
|
||||
}
|
||||
// Check cargo bin path
|
||||
let cargo_bin = home.join(".cargo").join("bin").join(if cfg!(windows) {
|
||||
"openfang.exe"
|
||||
} else {
|
||||
"openfang"
|
||||
});
|
||||
let cargo_bin = dirs::home_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join(".cargo")
|
||||
.join("bin")
|
||||
.join(if cfg!(windows) {
|
||||
"openfang.exe"
|
||||
} else {
|
||||
"openfang"
|
||||
});
|
||||
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
|
||||
println!(" • Remove cargo binary: {}", cargo_bin.display());
|
||||
}
|
||||
@@ -5864,12 +6192,13 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
|
||||
}
|
||||
|
||||
// Step 4: Remove auto-start entries
|
||||
remove_autostart_entries(&home);
|
||||
let user_home = dirs::home_dir().unwrap_or_else(std::env::temp_dir);
|
||||
remove_autostart_entries(&user_home);
|
||||
|
||||
// Step 5: Clean PATH from shell configs
|
||||
if let Some(ref exe) = exe_path {
|
||||
if let Some(bin_dir) = exe.parent() {
|
||||
clean_path_entries(&home, &bin_dir.to_string_lossy());
|
||||
clean_path_entries(&user_home, &bin_dir.to_string_lossy());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -220,6 +220,8 @@ impl StandaloneChat {
|
||||
}
|
||||
ChatAction::SendMessage(msg) => self.send_message(msg),
|
||||
ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
|
||||
ChatAction::OpenModelPicker => self.open_model_picker(),
|
||||
ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,12 +269,13 @@ impl StandaloneChat {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/model \u{2014} show current model",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent & quit",
|
||||
"/exit \u{2014} end chat session",
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent & quit",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
@@ -294,8 +297,14 @@ impl StandaloneChat {
|
||||
self.chat.push_message(Role::System, s.join("\n"));
|
||||
}
|
||||
"/model" => {
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Model: {}", self.chat.model_label));
|
||||
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
||||
if args.is_empty() {
|
||||
// No argument: open the model picker
|
||||
self.open_model_picker();
|
||||
} else {
|
||||
// With argument: switch directly
|
||||
self.switch_model(args);
|
||||
}
|
||||
}
|
||||
"/clear" => {
|
||||
let name = self.chat.agent_name.clone();
|
||||
@@ -364,6 +373,160 @@ impl StandaloneChat {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model picker helpers ──────────────────────────────────────────────────
|
||||
|
||||
fn open_model_picker(&mut self) {
|
||||
use super::screens::chat::ModelEntry;
|
||||
|
||||
let models = match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
let client = crate::daemon_client();
|
||||
match client.get(format!("{base_url}/api/models")).send() {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>() {
|
||||
Ok(body) => body["models"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter(|m| m["available"].as_bool().unwrap_or(false))
|
||||
.map(|m| ModelEntry {
|
||||
id: m["id"].as_str().unwrap_or("").to_string(),
|
||||
display_name: m["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
let catalog = kernel.model_catalog.read().unwrap();
|
||||
catalog
|
||||
.available_models()
|
||||
.into_iter()
|
||||
.map(|e| ModelEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
provider: e.provider.clone(),
|
||||
tier: format!("{:?}", e.tier),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Backend::None => Vec::new(),
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
self.chat
|
||||
.push_message(Role::System, "No models available.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.chat.model_picker_models = models;
|
||||
self.chat.model_picker_filter.clear();
|
||||
self.chat.model_picker_idx = 0;
|
||||
self.chat.show_model_picker = true;
|
||||
}
|
||||
|
||||
fn switch_model(&mut self, model_id: &str) {
|
||||
// Skip if already on this model
|
||||
if self.chat.model_label.ends_with(model_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
if let Some(ref agent_id) = self.agent_id_daemon {
|
||||
let client = crate::daemon_client();
|
||||
let url = format!("{base_url}/api/agents/{agent_id}/model");
|
||||
match client
|
||||
.put(&url)
|
||||
.json(&serde_json::json!({"model": model_id}))
|
||||
.send()
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
// Re-fetch agent to get updated provider/model
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/agents/{agent_id}"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Failed to switch to {model_id}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
if let Some(id) = self.agent_id_inprocess {
|
||||
let provider = kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap()
|
||||
.find_model(model_id)
|
||||
.map(|e| e.provider.clone());
|
||||
let result = if let Some(ref prov) = provider {
|
||||
kernel.registry.update_model_and_provider(
|
||||
id,
|
||||
model_id.to_string(),
|
||||
prov.clone(),
|
||||
)
|
||||
} else {
|
||||
kernel.registry.update_model(id, model_id.to_string())
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let prov_label = provider.unwrap_or_else(|| {
|
||||
kernel
|
||||
.registry
|
||||
.get(id)
|
||||
.map(|e| e.manifest.model.provider.clone())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
});
|
||||
self.chat.model_label = format!("{prov_label}/{model_id}");
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend::None => {
|
||||
self.chat
|
||||
.push_message(Role::System, "No backend connected.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent resolution helpers ─────────────────────────────────────────────
|
||||
|
||||
fn enter_chat_daemon(&mut self, id: String, name: String) {
|
||||
|
||||
@@ -1364,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1848,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) {
|
||||
@@ -1858,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"),
|
||||
);
|
||||
@@ -1989,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 } => {
|
||||
|
||||
@@ -5,9 +5,18 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Padding, Paragraph};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Model entry for the picker.
|
||||
#[derive(Clone)]
|
||||
pub struct ModelEntry {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub provider: String,
|
||||
pub tier: String,
|
||||
}
|
||||
|
||||
/// Tool call metadata for rich rendering.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolInfo {
|
||||
@@ -68,6 +77,14 @@ pub struct ChatState {
|
||||
pub staged_messages: Vec<String>,
|
||||
/// Accumulates ToolInputDelta text for the current tool call.
|
||||
pub tool_input_buf: String,
|
||||
/// Model picker overlay state.
|
||||
pub show_model_picker: bool,
|
||||
/// Available models for the picker.
|
||||
pub model_picker_models: Vec<ModelEntry>,
|
||||
/// Filter text for model search.
|
||||
pub model_picker_filter: String,
|
||||
/// Selected index in the filtered model list.
|
||||
pub model_picker_idx: usize,
|
||||
}
|
||||
|
||||
pub enum ChatAction {
|
||||
@@ -75,6 +92,10 @@ pub enum ChatAction {
|
||||
SendMessage(String),
|
||||
Back,
|
||||
SlashCommand(String),
|
||||
/// Open the model picker (fetch models first).
|
||||
OpenModelPicker,
|
||||
/// Switch to a specific model by id.
|
||||
SwitchModel(String),
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
@@ -97,6 +118,10 @@ impl ChatState {
|
||||
status_msg: None,
|
||||
staged_messages: Vec::new(),
|
||||
tool_input_buf: String::new(),
|
||||
show_model_picker: false,
|
||||
model_picker_models: Vec::new(),
|
||||
model_picker_filter: String::new(),
|
||||
model_picker_idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +140,9 @@ impl ChatState {
|
||||
self.status_msg = None;
|
||||
self.staged_messages.clear();
|
||||
self.tool_input_buf.clear();
|
||||
self.show_model_picker = false;
|
||||
self.model_picker_filter.clear();
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
|
||||
/// Push a completed message into history.
|
||||
@@ -205,11 +233,81 @@ impl ChatState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return filtered models based on the current picker filter.
|
||||
pub fn filtered_models(&self) -> Vec<&ModelEntry> {
|
||||
if self.model_picker_filter.is_empty() {
|
||||
return self.model_picker_models.iter().collect();
|
||||
}
|
||||
let f = self.model_picker_filter.to_lowercase();
|
||||
self.model_picker_models
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.id.to_lowercase().contains(&f)
|
||||
|| m.display_name.to_lowercase().contains(&f)
|
||||
|| m.provider.to_lowercase().contains(&f)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> ChatAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.show_model_picker {
|
||||
self.show_model_picker = false;
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
return ChatAction::Back;
|
||||
}
|
||||
|
||||
// Ctrl+M: toggle model picker
|
||||
if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.is_streaming {
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
if self.show_model_picker {
|
||||
self.show_model_picker = false;
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
return ChatAction::OpenModelPicker;
|
||||
}
|
||||
|
||||
// Model picker mode: intercept all keys
|
||||
if self.show_model_picker {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_model_picker = false;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
self.model_picker_idx = self.model_picker_idx.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let max = self.filtered_models().len().saturating_sub(1);
|
||||
if self.model_picker_idx < max {
|
||||
self.model_picker_idx += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let filtered = self.filtered_models();
|
||||
if let Some(entry) = filtered.get(self.model_picker_idx) {
|
||||
let model_id = entry.id.clone();
|
||||
self.show_model_picker = false;
|
||||
self.model_picker_filter.clear();
|
||||
self.model_picker_idx = 0;
|
||||
return ChatAction::SwitchModel(model_id);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.model_picker_filter.pop();
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.model_picker_filter.push(c);
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
|
||||
// When streaming, allow typing + staging messages, scrolling, and Esc
|
||||
if self.is_streaming {
|
||||
match key.code {
|
||||
@@ -361,13 +459,136 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut ChatState) {
|
||||
f.render_widget(Paragraph::new(input_line), chunks[2]);
|
||||
|
||||
// ── Hints ────────────────────────────────────────────────────────────────
|
||||
let hints = if state.is_streaming {
|
||||
let hints = if state.show_model_picker {
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] Select [Esc] Close [type] Filter"
|
||||
} else if state.is_streaming {
|
||||
" [Enter] Stage [\u{2191}\u{2193}] Scroll [Esc] Stop"
|
||||
} else {
|
||||
" [Enter] Send [\u{2191}\u{2193}/PgUp/PgDn] Scroll [Esc] Back"
|
||||
" [Enter] Send [Ctrl+M] Models [\u{2191}\u{2193}] Scroll [Esc] Back"
|
||||
};
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(hints, theme::hint_style())]));
|
||||
f.render_widget(hints, chunks[3]);
|
||||
|
||||
// ── Model picker overlay ────────────────────────────────────────────────
|
||||
if state.show_model_picker {
|
||||
draw_model_picker(f, inner, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
let filtered = state.filtered_models();
|
||||
|
||||
// Center a popup — width ~50 cols, height capped at area
|
||||
if area.height < 6 || area.width < 20 {
|
||||
return; // Too small to show picker
|
||||
}
|
||||
let popup_w = area.width.clamp(30, 54);
|
||||
let popup_h = (filtered.len() as u16 + 4)
|
||||
.clamp(5, area.height.saturating_sub(2));
|
||||
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
|
||||
let popup_area = Rect::new(x, y, popup_w, popup_h);
|
||||
|
||||
// Clear background
|
||||
f.render_widget(Clear, popup_area);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Switch Model ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
f.render_widget(block, popup_area);
|
||||
|
||||
if inner.height < 2 || inner.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout: search bar | model list
|
||||
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
|
||||
|
||||
// Search bar
|
||||
let search_line = Line::from(vec![
|
||||
Span::styled("/ ", theme::dim_style()),
|
||||
Span::raw(&state.model_picker_filter),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(search_line), chunks[0]);
|
||||
|
||||
// Model list
|
||||
let visible_h = chunks[1].height as usize;
|
||||
let total = filtered.len();
|
||||
|
||||
if total == 0 {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" No models match",
|
||||
theme::dim_style(),
|
||||
)])),
|
||||
chunks[1],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll window: keep selected item visible
|
||||
let scroll_start = if state.model_picker_idx >= visible_h {
|
||||
state.model_picker_idx - visible_h + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let max_name = (chunks[1].width as usize).saturating_sub(14);
|
||||
for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) {
|
||||
let selected = i == state.model_picker_idx;
|
||||
let indicator = if selected { "\u{25b6} " } else { " " };
|
||||
|
||||
let name = if entry.display_name.is_empty() {
|
||||
&entry.id
|
||||
} else {
|
||||
&entry.display_name
|
||||
};
|
||||
let name_display = if name.len() > max_name && max_name > 1 {
|
||||
let truncated = openfang_types::truncate_str(name, max_name.saturating_sub(1));
|
||||
format!("{truncated}\u{2026}")
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
let tier_style = match entry.tier.to_lowercase().as_str() {
|
||||
"frontier" => Style::default().fg(theme::PURPLE),
|
||||
"smart" => Style::default().fg(theme::BLUE),
|
||||
"balanced" => Style::default().fg(theme::GREEN),
|
||||
"fast" => Style::default().fg(theme::YELLOW),
|
||||
_ => theme::dim_style(),
|
||||
};
|
||||
|
||||
let bg = if selected {
|
||||
Style::default()
|
||||
.fg(theme::TEXT_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme::TEXT_SECONDARY)
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(indicator, Style::default().fg(theme::ACCENT)),
|
||||
Span::styled(name_display, bg),
|
||||
Span::raw(" "),
|
||||
Span::styled(entry.tier.to_lowercase(), tier_style),
|
||||
]));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), chunks[1]);
|
||||
}
|
||||
|
||||
fn draw_messages(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
|
||||
@@ -72,7 +72,7 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
name: "openrouter",
|
||||
display: "OpenRouter",
|
||||
env_var: "OPENROUTER_API_KEY",
|
||||
default_model: "openrouter/auto",
|
||||
default_model: "openrouter/google/gemini-2.5-flash",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
@@ -100,6 +100,102 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "xai",
|
||||
display: "xAI (Grok)",
|
||||
env_var: "XAI_API_KEY",
|
||||
default_model: "grok-4-0709",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "perplexity",
|
||||
display: "Perplexity",
|
||||
env_var: "PERPLEXITY_API_KEY",
|
||||
default_model: "sonar-pro",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "cohere",
|
||||
display: "Cohere",
|
||||
env_var: "COHERE_API_KEY",
|
||||
default_model: "command-a-03-2025",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "cerebras",
|
||||
display: "Cerebras",
|
||||
env_var: "CEREBRAS_API_KEY",
|
||||
default_model: "llama-4-scout-17b-16e-instruct",
|
||||
needs_key: true,
|
||||
hint: "fast inference",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "sambanova",
|
||||
display: "SambaNova",
|
||||
env_var: "SAMBANOVA_API_KEY",
|
||||
default_model: "DeepSeek-R1",
|
||||
needs_key: true,
|
||||
hint: "fast inference",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "qwen",
|
||||
display: "Qwen (Alibaba)",
|
||||
env_var: "QWEN_API_KEY",
|
||||
default_model: "qwen-plus",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "huggingface",
|
||||
display: "Hugging Face",
|
||||
env_var: "HUGGINGFACE_API_KEY",
|
||||
default_model: "meta-llama/Llama-3.3-70B-Instruct",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "github-copilot",
|
||||
display: "GitHub Copilot",
|
||||
env_var: "GITHUB_TOKEN",
|
||||
default_model: "gpt-4o",
|
||||
needs_key: true,
|
||||
hint: "via PAT",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "replicate",
|
||||
display: "Replicate",
|
||||
env_var: "REPLICATE_API_KEY",
|
||||
default_model: "meta/meta-llama-3-70b-instruct",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "venice",
|
||||
display: "Venice.ai",
|
||||
env_var: "VENICE_API_KEY",
|
||||
default_model: "venice-uncensored",
|
||||
needs_key: true,
|
||||
hint: "uncensored",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ai21",
|
||||
display: "AI21",
|
||||
env_var: "AI21_API_KEY",
|
||||
default_model: "jamba-1.5-large",
|
||||
needs_key: true,
|
||||
hint: "",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
display: "Claude Code",
|
||||
env_var: "",
|
||||
default_model: "claude-code/sonnet",
|
||||
needs_key: false,
|
||||
hint: "no API key",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ollama",
|
||||
display: "Ollama",
|
||||
@@ -116,6 +212,14 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
needs_key: false,
|
||||
hint: "local",
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "vllm",
|
||||
display: "vLLM",
|
||||
env_var: "VLLM_API_KEY",
|
||||
default_model: "local-model",
|
||||
needs_key: false,
|
||||
hint: "local",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Public result type ─────────────────────────────────────────────────────
|
||||
@@ -287,15 +391,23 @@ impl State {
|
||||
self.provider_order.clear();
|
||||
let gemini_via_google = std::env::var("GOOGLE_API_KEY").is_ok();
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
let detected =
|
||||
std::env::var(p.env_var).is_ok() || (p.name == "gemini" && gemini_via_google);
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|
||||
|| (p.name == "gemini" && gemini_via_google)
|
||||
};
|
||||
if detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
let detected =
|
||||
std::env::var(p.env_var).is_ok() || (p.name == "gemini" && gemini_via_google);
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|
||||
|| (p.name == "gemini" && gemini_via_google)
|
||||
};
|
||||
if !detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
@@ -334,7 +446,10 @@ impl State {
|
||||
|
||||
fn is_provider_detected(&self, prov_idx: usize) -> bool {
|
||||
let p = &PROVIDERS[prov_idx];
|
||||
std::env::var(p.env_var).is_ok()
|
||||
if p.name == "claude-code" {
|
||||
return openfang_runtime::drivers::claude_code::claude_code_available();
|
||||
}
|
||||
(!p.env_var.is_empty() && std::env::var(p.env_var).is_ok())
|
||||
|| (p.name == "gemini" && std::env::var("GOOGLE_API_KEY").is_ok())
|
||||
}
|
||||
|
||||
@@ -468,6 +583,13 @@ fn tier_label(tier: ModelTier) -> &'static str {
|
||||
// ── Entry point ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn run() -> InitResult {
|
||||
// Guard against non-TTY environments (Docker, piped, CI/CD)
|
||||
if !std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
|| !std::io::IsTerminal::is_terminal(&std::io::stdout())
|
||||
{
|
||||
return InitResult::Cancelled;
|
||||
}
|
||||
|
||||
let original_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
ratatui::restore();
|
||||
@@ -825,8 +947,11 @@ fn handle_migration_key(
|
||||
if yes {
|
||||
state.migration_phase = MigrationPhase::Running;
|
||||
let source_dir = state.openclaw_path.clone().unwrap_or_default();
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
let target_dir = home.join(".openfang");
|
||||
let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
PathBuf::from(h)
|
||||
} else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang")
|
||||
};
|
||||
let tx = migrate_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let options = openfang_migrate::MigrateOptions {
|
||||
@@ -945,15 +1070,17 @@ fn save_config(state: &mut State) {
|
||||
}
|
||||
};
|
||||
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
state.save_error = "Could not determine home directory".to_string();
|
||||
return;
|
||||
let openfang_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
PathBuf::from(h)
|
||||
} else {
|
||||
match dirs::home_dir() {
|
||||
Some(h) => h.join(".openfang"),
|
||||
None => {
|
||||
state.save_error = "Could not determine home directory".to_string();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let openfang_dir = home.join(".openfang");
|
||||
let _ = std::fs::create_dir_all(openfang_dir.join("agents"));
|
||||
let _ = std::fs::create_dir_all(openfang_dir.join("data"));
|
||||
crate::restrict_dir_permissions(&openfang_dir);
|
||||
@@ -983,6 +1110,12 @@ complex_threshold = 500
|
||||
};
|
||||
|
||||
let config_path = openfang_dir.join("config.toml");
|
||||
let api_key_line = if p.env_var.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("api_key_env = \"{}\"", p.env_var)
|
||||
};
|
||||
|
||||
let config = format!(
|
||||
r#"# OpenFang Agent OS configuration
|
||||
# See https://github.com/RightNow-AI/openfang for documentation
|
||||
@@ -992,13 +1125,12 @@ api_listen = "127.0.0.1:4200"
|
||||
[default_model]
|
||||
provider = "{provider}"
|
||||
model = "{model}"
|
||||
api_key_env = "{env_var}"
|
||||
{api_key_line}
|
||||
|
||||
[memory]
|
||||
decay_rate = 0.05
|
||||
{routing_section}"#,
|
||||
provider = p.name,
|
||||
env_var = p.env_var,
|
||||
);
|
||||
|
||||
match std::fs::write(&config_path, &config) {
|
||||
@@ -1595,7 +1727,13 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut State) {
|
||||
Span::styled(" ", Style::default())
|
||||
};
|
||||
let name_span = Span::raw(format!("{:<14}", p.display));
|
||||
let hint_text = if detected {
|
||||
let hint_text = if p.name == "claude-code" {
|
||||
if detected {
|
||||
"CLI detected".to_string()
|
||||
} else {
|
||||
"no API key needed".to_string()
|
||||
}
|
||||
} else if detected {
|
||||
format!("{} detected", p.env_var)
|
||||
} else if !p.needs_key {
|
||||
"local, no key needed".to_string()
|
||||
@@ -1913,11 +2051,7 @@ fn draw_routing_pick(f: &mut Frame, area: Rect, state: &mut State, tier: usize)
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or(&state.routing_models[t]);
|
||||
let display = if short.len() > 14 {
|
||||
&short[..14]
|
||||
} else {
|
||||
short
|
||||
};
|
||||
let display = openfang_types::truncate_str(short, 14);
|
||||
summary_spans.push(Span::styled(
|
||||
format!("{name}:{display}"),
|
||||
Style::default().fg(*c),
|
||||
|
||||
@@ -317,7 +317,10 @@ fn draw_agent_select(f: &mut Frame, area: Rect, state: &mut MemoryState) {
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let id_short = if a.id.len() > 12 {
|
||||
format!("{}\u{2026}", &a.id[..12])
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&a.id, 12)
|
||||
)
|
||||
} else {
|
||||
a.id.clone()
|
||||
};
|
||||
@@ -405,7 +408,10 @@ fn draw_kv_browser(f: &mut Frame, area: Rect, state: &mut MemoryState) {
|
||||
.iter()
|
||||
.map(|kv| {
|
||||
let val_display = if kv.value.len() > 40 {
|
||||
format!("{}\u{2026}", &kv.value[..39])
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&kv.value, 39)
|
||||
)
|
||||
} else {
|
||||
kv.value.clone()
|
||||
};
|
||||
|
||||
@@ -149,7 +149,10 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut PeersState) {
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let id_short = if p.node_id.len() > 12 {
|
||||
format!("{}\u{2026}", &p.node_id[..12])
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&p.node_id, 12)
|
||||
)
|
||||
} else {
|
||||
p.node_id.clone()
|
||||
};
|
||||
|
||||
@@ -251,7 +251,10 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut SessionsState) {
|
||||
.map(|&idx| {
|
||||
let s = &state.sessions[idx];
|
||||
let id_short = if s.id.len() > 12 {
|
||||
format!("{}\u{2026}", &s.id[..12])
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(&s.id, 12)
|
||||
)
|
||||
} else {
|
||||
s.id.clone()
|
||||
};
|
||||
|
||||
@@ -40,7 +40,7 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
ProviderInfo {
|
||||
name: "openrouter",
|
||||
env_var: "OPENROUTER_API_KEY",
|
||||
default_model: "anthropic/claude-sonnet-4-20250514",
|
||||
default_model: "google/gemini-2.5-flash",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
@@ -67,6 +67,72 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
default_model: "accounts/fireworks/models/llama-v3p3-70b-instruct",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "gemini",
|
||||
env_var: "GEMINI_API_KEY",
|
||||
default_model: "gemini-2.5-flash",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "xai",
|
||||
env_var: "XAI_API_KEY",
|
||||
default_model: "grok-4-0709",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "qwen",
|
||||
env_var: "DASHSCOPE_API_KEY",
|
||||
default_model: "qwen-plus",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "perplexity",
|
||||
env_var: "PERPLEXITY_API_KEY",
|
||||
default_model: "sonar-pro",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "cohere",
|
||||
env_var: "CO_API_KEY",
|
||||
default_model: "command-a",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "cerebras",
|
||||
env_var: "CEREBRAS_API_KEY",
|
||||
default_model: "llama-3.3-70b",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "sambanova",
|
||||
env_var: "SAMBANOVA_API_KEY",
|
||||
default_model: "Meta-Llama-3.3-70B-Instruct",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "moonshot",
|
||||
env_var: "MOONSHOT_API_KEY",
|
||||
default_model: "moonshot-v1-128k",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "zhipu",
|
||||
env_var: "ZHIPU_API_KEY",
|
||||
default_model: "glm-4-plus",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "zhipu_coding",
|
||||
env_var: "ZHIPU_API_KEY",
|
||||
default_model: "codegeex-4",
|
||||
needs_key: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "claude-code",
|
||||
env_var: "",
|
||||
default_model: "claude-code/sonnet",
|
||||
needs_key: false,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "ollama",
|
||||
env_var: "OLLAMA_API_KEY",
|
||||
@@ -89,11 +155,15 @@ const PROVIDERS: &[ProviderInfo] = &[
|
||||
|
||||
/// Check if first-run setup is needed.
|
||||
pub fn needs_setup() -> bool {
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => return true,
|
||||
let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
std::path::PathBuf::from(h)
|
||||
} else {
|
||||
match dirs::home_dir() {
|
||||
Some(h) => h.join(".openfang"),
|
||||
None => return true,
|
||||
}
|
||||
};
|
||||
!home.join(".openfang").join("config.toml").exists()
|
||||
!of_home.join("config.toml").exists()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -151,13 +221,23 @@ impl WizardState {
|
||||
self.provider_order.clear();
|
||||
// Detected providers first
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
if std::env::var(p.env_var).is_ok() {
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
!p.env_var.is_empty() && std::env::var(p.env_var).is_ok()
|
||||
};
|
||||
if detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
// Then the rest
|
||||
for (i, p) in PROVIDERS.iter().enumerate() {
|
||||
if std::env::var(p.env_var).is_err() {
|
||||
let detected = if p.name == "claude-code" {
|
||||
openfang_runtime::drivers::claude_code::claude_code_available()
|
||||
} else {
|
||||
!p.env_var.is_empty() && std::env::var(p.env_var).is_ok()
|
||||
};
|
||||
if !detected {
|
||||
self.provider_order.push(i);
|
||||
}
|
||||
}
|
||||
@@ -294,22 +374,26 @@ impl WizardState {
|
||||
}
|
||||
};
|
||||
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
self.status_msg = "Could not determine home directory".to_string();
|
||||
self.step = WizardStep::Done;
|
||||
return;
|
||||
let openfang_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
std::path::PathBuf::from(h)
|
||||
} else {
|
||||
match dirs::home_dir() {
|
||||
Some(h) => h.join(".openfang"),
|
||||
None => {
|
||||
self.status_msg = "Could not determine home directory".to_string();
|
||||
self.step = WizardStep::Done;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let openfang_dir = home.join(".openfang");
|
||||
let _ = std::fs::create_dir_all(openfang_dir.join("agents"));
|
||||
let _ = std::fs::create_dir_all(openfang_dir.join("data"));
|
||||
crate::restrict_dir_permissions(&openfang_dir);
|
||||
|
||||
let api_key_line = if !self.api_key_input.is_empty() {
|
||||
format!("api_key = \"{}\"", self.api_key_input)
|
||||
} else if p.env_var.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("api_key_env = \"{}\"", p.env_var)
|
||||
};
|
||||
@@ -440,9 +524,15 @@ fn draw_provider(f: &mut Frame, area: Rect, state: &mut WizardState) {
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
let p = &PROVIDERS[idx];
|
||||
let hint = if !p.needs_key {
|
||||
let hint = if p.name == "claude-code" {
|
||||
if openfang_runtime::drivers::claude_code::claude_code_available() {
|
||||
"CLI detected".to_string()
|
||||
} else {
|
||||
"no API key needed".to_string()
|
||||
}
|
||||
} else if !p.needs_key {
|
||||
"local, no key needed".to_string()
|
||||
} else if std::env::var(p.env_var).is_ok() {
|
||||
} else if !p.env_var.is_empty() && std::env::var(p.env_var).is_ok() {
|
||||
format!("{} detected", p.env_var)
|
||||
} else {
|
||||
format!("requires {}", p.env_var)
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["cloud", "amazon", "infrastructure", "s3", "ec2", "lambda", "devops"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-aws"]
|
||||
args = ["-y", "@aws-mcp/server-aws"]
|
||||
|
||||
[[required_env]]
|
||||
name = "AWS_ACCESS_KEY_ID"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["cloud", "microsoft", "infrastructure", "azure", "devops", "enterprise"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-azure"]
|
||||
args = ["-y", "@azure/mcp@latest", "server", "start"]
|
||||
|
||||
[[required_env]]
|
||||
name = "AZURE_SUBSCRIPTION_ID"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["git", "vcs", "code", "pull-requests", "ci", "atlassian"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-bitbucket"]
|
||||
args = ["-y", "@atlassian-mcp-server/bitbucket"]
|
||||
|
||||
[[required_env]]
|
||||
name = "BITBUCKET_USERNAME"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["search", "web", "brave", "api", "information-retrieval"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-brave-search"]
|
||||
args = ["-y", "@modelcontextprotocol/server-brave-search"]
|
||||
|
||||
[[required_env]]
|
||||
name = "BRAVE_API_KEY"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["chat", "messaging", "community", "gaming", "voice"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-discord"]
|
||||
args = ["-y", "mcp-discord"]
|
||||
|
||||
[[required_env]]
|
||||
name = "DISCORD_BOT_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["files", "storage", "cloud-storage", "sync", "sharing"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-dropbox"]
|
||||
args = ["-y", "@microagents/mcp-server-dropbox"]
|
||||
|
||||
[[required_env]]
|
||||
name = "DROPBOX_ACCESS_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["search", "database", "indexing", "analytics", "full-text"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-elasticsearch"]
|
||||
args = ["-y", "@elastic/mcp-server-elasticsearch"]
|
||||
|
||||
[[required_env]]
|
||||
name = "ELASTICSEARCH_URL"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["search", "web", "ai", "neural", "semantic", "information-retrieval"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-exa"]
|
||||
args = ["-y", "exa-mcp-server"]
|
||||
|
||||
[[required_env]]
|
||||
name = "EXA_API_KEY"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["cloud", "google", "infrastructure", "gce", "gcs", "bigquery", "devops"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-gcp"]
|
||||
args = ["-y", "@google-cloud/gcloud-mcp"]
|
||||
|
||||
[[required_env]]
|
||||
name = "GOOGLE_APPLICATION_CREDENTIALS"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["git", "vcs", "code", "issues", "pull-requests", "ci"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-github"]
|
||||
args = ["-y", "@modelcontextprotocol/server-github"]
|
||||
|
||||
[[required_env]]
|
||||
name = "GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
|
||||
@@ -8,7 +8,7 @@ tags = ["git", "vcs", "code", "merge-requests", "ci", "devops"]
|
||||
[transport]
|
||||
type = "stdio"
|
||||
command = "npx"
|
||||
args = ["@modelcontextprotocol/server-gitlab"]
|
||||
args = ["-y", "@modelcontextprotocol/server-gitlab"]
|
||||
|
||||
[[required_env]]
|
||||
name = "GITLAB_PERSONAL_ACCESS_TOKEN"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user