mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
772cbdbe38 | ||
|
|
b2e2b1a038 | ||
|
|
d237ecf161 | ||
|
|
4a3d570155 | ||
|
|
ebcdc17c13 | ||
|
|
45e06b9bad | ||
|
|
c6b46ccbe1 | ||
|
|
9fc0fe71bf | ||
|
|
06df0795c8 | ||
|
|
eafeb6a012 | ||
|
|
05431509be | ||
|
|
9d3136e512 | ||
|
|
60566f22fb | ||
|
|
50440e4047 | ||
|
|
f45268aedc | ||
|
|
cc54e14114 | ||
|
|
1037ef768d | ||
|
|
c3dcf02e3c | ||
|
|
b157e3c7e6 | ||
|
|
74ac992420 | ||
|
|
53e1b31777 | ||
|
|
603a94e560 | ||
|
|
fac4ad33e5 | ||
|
|
fe96cd1004 | ||
|
|
7c85308cf6 | ||
|
|
a4a83b1699 | ||
|
|
8942d8c2b6 | ||
|
|
260dd7a125 | ||
|
|
294f0e7af8 | ||
|
|
444d82e4d6 | ||
|
|
62e6e0f088 | ||
|
|
d3385f2cdc | ||
|
|
516f163dfb | ||
|
|
a54bb1cd4f | ||
|
|
6f3c4e7778 | ||
|
|
73ad49a3a1 | ||
|
|
7ec0e024c7 | ||
|
|
7c81c187c4 | ||
|
|
7ae80b1b9f |
@@ -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,9 @@ 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: codesign --force --sign - target/${{ matrix.target }}/release/openfang
|
||||
- name: Package (Unix)
|
||||
if: matrix.archive == 'tar.gz'
|
||||
run: |
|
||||
|
||||
Generated
+78
-20
@@ -2506,6 +2506,15 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html-escape"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
|
||||
dependencies = [
|
||||
"utf8-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@@ -2619,7 +2628,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"socket2 0.6.2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3128,7 +3137,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"socket2 0.6.2",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"url",
|
||||
@@ -3866,7 +3875,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3888,6 +3897,7 @@ dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"socket2 0.5.10",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
@@ -3902,7 +3912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3912,6 +3922,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
"html-escape",
|
||||
"imap",
|
||||
"lettre",
|
||||
"mailparse",
|
||||
@@ -3933,7 +3944,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3960,7 +3971,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3986,7 +3997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4014,7 +4025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4031,7 +4042,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4067,7 +4078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4086,7 +4097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4105,7 +4116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4128,6 +4139,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-test",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"wasmtime",
|
||||
@@ -4136,7 +4148,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4154,11 +4166,12 @@ dependencies = [
|
||||
"tracing",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4177,7 +4190,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4868,7 +4881,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"socket2 0.6.2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4905,7 +4918,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"socket2 0.6.2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -5965,6 +5978,16 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.2"
|
||||
@@ -6552,7 +6575,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6818,7 +6841,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"socket2 0.6.2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -7364,6 +7387,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-width"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -8789,7 +8818,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.1"
|
||||
version = "0.3.26"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
@@ -8969,6 +8998,23 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
@@ -8987,6 +9033,18 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
|
||||
+10
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.2"
|
||||
version = "0.3.27"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -119,9 +119,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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -934,30 +934,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) {
|
||||
@@ -1065,6 +1041,7 @@ 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.intents,
|
||||
));
|
||||
adapters.push((adapter, dc_config.default_agent.clone()));
|
||||
@@ -1545,12 +1522,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 +1621,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));
|
||||
|
||||
@@ -52,36 +52,30 @@ pub async fn auth(
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
// If no API key configured, restrict to loopback addresses only.
|
||||
// If no API key configured, skip authentication entirely (open access).
|
||||
if api_key.is_empty() {
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// 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(true); // default true for unix sockets / tests
|
||||
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 == "/"
|
||||
|| path == "/logo.png"
|
||||
|| path == "/favicon.ico"
|
||||
|| path == "/.well-known/agent.json"
|
||||
|| path.starts_with("/a2a/")
|
||||
|| path == "/api/health"
|
||||
|| path == "/api/health/detail"
|
||||
|| path == "/api/status"
|
||||
@@ -103,26 +97,38 @@ pub async fn auth(
|
||||
|| 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/")
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Check Authorization: Bearer <token> header
|
||||
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
|
||||
let bearer_token = request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "));
|
||||
|
||||
let api_token = bearer_token.or_else(|| {
|
||||
request
|
||||
.headers()
|
||||
.get("x-api-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
});
|
||||
|
||||
// SECURITY: Use constant-time comparison to prevent timing attacks.
|
||||
let header_auth = bearer_token.map(|token| {
|
||||
let header_auth = api_token.map(|token| {
|
||||
use subtle::ConstantTimeEq;
|
||||
if token.len() != api_key.len() {
|
||||
return false;
|
||||
@@ -190,6 +196,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",
|
||||
|
||||
+1646
-221
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,7 @@ 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(),
|
||||
});
|
||||
|
||||
// CORS: allow localhost origins by default. If API key is set, the API
|
||||
@@ -125,7 +126,7 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}",
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent),
|
||||
axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/mode",
|
||||
@@ -156,6 +157,10 @@ pub async fn build_router(
|
||||
"/api/agents/{id}/session/reset",
|
||||
axum::routing::post(routes::reset_session),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/history",
|
||||
axum::routing::delete(routes::clear_agent_history),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/session/compact",
|
||||
axum::routing::post(routes::compact_session),
|
||||
@@ -168,6 +173,10 @@ pub async fn build_router(
|
||||
"/api/agents/{id}/model",
|
||||
axum::routing::put(routes::set_model),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/tools",
|
||||
axum::routing::get(routes::get_agent_tools).put(routes::set_agent_tools),
|
||||
)
|
||||
.route(
|
||||
"/api/agents/{id}/skills",
|
||||
axum::routing::get(routes::get_agent_skills).put(routes::set_agent_skills),
|
||||
@@ -312,12 +321,20 @@ pub async fn build_router(
|
||||
"/api/clawhub/skill/{slug}",
|
||||
axum::routing::get(routes::clawhub_skill_detail),
|
||||
)
|
||||
.route(
|
||||
"/api/clawhub/skill/{slug}/code",
|
||||
axum::routing::get(routes::clawhub_skill_code),
|
||||
)
|
||||
.route(
|
||||
"/api/clawhub/install",
|
||||
axum::routing::post(routes::clawhub_install),
|
||||
)
|
||||
// Hands endpoints
|
||||
.route("/api/hands", axum::routing::get(routes::list_hands))
|
||||
.route(
|
||||
"/api/hands/install",
|
||||
axum::routing::post(routes::install_hand),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/active",
|
||||
axum::routing::get(routes::list_active_hands),
|
||||
@@ -335,6 +352,11 @@ pub async fn build_router(
|
||||
"/api/hands/{hand_id}/install-deps",
|
||||
axum::routing::post(routes::install_hand_deps),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/{hand_id}/settings",
|
||||
axum::routing::get(routes::get_hand_settings)
|
||||
.put(routes::update_hand_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/hands/instances/{id}/pause",
|
||||
axum::routing::post(routes::pause_hand),
|
||||
@@ -377,6 +399,27 @@ pub async fn build_router(
|
||||
"/api/network/status",
|
||||
axum::routing::get(routes::network_status),
|
||||
)
|
||||
// Agent communication (Comms) endpoints
|
||||
.route(
|
||||
"/api/comms/topology",
|
||||
axum::routing::get(routes::comms_topology),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/events",
|
||||
axum::routing::get(routes::comms_events),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/events/stream",
|
||||
axum::routing::get(routes::comms_events_stream),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/send",
|
||||
axum::routing::post(routes::comms_send),
|
||||
)
|
||||
.route(
|
||||
"/api/comms/task",
|
||||
axum::routing::post(routes::comms_task),
|
||||
)
|
||||
// Tools endpoint
|
||||
.route("/api/tools", axum::routing::get(routes::list_tools))
|
||||
// Config endpoints
|
||||
@@ -421,7 +464,8 @@ pub async fn build_router(
|
||||
)
|
||||
.route(
|
||||
"/api/budget/agents/{id}",
|
||||
axum::routing::get(routes::agent_budget_status),
|
||||
axum::routing::get(routes::agent_budget_status)
|
||||
.put(routes::update_agent_budget),
|
||||
)
|
||||
// Session endpoints
|
||||
.route("/api/sessions", axum::routing::get(routes::list_sessions))
|
||||
@@ -450,6 +494,14 @@ pub async fn build_router(
|
||||
"/api/models/aliases",
|
||||
axum::routing::get(routes::list_aliases),
|
||||
)
|
||||
.route(
|
||||
"/api/models/custom",
|
||||
axum::routing::post(routes::add_custom_model),
|
||||
)
|
||||
.route(
|
||||
"/api/models/custom/{*id}",
|
||||
axum::routing::delete(routes::remove_custom_model),
|
||||
)
|
||||
.route("/api/models/{*id}", axum::routing::get(routes::get_model))
|
||||
.route("/api/providers", axum::routing::get(routes::list_providers))
|
||||
// Copilot OAuth (must be before parametric {name} routes)
|
||||
@@ -688,7 +740,8 @@ pub async fn run_daemon(
|
||||
if info_path.exists() {
|
||||
if let Ok(existing) = std::fs::read_to_string(info_path) {
|
||||
if let Ok(info) = serde_json::from_str::<DaemonInfo>(&existing) {
|
||||
if is_process_alive(info.pid) {
|
||||
// PID alive AND the health endpoint responds → truly running
|
||||
if is_process_alive(info.pid) && is_daemon_responding(&info.listen_addr) {
|
||||
return Err(format!(
|
||||
"Another daemon (PID {}) is already running at {}",
|
||||
info.pid, info.listen_addr
|
||||
@@ -697,7 +750,8 @@ pub async fn run_daemon(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stale PID file, remove it
|
||||
// Stale PID file (process dead or different process reused PID), remove it
|
||||
info!("Removing stale daemon info file");
|
||||
let _ = std::fs::remove_file(info_path);
|
||||
}
|
||||
|
||||
@@ -719,7 +773,22 @@ pub async fn run_daemon(
|
||||
info!("WebChat UI available at http://{addr}/",);
|
||||
info!("WebSocket endpoint: ws://{addr}/api/agents/{{id}}/ws",);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
// Use SO_REUSEADDR to allow binding immediately after reboot (avoids TIME_WAIT).
|
||||
let socket = socket2::Socket::new(
|
||||
if addr.is_ipv4() {
|
||||
socket2::Domain::IPV4
|
||||
} else {
|
||||
socket2::Domain::IPV6
|
||||
},
|
||||
socket2::Type::STREAM,
|
||||
None,
|
||||
)?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
socket.bind(&addr.into())?;
|
||||
socket.listen(1024)?;
|
||||
let listener =
|
||||
tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
|
||||
|
||||
// Run server with graceful shutdown.
|
||||
// SECURITY: `into_make_service_with_connect_info` injects the peer
|
||||
@@ -839,3 +908,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,9 +102,15 @@ impl StreamChunker {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Forced break at max_chunk_chars
|
||||
// Priority 4: Forced break at max_chunk_chars (char-boundary safe)
|
||||
if self.buffer.len() >= self.max_chunk_chars {
|
||||
let break_at = self.max_chunk_chars;
|
||||
let mut break_at = self.max_chunk_chars;
|
||||
while break_at > 0 && !self.buffer.is_char_boundary(break_at) {
|
||||
break_at -= 1;
|
||||
}
|
||||
if break_at == 0 {
|
||||
break_at = self.buffer.len();
|
||||
}
|
||||
let chunk = self.buffer[..break_at].to_string();
|
||||
self.buffer = self.buffer[break_at..].to_string();
|
||||
return Some(chunk);
|
||||
|
||||
@@ -123,6 +123,10 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/js/pages/wizard.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/approvals.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/comms.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/runtime.js"),
|
||||
"\n</script>\n",
|
||||
// Alpine.js MUST be last — it processes x-data and fires alpine:init
|
||||
"<script>\n",
|
||||
|
||||
@@ -621,8 +621,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 +634,7 @@ async fn handle_text_message(
|
||||
result.iterations,
|
||||
)
|
||||
} else {
|
||||
result.response
|
||||
cleaned_response
|
||||
};
|
||||
|
||||
// Estimate context pressure from last call
|
||||
@@ -796,7 +800,12 @@ 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}")})
|
||||
let msg = if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
format!("Model switched to: {} (provider: {})", entry.manifest.model.model, entry.manifest.model.provider)
|
||||
} else {
|
||||
format!("Model switched to: {args}")
|
||||
};
|
||||
serde_json::json!({"type": "command_result", "command": cmd, "message": msg})
|
||||
}
|
||||
Err(e) => {
|
||||
serde_json::json!({"type": "error", "content": format!("Model switch failed: {e}")})
|
||||
@@ -1109,7 +1118,11 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
|
||||
}
|
||||
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>` to download it, then try again. Use /model to see options.".to_string()
|
||||
} else {
|
||||
"Model unavailable. Use /model to see options or check your provider configuration.".to_string()
|
||||
}
|
||||
}
|
||||
llm_errors::LlmErrorCategory::Format => {
|
||||
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
|
||||
@@ -1147,6 +1160,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1223,4 +1257,18 @@ mod tests {
|
||||
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;
|
||||
@@ -3072,4 +3225,16 @@ mark.search-highlight {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.flex-col { flex-direction: column; }
|
||||
/* Comms page */
|
||||
.comms-topo-tree { padding: 4px 0 4px 8px; }
|
||||
.comms-topo-child { padding: 0 0 0 20px; display: flex; align-items: center; gap: 4px; }
|
||||
.comms-topo-branch { color: var(--text-dim); font-family: var(--font-mono); white-space: pre; }
|
||||
.comms-topo-node { display: flex; align-items: center; gap: 4px; padding: 2px 0; }
|
||||
.comms-event-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 12px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; transition: background var(--transition-fast);
|
||||
}
|
||||
.comms-event-row:hover { background: var(--bg-hover); }
|
||||
.comms-event-time { min-width: 50px; text-align: right; }
|
||||
.comms-event-detail { margin-left: auto; }
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
[data-theme="light"] .sidebar-logo img,
|
||||
[data-theme="light"] .message-avatar img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -96,6 +96,10 @@
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
|
||||
<span class="nav-label">Approvals</span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'comms' }" @click="navigate('comms')" :aria-current="page === 'comms' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
|
||||
<span class="nav-label">Comms</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -152,6 +156,10 @@
|
||||
</div>
|
||||
<template x-if="!collapsed">
|
||||
<div x-transition>
|
||||
<a class="nav-item" :class="{ active: page === 'runtime' }" @click="navigate('runtime')" :aria-current="page === 'runtime' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></span>
|
||||
<span class="nav-label">Runtime</span>
|
||||
</a>
|
||||
<a class="nav-item" :class="{ active: page === 'settings' }" @click="navigate('settings')" :aria-current="page === 'settings' ? 'page' : false">
|
||||
<span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3"/><path d="M1 14h6M9 8h6M17 16h6"/></svg></span>
|
||||
<span class="nav-label">Settings</span>
|
||||
@@ -565,7 +573,7 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="$store.app.toggleFocusMode()" title="Ctrl+Shift+F">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><template x-if="!$store.app.focusMode"><g><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g></template><template x-if="$store.app.focusMode"><g><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></template></svg>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g x-show="!$store.app.focusMode"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></g><g x-show="$store.app.focusMode"><path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/><path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/></g></svg>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" @click="killAgent()">Stop</button>
|
||||
</div>
|
||||
@@ -581,6 +589,14 @@
|
||||
|
||||
<!-- Messages area -->
|
||||
<div class="messages" id="messages" @dragover.prevent="dragOver = true" @dragleave="dragOver = false" @drop.prevent="handleDrop($event); dragOver = false">
|
||||
<!-- Empty state: no agent selected -->
|
||||
<template x-if="!currentAgent">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center;padding:32px;opacity:0.8">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:16px;opacity:0.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
<h3 style="margin:0 0 8px;font-size:16px;font-weight:600">Select an agent to start chatting</h3>
|
||||
<p class="text-dim" style="font-size:13px;max-width:320px">Choose an agent from the sidebar or go to the Agents tab to create a new one.</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Message list -->
|
||||
<template x-if="currentAgent">
|
||||
<div>
|
||||
@@ -692,6 +708,16 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Model autocomplete picker -->
|
||||
<div x-show="showModelPicker && filteredModelPicker.length" class="slash-menu" style="max-height:280px;overflow-y:auto">
|
||||
<div class="text-xs text-dim" style="padding:4px 10px;border-bottom:1px solid var(--border)">Available models — pick one or keep typing</div>
|
||||
<template x-for="(m, idx) in filteredModelPicker" :key="m.id">
|
||||
<div class="slash-menu-item" :class="{ 'slash-active': idx === modelPickerIdx }" @click="pickModel(m.id)" @mouseenter="modelPickerIdx = idx">
|
||||
<span class="font-bold" style="font-size:12px;font-family:var(--font-mono)" x-text="m.id"></span>
|
||||
<span class="text-xs text-dim" x-text="m.provider + (m.display_name && m.display_name !== m.id ? ' · ' + m.display_name : '')"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Input row -->
|
||||
<div class="input-row">
|
||||
<button class="btn btn-ghost btn-sm" @click="$refs.fileInput.click()" title="Attach file" style="padding:6px 8px;flex-shrink:0">
|
||||
@@ -709,10 +735,10 @@
|
||||
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
|
||||
</div>
|
||||
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
|
||||
@keydown.enter.prevent="if(!$event.shiftKey){if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false"
|
||||
@keydown.arrow-up.prevent="if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
@keydown.enter.prevent="if(!$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
|
||||
@keydown.escape="showSlashMenu = false; showModelPicker = false"
|
||||
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
|
||||
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
|
||||
@input="$el.style.height='auto';$el.style.height=Math.min($el.scrollHeight,150)+'px'"
|
||||
x-model="inputText"
|
||||
:class="{ 'streaming-active': sending }"></textarea>
|
||||
@@ -728,9 +754,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>
|
||||
@@ -769,6 +847,9 @@
|
||||
<div class="text-xs text-dim font-mono" style="font-size:11px" x-text="agent.model_name"></div>
|
||||
</div>
|
||||
<span class="badge" :class="'badge-' + agent.state.toLowerCase()" x-text="agent.state" style="font-size:10px"></span>
|
||||
<button class="agent-chip-config-btn" @click.stop="showDetail(agent)" title="Agent settings" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--text-dim);transition:all 0.15s;flex-shrink:0" @mouseenter="$el.style.borderColor='var(--accent)';$el.style.color='var(--accent)';$el.style.background='var(--surface2)'" @mouseleave="$el.style.borderColor='var(--border)';$el.style.color='var(--text-dim)';$el.style.background='transparent'">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -807,7 +888,7 @@
|
||||
<div class="tabs" style="margin-bottom:16px">
|
||||
<div class="tab" :class="{ active: detailTab === 'info' }" @click="detailTab = 'info'">Info</div>
|
||||
<div class="tab" :class="{ active: detailTab === 'files' }" @click="detailTab = 'files'; loadAgentFiles()">Files</div>
|
||||
<div class="tab" :class="{ active: detailTab === 'config' }" @click="detailTab = 'config'">Config</div>
|
||||
<div class="tab" :class="{ active: detailTab === 'config' }" @click="detailTab = 'config'; loadToolFilters()">Config</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Info -->
|
||||
@@ -822,12 +903,59 @@
|
||||
</div>
|
||||
<div class="detail-row" x-show="detailAgent.profile"><span class="detail-label">Profile</span><span class="detail-value" style="text-transform:capitalize" x-text="detailAgent.profile || '-'"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Provider</span><span class="detail-value" x-text="detailAgent.model_provider"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Model</span><span class="detail-value" x-text="detailAgent.model_name"></span></div>
|
||||
<div class="detail-row"><span class="detail-label">Model</span>
|
||||
<template x-if="!editingModel">
|
||||
<span>
|
||||
<span class="detail-value" x-text="detailAgent.model_name"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="margin-left:8px;padding:2px 8px;font-size:11px" @click="editingModel = true; newModelValue = detailAgent.model_provider + '/' + detailAgent.model_name">Change</button>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="editingModel">
|
||||
<span class="flex gap-1" style="align-items:center">
|
||||
<input class="form-input" style="width:240px;font-size:12px" x-model="newModelValue" placeholder="provider/model" @keydown.enter="changeModel()" @keydown.escape="editingModel = false">
|
||||
<button class="btn btn-primary btn-sm" @click="changeModel()" :disabled="modelSaving" style="padding:2px 10px">
|
||||
<span x-show="!modelSaving">Save</span><span x-show="modelSaving">...</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingModel = false" style="padding:2px 8px">Cancel</button>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value" x-text="detailAgent.created_at ? new Date(detailAgent.created_at).toLocaleString() : '-'"></span></div>
|
||||
|
||||
<!-- Fallback Model Chain -->
|
||||
<div class="detail-row" style="align-items:flex-start">
|
||||
<span class="detail-label">Fallbacks</span>
|
||||
<div style="flex:1">
|
||||
<template x-if="detailAgent._fallbacks && detailAgent._fallbacks.length > 0">
|
||||
<div>
|
||||
<template x-for="(fb, idx) in detailAgent._fallbacks" :key="idx">
|
||||
<div class="flex gap-1 items-center" style="margin-bottom:4px">
|
||||
<span class="badge" style="font-size:11px;font-family:var(--font-mono)" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
|
||||
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger)" @click="removeFallback(idx)">×</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!detailAgent._fallbacks || detailAgent._fallbacks.length === 0">
|
||||
<span class="text-dim" style="font-size:12px">None — add a fallback chain</span>
|
||||
</template>
|
||||
<template x-if="!editingFallback">
|
||||
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px;margin-top:4px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
|
||||
</template>
|
||||
<template x-if="editingFallback">
|
||||
<div class="flex gap-1 mt-1" style="align-items:center">
|
||||
<input class="form-input" style="width:220px;font-size:12px" x-model="newFallbackValue" placeholder="provider/model" @keydown.enter="addFallback()" @keydown.escape="editingFallback = false">
|
||||
<button class="btn btn-primary btn-sm" @click="addFallback()" style="padding:2px 10px;font-size:11px">Add</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="editingFallback = false" style="padding:2px 8px;font-size:11px">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" @click="chatWithAgent(detailAgent); showDetailModal = false">Chat</button>
|
||||
<button class="btn btn-ghost" @click="cloneAgent(detailAgent)">Clone</button>
|
||||
<button class="btn btn-ghost" @click="clearHistory(detailAgent)">Clear History</button>
|
||||
<button class="btn btn-danger" @click="killAgent(detailAgent)">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -886,6 +1014,36 @@
|
||||
<button class="btn btn-primary mt-4" @click="saveConfig()" :disabled="configSaving">
|
||||
<span x-show="!configSaving">Save Config</span><span x-show="configSaving">Saving...</span>
|
||||
</button>
|
||||
|
||||
<!-- Tool Filters -->
|
||||
<div class="mt-4" style="border-top:1px solid var(--border);padding-top:16px">
|
||||
<h4 style="margin-bottom:8px;font-size:13px">Tool Filters</h4>
|
||||
<p class="text-xs text-dim" style="margin-bottom:12px">Allowlist: only these tools available (empty = all). Blocklist: these tools excluded.</p>
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px">Allowlist <span class="text-dim" x-text="'(' + toolFilters.tool_allowlist.length + ')'"></span></label>
|
||||
<div class="flex flex-wrap gap-1 mb-1">
|
||||
<template x-for="(t, i) in toolFilters.tool_allowlist" :key="'al-'+i">
|
||||
<span class="badge" style="cursor:pointer" @click="removeAllowTool(t)" :title="'Click to remove ' + t"><span x-text="t"></span> ×</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<input class="form-input" style="font-size:12px;flex:1" x-model="newAllowTool" placeholder="tool name" @keydown.enter="addAllowTool()">
|
||||
<button class="btn btn-ghost btn-sm" @click="addAllowTool()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="font-size:12px">Blocklist <span class="text-dim" x-text="'(' + toolFilters.tool_blocklist.length + ')'"></span></label>
|
||||
<div class="flex flex-wrap gap-1 mb-1">
|
||||
<template x-for="(t, i) in toolFilters.tool_blocklist" :key="'bl-'+i">
|
||||
<span class="badge badge-danger" style="cursor:pointer" @click="removeBlockTool(t)" :title="'Click to remove ' + t"><span x-text="t"></span> ×</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<input class="form-input" style="font-size:12px;flex:1" x-model="newBlockTool" placeholder="tool name" @keydown.enter="addBlockTool()">
|
||||
<button class="btn btn-ghost btn-sm" @click="addBlockTool()">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1288,63 +1446,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>
|
||||
|
||||
@@ -2299,7 +2402,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary btn-block" @click="installFromClawHub(skillDetail.slug)" :disabled="installingSlug === skillDetail.slug || skillDetail.installed || isSkillInstalled(skillDetail.slug)" x-text="skillDetail.installed || isSkillInstalled(skillDetail.slug) ? 'Already Installed' : installingSlug === skillDetail.slug ? 'Installing...' : 'Install from ClawHub'"></button>
|
||||
<button class="btn btn-primary" style="flex:1" @click="installFromClawHub(skillDetail.slug)" :disabled="installingSlug === skillDetail.slug || skillDetail.installed || isSkillInstalled(skillDetail.slug)" x-text="skillDetail.installed || isSkillInstalled(skillDetail.slug) ? 'Already Installed' : installingSlug === skillDetail.slug ? 'Installing...' : 'Install from ClawHub'"></button>
|
||||
<button class="btn btn-ghost" @click="viewSkillCode(skillDetail.slug)" :disabled="skillCodeLoading" x-text="skillCodeLoading ? 'Loading...' : showSkillCode ? 'Hide Code' : 'View Code'"></button>
|
||||
</div>
|
||||
<div x-show="showSkillCode && skillCode" x-transition class="mt-3" style="max-height:300px;overflow:auto;border:1px solid var(--border);border-radius:8px;background:var(--bg-inset)">
|
||||
<div class="flex justify-between items-center" style="padding:6px 12px;border-bottom:1px solid var(--border)">
|
||||
<span class="text-xs text-dim" x-text="skillCodeFilename"></span>
|
||||
</div>
|
||||
<pre style="margin:0;padding:12px;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all" x-text="skillCode"></pre>
|
||||
</div>
|
||||
<div class="text-xs text-dim mt-2 text-center">Skills are security-scanned before installation. Prompt injection and malware patterns are blocked.</div>
|
||||
</div>
|
||||
@@ -2401,7 +2511,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<span style="font-size:1.4rem" x-text="getHandIcon(inst.hand_id)"></span>
|
||||
<div class="card-header" style="margin:0" x-text="inst.agent_name || inst.hand_id"></div>
|
||||
</div>
|
||||
<span class="badge" :class="inst.status === 'Active' ? 'badge-success' : inst.status === 'Paused' ? 'badge-dim' : 'badge-info'" x-text="inst.status"></span>
|
||||
<span class="badge" :class="{ 'badge-success': inst.status === 'Active', 'badge-dim': inst.status === 'Paused', 'badge-warn': inst.status && inst.status.startsWith('Error'), 'badge-info': inst.status === 'Inactive' }" x-text="inst.status"></span>
|
||||
</div>
|
||||
<div class="text-xs text-dim" x-text="'Activated: ' + new Date(inst.activated_at).toLocaleString()"></div>
|
||||
<div class="text-xs text-dim" x-show="inst.agent_id" x-text="'Agent: ' + inst.agent_id"></div>
|
||||
@@ -2428,6 +2538,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<template x-if="inst.status === 'Paused'">
|
||||
<button class="btn btn-ghost btn-sm" @click="resumeHand(inst)">Resume</button>
|
||||
</template>
|
||||
<template x-if="inst.status && inst.status.startsWith('Error')">
|
||||
<span class="text-xs text-dim">Error — deactivate and reactivate</span>
|
||||
</template>
|
||||
<button class="btn btn-danger btn-sm" @click="deactivate(inst)">Deactivate</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2713,7 +2826,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
|
||||
<!-- Text type -->
|
||||
<template x-if="setting.setting_type === 'text'">
|
||||
<input type="text" class="input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
|
||||
<input type="text" class="form-input" x-model="settingsValues[setting.key]" :placeholder="setting.label" style="width:100%">
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2929,6 +3042,30 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Add Custom Provider -->
|
||||
<div class="info-card mt-4" style="border:1px solid var(--border)">
|
||||
<h4 style="margin-top:0">Add Custom Provider</h4>
|
||||
<p class="text-xs text-dim mb-2">Connect any OpenAI-compatible API (vLLM, LiteLLM, LocalAI, etc.)</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
|
||||
<div>
|
||||
<label class="text-xs text-dim">Provider Name</label>
|
||||
<input class="form-input" x-model="customProviderName" placeholder="e.g. my-local-llm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Base URL (required)</label>
|
||||
<input class="form-input" x-model="customProviderUrl" placeholder="http://localhost:8080/v1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="text-xs text-dim">API Key (optional)</label>
|
||||
<input class="form-input" type="password" x-model="customProviderKey" placeholder="sk-... (leave blank if not needed)">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="addCustomProvider()" :disabled="!customProviderName.trim() || !customProviderUrl.trim() || addingCustomProvider">
|
||||
<span x-show="!addingCustomProvider">Add Provider</span>
|
||||
<span x-show="addingCustomProvider" class="spinner" style="width:10px;height:10px;border-width:2px"></span>
|
||||
</button>
|
||||
<span class="text-xs text-dim ml-2" x-text="customProviderStatus"></span>
|
||||
</div>
|
||||
<div class="empty-state" x-show="!providers.length">
|
||||
<h4>No providers found</h4>
|
||||
<p class="hint">Provider information could not be loaded. Check that the API is running.</p>
|
||||
@@ -2958,21 +3095,53 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<option :value="t" x-text="t"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" @click="showCustomModelForm = !showCustomModelForm" x-text="showCustomModelForm ? 'Cancel' : '+ Custom Model'"></button>
|
||||
</div>
|
||||
<!-- Custom model form -->
|
||||
<div x-show="showCustomModelForm" class="info-card mb-4" style="border:1px solid var(--accent,#7c3aed)">
|
||||
<h4 style="margin-top:0">Add Custom Model</h4>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem">
|
||||
<div>
|
||||
<label class="text-xs text-dim">Model ID (required)</label>
|
||||
<input class="form-input" x-model="customModelId" placeholder="e.g. my-org/my-model">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Provider</label>
|
||||
<input class="form-input" x-model="customModelProvider" placeholder="openrouter">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Context Window</label>
|
||||
<input class="form-input" type="number" x-model.number="customModelContext" placeholder="128000">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim">Max Output Tokens</label>
|
||||
<input class="form-input" type="number" x-model.number="customModelMaxOutput" placeholder="8192">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="addCustomModel()" :disabled="!customModelId.trim()">Add Model</button>
|
||||
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
|
||||
<div x-show="!filteredModels.length && !settingsLoading" style="text-align:center;padding:32px 16px">
|
||||
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">🤖</div>
|
||||
<h3 style="margin:0 0 4px;font-size:14px" x-text="models.length ? 'No models match your search' : 'No models available'"></h3>
|
||||
<p class="text-xs text-dim" x-text="models.length ? 'Try a different search term or clear filters.' : 'Configure an LLM provider to see available models.'"></p>
|
||||
<button class="btn btn-ghost btn-sm mt-2" x-show="models.length && (modelSearch || modelProviderFilter)" @click="modelSearch=''; modelProviderFilter=''">Clear Filters</button>
|
||||
</div>
|
||||
<div class="table-wrap" x-show="filteredModels.length">
|
||||
<table>
|
||||
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th></tr></thead>
|
||||
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="m in filteredModels" :key="m.id">
|
||||
<tr>
|
||||
<td class="font-bold" style="font-size:11px" x-text="m.display_name || m.id"></td>
|
||||
<td style="font-size:11px"><div class="font-bold" x-text="m.display_name || m.id"></div><div class="text-xs text-dim" style="font-family:var(--font-mono);opacity:0.7;user-select:all" x-show="m.display_name && m.display_name !== m.id" x-text="m.id"></div></td>
|
||||
<td class="text-dim" x-text="m.provider"></td>
|
||||
<td><span class="tier-badge" :class="tierBadgeClass(m.tier)" x-text="m.tier || '-'"></span></td>
|
||||
<td class="text-xs" x-text="formatContext(m.context_window)"></td>
|
||||
<td class="text-xs" x-text="formatCost(m.input_cost_per_m)"></td>
|
||||
<td class="text-xs" x-text="formatCost(m.output_cost_per_m)"></td>
|
||||
<td><span class="badge" :class="m.available ? 'badge-success' : 'badge-muted'" x-text="m.available ? 'Available' : 'Needs Key'"></span></td>
|
||||
<td><button x-show="m.tier === 'custom'" class="btn btn-ghost btn-sm" @click="deleteCustomModel(m.id)" title="Delete custom model" style="padding:2px 6px;font-size:11px;color:var(--text-muted)">✕</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
@@ -2987,6 +3156,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>
|
||||
@@ -3995,6 +4170,176 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Page: Comms -->
|
||||
<template x-if="page === 'comms'">
|
||||
<div x-data="commsPage" x-init="loadData()" @page-leave.window="stopSSE()">
|
||||
<div class="page-header">
|
||||
<h2>Agent Comms</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-primary btn-sm" @click="openSendModal()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/></svg>
|
||||
Send Message
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="openTaskModal()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
|
||||
Post Task
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="loadData()" title="Refresh">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-body">
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" style="animation:fadeIn 0.2s">
|
||||
<div class="card mb-4"><div class="skeleton skeleton-text" style="width:160px;margin-bottom:8px"></div><div class="skeleton skeleton-card" style="height:120px"></div></div>
|
||||
<div class="card"><div class="skeleton skeleton-text" style="width:120px;margin-bottom:8px"></div><div class="skeleton skeleton-card" style="height:200px"></div></div>
|
||||
</div>
|
||||
<!-- Error -->
|
||||
<div x-show="!loading && loadError" class="error-state" style="animation:fadeIn 0.3s">
|
||||
<h3 style="color:var(--error)">Connection Error</h3>
|
||||
<p class="text-xs text-dim" x-text="loadError"></p>
|
||||
<button class="btn btn-primary btn-sm" @click="loadData()" style="margin-top:8px">Retry</button>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div x-show="!loading && !loadError" style="animation:fadeIn 0.3s">
|
||||
<!-- Topology -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98"/></svg>
|
||||
Agent Topology
|
||||
<span class="badge badge-dim" style="margin-left:8px;font-weight:400" x-text="topology.nodes.length + ' agents'"></span>
|
||||
</div>
|
||||
<div style="padding:8px 0;font-family:var(--font-mono);font-size:12px;line-height:1.8">
|
||||
<template x-if="topology.nodes.length === 0">
|
||||
<div class="text-dim" style="text-align:center;padding:24px">No agents running</div>
|
||||
</template>
|
||||
<template x-for="root in rootNodes()" :key="root.id">
|
||||
<div class="comms-topo-tree">
|
||||
<div class="comms-topo-node" :title="root.id">
|
||||
<span :class="stateBadgeClass(root.state)" style="font-size:10px;padding:1px 6px" x-text="root.state"></span>
|
||||
<strong x-text="root.name" style="margin:0 4px"></strong>
|
||||
<span class="text-dim" x-text="root.model"></span>
|
||||
<template x-for="peer in peersOf(root.id)" :key="peer.id">
|
||||
<span class="text-dim" style="margin-left:8px" x-text="'\u2194 ' + peer.name"></span>
|
||||
</template>
|
||||
</div>
|
||||
<template x-for="(child, ci) in childrenOf(root.id)" :key="child.id">
|
||||
<div class="comms-topo-child">
|
||||
<span class="comms-topo-branch" x-text="ci < childrenOf(root.id).length - 1 ? '\u251c\u2500\u2500 ' : '\u2514\u2500\u2500 '"></span>
|
||||
<span :class="stateBadgeClass(child.state)" style="font-size:10px;padding:1px 6px" x-text="child.state"></span>
|
||||
<strong x-text="child.name" style="margin:0 4px"></strong>
|
||||
<span class="text-dim" x-text="child.model"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Event Feed -->
|
||||
<div class="card">
|
||||
<div class="card-header flex justify-between items-center">
|
||||
<div>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline;margin-right:4px;vertical-align:-2px"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||
Live Event Feed
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-success" style="font-size:9px;padding:2px 6px;animation:pulse-ring 2s infinite">LIVE</span>
|
||||
<span class="text-xs text-dim" x-text="events.length + ' events'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="max-height:400px;overflow-y:auto">
|
||||
<template x-if="events.length === 0">
|
||||
<div class="text-dim" style="text-align:center;padding:24px">No inter-agent events yet</div>
|
||||
</template>
|
||||
<template x-for="ev in events" :key="ev.id">
|
||||
<div class="comms-event-row">
|
||||
<span class="comms-event-time text-xs text-dim" x-text="timeAgo(ev.timestamp)"></span>
|
||||
<span :class="eventBadgeClass(ev.kind)" style="font-size:10px;padding:1px 6px;min-width:70px;text-align:center" x-text="eventLabel(ev.kind)"></span>
|
||||
<span style="font-weight:600;font-size:12px" x-text="ev.source_name"></span>
|
||||
<span class="text-dim" x-show="ev.target_name" x-text="'\u2192 ' + ev.target_name"></span>
|
||||
<span class="comms-event-detail text-dim text-xs" x-text="ev.detail" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Message Modal -->
|
||||
<div x-show="showSendModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showSendModal=false" x-transition>
|
||||
<div class="card" style="width:420px;max-width:90vw" @click.stop>
|
||||
<div class="card-header">Send Agent Message</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">From Agent</label>
|
||||
<select x-model="sendFrom" class="input" style="width:100%">
|
||||
<option value="">Select agent...</option>
|
||||
<template x-for="n in topology.nodes" :key="n.id">
|
||||
<option :value="n.id" x-text="n.name + ' (' + n.state + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">To Agent</label>
|
||||
<select x-model="sendTo" class="input" style="width:100%">
|
||||
<option value="">Select agent...</option>
|
||||
<template x-for="n in topology.nodes" :key="n.id">
|
||||
<option :value="n.id" x-text="n.name + ' (' + n.state + ')'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Message</label>
|
||||
<textarea x-model="sendMsg" class="input" rows="3" placeholder="Type a message..." style="width:100%;resize:vertical"></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2" style="justify-content:flex-end">
|
||||
<button class="btn btn-ghost btn-sm" @click="showSendModal=false">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @click="submitSend()" :disabled="sendLoading || !sendFrom || !sendTo || !sendMsg.trim()">
|
||||
<span x-show="sendLoading">Sending...</span>
|
||||
<span x-show="!sendLoading">Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Task Modal -->
|
||||
<div x-show="showTaskModal" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px)" @click.self="showTaskModal=false" x-transition>
|
||||
<div class="card" style="width:420px;max-width:90vw" @click.stop>
|
||||
<div class="card-header">Post Task</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;margin-top:12px">
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Title</label>
|
||||
<input type="text" x-model="taskTitle" class="input" placeholder="Task title..." style="width:100%">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Description</label>
|
||||
<textarea x-model="taskDesc" class="input" rows="3" placeholder="Task description..." style="width:100%;resize:vertical"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-dim" style="display:block;margin-bottom:4px">Assign To (optional)</label>
|
||||
<select x-model="taskAssign" class="input" style="width:100%">
|
||||
<option value="">Unassigned</option>
|
||||
<template x-for="n in topology.nodes" :key="n.id">
|
||||
<option :value="n.id" x-text="n.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2" style="justify-content:flex-end">
|
||||
<button class="btn btn-ghost btn-sm" @click="showTaskModal=false">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @click="submitTask()" :disabled="taskLoading || !taskTitle.trim()">
|
||||
<span x-show="taskLoading">Posting...</span>
|
||||
<span x-show="!taskLoading">Post Task</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Page: Setup Wizard -->
|
||||
<template x-if="page === 'wizard'">
|
||||
<div x-data="wizardPage">
|
||||
@@ -4359,6 +4704,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>
|
||||
|
||||
|
||||
@@ -155,10 +155,19 @@ document.addEventListener('alpine:init', function() {
|
||||
|
||||
async checkAuth() {
|
||||
try {
|
||||
await OpenFangAPI.get('/api/providers');
|
||||
// Use a protected endpoint (not in the public allowlist) to detect
|
||||
// whether the server requires an API key.
|
||||
await OpenFangAPI.get('/api/tools');
|
||||
this.showAuthPrompt = false;
|
||||
} catch(e) {
|
||||
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0)) {
|
||||
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
|
||||
// Only show prompt if we don't already have a saved key
|
||||
var saved = localStorage.getItem('openfang-api-key');
|
||||
if (saved) {
|
||||
// Saved key might be stale — clear it and show prompt
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
}
|
||||
this.showAuthPrompt = true;
|
||||
}
|
||||
}
|
||||
@@ -209,7 +218,7 @@ function app() {
|
||||
});
|
||||
|
||||
// Hash routing
|
||||
var validPages = ['overview','agents','sessions','approvals','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
|
||||
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','runtime','settings','wizard'];
|
||||
var pageRedirects = {
|
||||
'chat': 'agents',
|
||||
'templates': 'agents',
|
||||
|
||||
@@ -54,6 +54,18 @@ function agentsPage() {
|
||||
filesLoading: false,
|
||||
configForm: {},
|
||||
configSaving: false,
|
||||
// -- Tool filters --
|
||||
toolFilters: { tool_allowlist: [], tool_blocklist: [] },
|
||||
toolFiltersLoading: false,
|
||||
newAllowTool: '',
|
||||
newBlockTool: '',
|
||||
// -- Model switch --
|
||||
editingModel: false,
|
||||
newModelValue: '',
|
||||
modelSaving: false,
|
||||
// -- Fallback chain --
|
||||
editingFallback: false,
|
||||
newFallbackValue: '',
|
||||
|
||||
// -- Templates state --
|
||||
tplTemplates: [],
|
||||
@@ -307,12 +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 || '',
|
||||
@@ -322,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) {
|
||||
@@ -559,6 +579,123 @@ function agentsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Clear agent history ──
|
||||
async clearHistory(agent) {
|
||||
var self = this;
|
||||
OpenFangToast.confirm('Clear History', 'Clear all conversation history for "' + agent.name + '"? This cannot be undone.', async function() {
|
||||
try {
|
||||
await OpenFangAPI.del('/api/agents/' + agent.id + '/history');
|
||||
OpenFangToast.success('History cleared for "' + agent.name + '"');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to clear history: ' + e.message);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ── Model switch ──
|
||||
async changeModel() {
|
||||
if (!this.detailAgent || !this.newModelValue.trim()) return;
|
||||
this.modelSaving = true;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
|
||||
OpenFangToast.success('Model changed (memory reset)');
|
||||
this.editingModel = false;
|
||||
await Alpine.store('app').refreshAgents();
|
||||
// Refresh detailAgent
|
||||
var agents = Alpine.store('app').agents;
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
|
||||
}
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to change model: ' + e.message);
|
||||
}
|
||||
this.modelSaving = false;
|
||||
},
|
||||
|
||||
// ── Fallback model chain ──
|
||||
async addFallback() {
|
||||
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
|
||||
var parts = this.newFallbackValue.trim().split('/');
|
||||
var provider = parts.length > 1 ? parts[0] : this.detailAgent.model_provider;
|
||||
var model = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
|
||||
if (!this.detailAgent._fallbacks) this.detailAgent._fallbacks = [];
|
||||
this.detailAgent._fallbacks.push({ provider: provider, model: model });
|
||||
try {
|
||||
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
|
||||
fallback_models: this.detailAgent._fallbacks
|
||||
});
|
||||
OpenFangToast.success('Fallback added: ' + provider + '/' + model);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
|
||||
this.detailAgent._fallbacks.pop();
|
||||
}
|
||||
this.editingFallback = false;
|
||||
this.newFallbackValue = '';
|
||||
},
|
||||
|
||||
async removeFallback(idx) {
|
||||
if (!this.detailAgent || !this.detailAgent._fallbacks) return;
|
||||
var removed = this.detailAgent._fallbacks.splice(idx, 1);
|
||||
try {
|
||||
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
|
||||
fallback_models: this.detailAgent._fallbacks
|
||||
});
|
||||
OpenFangToast.success('Fallback removed');
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
|
||||
this.detailAgent._fallbacks.splice(idx, 0, removed[0]);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tool filters ──
|
||||
async loadToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
this.toolFiltersLoading = true;
|
||||
try {
|
||||
this.toolFilters = await OpenFangAPI.get('/api/agents/' + this.detailAgent.id + '/tools');
|
||||
} catch(e) {
|
||||
this.toolFilters = { tool_allowlist: [], tool_blocklist: [] };
|
||||
}
|
||||
this.toolFiltersLoading = false;
|
||||
},
|
||||
|
||||
addAllowTool() {
|
||||
var t = this.newAllowTool.trim();
|
||||
if (t && this.toolFilters.tool_allowlist.indexOf(t) === -1) {
|
||||
this.toolFilters.tool_allowlist.push(t);
|
||||
this.newAllowTool = '';
|
||||
this.saveToolFilters();
|
||||
}
|
||||
},
|
||||
|
||||
removeAllowTool(tool) {
|
||||
this.toolFilters.tool_allowlist = this.toolFilters.tool_allowlist.filter(function(t) { return t !== tool; });
|
||||
this.saveToolFilters();
|
||||
},
|
||||
|
||||
addBlockTool() {
|
||||
var t = this.newBlockTool.trim();
|
||||
if (t && this.toolFilters.tool_blocklist.indexOf(t) === -1) {
|
||||
this.toolFilters.tool_blocklist.push(t);
|
||||
this.newBlockTool = '';
|
||||
this.saveToolFilters();
|
||||
}
|
||||
},
|
||||
|
||||
removeBlockTool(tool) {
|
||||
this.toolFilters.tool_blocklist = this.toolFilters.tool_blocklist.filter(function(t) { return t !== tool; });
|
||||
this.saveToolFilters();
|
||||
},
|
||||
|
||||
async saveToolFilters() {
|
||||
if (!this.detailAgent) return;
|
||||
try {
|
||||
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/tools', this.toolFilters);
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to update tool filters: ' + e.message);
|
||||
}
|
||||
},
|
||||
|
||||
async spawnBuiltin(t) {
|
||||
var toml = 'name = "' + t.name + '"\n';
|
||||
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
|
||||
|
||||
@@ -141,7 +141,16 @@ function channelsPage() {
|
||||
|
||||
openSetup(ch) {
|
||||
this.setupModal = ch;
|
||||
this.formValues = {};
|
||||
// Pre-populate form values from saved config (non-secret fields).
|
||||
var vals = {};
|
||||
if (ch.fields) {
|
||||
ch.fields.forEach(function(f) {
|
||||
if (f.value !== undefined && f.value !== null && f.type !== 'secret') {
|
||||
vals[f.key] = String(f.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.formValues = vals;
|
||||
this.showAdvanced = false;
|
||||
this.showBusinessApi = false;
|
||||
this.setupStep = ch.configured ? 3 : 1;
|
||||
|
||||
@@ -29,6 +29,19 @@ function chatPage() {
|
||||
_audioChunks: [],
|
||||
recordingTime: 0,
|
||||
_recordingTimer: null,
|
||||
// Model autocomplete state
|
||||
showModelPicker: false,
|
||||
modelPickerList: [],
|
||||
modelPickerFilter: '',
|
||||
modelPickerIdx: 0,
|
||||
// Model switcher dropdown
|
||||
showModelSwitcher: false,
|
||||
modelSwitcherFilter: '',
|
||||
modelSwitcherProviderFilter: '',
|
||||
modelSwitcherIdx: 0,
|
||||
modelSwitching: false,
|
||||
_modelCache: null,
|
||||
_modelCacheTime: 0,
|
||||
slashCommands: [
|
||||
{ cmd: '/help', desc: 'Show available commands' },
|
||||
{ cmd: '/agents', desc: 'Switch to Agents page' },
|
||||
@@ -80,6 +93,47 @@ function chatPage() {
|
||||
}
|
||||
},
|
||||
|
||||
get modelDisplayName() {
|
||||
if (!this.currentAgent) return '';
|
||||
var name = this.currentAgent.model_name || '';
|
||||
var short = name.replace(/-\d{8}$/, '');
|
||||
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
|
||||
},
|
||||
|
||||
get switcherProviders() {
|
||||
var seen = {};
|
||||
(this._modelCache || []).forEach(function(m) { seen[m.provider] = true; });
|
||||
return Object.keys(seen).sort();
|
||||
},
|
||||
|
||||
get filteredSwitcherModels() {
|
||||
var models = this._modelCache || [];
|
||||
var provFilter = this.modelSwitcherProviderFilter;
|
||||
var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : '';
|
||||
if (!provFilter && !textFilter) return models;
|
||||
return models.filter(function(m) {
|
||||
if (provFilter && m.provider !== provFilter) return false;
|
||||
if (textFilter) {
|
||||
return m.id.toLowerCase().indexOf(textFilter) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(textFilter) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(textFilter) !== -1;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
get groupedSwitcherModels() {
|
||||
var filtered = this.filteredSwitcherModels;
|
||||
var groups = {}, order = [];
|
||||
filtered.forEach(function(m) {
|
||||
if (!groups[m.provider]) { groups[m.provider] = []; order.push(m.provider); }
|
||||
groups[m.provider].push(m);
|
||||
});
|
||||
return order.map(function(p) {
|
||||
return { provider: p.charAt(0).toUpperCase() + p.slice(1), models: groups[p] };
|
||||
});
|
||||
},
|
||||
|
||||
init() {
|
||||
var self = this;
|
||||
|
||||
@@ -96,6 +150,11 @@ function chatPage() {
|
||||
var input = document.getElementById('msg-input');
|
||||
if (input) { input.focus(); self.inputText = '/'; }
|
||||
}
|
||||
// Ctrl+M for model switcher
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'm' && self.currentAgent) {
|
||||
e.preventDefault();
|
||||
self.toggleModelSwitcher();
|
||||
}
|
||||
// Ctrl+F for chat search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && self.currentAgent) {
|
||||
e.preventDefault();
|
||||
@@ -126,18 +185,97 @@ function chatPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for slash commands
|
||||
// Watch for slash commands + model autocomplete
|
||||
this.$watch('inputText', function(val) {
|
||||
if (val.startsWith('/')) {
|
||||
var modelMatch = val.match(/^\/model\s+(.*)$/i);
|
||||
if (modelMatch) {
|
||||
self.showSlashMenu = false;
|
||||
self.modelPickerFilter = modelMatch[1].toLowerCase();
|
||||
if (!self.modelPickerList.length) {
|
||||
OpenFangAPI.get('/api/models').then(function(data) {
|
||||
self.modelPickerList = (data.models || []).filter(function(m) { return m.available; });
|
||||
self.showModelPicker = true;
|
||||
self.modelPickerIdx = 0;
|
||||
}).catch(function() {});
|
||||
} else {
|
||||
self.showModelPicker = true;
|
||||
}
|
||||
} else if (val.startsWith('/')) {
|
||||
self.showModelPicker = false;
|
||||
self.slashFilter = val.slice(1).toLowerCase();
|
||||
self.showSlashMenu = true;
|
||||
self.slashIdx = 0;
|
||||
} else {
|
||||
self.showSlashMenu = false;
|
||||
self.showModelPicker = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
get filteredModelPicker() {
|
||||
if (!this.modelPickerFilter) return this.modelPickerList.slice(0, 15);
|
||||
var f = this.modelPickerFilter;
|
||||
return this.modelPickerList.filter(function(m) {
|
||||
return m.id.toLowerCase().indexOf(f) !== -1 || (m.display_name || '').toLowerCase().indexOf(f) !== -1 || m.provider.toLowerCase().indexOf(f) !== -1;
|
||||
}).slice(0, 15);
|
||||
},
|
||||
|
||||
pickModel(modelId) {
|
||||
this.showModelPicker = false;
|
||||
this.inputText = '/model ' + modelId;
|
||||
this.sendMessage();
|
||||
},
|
||||
|
||||
toggleModelSwitcher() {
|
||||
if (this.showModelSwitcher) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
var now = Date.now();
|
||||
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
|
||||
this.modelSwitcherFilter = '';
|
||||
this.modelSwitcherProviderFilter = '';
|
||||
this.modelSwitcherIdx = 0;
|
||||
this.showModelSwitcher = true;
|
||||
this.$nextTick(function() {
|
||||
var el = document.getElementById('model-switcher-search');
|
||||
if (el) el.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
OpenFangAPI.get('/api/models').then(function(data) {
|
||||
var models = (data.models || []).filter(function(m) { return m.available; });
|
||||
self._modelCache = models;
|
||||
self._modelCacheTime = Date.now();
|
||||
self.modelPickerList = models;
|
||||
self.modelSwitcherFilter = '';
|
||||
self.modelSwitcherProviderFilter = '';
|
||||
self.modelSwitcherIdx = 0;
|
||||
self.showModelSwitcher = true;
|
||||
self.$nextTick(function() {
|
||||
var el = document.getElementById('model-switcher-search');
|
||||
if (el) el.focus();
|
||||
});
|
||||
}).catch(function(e) {
|
||||
OpenFangToast.error('Failed to load models: ' + e.message);
|
||||
});
|
||||
},
|
||||
|
||||
switchModel(model) {
|
||||
if (!this.currentAgent) return;
|
||||
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
this.modelSwitching = true;
|
||||
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function() {
|
||||
self.currentAgent.model_name = model.id;
|
||||
self.currentAgent.model_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;
|
||||
@@ -393,7 +531,10 @@ function chatPage() {
|
||||
is_error: !!t.is_error
|
||||
};
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools };
|
||||
var images = (m.images || []).map(function(img) {
|
||||
return { file_id: img.file_id, filename: img.filename || 'image' };
|
||||
});
|
||||
return { id: ++msgId, role: role, text: text, meta: '', tools: tools, images: images };
|
||||
});
|
||||
self.$nextTick(function() { self.scrollToBottom(); });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// OpenFang Comms Page — Agent topology & inter-agent communication feed
|
||||
'use strict';
|
||||
|
||||
function commsPage() {
|
||||
return {
|
||||
topology: { nodes: [], edges: [] },
|
||||
events: [],
|
||||
loading: true,
|
||||
loadError: '',
|
||||
sseSource: null,
|
||||
showSendModal: false,
|
||||
showTaskModal: false,
|
||||
sendFrom: '',
|
||||
sendTo: '',
|
||||
sendMsg: '',
|
||||
sendLoading: false,
|
||||
taskTitle: '',
|
||||
taskDesc: '',
|
||||
taskAssign: '',
|
||||
taskLoading: false,
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/comms/topology'),
|
||||
OpenFangAPI.get('/api/comms/events?limit=200')
|
||||
]);
|
||||
this.topology = results[0] || { nodes: [], edges: [] };
|
||||
this.events = results[1] || [];
|
||||
this.startSSE();
|
||||
} catch(e) {
|
||||
this.loadError = e.message || 'Could not load comms data.';
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
startSSE() {
|
||||
if (this.sseSource) this.sseSource.close();
|
||||
var self = this;
|
||||
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
|
||||
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
|
||||
this.sseSource = new EventSource(url);
|
||||
this.sseSource.onmessage = function(ev) {
|
||||
if (ev.data === 'ping') return;
|
||||
try {
|
||||
var event = JSON.parse(ev.data);
|
||||
self.events.unshift(event);
|
||||
if (self.events.length > 200) self.events.length = 200;
|
||||
// Refresh topology on spawn/terminate events
|
||||
if (event.kind === 'agent_spawned' || event.kind === 'agent_terminated') {
|
||||
self.refreshTopology();
|
||||
}
|
||||
} catch(e) { /* ignore parse errors */ }
|
||||
};
|
||||
},
|
||||
|
||||
stopSSE() {
|
||||
if (this.sseSource) {
|
||||
this.sseSource.close();
|
||||
this.sseSource = null;
|
||||
}
|
||||
},
|
||||
|
||||
async refreshTopology() {
|
||||
try {
|
||||
this.topology = await OpenFangAPI.get('/api/comms/topology');
|
||||
} catch(e) { /* silent */ }
|
||||
},
|
||||
|
||||
rootNodes() {
|
||||
var childIds = {};
|
||||
var self = this;
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'parent_child') childIds[e.to] = true;
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return !childIds[n.id]; });
|
||||
},
|
||||
|
||||
childrenOf(id) {
|
||||
var childIds = {};
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'parent_child' && e.from === id) childIds[e.to] = true;
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return childIds[n.id]; });
|
||||
},
|
||||
|
||||
peersOf(id) {
|
||||
var peerIds = {};
|
||||
this.topology.edges.forEach(function(e) {
|
||||
if (e.kind === 'peer') {
|
||||
if (e.from === id) peerIds[e.to] = true;
|
||||
if (e.to === id) peerIds[e.from] = true;
|
||||
}
|
||||
});
|
||||
return this.topology.nodes.filter(function(n) { return peerIds[n.id]; });
|
||||
},
|
||||
|
||||
stateBadgeClass(state) {
|
||||
switch(state) {
|
||||
case 'Running': return 'badge badge-success';
|
||||
case 'Suspended': return 'badge badge-warning';
|
||||
case 'Terminated': case 'Crashed': return 'badge badge-danger';
|
||||
default: return 'badge badge-dim';
|
||||
}
|
||||
},
|
||||
|
||||
eventBadgeClass(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return 'badge badge-info';
|
||||
case 'agent_spawned': return 'badge badge-success';
|
||||
case 'agent_terminated': return 'badge badge-danger';
|
||||
case 'task_posted': return 'badge badge-warning';
|
||||
case 'task_claimed': return 'badge badge-info';
|
||||
case 'task_completed': return 'badge badge-success';
|
||||
default: return 'badge badge-dim';
|
||||
}
|
||||
},
|
||||
|
||||
eventIcon(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return '\u2709';
|
||||
case 'agent_spawned': return '+';
|
||||
case 'agent_terminated': return '\u2715';
|
||||
case 'task_posted': return '\u2691';
|
||||
case 'task_claimed': return '\u2690';
|
||||
case 'task_completed': return '\u2713';
|
||||
default: return '\u2022';
|
||||
}
|
||||
},
|
||||
|
||||
eventLabel(kind) {
|
||||
switch(kind) {
|
||||
case 'agent_message': return 'Message';
|
||||
case 'agent_spawned': return 'Spawned';
|
||||
case 'agent_terminated': return 'Terminated';
|
||||
case 'task_posted': return 'Task Posted';
|
||||
case 'task_claimed': return 'Task Claimed';
|
||||
case 'task_completed': return 'Task Done';
|
||||
default: return kind;
|
||||
}
|
||||
},
|
||||
|
||||
timeAgo(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
var d = new Date(dateStr);
|
||||
var secs = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (secs < 60) return secs + 's ago';
|
||||
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
|
||||
if (secs < 86400) return Math.floor(secs / 3600) + 'h ago';
|
||||
return Math.floor(secs / 86400) + 'd ago';
|
||||
},
|
||||
|
||||
openSendModal() {
|
||||
this.sendFrom = '';
|
||||
this.sendTo = '';
|
||||
this.sendMsg = '';
|
||||
this.showSendModal = true;
|
||||
},
|
||||
|
||||
async submitSend() {
|
||||
if (!this.sendFrom || !this.sendTo || !this.sendMsg.trim()) return;
|
||||
this.sendLoading = true;
|
||||
try {
|
||||
await OpenFangAPI.post('/api/comms/send', {
|
||||
from_agent_id: this.sendFrom,
|
||||
to_agent_id: this.sendTo,
|
||||
message: this.sendMsg
|
||||
});
|
||||
OpenFangToast.success('Message sent');
|
||||
this.showSendModal = false;
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Send failed');
|
||||
}
|
||||
this.sendLoading = false;
|
||||
},
|
||||
|
||||
openTaskModal() {
|
||||
this.taskTitle = '';
|
||||
this.taskDesc = '';
|
||||
this.taskAssign = '';
|
||||
this.showTaskModal = true;
|
||||
},
|
||||
|
||||
async submitTask() {
|
||||
if (!this.taskTitle.trim()) return;
|
||||
this.taskLoading = true;
|
||||
try {
|
||||
var body = { title: this.taskTitle, description: this.taskDesc };
|
||||
if (this.taskAssign) body.assigned_to = this.taskAssign;
|
||||
await OpenFangAPI.post('/api/comms/task', body);
|
||||
OpenFangToast.success('Task posted');
|
||||
this.showTaskModal = false;
|
||||
} catch(e) {
|
||||
OpenFangToast.error(e.message || 'Task failed');
|
||||
}
|
||||
this.taskLoading = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Runtime page — system overview and provider status
|
||||
document.addEventListener('alpine:init', function() {
|
||||
Alpine.data('runtimePage', function() {
|
||||
return {
|
||||
loading: true,
|
||||
uptime: '-',
|
||||
agentCount: 0,
|
||||
version: '-',
|
||||
defaultModel: '-',
|
||||
platform: '-',
|
||||
arch: '-',
|
||||
apiListen: '-',
|
||||
homeDir: '-',
|
||||
logLevel: '-',
|
||||
networkEnabled: false,
|
||||
providers: [],
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
OpenFangAPI.get('/api/status'),
|
||||
OpenFangAPI.get('/api/version'),
|
||||
OpenFangAPI.get('/api/providers'),
|
||||
OpenFangAPI.get('/api/agents')
|
||||
]);
|
||||
var status = results[0];
|
||||
var ver = results[1];
|
||||
var prov = results[2];
|
||||
var agents = results[3];
|
||||
|
||||
this.version = ver.version || '-';
|
||||
this.platform = ver.platform || '-';
|
||||
this.arch = ver.arch || '-';
|
||||
this.agentCount = Array.isArray(agents) ? agents.length : 0;
|
||||
this.defaultModel = status.default_model || '-';
|
||||
this.apiListen = status.api_listen || status.listen || '-';
|
||||
this.homeDir = status.home_dir || '-';
|
||||
this.logLevel = status.log_level || '-';
|
||||
this.networkEnabled = !!status.network_enabled;
|
||||
|
||||
// Compute uptime from uptime_seconds
|
||||
var diff = status.uptime_seconds || 0;
|
||||
if (diff < 60) this.uptime = diff + 's';
|
||||
else if (diff < 3600) this.uptime = Math.floor(diff / 60) + 'm ' + (diff % 60) + 's';
|
||||
else if (diff < 86400) this.uptime = Math.floor(diff / 3600) + 'h ' + Math.floor((diff % 3600) / 60) + 'm';
|
||||
else this.uptime = Math.floor(diff / 86400) + 'd ' + Math.floor((diff % 86400) / 3600) + 'h';
|
||||
|
||||
this.providers = (prov.providers || []).filter(function(p) {
|
||||
return p.auth_status === 'Configured' || p.reachable || p.is_local;
|
||||
});
|
||||
} catch(e) {
|
||||
console.error('Runtime load error:', e);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -14,12 +14,23 @@ function settingsPage() {
|
||||
modelSearch: '',
|
||||
modelProviderFilter: '',
|
||||
modelTierFilter: '',
|
||||
showCustomModelForm: false,
|
||||
customModelId: '',
|
||||
customModelProvider: 'openrouter',
|
||||
customModelContext: 128000,
|
||||
customModelMaxOutput: 8192,
|
||||
customModelStatus: '',
|
||||
providerKeyInputs: {},
|
||||
providerUrlInputs: {},
|
||||
providerUrlSaving: {},
|
||||
providerTesting: {},
|
||||
providerTestResults: {},
|
||||
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
|
||||
customProviderName: '',
|
||||
customProviderUrl: '',
|
||||
customProviderKey: '',
|
||||
customProviderStatus: '',
|
||||
addingCustomProvider: false,
|
||||
loading: true,
|
||||
loadError: '',
|
||||
|
||||
@@ -213,8 +224,13 @@ function settingsPage() {
|
||||
this.providers = data.providers || [];
|
||||
for (var i = 0; i < this.providers.length; i++) {
|
||||
var p = this.providers[i];
|
||||
if (p.is_local && p.base_url && !this.providerUrlInputs[p.id]) {
|
||||
this.providerUrlInputs[p.id] = p.base_url;
|
||||
if (p.is_local) {
|
||||
if (!this.providerUrlInputs[p.id]) {
|
||||
this.providerUrlInputs[p.id] = p.base_url || '';
|
||||
}
|
||||
if (this.providerUrlSaving[p.id] === undefined) {
|
||||
this.providerUrlSaving[p.id] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) { this.providers = []; }
|
||||
@@ -227,6 +243,37 @@ function settingsPage() {
|
||||
} catch(e) { this.models = []; }
|
||||
},
|
||||
|
||||
async addCustomModel() {
|
||||
var id = this.customModelId.trim();
|
||||
if (!id) return;
|
||||
this.customModelStatus = 'Adding...';
|
||||
try {
|
||||
await OpenFangAPI.post('/api/models/custom', {
|
||||
id: id,
|
||||
provider: this.customModelProvider || 'openrouter',
|
||||
context_window: this.customModelContext || 128000,
|
||||
max_output_tokens: this.customModelMaxOutput || 8192,
|
||||
});
|
||||
this.customModelStatus = 'Added!';
|
||||
this.customModelId = '';
|
||||
this.showCustomModelForm = false;
|
||||
await this.loadModels();
|
||||
} catch(e) {
|
||||
this.customModelStatus = 'Error: ' + (e.message || 'Failed');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCustomModel(modelId) {
|
||||
if (!confirm('Delete custom model "' + modelId + '"?')) return;
|
||||
try {
|
||||
await OpenFangAPI.del('/api/models/custom/' + encodeURIComponent(modelId));
|
||||
OpenFangToast.success('Model deleted');
|
||||
await this.loadModels();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete: ' + (e.message || 'Unknown error'));
|
||||
}
|
||||
},
|
||||
|
||||
async loadConfigSchema() {
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
@@ -457,6 +504,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
|
||||
|
||||
@@ -320,7 +320,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 +329,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 +355,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;
|
||||
},
|
||||
@@ -441,8 +442,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 {
|
||||
@@ -474,7 +475,8 @@ function wizardPage() {
|
||||
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,8 @@ function workflowBuilder() {
|
||||
{ type: 'end', label: 'End', color: '#ef4444', icon: 'E', ports: { in: 1, out: 0 } }
|
||||
],
|
||||
|
||||
_renderScheduled: false,
|
||||
|
||||
async init() {
|
||||
var self = this;
|
||||
// Load agents for the agent step dropdown
|
||||
@@ -50,6 +52,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 +236,7 @@ function workflowBuilder() {
|
||||
node.config = { strategy: 'all' };
|
||||
}
|
||||
this.nodes.push(node);
|
||||
this.scheduleRender();
|
||||
return node;
|
||||
},
|
||||
|
||||
@@ -95,6 +249,7 @@ function workflowBuilder() {
|
||||
this.selectedNode = null;
|
||||
this.showNodeEditor = false;
|
||||
}
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
duplicateNode: function(node) {
|
||||
@@ -166,11 +321,13 @@ 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 ────────────────────────────────────
|
||||
@@ -205,11 +362,13 @@ function workflowBuilder() {
|
||||
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.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.canvasOffset = {
|
||||
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
|
||||
@@ -223,6 +382,7 @@ function workflowBuilder() {
|
||||
this.connecting = null;
|
||||
this.connectPreview = null;
|
||||
this.canvasDragging = false;
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
onCanvasWheel: function(e) {
|
||||
@@ -386,7 +546,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 +565,7 @@ function workflowBuilder() {
|
||||
this.nodes[i].y = y;
|
||||
y += 120;
|
||||
}
|
||||
this.scheduleRender();
|
||||
},
|
||||
|
||||
// ── Clear ────────────────────────────────────────────
|
||||
@@ -414,7 +575,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,7 @@ 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(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -221,10 +222,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 +247,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 +255,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 +270,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 +278,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 +618,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 +634,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 +644,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 +662,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 +704,7 @@ 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(),
|
||||
});
|
||||
|
||||
let api_key_state = state.kernel.config.api_key.clone();
|
||||
|
||||
@@ -113,6 +113,7 @@ 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(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -236,6 +237,7 @@ 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(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -57,6 +57,7 @@ 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(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
@@ -540,7 +541,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 }
|
||||
|
||||
@@ -373,10 +373,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 +407,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 => {}
|
||||
}
|
||||
@@ -441,16 +453,21 @@ async fn dispatch_message(
|
||||
send_response(adapter, &message.sender, result, thread_id, output_format).await;
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
send_response(
|
||||
adapter,
|
||||
&message.sender,
|
||||
"I can only handle text messages for now.".to_string(),
|
||||
thread_id,
|
||||
output_format,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
ChannelContent::Image { ref url, ref caption } => {
|
||||
let desc = match caption {
|
||||
Some(c) => format!("[User sent a photo: {url}]\nCaption: {c}"),
|
||||
None => format!("[User sent a photo: {url}]"),
|
||||
};
|
||||
desc
|
||||
}
|
||||
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}]")
|
||||
}
|
||||
};
|
||||
|
||||
@@ -577,14 +594,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ 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>,
|
||||
intents: u64,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
@@ -51,12 +52,18 @@ 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>,
|
||||
intents: u64,
|
||||
) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
token: Zeroizing::new(token),
|
||||
client: reqwest::Client::new(),
|
||||
allowed_guilds,
|
||||
allowed_users,
|
||||
intents,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
@@ -147,6 +154,7 @@ 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 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 +315,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)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
@@ -422,7 +430,8 @@ 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],
|
||||
) -> Option<ChannelMessage> {
|
||||
let author = d.get("author")?;
|
||||
let author_id = author["id"].as_str()?;
|
||||
@@ -439,11 +448,16 @@ async fn parse_discord_message(
|
||||
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 +501,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 +535,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 +561,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, &[], &[]).await.unwrap();
|
||||
assert_eq!(msg.channel, ChannelType::Discord);
|
||||
assert_eq!(msg.sender.display_name, "alice");
|
||||
assert_eq!(msg.sender.platform_id, "ch1");
|
||||
@@ -546,7 +583,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, &[], &[]).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -566,7 +603,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, &[], &[]).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -587,11 +624,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()], &[]).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()], &[]).await;
|
||||
assert!(msg.is_some());
|
||||
}
|
||||
|
||||
@@ -610,7 +647,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, &[], &[]).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -635,7 +672,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, &[], &[]).await;
|
||||
assert!(msg.is_none());
|
||||
}
|
||||
|
||||
@@ -654,7 +691,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, &[], &[]).await.unwrap();
|
||||
assert_eq!(msg.sender.display_name, "alice#1234");
|
||||
}
|
||||
|
||||
@@ -676,16 +713,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, &[], &[]).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()]).await;
|
||||
assert!(msg.is_none());
|
||||
|
||||
// In allowed users
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &["user999".into()]).await;
|
||||
assert!(msg.is_some());
|
||||
|
||||
// Empty allowed_users = allow all
|
||||
let msg = parse_discord_message(&d, &bot_id, &[], &[]).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, &[], &[]).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, &[], &[]).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, &[], &[]).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![], 37376);
|
||||
assert_eq!(adapter.name(), "discord");
|
||||
assert_eq!(adapter.channel_type(), ChannelType::Discord);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -13,7 +13,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.
|
||||
@@ -28,7 +28,7 @@ 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,
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
@@ -39,7 +39,7 @@ 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 {
|
||||
pub fn new(token: String, allowed_users: Vec<String>, poll_interval: Duration) -> Self {
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
Self {
|
||||
token: Zeroizing::new(token),
|
||||
@@ -79,12 +79,18 @@ impl TelegramAdapter {
|
||||
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!({
|
||||
"chat_id": chat_id,
|
||||
"text": chunk,
|
||||
"parse_mode": "HTML",
|
||||
});
|
||||
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
@@ -97,6 +103,103 @@ impl TelegramAdapter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `sendPhoto` on the Telegram API.
|
||||
async fn api_send_photo(
|
||||
&self,
|
||||
chat_id: i64,
|
||||
photo_url: &str,
|
||||
caption: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendPhoto",
|
||||
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());
|
||||
}
|
||||
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,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendDocument",
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"document": document_url,
|
||||
"caption": filename,
|
||||
});
|
||||
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,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendVoice",
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"voice": voice_url,
|
||||
});
|
||||
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,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendLocation",
|
||||
self.token.as_str()
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
});
|
||||
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.
|
||||
async fn api_send_typing(&self, chat_id: i64) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!(
|
||||
@@ -130,6 +233,26 @@ 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!(
|
||||
"https://api.telegram.org/bot{}/deleteWebhook",
|
||||
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();
|
||||
@@ -195,10 +318,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 +371,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).await {
|
||||
Some(m) => m,
|
||||
None => continue, // filtered out or unparseable
|
||||
};
|
||||
@@ -285,10 +412,23 @@ impl ChannelAdapter for TelegramAdapter {
|
||||
ChannelContent::Text(text) => {
|
||||
self.api_send_message(chat_id, &text).await?;
|
||||
}
|
||||
_ => {
|
||||
self.api_send_message(chat_id, "(Unsupported content type)")
|
||||
ChannelContent::Image { url, caption } => {
|
||||
self.api_send_photo(chat_id, &url, caption.as_deref())
|
||||
.await?;
|
||||
}
|
||||
ChannelContent::File { url, filename } => {
|
||||
self.api_send_document(chat_id, &url, &filename).await?;
|
||||
}
|
||||
ChannelContent::Voice { url, .. } => {
|
||||
self.api_send_voice(chat_id, &url).await?;
|
||||
}
|
||||
ChannelContent::Location { lat, lon } => {
|
||||
self.api_send_location(chat_id, lat, lon).await?;
|
||||
}
|
||||
ChannelContent::Command { name, args } => {
|
||||
let text = format!("/{name} {}", args.join(" "));
|
||||
self.api_send_message(chat_id, text.trim()).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -309,9 +449,34 @@ 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,
|
||||
) -> Option<String> {
|
||||
let url = format!("https://api.telegram.org/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!(
|
||||
"https://api.telegram.org/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,
|
||||
) -> Option<ChannelMessage> {
|
||||
let message = update
|
||||
.get("message")
|
||||
@@ -319,8 +484,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 +502,81 @@ 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).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).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).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)
|
||||
Some(ChannelMessage {
|
||||
channel: ChannelType::Telegram,
|
||||
platform_message_id: message_id.to_string(),
|
||||
@@ -393,12 +599,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 +684,16 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).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 +716,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agent");
|
||||
@@ -458,8 +727,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 +746,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).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).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).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 +784,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).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 +806,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 +820,8 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
let msg = parse_telegram_update(&update, &[]).unwrap();
|
||||
let client = test_client();
|
||||
let msg = parse_telegram_update(&update, &[], "fake:token", &client).await.unwrap();
|
||||
match &msg.content {
|
||||
ChannelContent::Command { name, args } => {
|
||||
assert_eq!(name, "agents");
|
||||
@@ -555,4 +830,22 @@ 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).await.unwrap();
|
||||
assert!(matches!(msg.content, ChannelContent::Location { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+978
-56
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -189,6 +189,17 @@ pub enum AppEvent {
|
||||
AgentSkillsUpdated(String),
|
||||
/// Agent MCP servers updated.
|
||||
AgentMcpServersUpdated(String),
|
||||
/// Comms topology loaded.
|
||||
CommsTopologyLoaded {
|
||||
nodes: Vec<super::screens::comms::CommsNode>,
|
||||
edges: Vec<super::screens::comms::CommsEdge>,
|
||||
},
|
||||
/// Comms events loaded.
|
||||
CommsEventsLoaded(Vec<super::screens::comms::CommsEventItem>),
|
||||
/// Comms send result.
|
||||
CommsSendResult(String),
|
||||
/// Comms task post result.
|
||||
CommsTaskResult(String),
|
||||
}
|
||||
|
||||
/// Spawn the crossterm polling + tick thread. Returns sender + receiver.
|
||||
@@ -1481,7 +1492,13 @@ pub fn spawn_browse_clawhub(backend: BackendRef, sort: String, tx: mpsc::Sender<
|
||||
}
|
||||
|
||||
fn parse_clawhub_results(body: &serde_json::Value) -> Vec<ClawHubResult> {
|
||||
body.as_array()
|
||||
// API returns {"items": [...]} wrapper, fall back to bare array for compat
|
||||
let items = body
|
||||
.get("items")
|
||||
.and_then(|v| v.as_array())
|
||||
.or_else(|| body.as_array());
|
||||
|
||||
items
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|r| ClawHubResult {
|
||||
@@ -2592,3 +2609,176 @@ pub fn spawn_reconnect_extension(backend: BackendRef, id: String, tx: mpsc::Send
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch comms topology + events.
|
||||
pub fn spawn_fetch_comms(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
|
||||
use super::screens::comms::{CommsEdge, CommsEventItem, CommsNode};
|
||||
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
// Fetch topology
|
||||
if let Ok(resp) = client.get(format!("{base_url}/api/comms/topology")).send() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let nodes: Vec<CommsNode> = body["nodes"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|n| CommsNode {
|
||||
id: n["id"].as_str().unwrap_or("").to_string(),
|
||||
name: n["name"].as_str().unwrap_or("").to_string(),
|
||||
state: n["state"].as_str().unwrap_or("").to_string(),
|
||||
model: n["model"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let edges: Vec<CommsEdge> = body["edges"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|e| CommsEdge {
|
||||
from: e["from"].as_str().unwrap_or("").to_string(),
|
||||
to: e["to"].as_str().unwrap_or("").to_string(),
|
||||
kind: e["kind"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = tx.send(AppEvent::CommsTopologyLoaded { nodes, edges });
|
||||
}
|
||||
}
|
||||
// Fetch events
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/comms/events?limit=100"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let events: Vec<CommsEventItem> = body
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|e| CommsEventItem {
|
||||
id: e["id"].as_str().unwrap_or("").to_string(),
|
||||
timestamp: e["timestamp"].as_str().unwrap_or("").to_string(),
|
||||
kind: e["kind"].as_str().unwrap_or("").to_string(),
|
||||
source_name: e["source_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
target_name: e["target_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
detail: e["detail"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = tx.send(AppEvent::CommsEventsLoaded(events));
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(_) => {
|
||||
let _ = tx.send(AppEvent::CommsTopologyLoaded {
|
||||
nodes: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
});
|
||||
let _ = tx.send(AppEvent::CommsEventsLoaded(Vec::new()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Send a message between agents via comms endpoint.
|
||||
pub fn spawn_comms_send(
|
||||
backend: BackendRef,
|
||||
from: String,
|
||||
to: String,
|
||||
msg: String,
|
||||
tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
let body = serde_json::json!({
|
||||
"from_agent_id": from,
|
||||
"to_agent_id": to,
|
||||
"message": msg,
|
||||
});
|
||||
match client
|
||||
.post(format!("{base_url}/api/comms/send"))
|
||||
.json(&body)
|
||||
.send()
|
||||
{
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
let _ = tx.send(AppEvent::CommsSendResult("Message sent".to_string()));
|
||||
} else {
|
||||
let err = resp
|
||||
.json::<serde_json::Value>()
|
||||
.ok()
|
||||
.and_then(|v| v["error"].as_str().map(String::from))
|
||||
.unwrap_or_else(|| "Send failed".to_string());
|
||||
let _ = tx.send(AppEvent::CommsSendResult(err));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(AppEvent::CommsSendResult(format!("Error: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(_) => {
|
||||
let _ = tx.send(AppEvent::CommsSendResult(
|
||||
"Send not supported in-process".to_string(),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Post a task via comms endpoint.
|
||||
pub fn spawn_comms_task(
|
||||
backend: BackendRef,
|
||||
title: String,
|
||||
desc: String,
|
||||
assign: String,
|
||||
tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
std::thread::spawn(move || match backend {
|
||||
BackendRef::Daemon(base_url) => {
|
||||
let client = daemon_client();
|
||||
let mut body = serde_json::json!({
|
||||
"title": title,
|
||||
"description": desc,
|
||||
});
|
||||
if !assign.is_empty() {
|
||||
body["assigned_to"] = serde_json::Value::String(assign);
|
||||
}
|
||||
match client
|
||||
.post(format!("{base_url}/api/comms/task"))
|
||||
.json(&body)
|
||||
.send()
|
||||
{
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
let _ = tx.send(AppEvent::CommsTaskResult("Task posted".to_string()));
|
||||
} else {
|
||||
let err = resp
|
||||
.json::<serde_json::Value>()
|
||||
.ok()
|
||||
.and_then(|v| v["error"].as_str().map(String::from))
|
||||
.unwrap_or_else(|| "Post failed".to_string());
|
||||
let _ = tx.send(AppEvent::CommsTaskResult(err));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(AppEvent::CommsTaskResult(format!("Error: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendRef::InProcess(_) => {
|
||||
let _ = tx.send(AppEvent::CommsTaskResult(
|
||||
"Task post not supported in-process".to_string(),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ use openfang_kernel::OpenFangKernel;
|
||||
use openfang_runtime::llm_driver::StreamEvent;
|
||||
use openfang_types::agent::AgentId;
|
||||
use screens::{
|
||||
agents, audit, channels, chat, dashboard, extensions, hands, logs, memory, peers, security,
|
||||
sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
|
||||
agents, audit, channels, chat, comms, dashboard, extensions, hands, logs, memory, peers,
|
||||
security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{mpsc, Arc};
|
||||
@@ -53,6 +53,7 @@ enum Tab {
|
||||
Extensions,
|
||||
Templates,
|
||||
Peers,
|
||||
Comms,
|
||||
Security,
|
||||
Audit,
|
||||
Usage,
|
||||
@@ -74,6 +75,7 @@ const TABS: &[Tab] = &[
|
||||
Tab::Extensions,
|
||||
Tab::Templates,
|
||||
Tab::Peers,
|
||||
Tab::Comms,
|
||||
Tab::Security,
|
||||
Tab::Audit,
|
||||
Tab::Usage,
|
||||
@@ -97,6 +99,7 @@ impl Tab {
|
||||
Tab::Extensions => "Extensions",
|
||||
Tab::Templates => "Templates",
|
||||
Tab::Peers => "Peers",
|
||||
Tab::Comms => "Comms",
|
||||
Tab::Security => "Security",
|
||||
Tab::Audit => "Audit",
|
||||
Tab::Usage => "Usage",
|
||||
@@ -169,6 +172,7 @@ struct App {
|
||||
usage: usage::UsageState,
|
||||
settings: settings::SettingsState,
|
||||
peers: peers::PeersState,
|
||||
comms: comms::CommsState,
|
||||
logs: logs::LogsState,
|
||||
|
||||
kernel_booting: bool,
|
||||
@@ -207,6 +211,7 @@ impl App {
|
||||
usage: usage::UsageState::new(),
|
||||
settings: settings::SettingsState::new(),
|
||||
peers: peers::PeersState::new(),
|
||||
comms: comms::CommsState::new(),
|
||||
logs: logs::LogsState::new(),
|
||||
kernel_booting: false,
|
||||
kernel_boot_error: None,
|
||||
@@ -504,6 +509,26 @@ impl App {
|
||||
}
|
||||
self.peers.loading = false;
|
||||
}
|
||||
AppEvent::CommsTopologyLoaded { nodes, edges } => {
|
||||
self.comms.nodes = nodes;
|
||||
self.comms.edges = edges;
|
||||
self.comms.loading = false;
|
||||
}
|
||||
AppEvent::CommsEventsLoaded(events) => {
|
||||
self.comms.events = events;
|
||||
if !self.comms.events.is_empty()
|
||||
&& self.comms.event_list_state.selected().is_none()
|
||||
{
|
||||
self.comms.event_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
AppEvent::CommsSendResult(msg) => {
|
||||
self.comms.status_msg = msg;
|
||||
self.refresh_comms();
|
||||
}
|
||||
AppEvent::CommsTaskResult(msg) => {
|
||||
self.comms.status_msg = msg;
|
||||
}
|
||||
AppEvent::LogsLoaded(entries) => {
|
||||
self.logs.entries = entries;
|
||||
self.logs.refilter();
|
||||
@@ -845,6 +870,10 @@ impl App {
|
||||
let action = self.peers.handle_key(key);
|
||||
self.handle_peers_action(action);
|
||||
}
|
||||
Tab::Comms => {
|
||||
let action = self.comms.handle_key(key);
|
||||
self.handle_comms_action(action);
|
||||
}
|
||||
Tab::Logs => {
|
||||
let action = self.logs.handle_key(key);
|
||||
self.handle_logs_action(action);
|
||||
@@ -876,6 +905,7 @@ impl App {
|
||||
self.usage.tick();
|
||||
self.settings.tick();
|
||||
self.peers.tick();
|
||||
self.comms.tick();
|
||||
self.logs.tick();
|
||||
|
||||
// Auto-poll for active tabs
|
||||
@@ -883,6 +913,7 @@ impl App {
|
||||
match self.active_tab {
|
||||
Tab::Logs if self.logs.should_poll() => self.refresh_logs(),
|
||||
Tab::Peers if self.peers.should_poll() => self.refresh_peers(),
|
||||
Tab::Comms if self.comms.should_poll() => self.refresh_comms(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -932,6 +963,7 @@ impl App {
|
||||
Tab::Usage => self.refresh_usage(),
|
||||
Tab::Settings => self.refresh_settings_providers(),
|
||||
Tab::Peers => self.refresh_peers(),
|
||||
Tab::Comms => self.refresh_comms(),
|
||||
Tab::Logs => self.refresh_logs(),
|
||||
Tab::Chat => {} // Chat doesn't need refresh on enter
|
||||
}
|
||||
@@ -1089,6 +1121,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_comms(&mut self) {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
self.comms.loading = true;
|
||||
event::spawn_fetch_comms(backend, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_logs(&mut self) {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
self.logs.loading = true;
|
||||
@@ -1325,6 +1364,8 @@ impl App {
|
||||
}
|
||||
chat::ChatAction::SendMessage(msg) => self.send_message(msg),
|
||||
chat::ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
|
||||
chat::ChatAction::OpenModelPicker => self.open_model_picker(),
|
||||
chat::ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1660,6 +1701,27 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_comms_action(&mut self, action: comms::CommsAction) {
|
||||
match action {
|
||||
comms::CommsAction::Continue => {}
|
||||
comms::CommsAction::Refresh => self.refresh_comms(),
|
||||
comms::CommsAction::SendMessage { from, to, msg } => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_comms_send(backend, from, to, msg, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
comms::CommsAction::PostTask {
|
||||
title,
|
||||
desc,
|
||||
assign,
|
||||
} => {
|
||||
if let Some(backend) = self.backend.to_ref() {
|
||||
event::spawn_comms_task(backend, title, desc, assign, self.event_tx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_logs_action(&mut self, action: logs::LogsAction) {
|
||||
match action {
|
||||
logs::LogsAction::Continue => {}
|
||||
@@ -1788,6 +1850,159 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Model picker ────────────────────────────────────────────────────────
|
||||
|
||||
fn open_model_picker(&mut self) {
|
||||
let models = match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
let client = crate::daemon_client();
|
||||
match client.get(format!("{base_url}/api/models")).send() {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>() {
|
||||
Ok(body) => body["models"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter(|m| m["available"].as_bool().unwrap_or(false))
|
||||
.map(|m| chat::ModelEntry {
|
||||
id: m["id"].as_str().unwrap_or("").to_string(),
|
||||
display_name: m["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tier: m["tier"]
|
||||
.as_str()
|
||||
.unwrap_or("Balanced")
|
||||
.to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
let catalog = kernel.model_catalog.read().unwrap();
|
||||
catalog
|
||||
.available_models()
|
||||
.into_iter()
|
||||
.map(|e| chat::ModelEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
provider: e.provider.clone(),
|
||||
tier: format!("{:?}", e.tier),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Backend::None => Vec::new(),
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
self.chat
|
||||
.push_message(chat::Role::System, "No models available.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.chat.model_picker_models = models;
|
||||
self.chat.model_picker_filter.clear();
|
||||
self.chat.model_picker_idx = 0;
|
||||
self.chat.show_model_picker = true;
|
||||
}
|
||||
|
||||
fn switch_model(&mut self, model_id: &str) {
|
||||
if self.chat.model_label.ends_with(model_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match (&self.backend, &self.chat_target) {
|
||||
(Backend::Daemon { base_url }, Some(target)) => {
|
||||
if let Some(ref agent_id) = target.agent_id_daemon {
|
||||
let client = crate::daemon_client();
|
||||
let url = format!("{base_url}/api/agents/{agent_id}/model");
|
||||
match client
|
||||
.put(&url)
|
||||
.json(&serde_json::json!({"model": model_id}))
|
||||
.send()
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/agents/{agent_id}"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Failed to switch to {model_id}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(Backend::InProcess { kernel }, Some(target)) => {
|
||||
if let Some(id) = target.agent_id_inprocess {
|
||||
let provider = kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap()
|
||||
.find_model(model_id)
|
||||
.map(|e| e.provider.clone());
|
||||
let result = if let Some(ref prov) = provider {
|
||||
kernel.registry.update_model_and_provider(
|
||||
id,
|
||||
model_id.to_string(),
|
||||
prov.clone(),
|
||||
)
|
||||
} else {
|
||||
kernel.registry.update_model(id, model_id.to_string())
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let prov_label = provider.unwrap_or_else(|| {
|
||||
kernel
|
||||
.registry
|
||||
.get(id)
|
||||
.map(|e| e.manifest.model.provider.clone())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
});
|
||||
self.chat.model_label = format!("{prov_label}/{model_id}");
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.chat
|
||||
.push_message(chat::Role::System, "No backend connected.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Slash commands ──────────────────────────────────────────────────────
|
||||
|
||||
fn handle_slash_command(&mut self, cmd: &str) {
|
||||
@@ -1798,13 +2013,14 @@ impl App {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/model \u{2014} show current model",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
@@ -1929,10 +2145,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
"/model" => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Model: {}", self.chat.model_label),
|
||||
);
|
||||
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
||||
if args.is_empty() {
|
||||
self.open_model_picker();
|
||||
} else {
|
||||
self.switch_model(args);
|
||||
}
|
||||
}
|
||||
"/hands" => match &self.backend {
|
||||
Backend::InProcess { kernel } => {
|
||||
@@ -2029,6 +2247,7 @@ impl App {
|
||||
Tab::Usage => usage::draw(frame, chunks[1], &mut self.usage),
|
||||
Tab::Settings => settings::draw(frame, chunks[1], &mut self.settings),
|
||||
Tab::Peers => peers::draw(frame, chunks[1], &mut self.peers),
|
||||
Tab::Comms => comms::draw(frame, chunks[1], &mut self.comms),
|
||||
Tab::Logs => logs::draw(frame, chunks[1], &mut self.logs),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,18 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Padding, Paragraph};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Model entry for the picker.
|
||||
#[derive(Clone)]
|
||||
pub struct ModelEntry {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub provider: String,
|
||||
pub tier: String,
|
||||
}
|
||||
|
||||
/// Tool call metadata for rich rendering.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolInfo {
|
||||
@@ -68,6 +77,14 @@ pub struct ChatState {
|
||||
pub staged_messages: Vec<String>,
|
||||
/// Accumulates ToolInputDelta text for the current tool call.
|
||||
pub tool_input_buf: String,
|
||||
/// Model picker overlay state.
|
||||
pub show_model_picker: bool,
|
||||
/// Available models for the picker.
|
||||
pub model_picker_models: Vec<ModelEntry>,
|
||||
/// Filter text for model search.
|
||||
pub model_picker_filter: String,
|
||||
/// Selected index in the filtered model list.
|
||||
pub model_picker_idx: usize,
|
||||
}
|
||||
|
||||
pub enum ChatAction {
|
||||
@@ -75,6 +92,10 @@ pub enum ChatAction {
|
||||
SendMessage(String),
|
||||
Back,
|
||||
SlashCommand(String),
|
||||
/// Open the model picker (fetch models first).
|
||||
OpenModelPicker,
|
||||
/// Switch to a specific model by id.
|
||||
SwitchModel(String),
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
@@ -97,6 +118,10 @@ impl ChatState {
|
||||
status_msg: None,
|
||||
staged_messages: Vec::new(),
|
||||
tool_input_buf: String::new(),
|
||||
show_model_picker: false,
|
||||
model_picker_models: Vec::new(),
|
||||
model_picker_filter: String::new(),
|
||||
model_picker_idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +140,9 @@ impl ChatState {
|
||||
self.status_msg = None;
|
||||
self.staged_messages.clear();
|
||||
self.tool_input_buf.clear();
|
||||
self.show_model_picker = false;
|
||||
self.model_picker_filter.clear();
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
|
||||
/// Push a completed message into history.
|
||||
@@ -205,11 +233,81 @@ impl ChatState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return filtered models based on the current picker filter.
|
||||
pub fn filtered_models(&self) -> Vec<&ModelEntry> {
|
||||
if self.model_picker_filter.is_empty() {
|
||||
return self.model_picker_models.iter().collect();
|
||||
}
|
||||
let f = self.model_picker_filter.to_lowercase();
|
||||
self.model_picker_models
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.id.to_lowercase().contains(&f)
|
||||
|| m.display_name.to_lowercase().contains(&f)
|
||||
|| m.provider.to_lowercase().contains(&f)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> ChatAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.show_model_picker {
|
||||
self.show_model_picker = false;
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
return ChatAction::Back;
|
||||
}
|
||||
|
||||
// Ctrl+M: toggle model picker
|
||||
if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.is_streaming {
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
if self.show_model_picker {
|
||||
self.show_model_picker = false;
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
return ChatAction::OpenModelPicker;
|
||||
}
|
||||
|
||||
// Model picker mode: intercept all keys
|
||||
if self.show_model_picker {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_model_picker = false;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
self.model_picker_idx = self.model_picker_idx.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let max = self.filtered_models().len().saturating_sub(1);
|
||||
if self.model_picker_idx < max {
|
||||
self.model_picker_idx += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let filtered = self.filtered_models();
|
||||
if let Some(entry) = filtered.get(self.model_picker_idx) {
|
||||
let model_id = entry.id.clone();
|
||||
self.show_model_picker = false;
|
||||
self.model_picker_filter.clear();
|
||||
self.model_picker_idx = 0;
|
||||
return ChatAction::SwitchModel(model_id);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.model_picker_filter.pop();
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.model_picker_filter.push(c);
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
|
||||
// When streaming, allow typing + staging messages, scrolling, and Esc
|
||||
if self.is_streaming {
|
||||
match key.code {
|
||||
@@ -361,13 +459,136 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut ChatState) {
|
||||
f.render_widget(Paragraph::new(input_line), chunks[2]);
|
||||
|
||||
// ── Hints ────────────────────────────────────────────────────────────────
|
||||
let hints = if state.is_streaming {
|
||||
let hints = if state.show_model_picker {
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] Select [Esc] Close [type] Filter"
|
||||
} else if state.is_streaming {
|
||||
" [Enter] Stage [\u{2191}\u{2193}] Scroll [Esc] Stop"
|
||||
} else {
|
||||
" [Enter] Send [\u{2191}\u{2193}/PgUp/PgDn] Scroll [Esc] Back"
|
||||
" [Enter] Send [Ctrl+M] Models [\u{2191}\u{2193}] Scroll [Esc] Back"
|
||||
};
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(hints, theme::hint_style())]));
|
||||
f.render_widget(hints, chunks[3]);
|
||||
|
||||
// ── Model picker overlay ────────────────────────────────────────────────
|
||||
if state.show_model_picker {
|
||||
draw_model_picker(f, inner, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
let filtered = state.filtered_models();
|
||||
|
||||
// Center a popup — width ~50 cols, height capped at area
|
||||
if area.height < 6 || area.width < 20 {
|
||||
return; // Too small to show picker
|
||||
}
|
||||
let popup_w = area.width.clamp(30, 54);
|
||||
let popup_h = (filtered.len() as u16 + 4)
|
||||
.clamp(5, area.height.saturating_sub(2));
|
||||
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
|
||||
let popup_area = Rect::new(x, y, popup_w, popup_h);
|
||||
|
||||
// Clear background
|
||||
f.render_widget(Clear, popup_area);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Switch Model ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
f.render_widget(block, popup_area);
|
||||
|
||||
if inner.height < 2 || inner.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout: search bar | model list
|
||||
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
|
||||
|
||||
// Search bar
|
||||
let search_line = Line::from(vec![
|
||||
Span::styled("/ ", theme::dim_style()),
|
||||
Span::raw(&state.model_picker_filter),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(search_line), chunks[0]);
|
||||
|
||||
// Model list
|
||||
let visible_h = chunks[1].height as usize;
|
||||
let total = filtered.len();
|
||||
|
||||
if total == 0 {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" No models match",
|
||||
theme::dim_style(),
|
||||
)])),
|
||||
chunks[1],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll window: keep selected item visible
|
||||
let scroll_start = if state.model_picker_idx >= visible_h {
|
||||
state.model_picker_idx - visible_h + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let max_name = (chunks[1].width as usize).saturating_sub(14);
|
||||
for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) {
|
||||
let selected = i == state.model_picker_idx;
|
||||
let indicator = if selected { "\u{25b6} " } else { " " };
|
||||
|
||||
let name = if entry.display_name.is_empty() {
|
||||
&entry.id
|
||||
} else {
|
||||
&entry.display_name
|
||||
};
|
||||
let name_display = if name.len() > max_name && max_name > 1 {
|
||||
let truncated = openfang_types::truncate_str(name, max_name.saturating_sub(1));
|
||||
format!("{truncated}\u{2026}")
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
let tier_style = match entry.tier.to_lowercase().as_str() {
|
||||
"frontier" => Style::default().fg(theme::PURPLE),
|
||||
"smart" => Style::default().fg(theme::BLUE),
|
||||
"balanced" => Style::default().fg(theme::GREEN),
|
||||
"fast" => Style::default().fg(theme::YELLOW),
|
||||
_ => theme::dim_style(),
|
||||
};
|
||||
|
||||
let bg = if selected {
|
||||
Style::default()
|
||||
.fg(theme::TEXT_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme::TEXT_SECONDARY)
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(indicator, Style::default().fg(theme::ACCENT)),
|
||||
Span::styled(name_display, bg),
|
||||
Span::raw(" "),
|
||||
Span::styled(entry.tier.to_lowercase(), tier_style),
|
||||
]));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), chunks[1]);
|
||||
}
|
||||
|
||||
fn draw_messages(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
//! Comms screen: Agent communication topology + live event feed.
|
||||
|
||||
use crate::tui::theme;
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
// ── Data types ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommsNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub state: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommsEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub kind: String, // "parent_child" or "peer"
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CommsEventItem {
|
||||
/// Event ID — used by the dashboard for dedup, kept for wire compat.
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
pub timestamp: String,
|
||||
pub kind: String,
|
||||
pub source_name: String,
|
||||
pub target_name: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommsFocus {
|
||||
Topology,
|
||||
EventList,
|
||||
}
|
||||
|
||||
pub struct CommsState {
|
||||
pub nodes: Vec<CommsNode>,
|
||||
pub edges: Vec<CommsEdge>,
|
||||
pub events: Vec<CommsEventItem>,
|
||||
pub event_list_state: ListState,
|
||||
pub focus: CommsFocus,
|
||||
pub loading: bool,
|
||||
pub tick: usize,
|
||||
pub poll_tick: usize,
|
||||
// Send modal
|
||||
pub show_send_modal: bool,
|
||||
pub send_from: String,
|
||||
pub send_to: String,
|
||||
pub send_msg: String,
|
||||
pub send_field: usize,
|
||||
// Task modal
|
||||
pub show_task_modal: bool,
|
||||
pub task_title: String,
|
||||
pub task_desc: String,
|
||||
pub task_assign: String,
|
||||
pub task_field: usize,
|
||||
// Status
|
||||
pub status_msg: String,
|
||||
}
|
||||
|
||||
pub enum CommsAction {
|
||||
Continue,
|
||||
Refresh,
|
||||
SendMessage {
|
||||
from: String,
|
||||
to: String,
|
||||
msg: String,
|
||||
},
|
||||
PostTask {
|
||||
title: String,
|
||||
desc: String,
|
||||
assign: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CommsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nodes: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
events: Vec::new(),
|
||||
event_list_state: ListState::default(),
|
||||
focus: CommsFocus::Topology,
|
||||
loading: false,
|
||||
tick: 0,
|
||||
poll_tick: 0,
|
||||
show_send_modal: false,
|
||||
send_from: String::new(),
|
||||
send_to: String::new(),
|
||||
send_msg: String::new(),
|
||||
send_field: 0,
|
||||
show_task_modal: false,
|
||||
task_title: String::new(),
|
||||
task_desc: String::new(),
|
||||
task_assign: String::new(),
|
||||
task_field: 0,
|
||||
status_msg: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self) {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
self.poll_tick = self.poll_tick.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Auto-refresh every ~5s at 20fps tick rate.
|
||||
pub fn should_poll(&self) -> bool {
|
||||
self.poll_tick > 0 && self.poll_tick.is_multiple_of(100)
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> CommsAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
return CommsAction::Continue;
|
||||
}
|
||||
|
||||
// Modal key handling
|
||||
if self.show_send_modal {
|
||||
return self.handle_send_modal_key(key);
|
||||
}
|
||||
if self.show_task_modal {
|
||||
return self.handle_task_modal_key(key);
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Tab => {
|
||||
self.focus = match self.focus {
|
||||
CommsFocus::Topology => CommsFocus::EventList,
|
||||
CommsFocus::EventList => CommsFocus::Topology,
|
||||
};
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
self.show_send_modal = true;
|
||||
self.send_from.clear();
|
||||
self.send_to.clear();
|
||||
self.send_msg.clear();
|
||||
self.send_field = 0;
|
||||
}
|
||||
KeyCode::Char('t') => {
|
||||
self.show_task_modal = true;
|
||||
self.task_title.clear();
|
||||
self.task_desc.clear();
|
||||
self.task_assign.clear();
|
||||
self.task_field = 0;
|
||||
}
|
||||
KeyCode::Char('r') => return CommsAction::Refresh,
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = if i == 0 {
|
||||
self.events.len() - 1
|
||||
} else {
|
||||
i - 1
|
||||
};
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
|
||||
let i = self.event_list_state.selected().unwrap_or(0);
|
||||
let next = (i + 1) % self.events.len();
|
||||
self.event_list_state.select(Some(next));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
CommsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_send_modal_key(&mut self, key: KeyEvent) -> CommsAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_send_modal = false;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.send_field = (self.send_field + 1) % 3;
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
self.send_field = if self.send_field == 0 {
|
||||
2
|
||||
} else {
|
||||
self.send_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.send_from.is_empty()
|
||||
&& !self.send_to.is_empty()
|
||||
&& !self.send_msg.is_empty()
|
||||
{
|
||||
self.show_send_modal = false;
|
||||
return CommsAction::SendMessage {
|
||||
from: self.send_from.clone(),
|
||||
to: self.send_to.clone(),
|
||||
msg: self.send_msg.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => match self.send_field {
|
||||
0 => self.send_from.push(c),
|
||||
1 => self.send_to.push(c),
|
||||
_ => self.send_msg.push(c),
|
||||
},
|
||||
KeyCode::Backspace => match self.send_field {
|
||||
0 => {
|
||||
self.send_from.pop();
|
||||
}
|
||||
1 => {
|
||||
self.send_to.pop();
|
||||
}
|
||||
_ => {
|
||||
self.send_msg.pop();
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
CommsAction::Continue
|
||||
}
|
||||
|
||||
fn handle_task_modal_key(&mut self, key: KeyEvent) -> CommsAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_task_modal = false;
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
self.task_field = (self.task_field + 1) % 3;
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
self.task_field = if self.task_field == 0 {
|
||||
2
|
||||
} else {
|
||||
self.task_field - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if !self.task_title.is_empty() {
|
||||
self.show_task_modal = false;
|
||||
return CommsAction::PostTask {
|
||||
title: self.task_title.clone(),
|
||||
desc: self.task_desc.clone(),
|
||||
assign: self.task_assign.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => match self.task_field {
|
||||
0 => self.task_title.push(c),
|
||||
1 => self.task_desc.push(c),
|
||||
_ => self.task_assign.push(c),
|
||||
},
|
||||
KeyCode::Backspace => match self.task_field {
|
||||
0 => {
|
||||
self.task_title.pop();
|
||||
}
|
||||
1 => {
|
||||
self.task_desc.pop();
|
||||
}
|
||||
_ => {
|
||||
self.task_assign.pop();
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
CommsAction::Continue
|
||||
}
|
||||
|
||||
// ── Topology helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn root_nodes(&self) -> Vec<&CommsNode> {
|
||||
let child_ids: std::collections::HashSet<&str> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == "parent_child")
|
||||
.map(|e| e.to.as_str())
|
||||
.collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| !child_ids.contains(n.id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn children_of(&self, id: &str) -> Vec<&CommsNode> {
|
||||
let child_ids: Vec<&str> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == "parent_child" && e.from == id)
|
||||
.map(|e| e.to.as_str())
|
||||
.collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| child_ids.contains(&n.id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn peers_of(&self, id: &str) -> Vec<&CommsNode> {
|
||||
let peer_ids: std::collections::HashSet<&str> = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.kind == "peer")
|
||||
.filter_map(|e| {
|
||||
if e.from == id {
|
||||
Some(e.to.as_str())
|
||||
} else if e.to == id {
|
||||
Some(e.from.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.nodes
|
||||
.iter()
|
||||
.filter(|n| peer_ids.contains(n.id.as_str()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) {
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Comms ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Percentage(35), // topology
|
||||
Constraint::Length(1), // separator
|
||||
Constraint::Min(4), // event list
|
||||
Constraint::Length(1), // hints
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Header
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(vec![Span::styled(
|
||||
format!(
|
||||
" Agent Topology ({} agents, {} edges)",
|
||||
state.nodes.len(),
|
||||
state.edges.len()
|
||||
),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(""),
|
||||
]),
|
||||
chunks[0],
|
||||
);
|
||||
|
||||
// Separator
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
"\u{2500}".repeat(inner.width as usize),
|
||||
theme::dim_style(),
|
||||
))),
|
||||
chunks[1],
|
||||
);
|
||||
|
||||
// Topology tree
|
||||
draw_topology(f, chunks[2], state);
|
||||
|
||||
// Separator
|
||||
let event_label = if state.focus == CommsFocus::EventList {
|
||||
" \u{25b6} Live Event Feed"
|
||||
} else {
|
||||
" Live Event Feed"
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
event_label,
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ({} events)", state.events.len()),
|
||||
theme::dim_style(),
|
||||
),
|
||||
])),
|
||||
chunks[3],
|
||||
);
|
||||
|
||||
// Event list
|
||||
draw_event_list(f, chunks[4], state);
|
||||
|
||||
// Status message or hints
|
||||
let hint_text = if !state.status_msg.is_empty() {
|
||||
format!(
|
||||
" {} | [s]end [t]ask [r]efresh [Tab] focus [\u{2191}\u{2193}] scroll",
|
||||
state.status_msg
|
||||
)
|
||||
} else {
|
||||
" [s]end [t]ask [r]efresh [Tab] focus [\u{2191}\u{2193}] scroll".to_string()
|
||||
};
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(hint_text, theme::hint_style()))),
|
||||
chunks[5],
|
||||
);
|
||||
|
||||
// Modal overlays
|
||||
if state.show_send_modal {
|
||||
draw_send_modal(f, area, state);
|
||||
}
|
||||
if state.show_task_modal {
|
||||
draw_task_modal(f, area, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
if state.loading && state.nodes.is_empty() {
|
||||
let spinner = theme::SPINNER_FRAMES[state.tick % theme::SPINNER_FRAMES.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {spinner} "), Style::default().fg(theme::CYAN)),
|
||||
Span::styled("Loading topology\u{2026}", theme::dim_style()),
|
||||
])),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if state.nodes.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No agents running.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let focus_highlight = state.focus == CommsFocus::Topology;
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for root in state.root_nodes() {
|
||||
let state_style = state_color(&root.state);
|
||||
let mut spans = vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(format!("[{}]", &root.state), state_style),
|
||||
Span::styled(
|
||||
format!(" {} ", root.name),
|
||||
Style::default()
|
||||
.fg(if focus_highlight {
|
||||
theme::CYAN
|
||||
} else {
|
||||
theme::TEXT
|
||||
})
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!("({})", root.model), theme::dim_style()),
|
||||
];
|
||||
// Peer annotations
|
||||
for peer in state.peers_of(&root.id) {
|
||||
spans.push(Span::styled(
|
||||
format!(" \u{2194} {}", peer.name),
|
||||
Style::default().fg(theme::PURPLE),
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
|
||||
// Children
|
||||
let children = state.children_of(&root.id);
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
let branch = if i < children.len() - 1 {
|
||||
"\u{251c}\u{2500}\u{2500} "
|
||||
} else {
|
||||
"\u{2514}\u{2500}\u{2500} "
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(branch, theme::dim_style()),
|
||||
Span::styled(format!("[{}]", child.state), state_color(&child.state)),
|
||||
Span::styled(format!(" {} ", child.name), Style::default().fg(theme::TEXT)),
|
||||
Span::styled(format!("({})", child.model), theme::dim_style()),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), area);
|
||||
}
|
||||
|
||||
fn draw_event_list(f: &mut Frame, area: Rect, state: &mut CommsState) {
|
||||
if state.events.is_empty() {
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" No inter-agent events yet.",
|
||||
theme::dim_style(),
|
||||
)),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let items: Vec<ListItem> = state
|
||||
.events
|
||||
.iter()
|
||||
.map(|ev| {
|
||||
let kind_style = kind_color(&ev.kind);
|
||||
let kind_label = kind_short(&ev.kind);
|
||||
let target_part = if ev.target_name.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" \u{2192} {}", ev.target_name)
|
||||
};
|
||||
let detail = truncate(&ev.detail, 50);
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<8}", short_time(&ev.timestamp)),
|
||||
theme::dim_style(),
|
||||
),
|
||||
Span::styled(format!(" {:<10}", kind_label), kind_style),
|
||||
Span::styled(
|
||||
format!(" {}", ev.source_name),
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(target_part, Style::default().fg(theme::PURPLE)),
|
||||
Span::styled(format!(" {detail}"), theme::dim_style()),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.highlight_style(theme::selected_style())
|
||||
.highlight_symbol("> ");
|
||||
f.render_stateful_widget(list, area, &mut state.event_list_state);
|
||||
}
|
||||
|
||||
fn draw_send_modal(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
let modal = centered_rect(50, 12, area);
|
||||
f.render_widget(Clear, modal);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(" Send Message ", theme::title_style()))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::uniform(1));
|
||||
let inner = block.inner(modal);
|
||||
f.render_widget(block, modal);
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let field_style = |idx: usize| {
|
||||
if state.send_field == idx {
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
theme::dim_style()
|
||||
}
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("From (agent ID):", field_style(0))),
|
||||
rows[0],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.send_from),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("To (agent ID):", field_style(1))),
|
||||
rows[2],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.send_to),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[3],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Message:", field_style(2))),
|
||||
rows[4],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.send_msg),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[5],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
"[Tab] field [Enter] send [Esc] cancel",
|
||||
theme::hint_style(),
|
||||
)),
|
||||
rows[6],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) {
|
||||
let modal = centered_rect(50, 12, area);
|
||||
f.render_widget(Clear, modal);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Span::styled(" Post Task ", theme::title_style()))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::uniform(1));
|
||||
let inner = block.inner(modal);
|
||||
f.render_widget(block, modal);
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let field_style = |idx: usize| {
|
||||
if state.task_field == idx {
|
||||
Style::default()
|
||||
.fg(theme::CYAN)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
theme::dim_style()
|
||||
}
|
||||
};
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Title:", field_style(0))),
|
||||
rows[0],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.task_title),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[1],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Description:", field_style(1))),
|
||||
rows[2],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.task_desc),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[3],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))),
|
||||
rows[4],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!(" {}\u{2588}", &state.task_assign),
|
||||
Style::default().fg(theme::TEXT),
|
||||
)),
|
||||
rows[5],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
"[Tab] field [Enter] post [Esc] cancel",
|
||||
theme::hint_style(),
|
||||
)),
|
||||
rows[6],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn state_color(state: &str) -> Style {
|
||||
match state {
|
||||
"Running" => Style::default().fg(theme::GREEN),
|
||||
"Suspended" => Style::default().fg(theme::YELLOW),
|
||||
"Terminated" | "Crashed" => Style::default().fg(theme::RED),
|
||||
_ => theme::dim_style(),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_color(kind: &str) -> Style {
|
||||
match kind {
|
||||
"agent_message" => Style::default().fg(theme::CYAN),
|
||||
"agent_spawned" => Style::default().fg(theme::GREEN),
|
||||
"agent_terminated" => Style::default().fg(theme::RED),
|
||||
"task_posted" => Style::default().fg(theme::YELLOW),
|
||||
"task_claimed" => Style::default().fg(theme::CYAN),
|
||||
"task_completed" => Style::default().fg(theme::GREEN),
|
||||
_ => theme::dim_style(),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_short(kind: &str) -> &str {
|
||||
match kind {
|
||||
"agent_message" => "MSG",
|
||||
"agent_spawned" => "SPAWNED",
|
||||
"agent_terminated" => "KILLED",
|
||||
"task_posted" => "TASK+",
|
||||
"task_claimed" => "CLAIM",
|
||||
"task_completed" => "DONE",
|
||||
_ => kind,
|
||||
}
|
||||
}
|
||||
|
||||
fn short_time(ts: &str) -> String {
|
||||
// Extract HH:MM:SS from ISO-8601
|
||||
if let Some(t_pos) = ts.find('T') {
|
||||
let time_part = &ts[t_pos + 1..];
|
||||
if time_part.len() >= 8 {
|
||||
return time_part[..8].to_string();
|
||||
}
|
||||
}
|
||||
ts.chars().take(8).collect()
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{}\u{2026}",
|
||||
openfang_types::truncate_str(s, max.saturating_sub(1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect {
|
||||
let w = area.width * percent_x / 100;
|
||||
let x = area.x + (area.width.saturating_sub(w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||
Rect::new(x, y, w, height.min(area.height))
|
||||
}
|
||||
@@ -100,6 +100,94 @@ 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: "ollama",
|
||||
display: "Ollama",
|
||||
@@ -116,6 +204,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 ─────────────────────────────────────────────────────
|
||||
@@ -408,7 +504,7 @@ impl State {
|
||||
|
||||
for m in &models {
|
||||
match m.tier {
|
||||
ModelTier::Fast | ModelTier::Local => {
|
||||
ModelTier::Fast | ModelTier::Local | ModelTier::Custom => {
|
||||
if fast.is_none() {
|
||||
fast = Some(&m.id);
|
||||
}
|
||||
@@ -461,6 +557,7 @@ fn tier_label(tier: ModelTier) -> &'static str {
|
||||
ModelTier::Balanced => "balanced",
|
||||
ModelTier::Fast => "fast",
|
||||
ModelTier::Local => "local",
|
||||
ModelTier::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,8 +921,11 @@ fn handle_migration_key(
|
||||
if yes {
|
||||
state.migration_phase = MigrationPhase::Running;
|
||||
let source_dir = state.openclaw_path.clone().unwrap_or_default();
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
let target_dir = home.join(".openfang");
|
||||
let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
PathBuf::from(h)
|
||||
} else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang")
|
||||
};
|
||||
let tx = migrate_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let options = openfang_migrate::MigrateOptions {
|
||||
@@ -944,15 +1044,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);
|
||||
@@ -1912,11 +2014,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),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod agents;
|
||||
pub mod audit;
|
||||
pub mod channels;
|
||||
pub mod comms;
|
||||
pub mod chat;
|
||||
pub mod dashboard;
|
||||
pub mod extensions;
|
||||
|
||||
@@ -67,6 +67,66 @@ 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: "ollama",
|
||||
env_var: "OLLAMA_API_KEY",
|
||||
@@ -89,11 +149,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)]
|
||||
@@ -294,16 +358,18 @@ 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);
|
||||
|
||||
@@ -34,6 +34,7 @@ pub const PURPLE: Color = Color::Rgb(168, 85, 247); // #A855F7 — decorators
|
||||
|
||||
pub const CYAN: Color = BLUE;
|
||||
pub const DIM: Color = TEXT_SECONDARY;
|
||||
pub const TEXT: Color = TEXT_PRIMARY;
|
||||
|
||||
// ── Reusable styles ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -113,6 +113,14 @@ value = "elevenlabs"
|
||||
label = "ElevenLabs"
|
||||
provider_env = "ELEVENLABS_API_KEY"
|
||||
|
||||
[[settings]]
|
||||
key = "elevenlabs_api_key"
|
||||
label = "ElevenLabs API Key"
|
||||
description = "API key from elevenlabs.io for high-quality text-to-speech. Required when ElevenLabs TTS is selected."
|
||||
setting_type = "text"
|
||||
env_var = "ELEVENLABS_API_KEY"
|
||||
default = ""
|
||||
|
||||
# ─── Publishing settings ────────────────────────────────────────────────────
|
||||
|
||||
[[settings]]
|
||||
|
||||
@@ -28,6 +28,13 @@ steps = [
|
||||
|
||||
# ─── Configurable settings ───────────────────────────────────────────────────
|
||||
|
||||
[[settings]]
|
||||
key = "twitter_bearer_token"
|
||||
label = "Twitter Bearer Token"
|
||||
description = "Bearer Token from the Twitter/X Developer Portal. Required for all Twitter API operations."
|
||||
setting_type = "text"
|
||||
default = ""
|
||||
|
||||
[[settings]]
|
||||
key = "twitter_style"
|
||||
label = "Content Style"
|
||||
|
||||
@@ -172,6 +172,10 @@ pub struct HandSetting {
|
||||
pub default: String,
|
||||
#[serde(default)]
|
||||
pub options: Vec<HandSettingOption>,
|
||||
/// Env var name to expose when a text-type setting has a value
|
||||
/// (e.g. `ELEVENLABS_API_KEY` for an API key text field).
|
||||
#[serde(default)]
|
||||
pub env_var: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of resolving user-chosen settings against the schema.
|
||||
@@ -227,6 +231,9 @@ pub fn resolve_settings(
|
||||
HandSettingType::Text => {
|
||||
if !chosen_value.is_empty() {
|
||||
lines.push(format!("- {}: {}", setting.label, chosen_value));
|
||||
if let Some(ref env) = setting.env_var {
|
||||
env_vars.push(env.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -550,6 +557,7 @@ metrics = []
|
||||
binary: None,
|
||||
},
|
||||
],
|
||||
env_var: None,
|
||||
}];
|
||||
|
||||
// User picks groq
|
||||
@@ -583,6 +591,7 @@ metrics = []
|
||||
binary: None,
|
||||
},
|
||||
],
|
||||
env_var: None,
|
||||
}];
|
||||
|
||||
// Empty config → uses default "auto"
|
||||
@@ -604,6 +613,7 @@ metrics = []
|
||||
setting_type: HandSettingType::Toggle,
|
||||
default: "false".to_string(),
|
||||
options: vec![],
|
||||
env_var: None,
|
||||
},
|
||||
HandSetting {
|
||||
key: "custom_model".to_string(),
|
||||
@@ -612,6 +622,7 @@ metrics = []
|
||||
setting_type: HandSettingType::Text,
|
||||
default: String::new(),
|
||||
options: vec![],
|
||||
env_var: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ pub struct SettingStatus {
|
||||
/// The Hand registry — stores definitions and tracks active instances.
|
||||
pub struct HandRegistry {
|
||||
/// All known hand definitions, keyed by hand_id.
|
||||
definitions: HashMap<String, HandDefinition>,
|
||||
definitions: DashMap<String, HandDefinition>,
|
||||
/// Active hand instances, keyed by instance UUID.
|
||||
instances: DashMap<Uuid, HandInstance>,
|
||||
}
|
||||
@@ -47,13 +47,13 @@ impl HandRegistry {
|
||||
/// Create an empty registry.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
definitions: HashMap::new(),
|
||||
definitions: DashMap::new(),
|
||||
instances: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all bundled hand definitions. Returns count of definitions loaded.
|
||||
pub fn load_bundled(&mut self) -> usize {
|
||||
pub fn load_bundled(&self) -> usize {
|
||||
let bundled = bundled::bundled_hands();
|
||||
let mut count = 0;
|
||||
for (id, toml_content, skill_content) in bundled {
|
||||
@@ -71,16 +71,60 @@ impl HandRegistry {
|
||||
count
|
||||
}
|
||||
|
||||
/// Install a hand from a directory containing HAND.toml (and optional SKILL.md).
|
||||
pub fn install_from_path(&self, path: &std::path::Path) -> HandResult<HandDefinition> {
|
||||
let toml_path = path.join("HAND.toml");
|
||||
let skill_path = path.join("SKILL.md");
|
||||
|
||||
let toml_content = std::fs::read_to_string(&toml_path).map_err(|e| {
|
||||
HandError::NotFound(format!("Cannot read {}: {e}", toml_path.display()))
|
||||
})?;
|
||||
let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default();
|
||||
|
||||
let def = bundled::parse_bundled("custom", &toml_content, &skill_content)?;
|
||||
|
||||
if self.definitions.contains_key(&def.id) {
|
||||
return Err(HandError::AlreadyActive(format!(
|
||||
"Hand '{}' already registered",
|
||||
def.id
|
||||
)));
|
||||
}
|
||||
|
||||
info!(hand = %def.id, name = %def.name, path = %path.display(), "Installed hand from path");
|
||||
self.definitions.insert(def.id.clone(), def.clone());
|
||||
Ok(def)
|
||||
}
|
||||
|
||||
/// Install a hand from raw TOML + skill content (for API-based installs).
|
||||
pub fn install_from_content(
|
||||
&self,
|
||||
toml_content: &str,
|
||||
skill_content: &str,
|
||||
) -> HandResult<HandDefinition> {
|
||||
let def = bundled::parse_bundled("custom", toml_content, skill_content)?;
|
||||
|
||||
if self.definitions.contains_key(&def.id) {
|
||||
return Err(HandError::AlreadyActive(format!(
|
||||
"Hand '{}' already registered",
|
||||
def.id
|
||||
)));
|
||||
}
|
||||
|
||||
info!(hand = %def.id, name = %def.name, "Installed hand from content");
|
||||
self.definitions.insert(def.id.clone(), def.clone());
|
||||
Ok(def)
|
||||
}
|
||||
|
||||
/// List all known hand definitions.
|
||||
pub fn list_definitions(&self) -> Vec<&HandDefinition> {
|
||||
let mut defs: Vec<&HandDefinition> = self.definitions.values().collect();
|
||||
defs.sort_by_key(|d| &d.name);
|
||||
pub fn list_definitions(&self) -> Vec<HandDefinition> {
|
||||
let mut defs: Vec<HandDefinition> = self.definitions.iter().map(|r| r.value().clone()).collect();
|
||||
defs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
defs
|
||||
}
|
||||
|
||||
/// Get a specific hand definition by ID.
|
||||
pub fn get_definition(&self, hand_id: &str) -> Option<&HandDefinition> {
|
||||
self.definitions.get(hand_id)
|
||||
pub fn get_definition(&self, hand_id: &str) -> Option<HandDefinition> {
|
||||
self.definitions.get(hand_id).map(|r| r.value().clone())
|
||||
}
|
||||
|
||||
/// Activate a hand — creates an instance (agent spawning is done by kernel).
|
||||
@@ -230,6 +274,21 @@ impl HandRegistry {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Update config for an active hand instance.
|
||||
pub fn update_config(
|
||||
&self,
|
||||
instance_id: Uuid,
|
||||
config: HashMap<String, serde_json::Value>,
|
||||
) -> HandResult<()> {
|
||||
let mut entry = self
|
||||
.instances
|
||||
.get_mut(&instance_id)
|
||||
.ok_or(HandError::InstanceNotFound(instance_id))?;
|
||||
entry.config = config;
|
||||
entry.updated_at = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark an instance as errored.
|
||||
pub fn set_error(&self, instance_id: Uuid, message: String) -> HandResult<()> {
|
||||
let mut entry = self
|
||||
@@ -329,7 +388,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn load_bundled_hands() {
|
||||
let mut reg = HandRegistry::new();
|
||||
let reg = HandRegistry::new();
|
||||
let count = reg.load_bundled();
|
||||
assert_eq!(count, 7);
|
||||
assert!(!reg.list_definitions().is_empty());
|
||||
@@ -353,7 +412,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn activate_and_deactivate() {
|
||||
let mut reg = HandRegistry::new();
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("clip", HashMap::new()).unwrap();
|
||||
@@ -375,7 +434,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pause_and_resume() {
|
||||
let mut reg = HandRegistry::new();
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("clip", HashMap::new()).unwrap();
|
||||
@@ -394,7 +453,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn set_agent() {
|
||||
let mut reg = HandRegistry::new();
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("clip", HashMap::new()).unwrap();
|
||||
@@ -412,7 +471,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn check_requirements() {
|
||||
let mut reg = HandRegistry::new();
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let results = reg.check_requirements("clip").unwrap();
|
||||
@@ -437,7 +496,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn set_error_status() {
|
||||
let mut reg = HandRegistry::new();
|
||||
let reg = HandRegistry::new();
|
||||
reg.load_bundled();
|
||||
|
||||
let instance = reg.activate("clip", HashMap::new()).unwrap();
|
||||
|
||||
@@ -181,6 +181,7 @@ mod tests {
|
||||
require_approval: vec!["file_write".to_string(), "file_delete".to_string()],
|
||||
timeout_secs: 30,
|
||||
auto_approve_autonomous: false,
|
||||
auto_approve: false,
|
||||
};
|
||||
let mgr = ApprovalManager::new(policy);
|
||||
assert!(mgr.requires_approval("file_write"));
|
||||
@@ -258,6 +259,7 @@ mod tests {
|
||||
require_approval: vec!["file_write".to_string()],
|
||||
timeout_secs: 120,
|
||||
auto_approve_autonomous: true,
|
||||
auto_approve: false,
|
||||
};
|
||||
mgr.update_policy(new_policy);
|
||||
|
||||
|
||||
@@ -224,15 +224,19 @@ pub fn deep_merge_toml(base: &mut toml::Value, overlay: &toml::Value) {
|
||||
}
|
||||
|
||||
/// Get the default config file path.
|
||||
///
|
||||
/// Respects `OPENFANG_HOME` env var (e.g. `OPENFANG_HOME=/opt/openfang`).
|
||||
pub fn default_config_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join(".openfang")
|
||||
.join("config.toml")
|
||||
openfang_home().join("config.toml")
|
||||
}
|
||||
|
||||
/// Get the default OpenFang home directory.
|
||||
/// Get the OpenFang home directory.
|
||||
///
|
||||
/// Priority: `OPENFANG_HOME` env var > `~/.openfang`.
|
||||
pub fn openfang_home() -> PathBuf {
|
||||
if let Ok(home) = std::env::var("OPENFANG_HOME") {
|
||||
return PathBuf::from(home);
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join(".openfang")
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! **No-op** (informational only): log_level, language, mode.
|
||||
//!
|
||||
//! **Restart required**: api_listen, api_key, network, memory, default_model.
|
||||
//! **Restart required**: api_listen, api_key, network, memory.
|
||||
|
||||
use openfang_types::config::{KernelConfig, ReloadMode};
|
||||
use tracing::{info, warn};
|
||||
@@ -43,6 +43,8 @@ pub enum HotAction {
|
||||
ReloadFallbackProviders,
|
||||
/// Provider base URL overrides changed.
|
||||
ReloadProviderUrls,
|
||||
/// Default model changed — update in-place without restart.
|
||||
UpdateDefaultModel,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -161,11 +163,9 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan {
|
||||
.push("memory config changed".to_string());
|
||||
}
|
||||
|
||||
// Default model (driver needs recreation)
|
||||
// Default model — hot-reloadable (just swap config fields, new agents pick it up)
|
||||
if field_changed(&old.default_model, &new.default_model) {
|
||||
plan.restart_required = true;
|
||||
plan.restart_reasons
|
||||
.push("default_model changed".to_string());
|
||||
plan.hot_actions.push(HotAction::UpdateDefaultModel);
|
||||
}
|
||||
|
||||
// Home/data directory changes
|
||||
@@ -406,16 +406,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_model_requires_restart() {
|
||||
fn test_default_model_hot_reloadable() {
|
||||
let a = default_cfg();
|
||||
let mut b = default_cfg();
|
||||
b.default_model.model = "gpt-4".to_string();
|
||||
let plan = build_reload_plan(&a, &b);
|
||||
assert!(plan.restart_required);
|
||||
assert!(plan
|
||||
.restart_reasons
|
||||
.iter()
|
||||
.any(|r| r.contains("default_model")));
|
||||
assert!(!plan.restart_required, "default_model should be hot-reloadable");
|
||||
assert!(plan.hot_actions.contains(&HotAction::UpdateDefaultModel));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -234,9 +234,9 @@ impl CronScheduler {
|
||||
if meta.job.enabled && meta.job.next_run.map(|t| t <= now).unwrap_or(false) {
|
||||
due.push(meta.job.clone());
|
||||
// Pre-advance next_run so the job won't fire again on the next
|
||||
// tick while it's still executing. record_success/record_failure
|
||||
// will recompute it again after execution completes.
|
||||
meta.job.next_run = Some(compute_next_run(&meta.job.schedule));
|
||||
// tick while it's still executing. Use `now` as the base so the
|
||||
// next fire time is computed strictly after the current moment.
|
||||
meta.job.next_run = Some(compute_next_run_after(&meta.job.schedule, now));
|
||||
}
|
||||
}
|
||||
due
|
||||
@@ -287,7 +287,8 @@ impl CronScheduler {
|
||||
);
|
||||
meta.job.enabled = false;
|
||||
} else {
|
||||
meta.job.next_run = Some(compute_next_run(&meta.job.schedule));
|
||||
meta.job.next_run =
|
||||
Some(compute_next_run_after(&meta.job.schedule, Utc::now()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,7 +298,7 @@ impl CronScheduler {
|
||||
// compute_next_run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the next fire time for a schedule.
|
||||
/// Compute the next fire time for a schedule, based on `now`.
|
||||
///
|
||||
/// - `At { at }` — returns `at` directly.
|
||||
/// - `Every { every_secs }` — returns `now + every_secs`.
|
||||
@@ -306,9 +307,23 @@ impl CronScheduler {
|
||||
/// 6-field (`sec min hour dom month dow`) formats by converting to the
|
||||
/// 7-field format required by the `cron` crate.
|
||||
pub fn compute_next_run(schedule: &CronSchedule) -> chrono::DateTime<Utc> {
|
||||
compute_next_run_after(schedule, Utc::now())
|
||||
}
|
||||
|
||||
/// Compute the next fire time for a schedule, strictly after `after`.
|
||||
///
|
||||
/// Uses `after + 1 second` as the base time so the `cron` crate's
|
||||
/// inclusive `.after()` always returns a strictly future time. Without
|
||||
/// this offset, calling `compute_next_run` right after a job fires can
|
||||
/// return the same minute (or even the same second), causing the
|
||||
/// scheduler to re-fire immediately.
|
||||
pub fn compute_next_run_after(
|
||||
schedule: &CronSchedule,
|
||||
after: chrono::DateTime<Utc>,
|
||||
) -> chrono::DateTime<Utc> {
|
||||
match schedule {
|
||||
CronSchedule::At { at } => *at,
|
||||
CronSchedule::Every { every_secs } => Utc::now() + Duration::seconds(*every_secs as i64),
|
||||
CronSchedule::Every { every_secs } => after + Duration::seconds(*every_secs as i64),
|
||||
CronSchedule::Cron { expr, tz: _ } => {
|
||||
// Convert standard 5/6-field cron to 7-field for the `cron` crate.
|
||||
// Standard 5-field: min hour dom month dow
|
||||
@@ -322,14 +337,17 @@ pub fn compute_next_run(schedule: &CronSchedule) -> chrono::DateTime<Utc> {
|
||||
_ => expr.clone(),
|
||||
};
|
||||
|
||||
// Add 1 second so `.after()` (inclusive) skips the current second.
|
||||
let base = after + Duration::seconds(1);
|
||||
|
||||
match seven_field.parse::<cron::Schedule>() {
|
||||
Ok(sched) => sched
|
||||
.after(&Utc::now())
|
||||
.after(&base)
|
||||
.next()
|
||||
.unwrap_or_else(|| Utc::now() + Duration::hours(1)),
|
||||
.unwrap_or_else(|| after + Duration::hours(1)),
|
||||
Err(e) => {
|
||||
warn!("Failed to parse cron expression '{}': {}", expr, e);
|
||||
Utc::now() + Duration::hours(1)
|
||||
after + Duration::hours(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -729,6 +747,27 @@ mod tests {
|
||||
|
||||
// -- error message truncation in record_failure -------------------------
|
||||
|
||||
#[test]
|
||||
fn test_compute_next_run_after_skips_current_second() {
|
||||
// A "every 4 hours" cron: next_run should be >= 4 hours from now,
|
||||
// not in the same minute (the bug from #55).
|
||||
let schedule = CronSchedule::Cron {
|
||||
expr: "0 */4 * * *".into(),
|
||||
tz: None,
|
||||
};
|
||||
let now = Utc::now();
|
||||
let next = compute_next_run_after(&schedule, now);
|
||||
// Must be strictly after `now` and at least ~1 hour away
|
||||
// (the closest 4-hourly boundary is at least minutes away).
|
||||
assert!(next > now, "next_run should be strictly after now");
|
||||
let diff = next - now;
|
||||
assert!(
|
||||
diff.num_minutes() >= 1,
|
||||
"Expected next_run at least 1 min away, got {} seconds",
|
||||
diff.num_seconds()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_failure_truncates_long_error() {
|
||||
let (sched, _tmp) = make_scheduler(100);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -343,6 +343,11 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
return (0.40, 0.40);
|
||||
}
|
||||
|
||||
// ── Venice.ai ──────────────────────────────────────────────
|
||||
if model.contains("venice") {
|
||||
return (0.20, 0.90);
|
||||
}
|
||||
|
||||
// ── Open-source (Groq, Together, etc.) ─────────────────────
|
||||
if model.contains("llama-4-maverick") {
|
||||
return (0.50, 0.77);
|
||||
@@ -376,6 +381,12 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
}
|
||||
|
||||
// ── Zhipu / GLM ─────────────────────────────────────────────
|
||||
if model.contains("glm-5") {
|
||||
return (2.00, 8.00);
|
||||
}
|
||||
if model.contains("glm-4.7") {
|
||||
return (1.50, 5.00);
|
||||
}
|
||||
if model.contains("glm-4-flash") {
|
||||
return (0.10, 0.10);
|
||||
}
|
||||
@@ -391,6 +402,20 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
return (0.80, 0.80);
|
||||
}
|
||||
|
||||
// ── Volcano Engine / Doubao ────────────────────────────────
|
||||
if model.contains("doubao-seed-code") {
|
||||
return (0.50, 1.00);
|
||||
}
|
||||
if model.contains("doubao") && model.contains("mini") {
|
||||
return (0.10, 0.10);
|
||||
}
|
||||
if model.contains("doubao") && model.contains("lite") {
|
||||
return (0.30, 0.60);
|
||||
}
|
||||
if model.contains("doubao") {
|
||||
return (0.80, 2.00);
|
||||
}
|
||||
|
||||
// ── Baidu ERNIE ─────────────────────────────────────────────
|
||||
if model.contains("ernie") {
|
||||
return (2.00, 6.00);
|
||||
@@ -429,7 +454,7 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) {
|
||||
}
|
||||
|
||||
// ── xAI / Grok ──────────────────────────────────────────────
|
||||
if model.contains("grok-4.1") {
|
||||
if model.contains("grok-4-1") {
|
||||
return (0.20, 0.50);
|
||||
}
|
||||
if model.contains("grok-4") {
|
||||
|
||||
@@ -177,6 +177,21 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's fallback model chain.
|
||||
pub fn update_fallback_models(
|
||||
&self,
|
||||
id: AgentId,
|
||||
fallback_models: Vec<openfang_types::agent::FallbackModel>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
entry.manifest.fallback_models = fallback_models;
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's skill allowlist.
|
||||
pub fn update_skills(&self, id: AgentId, skills: Vec<String>) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
@@ -199,6 +214,27 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's tool allowlist and blocklist.
|
||||
pub fn update_tool_filters(
|
||||
&self,
|
||||
id: AgentId,
|
||||
allowlist: Option<Vec<String>>,
|
||||
blocklist: Option<Vec<String>>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
if let Some(al) = allowlist {
|
||||
entry.manifest.tool_allowlist = al;
|
||||
}
|
||||
if let Some(bl) = blocklist {
|
||||
entry.manifest.tool_blocklist = bl;
|
||||
}
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's system prompt (hot-swap, takes effect on next message).
|
||||
pub fn update_system_prompt(&self, id: AgentId, new_prompt: String) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
@@ -241,6 +277,31 @@ impl AgentRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an agent's resource quota (budget limits).
|
||||
pub fn update_resources(
|
||||
&self,
|
||||
id: AgentId,
|
||||
hourly: Option<f64>,
|
||||
daily: Option<f64>,
|
||||
monthly: Option<f64>,
|
||||
) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
.agents
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| OpenFangError::AgentNotFound(id.to_string()))?;
|
||||
if let Some(v) = hourly {
|
||||
entry.manifest.resources.max_cost_per_hour_usd = v;
|
||||
}
|
||||
if let Some(v) = daily {
|
||||
entry.manifest.resources.max_cost_per_day_usd = v;
|
||||
}
|
||||
if let Some(v) = monthly {
|
||||
entry.manifest.resources.max_cost_per_month_usd = v;
|
||||
}
|
||||
entry.last_active = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark an agent's onboarding as complete.
|
||||
pub fn mark_onboarding_complete(&self, id: AgentId) -> OpenFangResult<()> {
|
||||
let mut entry = self
|
||||
@@ -295,6 +356,8 @@ mod tests {
|
||||
workspace: None,
|
||||
generate_identity_files: true,
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
},
|
||||
state: AgentState::Created,
|
||||
mode: AgentMode::default(),
|
||||
|
||||
@@ -54,10 +54,11 @@ impl SetupWizard {
|
||||
/// model configuration, capabilities, and schedule.
|
||||
pub fn build_plan(intent: AgentIntent) -> SetupPlan {
|
||||
// Map model tier to provider/model
|
||||
// Use "default" so the kernel applies config.toml's [default_model].
|
||||
// Only "complex" tier gets an explicit Anthropic override.
|
||||
let (provider, model) = match intent.model_tier.as_str() {
|
||||
"simple" => ("groq", "llama-3.3-70b-versatile"),
|
||||
"complex" => ("anthropic", "claude-sonnet-4-20250514"),
|
||||
_ => ("groq", "llama-3.3-70b-versatile"), // medium default
|
||||
_ => ("default", "default"),
|
||||
};
|
||||
|
||||
// Build capabilities from intent
|
||||
@@ -179,6 +180,8 @@ impl SetupWizard {
|
||||
profile: None,
|
||||
fallback_models: vec![],
|
||||
exec_policy: None,
|
||||
tool_allowlist: vec![],
|
||||
tool_blocklist: vec![],
|
||||
};
|
||||
|
||||
let skills_to_install: Vec<String> = intent
|
||||
@@ -283,7 +286,7 @@ mod tests {
|
||||
let plan = SetupWizard::build_plan(intent);
|
||||
|
||||
assert_eq!(plan.manifest.name, "research-bot");
|
||||
assert_eq!(plan.manifest.model.provider, "groq");
|
||||
assert_eq!(plan.manifest.model.provider, "default");
|
||||
assert!(plan
|
||||
.manifest
|
||||
.capabilities
|
||||
|
||||
@@ -349,9 +349,9 @@ async fn test_multiple_wasm_agents() {
|
||||
assert_eq!(hello_result.response, "hello from wasm");
|
||||
assert!(echo_result.response.contains("test data"));
|
||||
|
||||
// Verify agent list shows both
|
||||
// Verify agent list shows both + default assistant
|
||||
let agents = kernel.registry.list();
|
||||
assert_eq!(agents.len(), 2);
|
||||
assert_eq!(agents.len(), 3);
|
||||
|
||||
kernel.shutdown();
|
||||
}
|
||||
@@ -391,9 +391,9 @@ memory_write = ["self.*"]
|
||||
let llm_manifest: AgentManifest = toml::from_str(llm_toml).unwrap();
|
||||
let llm_id = kernel.spawn_agent(llm_manifest).unwrap();
|
||||
|
||||
// Verify both agents exist
|
||||
// Verify both agents exist + default assistant
|
||||
let agents = kernel.registry.list();
|
||||
assert_eq!(agents.len(), 2);
|
||||
assert_eq!(agents.len(), 3);
|
||||
|
||||
// WASM agent should work
|
||||
let result = kernel.send_message(wasm_id, "hello").await.unwrap();
|
||||
@@ -404,7 +404,7 @@ memory_write = ["self.*"]
|
||||
|
||||
// Kill WASM agent
|
||||
kernel.kill_agent(wasm_id).unwrap();
|
||||
assert_eq!(kernel.registry.list().len(), 1);
|
||||
assert_eq!(kernel.registry.list().len(), 2);
|
||||
|
||||
kernel.shutdown();
|
||||
}
|
||||
|
||||
@@ -128,6 +128,20 @@ impl SessionStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the canonical (cross-channel) session for an agent.
|
||||
pub fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM canonical_sessions WHERE agent_id = ?1",
|
||||
rusqlite::params![agent_id.0.to_string()],
|
||||
)
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all sessions with metadata (session_id, agent_id, message_count, created_at).
|
||||
pub fn list_sessions(&self) -> OpenFangResult<Vec<serde_json::Value>> {
|
||||
let conn = self
|
||||
@@ -556,6 +570,7 @@ impl SessionStore {
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
tool_name: _,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
|
||||
@@ -91,16 +91,21 @@ impl StructuredStore {
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![agent_id.0.to_string()], |row| {
|
||||
let key: String = row.get(0)?;
|
||||
let val_str: String = row.get(1)?;
|
||||
Ok((key, val_str))
|
||||
let blob: Vec<u8> = row.get(1)?;
|
||||
Ok((key, blob))
|
||||
})
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
|
||||
let mut pairs = Vec::new();
|
||||
for row in rows {
|
||||
let (key, val_str) = row.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&val_str).unwrap_or(serde_json::Value::String(val_str));
|
||||
let (key, blob) = row.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&blob)
|
||||
.unwrap_or_else(|_| {
|
||||
// Fallback: try as UTF-8 string
|
||||
String::from_utf8(blob)
|
||||
.map(serde_json::Value::String)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
});
|
||||
pairs.push((key, value));
|
||||
}
|
||||
Ok(pairs)
|
||||
@@ -125,11 +130,19 @@ impl StructuredStore {
|
||||
"ALTER TABLE agents ADD COLUMN session_id TEXT DEFAULT ''",
|
||||
[],
|
||||
);
|
||||
// Add identity column (migration compat)
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE agents ADD COLUMN identity TEXT DEFAULT '{}'",
|
||||
[],
|
||||
);
|
||||
|
||||
let identity_json = serde_json::to_string(&entry.identity)
|
||||
.map_err(|e| OpenFangError::Serialization(e.to_string()))?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO agents (id, name, manifest, state, created_at, updated_at, session_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(id) DO UPDATE SET name = ?2, manifest = ?3, state = ?4, updated_at = ?6, session_id = ?7",
|
||||
"INSERT INTO agents (id, name, manifest, state, created_at, updated_at, session_id, identity)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(id) DO UPDATE SET name = ?2, manifest = ?3, state = ?4, updated_at = ?6, session_id = ?7, identity = ?8",
|
||||
rusqlite::params![
|
||||
entry.id.0.to_string(),
|
||||
entry.name,
|
||||
@@ -138,6 +151,7 @@ impl StructuredStore {
|
||||
entry.created_at.to_rfc3339(),
|
||||
now,
|
||||
entry.session_id.0.to_string(),
|
||||
identity_json,
|
||||
],
|
||||
)
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
@@ -152,10 +166,13 @@ impl StructuredStore {
|
||||
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents WHERE id = ?1")
|
||||
.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id, identity FROM agents WHERE id = ?1")
|
||||
.or_else(|_| {
|
||||
// Fallback without session_id column for old DBs
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at FROM agents WHERE id = ?1")
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents WHERE id = ?1")
|
||||
.or_else(|_| {
|
||||
// Fallback without session_id column for old DBs
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at FROM agents WHERE id = ?1")
|
||||
})
|
||||
})
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
|
||||
@@ -170,11 +187,16 @@ impl StructuredStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str))
|
||||
let identity_str: Option<String> = if col_count >= 8 {
|
||||
row.get(7).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str, identity_str))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str)) => {
|
||||
Ok((name, manifest_blob, state_str, created_str, session_id_str, identity_str)) => {
|
||||
let manifest = rmp_serde::from_slice(&manifest_blob)
|
||||
.map_err(|e| OpenFangError::Serialization(e.to_string()))?;
|
||||
let state = serde_json::from_str(&state_str)
|
||||
@@ -186,6 +208,9 @@ impl StructuredStore {
|
||||
.and_then(|s| uuid::Uuid::parse_str(&s).ok())
|
||||
.map(openfang_types::agent::SessionId)
|
||||
.unwrap_or_else(openfang_types::agent::SessionId::new);
|
||||
let identity = identity_str
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Ok(Some(AgentEntry {
|
||||
id: agent_id,
|
||||
name,
|
||||
@@ -198,7 +223,7 @@ impl StructuredStore {
|
||||
children: vec![],
|
||||
session_id,
|
||||
tags: vec![],
|
||||
identity: Default::default(),
|
||||
identity,
|
||||
onboarding_completed: false,
|
||||
onboarding_completed_at: None,
|
||||
}))
|
||||
@@ -234,11 +259,14 @@ impl StructuredStore {
|
||||
.lock()
|
||||
.map_err(|e| OpenFangError::Internal(e.to_string()))?;
|
||||
|
||||
// Try with session_id column first, fall back without
|
||||
// Try with identity+session_id columns first, fall back gracefully
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents",
|
||||
"SELECT id, name, manifest, state, created_at, updated_at, session_id, identity FROM agents",
|
||||
)
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at, session_id FROM agents")
|
||||
})
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, manifest, state, created_at, updated_at FROM agents")
|
||||
})
|
||||
@@ -257,6 +285,11 @@ impl StructuredStore {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let identity_str: Option<String> = if col_count >= 8 {
|
||||
row.get(7).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((
|
||||
id_str,
|
||||
name,
|
||||
@@ -264,6 +297,7 @@ impl StructuredStore {
|
||||
state_str,
|
||||
created_str,
|
||||
session_id_str,
|
||||
identity_str,
|
||||
))
|
||||
})
|
||||
.map_err(|e| OpenFangError::Memory(e.to_string()))?;
|
||||
@@ -273,7 +307,7 @@ impl StructuredStore {
|
||||
let mut repair_queue: Vec<(String, Vec<u8>, String)> = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
let (id_str, name, manifest_blob, state_str, created_str, session_id_str) = match row {
|
||||
let (id_str, name, manifest_blob, state_str, created_str, session_id_str, identity_str) = match row {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("Skipping agent row with read error: {e}");
|
||||
@@ -337,6 +371,10 @@ impl StructuredStore {
|
||||
.map(openfang_types::agent::SessionId)
|
||||
.unwrap_or_else(openfang_types::agent::SessionId::new);
|
||||
|
||||
let identity = identity_str
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
agents.push(AgentEntry {
|
||||
id: agent_id,
|
||||
name,
|
||||
@@ -349,7 +387,7 @@ impl StructuredStore {
|
||||
children: vec![],
|
||||
session_id,
|
||||
tags: vec![],
|
||||
identity: Default::default(),
|
||||
identity,
|
||||
onboarding_completed: false,
|
||||
onboarding_completed_at: None,
|
||||
});
|
||||
|
||||
@@ -164,6 +164,16 @@ impl MemorySubstrate {
|
||||
self.sessions.delete_session(session_id)
|
||||
}
|
||||
|
||||
/// Delete all sessions belonging to an agent.
|
||||
pub fn delete_agent_sessions(&self, agent_id: AgentId) -> OpenFangResult<()> {
|
||||
self.sessions.delete_agent_sessions(agent_id)
|
||||
}
|
||||
|
||||
/// Delete the canonical (cross-channel) session for an agent.
|
||||
pub fn delete_canonical_session(&self, agent_id: AgentId) -> OpenFangResult<()> {
|
||||
self.sessions.delete_canonical_session(agent_id)
|
||||
}
|
||||
|
||||
/// Set or clear a session label.
|
||||
pub fn set_session_label(
|
||||
&self,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user