mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 08:52:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3dcf02e3c | ||
|
|
b157e3c7e6 | ||
|
|
74ac992420 | ||
|
|
53e1b31777 | ||
|
|
603a94e560 | ||
|
|
fac4ad33e5 | ||
|
|
fe96cd1004 | ||
|
|
7c85308cf6 | ||
|
|
a4a83b1699 | ||
|
|
8942d8c2b6 | ||
|
|
260dd7a125 | ||
|
|
294f0e7af8 | ||
|
|
444d82e4d6 | ||
|
|
62e6e0f088 | ||
|
|
d3385f2cdc |
Generated
+45
-15
@@ -3866,7 +3866,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3902,7 +3902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3933,7 +3933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3960,7 +3960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3986,7 +3986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4014,7 +4014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4031,7 +4031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4067,7 +4067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4086,7 +4086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4105,7 +4105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4137,7 +4137,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4155,11 +4155,12 @@ dependencies = [
|
||||
"tracing",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4178,7 +4179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -6553,7 +6554,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8790,7 +8791,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.8"
|
||||
version = "0.3.11"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
@@ -8970,6 +8971,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"
|
||||
@@ -8988,6 +9006,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"
|
||||
|
||||
+4
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.9"
|
||||
version = "0.3.12"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -122,6 +122,9 @@ argon2 = "0.5"
|
||||
# Lightweight regex
|
||||
regex-lite = "0.1"
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
@@ -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) {
|
||||
@@ -1545,12 +1521,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,9 +79,25 @@ pub async fn auth(
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth (dashboard needs these)
|
||||
// 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(true); // default true for unix sockets / tests
|
||||
if is_loopback {
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Public endpoints that don't require auth (dashboard needs these)
|
||||
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"
|
||||
|
||||
@@ -115,20 +115,63 @@ pub async fn spawn_agent(
|
||||
|
||||
/// GET /api/agents — List all agents.
|
||||
pub async fn list_agents(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
// Snapshot catalog once for enrichment
|
||||
let catalog = state.kernel.model_catalog.read().ok();
|
||||
let dm = &state.kernel.config.default_model;
|
||||
|
||||
let agents: Vec<serde_json::Value> = state
|
||||
.kernel
|
||||
.registry
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
// Resolve "default" provider/model to actual kernel defaults
|
||||
let provider = if e.manifest.model.provider.is_empty()
|
||||
|| e.manifest.model.provider == "default"
|
||||
{
|
||||
dm.provider.as_str()
|
||||
} else {
|
||||
e.manifest.model.provider.as_str()
|
||||
};
|
||||
let model = if e.manifest.model.model.is_empty()
|
||||
|| e.manifest.model.model == "default"
|
||||
{
|
||||
dm.model.as_str()
|
||||
} else {
|
||||
e.manifest.model.model.as_str()
|
||||
};
|
||||
|
||||
// Enrich from catalog
|
||||
let (tier, auth_status) = catalog
|
||||
.as_ref()
|
||||
.map(|cat| {
|
||||
let tier = cat
|
||||
.find_model(model)
|
||||
.map(|m| format!("{:?}", m.tier).to_lowercase())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let auth = cat
|
||||
.get_provider(provider)
|
||||
.map(|p| format!("{:?}", p.auth_status).to_lowercase())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
(tier, auth)
|
||||
})
|
||||
.unwrap_or(("unknown".to_string(), "unknown".to_string()));
|
||||
|
||||
let ready = matches!(e.state, openfang_types::agent::AgentState::Running)
|
||||
&& auth_status != "missing";
|
||||
|
||||
serde_json::json!({
|
||||
"id": e.id.to_string(),
|
||||
"name": e.name,
|
||||
"state": format!("{:?}", e.state),
|
||||
"mode": e.mode,
|
||||
"created_at": e.created_at.to_rfc3339(),
|
||||
"model_provider": e.manifest.model.provider,
|
||||
"model_name": e.manifest.model.model,
|
||||
"last_active": e.last_active.to_rfc3339(),
|
||||
"model_provider": provider,
|
||||
"model_name": model,
|
||||
"model_tier": tier,
|
||||
"auth_status": auth_status,
|
||||
"ready": ready,
|
||||
"profile": e.manifest.profile,
|
||||
"identity": {
|
||||
"emoji": e.identity.emoji,
|
||||
@@ -483,6 +526,10 @@ pub async fn status(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
"default_provider": state.kernel.config.default_model.provider,
|
||||
"default_model": state.kernel.config.default_model.model,
|
||||
"uptime_seconds": uptime,
|
||||
"api_listen": state.kernel.config.api_listen,
|
||||
"home_dir": state.kernel.config.home_dir.display().to_string(),
|
||||
"log_level": state.kernel.config.log_level,
|
||||
"network_enabled": state.kernel.config.network_enabled,
|
||||
"agents": agents,
|
||||
}))
|
||||
}
|
||||
@@ -1046,7 +1093,7 @@ pub async fn send_message_stream(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Field type for the channel configuration form.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum FieldType {
|
||||
Secret,
|
||||
Text,
|
||||
@@ -1123,7 +1170,7 @@ const CHANNEL_REGISTRY: &[ChannelMeta] = &[
|
||||
ChannelField { key: "bot_token_env", label: "Bot Token", field_type: FieldType::Secret, env_var: Some("DISCORD_BOT_TOKEN"), required: true, placeholder: "MTIz...", advanced: false },
|
||||
ChannelField { key: "allowed_guilds", label: "Allowed Guild IDs", field_type: FieldType::List, env_var: None, required: false, placeholder: "123456789, 987654321", advanced: true },
|
||||
ChannelField { key: "default_agent", label: "Default Agent", field_type: FieldType::Text, env_var: None, required: false, placeholder: "assistant", advanced: true },
|
||||
ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "33280", advanced: true },
|
||||
ChannelField { key: "intents", label: "Intents Bitmask", field_type: FieldType::Number, env_var: None, required: false, placeholder: "37376", advanced: true },
|
||||
],
|
||||
setup_steps: &["Go to discord.com/developers/applications", "Create a bot and copy the token", "Paste it below"],
|
||||
config_template: "[channels.discord]\nbot_token_env = \"DISCORD_BOT_TOKEN\"",
|
||||
@@ -1759,12 +1806,16 @@ fn is_channel_configured(config: &openfang_types::config::ChannelsConfig, name:
|
||||
}
|
||||
|
||||
/// Build a JSON field descriptor, checking env var presence but never exposing secrets.
|
||||
fn build_field_json(f: &ChannelField) -> serde_json::Value {
|
||||
/// For non-secret fields, includes the actual config value from `config_values` if available.
|
||||
fn build_field_json(
|
||||
f: &ChannelField,
|
||||
config_values: Option<&serde_json::Value>,
|
||||
) -> serde_json::Value {
|
||||
let has_value = f
|
||||
.env_var
|
||||
.map(|ev| std::env::var(ev).map(|v| !v.is_empty()).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
serde_json::json!({
|
||||
let mut field = serde_json::json!({
|
||||
"key": f.key,
|
||||
"label": f.label,
|
||||
"type": f.field_type.as_str(),
|
||||
@@ -1773,7 +1824,41 @@ fn build_field_json(f: &ChannelField) -> serde_json::Value {
|
||||
"has_value": has_value,
|
||||
"placeholder": f.placeholder,
|
||||
"advanced": f.advanced,
|
||||
})
|
||||
});
|
||||
// For non-secret fields, include the actual saved config value so the
|
||||
// dashboard can pre-populate forms when editing existing configs.
|
||||
if f.env_var.is_none() {
|
||||
if let Some(obj) = config_values.and_then(|v| v.as_object()) {
|
||||
if let Some(val) = obj.get(f.key) {
|
||||
// Convert arrays to comma-separated string for list fields
|
||||
let display_val = if f.field_type == FieldType::List {
|
||||
if let Some(arr) = val.as_array() {
|
||||
serde_json::Value::String(
|
||||
arr.iter()
|
||||
.filter_map(|v| {
|
||||
v.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| Some(v.to_string()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
} else {
|
||||
val.clone()
|
||||
}
|
||||
} else {
|
||||
val.clone()
|
||||
};
|
||||
field["value"] = display_val;
|
||||
if !val.is_null()
|
||||
&& val.as_str().map(|s| !s.is_empty()).unwrap_or(true)
|
||||
{
|
||||
field["has_value"] = serde_json::Value::Bool(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
field
|
||||
}
|
||||
|
||||
/// Find a channel definition by name.
|
||||
@@ -1781,6 +1866,56 @@ fn find_channel_meta(name: &str) -> Option<&'static ChannelMeta> {
|
||||
CHANNEL_REGISTRY.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// Serialize a channel's config to a JSON Value for pre-populating dashboard forms.
|
||||
fn channel_config_values(
|
||||
config: &openfang_types::config::ChannelsConfig,
|
||||
name: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
match name {
|
||||
"telegram" => config.telegram.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"discord" => config.discord.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"slack" => config.slack.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"whatsapp" => config.whatsapp.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"signal" => config.signal.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"matrix" => config.matrix.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"email" => config.email.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"teams" => config.teams.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"mattermost" => config.mattermost.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"irc" => config.irc.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"google_chat" => config.google_chat.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"twitch" => config.twitch.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"rocketchat" => config.rocketchat.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"zulip" => config.zulip.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"xmpp" => config.xmpp.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"line" => config.line.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"viber" => config.viber.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"messenger" => config.messenger.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"reddit" => config.reddit.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"mastodon" => config.mastodon.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"bluesky" => config.bluesky.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"feishu" => config.feishu.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"revolt" => config.revolt.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"nextcloud" => config.nextcloud.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"guilded" => config.guilded.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"keybase" => config.keybase.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"threema" => config.threema.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"nostr" => config.nostr.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"webex" => config.webex.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"pumble" => config.pumble.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"flock" => config.flock.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"twist" => config.twist.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"mumble" => config.mumble.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"dingtalk" => config.dingtalk.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"discourse" => config.discourse.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"gitter" => config.gitter.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"ntfy" => config.ntfy.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"gotify" => config.gotify.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"webhook" => config.webhook.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"linkedin" => config.linkedin.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/channels — List all 40 channel adapters with status and field metadata.
|
||||
pub async fn list_channels(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
// Read the live channels config (updated on every hot-reload) instead of the
|
||||
@@ -1806,7 +1941,12 @@ pub async fn list_channels(State(state): State<Arc<AppState>>) -> impl IntoRespo
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
let fields: Vec<serde_json::Value> = meta.fields.iter().map(build_field_json).collect();
|
||||
let config_vals = channel_config_values(&live_channels, meta.name);
|
||||
let fields: Vec<serde_json::Value> = meta
|
||||
.fields
|
||||
.iter()
|
||||
.map(|f| build_field_json(f, config_vals.as_ref()))
|
||||
.collect();
|
||||
|
||||
channels.push(serde_json::json!({
|
||||
"name": meta.name,
|
||||
@@ -1862,7 +2002,7 @@ pub async fn configure_channel(
|
||||
let home = openfang_kernel::config::openfang_home();
|
||||
let secrets_path = home.join("secrets.env");
|
||||
let config_path = home.join("config.toml");
|
||||
let mut config_fields: HashMap<String, String> = HashMap::new();
|
||||
let mut config_fields: HashMap<String, (String, FieldType)> = HashMap::new();
|
||||
|
||||
for field_def in meta.fields {
|
||||
let value = fields
|
||||
@@ -1885,9 +2025,15 @@ pub async fn configure_channel(
|
||||
unsafe {
|
||||
std::env::set_var(env_var, value);
|
||||
}
|
||||
// Also write the env var NAME to config.toml so the channel section
|
||||
// is not empty and the kernel knows which env var to read.
|
||||
config_fields.insert(
|
||||
field_def.key.to_string(),
|
||||
(env_var.to_string(), FieldType::Text),
|
||||
);
|
||||
} else {
|
||||
// Config field — collect for TOML write
|
||||
config_fields.insert(field_def.key.to_string(), value.to_string());
|
||||
// Config field — collect for TOML write with type info
|
||||
config_fields.insert(field_def.key.to_string(), (value.to_string(), field_def.field_type));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2358,19 +2504,14 @@ pub async fn get_template(Path(name): Path<String>) -> impl IntoResponse {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/memory/agents/:id/kv — List KV pairs for an agent.
|
||||
///
|
||||
/// Note: memory_store tool writes to a shared namespace, so we read from that
|
||||
/// same namespace regardless of which agent ID is in the URL.
|
||||
pub async fn get_agent_kv(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Path(_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let agent_id: AgentId = match id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Invalid agent ID"})),
|
||||
);
|
||||
}
|
||||
};
|
||||
let agent_id = openfang_kernel::kernel::shared_memory_agent_id();
|
||||
|
||||
match state.kernel.memory.list_kv(agent_id) {
|
||||
Ok(pairs) => {
|
||||
@@ -2381,7 +2522,7 @@ pub async fn get_agent_kv(
|
||||
(StatusCode::OK, Json(serde_json::json!({"kv_pairs": kv})))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Memory list_kv failed for agent {id}: {e}");
|
||||
tracing::warn!("Memory list_kv failed: {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "Memory operation failed"})),
|
||||
@@ -2393,17 +2534,9 @@ pub async fn get_agent_kv(
|
||||
/// GET /api/memory/agents/:id/kv/:key — Get a specific KV value.
|
||||
pub async fn get_agent_kv_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((id, key)): Path<(String, String)>,
|
||||
Path((_id, key)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let agent_id: AgentId = match id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Invalid agent ID"})),
|
||||
);
|
||||
}
|
||||
};
|
||||
let agent_id = openfang_kernel::kernel::shared_memory_agent_id();
|
||||
|
||||
match state.kernel.memory.structured_get(agent_id, &key) {
|
||||
Ok(Some(val)) => (
|
||||
@@ -2415,7 +2548,7 @@ pub async fn get_agent_kv_key(
|
||||
Json(serde_json::json!({"error": "Key not found"})),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!("Memory get failed for agent {id}, key '{key}': {e}");
|
||||
tracing::warn!("Memory get failed for key '{key}': {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "Memory operation failed"})),
|
||||
@@ -2427,18 +2560,10 @@ pub async fn get_agent_kv_key(
|
||||
/// PUT /api/memory/agents/:id/kv/:key — Set a KV value.
|
||||
pub async fn set_agent_kv_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((id, key)): Path<(String, String)>,
|
||||
Path((_id, key)): Path<(String, String)>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let agent_id: AgentId = match id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Invalid agent ID"})),
|
||||
);
|
||||
}
|
||||
};
|
||||
let agent_id = openfang_kernel::kernel::shared_memory_agent_id();
|
||||
|
||||
let value = body.get("value").cloned().unwrap_or(body);
|
||||
|
||||
@@ -2448,7 +2573,7 @@ pub async fn set_agent_kv_key(
|
||||
Json(serde_json::json!({"status": "stored", "key": key})),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!("Memory set failed for agent {id}, key '{key}': {e}");
|
||||
tracing::warn!("Memory set failed for key '{key}': {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "Memory operation failed"})),
|
||||
@@ -2460,17 +2585,9 @@ pub async fn set_agent_kv_key(
|
||||
/// DELETE /api/memory/agents/:id/kv/:key — Delete a KV value.
|
||||
pub async fn delete_agent_kv_key(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((id, key)): Path<(String, String)>,
|
||||
Path((_id, key)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let agent_id: AgentId = match id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Invalid agent ID"})),
|
||||
);
|
||||
}
|
||||
};
|
||||
let agent_id = openfang_kernel::kernel::shared_memory_agent_id();
|
||||
|
||||
match state.kernel.memory.structured_delete(agent_id, &key) {
|
||||
Ok(()) => (
|
||||
@@ -2478,7 +2595,7 @@ pub async fn delete_agent_kv_key(
|
||||
Json(serde_json::json!({"status": "deleted", "key": key})),
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::warn!("Memory delete failed for agent {id}, key '{key}': {e}");
|
||||
tracing::warn!("Memory delete failed for key '{key}': {e}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "Memory operation failed"})),
|
||||
@@ -2966,6 +3083,46 @@ pub async fn clawhub_skill_detail(
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/clawhub/skill/{slug}/code — Fetch the source code (SKILL.md) of a ClawHub skill.
|
||||
pub async fn clawhub_skill_code(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(slug): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let cache_dir = state.kernel.config.home_dir.join(".cache").join("clawhub");
|
||||
let client = openfang_skills::clawhub::ClawHubClient::new(cache_dir);
|
||||
|
||||
// Try to fetch SKILL.md first, then fallback to package.json
|
||||
let mut code = String::new();
|
||||
let mut filename = String::new();
|
||||
|
||||
if let Ok(content) = client.get_file(&slug, "SKILL.md").await {
|
||||
code = content;
|
||||
filename = "SKILL.md".to_string();
|
||||
} else if let Ok(content) = client.get_file(&slug, "package.json").await {
|
||||
code = content;
|
||||
filename = "package.json".to_string();
|
||||
} else if let Ok(content) = client.get_file(&slug, "skill.toml").await {
|
||||
code = content;
|
||||
filename = "skill.toml".to_string();
|
||||
}
|
||||
|
||||
if code.is_empty() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "No source code found for this skill"})),
|
||||
);
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"slug": slug,
|
||||
"filename": filename,
|
||||
"code": code,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// POST /api/clawhub/install — Install a skill from ClawHub.
|
||||
///
|
||||
/// Runs the full security pipeline: SHA256 verification, format detection,
|
||||
@@ -3022,13 +3179,18 @@ pub async fn clawhub_install(
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
let status = if e.to_string().contains("SecurityBlocked") {
|
||||
let msg = format!("{e}");
|
||||
let status = if msg.contains("SecurityBlocked") {
|
||||
StatusCode::FORBIDDEN
|
||||
} else if msg.contains("429") || msg.contains("rate limit") {
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
} else if msg.contains("Network error") || msg.contains("returned 4") || msg.contains("returned 5") {
|
||||
StatusCode::BAD_GATEWAY
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
};
|
||||
tracing::warn!("ClawHub install failed: {e}");
|
||||
(status, Json(serde_json::json!({"error": format!("{e}")})))
|
||||
tracing::warn!("ClawHub install failed: {msg}");
|
||||
(status, Json(serde_json::json!({"error": msg})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3472,6 +3634,42 @@ pub async fn install_hand_deps(
|
||||
)
|
||||
}
|
||||
|
||||
/// POST /api/hands/install — Install a hand from TOML content.
|
||||
pub async fn install_hand(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let toml_content = body["toml_content"].as_str().unwrap_or("");
|
||||
let skill_content = body["skill_content"].as_str().unwrap_or("");
|
||||
|
||||
if toml_content.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Missing toml_content field"})),
|
||||
);
|
||||
}
|
||||
|
||||
match state
|
||||
.kernel
|
||||
.hand_registry
|
||||
.install_from_content(toml_content, skill_content)
|
||||
{
|
||||
Ok(def) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"id": def.id,
|
||||
"name": def.name,
|
||||
"description": def.description,
|
||||
"category": format!("{:?}", def.category),
|
||||
})),
|
||||
),
|
||||
Err(e) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/hands/{hand_id}/activate — Activate a hand (spawns agent).
|
||||
pub async fn activate_hand(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -3481,17 +3679,36 @@ pub async fn activate_hand(
|
||||
let config = body.map(|b| b.0.config).unwrap_or_default();
|
||||
|
||||
match state.kernel.activate_hand(&hand_id, config) {
|
||||
Ok(instance) => (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"instance_id": instance.instance_id,
|
||||
"hand_id": instance.hand_id,
|
||||
"status": format!("{}", instance.status),
|
||||
"agent_id": instance.agent_id.map(|a| a.to_string()),
|
||||
"agent_name": instance.agent_name,
|
||||
"activated_at": instance.activated_at.to_rfc3339(),
|
||||
})),
|
||||
),
|
||||
Ok(instance) => {
|
||||
// If the hand agent has a non-reactive schedule (autonomous hands),
|
||||
// start its background loop so it begins running immediately.
|
||||
if let Some(agent_id) = instance.agent_id {
|
||||
let entry = state.kernel.registry.list().into_iter().find(|e| e.id == agent_id);
|
||||
if let Some(entry) = entry {
|
||||
if !matches!(
|
||||
entry.manifest.schedule,
|
||||
openfang_types::agent::ScheduleMode::Reactive
|
||||
) {
|
||||
state.kernel.start_background_for_agent(
|
||||
agent_id,
|
||||
&entry.name,
|
||||
&entry.manifest.schedule,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"instance_id": instance.instance_id,
|
||||
"hand_id": instance.hand_id,
|
||||
"status": format!("{}", instance.status),
|
||||
"agent_id": instance.agent_id.map(|a| a.to_string()),
|
||||
"agent_name": instance.agent_name,
|
||||
"activated_at": instance.activated_at.to_rfc3339(),
|
||||
})),
|
||||
)
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": format!("{e}")})),
|
||||
@@ -5021,6 +5238,10 @@ pub async fn list_providers(State(state): State<Arc<AppState>>) -> impl IntoResp
|
||||
entry["latency_ms"] = serde_json::json!(probe.latency_ms);
|
||||
if !probe.discovered_models.is_empty() {
|
||||
entry["discovered_models"] = serde_json::json!(probe.discovered_models);
|
||||
// Merge discovered models into the catalog so agents can use them
|
||||
if let Ok(mut catalog) = state.kernel.model_catalog.write() {
|
||||
catalog.merge_discovered_models(&p.id, &probe.discovered_models);
|
||||
}
|
||||
}
|
||||
if let Some(err) = &probe.error {
|
||||
entry["error"] = serde_json::json!(err);
|
||||
@@ -6382,16 +6603,25 @@ pub async fn set_provider_url(
|
||||
let probe =
|
||||
openfang_runtime::provider_health::probe_provider(&name, &base_url).await;
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"status": "saved",
|
||||
"provider": name,
|
||||
"base_url": base_url,
|
||||
"reachable": probe.reachable,
|
||||
"latency_ms": probe.latency_ms,
|
||||
})),
|
||||
)
|
||||
// Merge discovered models into catalog
|
||||
if !probe.discovered_models.is_empty() {
|
||||
if let Ok(mut catalog) = state.kernel.model_catalog.write() {
|
||||
catalog.merge_discovered_models(&name, &probe.discovered_models);
|
||||
}
|
||||
}
|
||||
|
||||
let mut resp = serde_json::json!({
|
||||
"status": "saved",
|
||||
"provider": name,
|
||||
"base_url": base_url,
|
||||
"reachable": probe.reachable,
|
||||
"latency_ms": probe.latency_ms,
|
||||
});
|
||||
if !probe.discovered_models.is_empty() {
|
||||
resp["discovered_models"] = serde_json::json!(probe.discovered_models);
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(resp))
|
||||
}
|
||||
|
||||
/// Upsert a provider URL in the `[provider_urls]` section of config.toml.
|
||||
@@ -6578,7 +6808,7 @@ fn remove_secret_env(path: &std::path::Path, key: &str) -> Result<(), std::io::E
|
||||
fn upsert_channel_config(
|
||||
config_path: &std::path::Path,
|
||||
channel_name: &str,
|
||||
fields: &HashMap<String, String>,
|
||||
fields: &HashMap<String, (String, FieldType)>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let content = if config_path.exists() {
|
||||
std::fs::read_to_string(config_path)?
|
||||
@@ -6606,10 +6836,20 @@ fn upsert_channel_config(
|
||||
.and_then(|v| v.as_table_mut())
|
||||
.ok_or("channels is not a table")?;
|
||||
|
||||
// Build channel sub-table
|
||||
// Build channel sub-table with correct TOML types
|
||||
let mut ch_table = toml::map::Map::new();
|
||||
for (k, v) in fields {
|
||||
ch_table.insert(k.clone(), toml::Value::String(v.clone()));
|
||||
for (k, (v, ft)) in fields {
|
||||
let toml_val = match ft {
|
||||
FieldType::Number => {
|
||||
if let Ok(n) = v.parse::<i64>() {
|
||||
toml::Value::Integer(n)
|
||||
} else {
|
||||
toml::Value::String(v.clone())
|
||||
}
|
||||
}
|
||||
_ => toml::Value::String(v.clone()),
|
||||
};
|
||||
ch_table.insert(k.clone(), toml_val);
|
||||
}
|
||||
channels_table.insert(channel_name.to_string(), toml::Value::Table(ch_table));
|
||||
|
||||
@@ -6987,6 +7227,29 @@ pub async fn create_schedule(
|
||||
}
|
||||
|
||||
let agent_id_str = req["agent_id"].as_str().unwrap_or("").to_string();
|
||||
if agent_id_str.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Missing required field: agent_id"})),
|
||||
);
|
||||
}
|
||||
// Validate agent exists (UUID or name lookup)
|
||||
let agent_exists = if let Ok(aid) = agent_id_str.parse::<AgentId>() {
|
||||
state.kernel.registry.get(aid).is_some()
|
||||
} else {
|
||||
state
|
||||
.kernel
|
||||
.registry
|
||||
.list()
|
||||
.iter()
|
||||
.any(|a| a.name == agent_id_str)
|
||||
};
|
||||
if !agent_exists {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": format!("Agent not found: {agent_id_str}")})),
|
||||
);
|
||||
}
|
||||
let message = req["message"].as_str().unwrap_or("").to_string();
|
||||
let enabled = req.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
@@ -7160,10 +7423,14 @@ pub async fn run_schedule(
|
||||
.unwrap_or("Scheduled task triggered manually.");
|
||||
let name = schedule["name"].as_str().unwrap_or("(unnamed)");
|
||||
|
||||
// Find the target agent
|
||||
// Find the target agent — require explicit agent_id, no silent fallback
|
||||
let target_agent = if !agent_id_str.is_empty() {
|
||||
if let Ok(aid) = agent_id_str.parse::<AgentId>() {
|
||||
Some(aid)
|
||||
if state.kernel.registry.get(aid).is_some() {
|
||||
Some(aid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
state
|
||||
.kernel
|
||||
@@ -7174,7 +7441,7 @@ pub async fn run_schedule(
|
||||
.map(|a| a.id)
|
||||
}
|
||||
} else {
|
||||
state.kernel.registry.list().first().map(|a| a.id)
|
||||
None
|
||||
};
|
||||
|
||||
let target_agent = match target_agent {
|
||||
@@ -7536,6 +7803,13 @@ pub async fn patch_agent_config(
|
||||
}
|
||||
}
|
||||
|
||||
// Persist updated manifest to database so changes survive restart
|
||||
if let Some(entry) = state.kernel.registry.get(agent_id) {
|
||||
if let Err(e) = state.kernel.memory.save_agent(&entry) {
|
||||
tracing::warn!("Failed to persist agent config update: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "agent_id": id})),
|
||||
@@ -8570,7 +8844,9 @@ fn json_to_toml_value(value: &serde_json::Value) -> toml::Value {
|
||||
match value {
|
||||
serde_json::Value::String(s) => toml::Value::String(s.clone()),
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
if let Some(i) = n.as_u64() {
|
||||
toml::Value::Integer(i as i64)
|
||||
} else if let Some(i) = n.as_i64() {
|
||||
toml::Value::Integer(i)
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
toml::Value::Float(f)
|
||||
|
||||
@@ -321,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),
|
||||
|
||||
@@ -125,6 +125,8 @@ const WEBCHAT_HTML: &str = concat!(
|
||||
include_str!("../static/js/pages/approvals.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/comms.js"),
|
||||
"\n",
|
||||
include_str!("../static/js/pages/runtime.js"),
|
||||
"\n</script>\n",
|
||||
// Alpine.js MUST be last — it processes x-data and fires alpine:init
|
||||
"<script>\n",
|
||||
|
||||
@@ -796,7 +796,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}")})
|
||||
|
||||
@@ -898,6 +898,121 @@ 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 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;
|
||||
|
||||
@@ -156,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>
|
||||
@@ -585,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>
|
||||
@@ -742,9 +754,55 @@
|
||||
</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])">
|
||||
</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>
|
||||
@@ -2305,7 +2363,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>
|
||||
@@ -2722,7 +2787,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>
|
||||
@@ -2994,9 +3059,15 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<span class="text-xs text-dim ml-2" x-text="customModelStatus"></span>
|
||||
</div>
|
||||
<div class="text-xs text-dim mb-2" x-text="filteredModels.length + ' of ' + models.length + ' models'"></div>
|
||||
<div x-show="!filteredModels.length && !settingsLoading" style="text-align:center;padding:32px 16px">
|
||||
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">🤖</div>
|
||||
<h3 style="margin:0 0 4px;font-size:14px" x-text="models.length ? 'No models match your search' : 'No models available'"></h3>
|
||||
<p class="text-xs text-dim" x-text="models.length ? 'Try a different search term or clear filters.' : 'Configure an LLM provider to see available models.'"></p>
|
||||
<button class="btn btn-ghost btn-sm mt-2" x-show="models.length && (modelSearch || modelProviderFilter)" @click="modelSearch=''; modelProviderFilter=''">Clear Filters</button>
|
||||
</div>
|
||||
<div class="table-wrap" x-show="filteredModels.length">
|
||||
<table>
|
||||
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th></tr></thead>
|
||||
<thead><tr><th>Model</th><th>Provider</th><th>Tier</th><th>Context</th><th>Input Cost</th><th>Output Cost</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="m in filteredModels" :key="m.id">
|
||||
<tr>
|
||||
@@ -3007,6 +3078,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
|
||||
<td class="text-xs" x-text="formatCost(m.input_cost_per_m)"></td>
|
||||
<td class="text-xs" x-text="formatCost(m.output_cost_per_m)"></td>
|
||||
<td><span class="badge" :class="m.available ? 'badge-success' : 'badge-muted'" x-text="m.available ? 'Available' : 'Needs Key'"></span></td>
|
||||
<td><button x-show="m.tier === 'custom'" class="btn btn-ghost btn-sm" @click="deleteCustomModel(m.id)" title="Delete custom model" style="padding:2px 6px;font-size:11px;color:var(--text-muted)">✕</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
@@ -3021,6 +3093,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>
|
||||
@@ -4563,6 +4641,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>
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ function app() {
|
||||
});
|
||||
|
||||
// Hash routing
|
||||
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','settings','wizard'];
|
||||
var validPages = ['overview','agents','sessions','approvals','comms','workflows','scheduler','channels','skills','hands','analytics','logs','runtime','settings','wizard'];
|
||||
var pageRedirects = {
|
||||
'chat': 'agents',
|
||||
'templates': 'agents',
|
||||
|
||||
@@ -141,7 +141,16 @@ function channelsPage() {
|
||||
|
||||
openSetup(ch) {
|
||||
this.setupModal = ch;
|
||||
this.formValues = {};
|
||||
// Pre-populate form values from saved config (non-secret fields).
|
||||
var vals = {};
|
||||
if (ch.fields) {
|
||||
ch.fields.forEach(function(f) {
|
||||
if (f.value !== undefined && f.value !== null && f.type !== 'secret') {
|
||||
vals[f.key] = String(f.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.formValues = vals;
|
||||
this.showAdvanced = false;
|
||||
this.showBusinessApi = false;
|
||||
this.setupStep = ch.configured ? 3 : 1;
|
||||
|
||||
@@ -34,6 +34,13 @@ function chatPage() {
|
||||
modelPickerList: [],
|
||||
modelPickerFilter: '',
|
||||
modelPickerIdx: 0,
|
||||
// Model switcher dropdown
|
||||
showModelSwitcher: false,
|
||||
modelSwitcherFilter: '',
|
||||
modelSwitcherIdx: 0,
|
||||
modelSwitching: false,
|
||||
_modelCache: null,
|
||||
_modelCacheTime: 0,
|
||||
slashCommands: [
|
||||
{ cmd: '/help', desc: 'Show available commands' },
|
||||
{ cmd: '/agents', desc: 'Switch to Agents page' },
|
||||
@@ -85,6 +92,36 @@ 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 filteredSwitcherModels() {
|
||||
var models = this._modelCache || [];
|
||||
if (!this.modelSwitcherFilter) return models;
|
||||
var f = this.modelSwitcherFilter.toLowerCase();
|
||||
return models.filter(function(m) {
|
||||
return m.id.toLowerCase().indexOf(f) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(f) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(f) !== -1;
|
||||
});
|
||||
},
|
||||
|
||||
get groupedSwitcherModels() {
|
||||
var filtered = this.filteredSwitcherModels;
|
||||
var groups = {}, order = [];
|
||||
filtered.forEach(function(m) {
|
||||
if (!groups[m.provider]) { groups[m.provider] = []; order.push(m.provider); }
|
||||
groups[m.provider].push(m);
|
||||
});
|
||||
return order.map(function(p) {
|
||||
return { provider: p.charAt(0).toUpperCase() + p.slice(1), models: groups[p] };
|
||||
});
|
||||
},
|
||||
|
||||
init() {
|
||||
var self = this;
|
||||
|
||||
@@ -101,6 +138,11 @@ function chatPage() {
|
||||
var input = document.getElementById('msg-input');
|
||||
if (input) { input.focus(); self.inputText = '/'; }
|
||||
}
|
||||
// Ctrl+M for model switcher
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'm' && self.currentAgent) {
|
||||
e.preventDefault();
|
||||
self.toggleModelSwitcher();
|
||||
}
|
||||
// Ctrl+F for chat search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && self.currentAgent) {
|
||||
e.preventDefault();
|
||||
@@ -172,6 +214,54 @@ function chatPage() {
|
||||
this.sendMessage();
|
||||
},
|
||||
|
||||
toggleModelSwitcher() {
|
||||
if (this.showModelSwitcher) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
var now = Date.now();
|
||||
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
|
||||
this.modelSwitcherFilter = '';
|
||||
this.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.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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -258,6 +258,17 @@ function settingsPage() {
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCustomModel(modelId) {
|
||||
if (!confirm('Delete custom model "' + modelId + '"?')) return;
|
||||
try {
|
||||
await OpenFangAPI.del('/api/models/custom/' + encodeURIComponent(modelId));
|
||||
OpenFangToast.success('Model deleted');
|
||||
await this.loadModels();
|
||||
} catch(e) {
|
||||
OpenFangToast.error('Failed to delete: ' + (e.message || 'Unknown error'));
|
||||
}
|
||||
},
|
||||
|
||||
async loadConfigSchema() {
|
||||
try {
|
||||
var results = await Promise.all([
|
||||
|
||||
@@ -25,6 +25,10 @@ function skillsPage() {
|
||||
// Skill detail modal
|
||||
skillDetail: null,
|
||||
detailLoading: false,
|
||||
showSkillCode: false,
|
||||
skillCode: '',
|
||||
skillCodeFilename: '',
|
||||
skillCodeLoading: false,
|
||||
|
||||
// MCP servers
|
||||
mcpServers: [],
|
||||
@@ -205,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
|
||||
|
||||
@@ -441,8 +441,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 {
|
||||
|
||||
@@ -222,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]
|
||||
@@ -247,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()
|
||||
@@ -255,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
|
||||
@@ -270,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()
|
||||
@@ -278,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]
|
||||
@@ -617,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
|
||||
@@ -633,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
|
||||
@@ -643,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]] {
|
||||
@@ -661,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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -541,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.
|
||||
|
||||
@@ -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()
|
||||
@@ -577,14 +582,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -684,7 +684,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_discord_adapter_creation() {
|
||||
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], 33280);
|
||||
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], 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("**") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!(
|
||||
@@ -305,10 +408,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(())
|
||||
}
|
||||
@@ -413,6 +529,63 @@ 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::*;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+314
-66
@@ -390,6 +390,11 @@ enum HandCommands {
|
||||
List,
|
||||
/// Show currently active hand instances.
|
||||
Active,
|
||||
/// Install a hand from a local directory containing HAND.toml.
|
||||
Install {
|
||||
/// Path to the hand directory (must contain HAND.toml).
|
||||
path: String,
|
||||
},
|
||||
/// Activate a hand by ID.
|
||||
Activate {
|
||||
/// Hand ID (e.g. "clip", "lead", "researcher").
|
||||
@@ -405,6 +410,26 @@ enum HandCommands {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Check dependency status for a hand.
|
||||
CheckDeps {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Install missing dependencies for a hand.
|
||||
InstallDeps {
|
||||
/// Hand ID.
|
||||
id: String,
|
||||
},
|
||||
/// Pause a running hand instance.
|
||||
Pause {
|
||||
/// Instance ID (from `hand active`).
|
||||
id: String,
|
||||
},
|
||||
/// Resume a paused hand instance.
|
||||
Resume {
|
||||
/// Instance ID (from `hand active`).
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -612,6 +637,9 @@ enum CronCommands {
|
||||
spec: String,
|
||||
/// Prompt to send when the job fires.
|
||||
prompt: String,
|
||||
/// Optional job name (auto-generated if omitted).
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Delete a scheduled job.
|
||||
Delete {
|
||||
@@ -758,11 +786,19 @@ fn init_tracing_stderr() {
|
||||
.init();
|
||||
}
|
||||
|
||||
/// Get the OpenFang home directory, respecting OPENFANG_HOME env var.
|
||||
fn cli_openfang_home() -> std::path::PathBuf {
|
||||
if let Ok(home) = std::env::var("OPENFANG_HOME") {
|
||||
return std::path::PathBuf::from(home);
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join(".openfang")
|
||||
}
|
||||
|
||||
/// Redirect tracing to a log file so it doesn't corrupt the ratatui TUI.
|
||||
fn init_tracing_file() {
|
||||
let log_dir = dirs::home_dir()
|
||||
.map(|h| h.join(".openfang"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
let log_dir = cli_openfang_home();
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
let log_path = log_dir.join("tui.log");
|
||||
|
||||
@@ -887,9 +923,14 @@ fn main() {
|
||||
Some(Commands::Hand(sub)) => match sub {
|
||||
HandCommands::List => cmd_hand_list(),
|
||||
HandCommands::Active => cmd_hand_active(),
|
||||
HandCommands::Install { path } => cmd_hand_install(&path),
|
||||
HandCommands::Activate { id } => cmd_hand_activate(&id),
|
||||
HandCommands::Deactivate { id } => cmd_hand_deactivate(&id),
|
||||
HandCommands::Info { id } => cmd_hand_info(&id),
|
||||
HandCommands::CheckDeps { id } => cmd_hand_check_deps(&id),
|
||||
HandCommands::InstallDeps { id } => cmd_hand_install_deps(&id),
|
||||
HandCommands::Pause { id } => cmd_hand_pause(&id),
|
||||
HandCommands::Resume { id } => cmd_hand_resume(&id),
|
||||
},
|
||||
Some(Commands::Config(sub)) => match sub {
|
||||
ConfigCommands::Show => cmd_config_show(),
|
||||
@@ -940,7 +981,8 @@ fn main() {
|
||||
agent,
|
||||
spec,
|
||||
prompt,
|
||||
} => cmd_cron_create(&agent, &spec, &prompt),
|
||||
name,
|
||||
} => cmd_cron_create(&agent, &spec, &prompt, name.as_deref()),
|
||||
CronCommands::Delete { id } => cmd_cron_delete(&id),
|
||||
CronCommands::Enable { id } => cmd_cron_toggle(&id, true),
|
||||
CronCommands::Disable { id } => cmd_cron_toggle(&id, false),
|
||||
@@ -1009,7 +1051,7 @@ pub(crate) fn restrict_dir_permissions(path: &std::path::Path) {
|
||||
pub(crate) fn restrict_dir_permissions(_path: &std::path::Path) {}
|
||||
|
||||
pub(crate) fn find_daemon() -> Option<String> {
|
||||
let home_dir = dirs::home_dir()?.join(".openfang");
|
||||
let home_dir = cli_openfang_home();
|
||||
let info = read_daemon_info(&home_dir)?;
|
||||
|
||||
// Normalize listen address: replace 0.0.0.0 with 127.0.0.1 to avoid
|
||||
@@ -1091,7 +1133,7 @@ fn cmd_init(quick: bool) {
|
||||
}
|
||||
};
|
||||
|
||||
let openfang_dir = home.join(".openfang");
|
||||
let openfang_dir = cli_openfang_home();
|
||||
|
||||
// --- Ensure directories exist ---
|
||||
if !openfang_dir.exists() {
|
||||
@@ -1264,8 +1306,14 @@ fn detect_best_provider() -> (&'static str, &'static str, &'static str) {
|
||||
ui::success("Detected Gemini (GOOGLE_API_KEY)");
|
||||
return ("gemini", "GOOGLE_API_KEY", "gemini-2.5-flash");
|
||||
}
|
||||
// Check if Ollama is running locally (no API key needed)
|
||||
if check_ollama_available() {
|
||||
ui::success("Detected Ollama running locally (no API key needed)");
|
||||
return ("ollama", "OLLAMA_API_KEY", "llama3.2");
|
||||
}
|
||||
ui::hint("No LLM provider API keys found");
|
||||
ui::hint("Groq offers a free tier: https://console.groq.com");
|
||||
ui::hint("Or install Ollama for local models: https://ollama.com");
|
||||
("groq", "GROQ_API_KEY", "llama-3.3-70b-versatile")
|
||||
}
|
||||
|
||||
@@ -1291,6 +1339,15 @@ fn provider_list() -> Vec<(&'static str, &'static str, &'static str, &'static st
|
||||
]
|
||||
}
|
||||
|
||||
/// Quick probe to check if Ollama is running on localhost.
|
||||
fn check_ollama_available() -> bool {
|
||||
std::net::TcpStream::connect_timeout(
|
||||
&std::net::SocketAddr::from(([127, 0, 0, 1], 11434)),
|
||||
std::time::Duration::from_millis(500),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Write config.toml if it doesn't already exist.
|
||||
fn write_config_if_missing(
|
||||
openfang_dir: &std::path::Path,
|
||||
@@ -1391,11 +1448,28 @@ fn cmd_start(config: Option<PathBuf>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Read the api_key from ~/.openfang/config.toml (if any).
|
||||
fn read_api_key() -> Option<String> {
|
||||
let config_path = cli_openfang_home().join("config.toml");
|
||||
let text = std::fs::read_to_string(config_path).ok()?;
|
||||
let table: toml::Value = text.parse().ok()?;
|
||||
let key = table.get("api_key")?.as_str()?;
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(key.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_stop() {
|
||||
match find_daemon() {
|
||||
Some(base) => {
|
||||
let client = daemon_client();
|
||||
match client.post(format!("{base}/api/shutdown")).send() {
|
||||
let mut req = client.post(format!("{base}/api/shutdown"));
|
||||
if let Some(key) = read_api_key() {
|
||||
req = req.bearer_auth(key);
|
||||
}
|
||||
match req.send() {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
// Wait for daemon to actually stop (up to 5 seconds)
|
||||
for _ in 0..10 {
|
||||
@@ -1406,8 +1480,8 @@ fn cmd_stop() {
|
||||
}
|
||||
}
|
||||
// Still alive — force kill via PID
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let of_dir = home.join(".openfang");
|
||||
{
|
||||
let of_dir = cli_openfang_home();
|
||||
if let Some(info) = read_daemon_info(&of_dir) {
|
||||
force_kill_pid(info.pid);
|
||||
let _ = std::fs::remove_file(of_dir.join("daemon.json"));
|
||||
@@ -1882,8 +1956,8 @@ fn cmd_doctor(json: bool, repair: bool) {
|
||||
}
|
||||
|
||||
let home = dirs::home_dir();
|
||||
if let Some(h) = &home {
|
||||
let openfang_dir = h.join(".openfang");
|
||||
if let Some(_h) = &home {
|
||||
let openfang_dir = cli_openfang_home();
|
||||
|
||||
// --- Check 1: OpenFang directory ---
|
||||
if openfang_dir.exists() {
|
||||
@@ -2040,7 +2114,20 @@ decay_rate = 0.05
|
||||
all_ok = false;
|
||||
}
|
||||
|
||||
// --- Check 4: Port 4200 availability ---
|
||||
// --- Check 4: Port availability ---
|
||||
// Read api_listen from config (default: 127.0.0.1:4200)
|
||||
let api_listen = {
|
||||
let cfg_path = openfang_dir.join("config.toml");
|
||||
if cfg_path.exists() {
|
||||
std::fs::read_to_string(&cfg_path)
|
||||
.ok()
|
||||
.and_then(|s| toml::from_str::<openfang_types::config::KernelConfig>(&s).ok())
|
||||
.map(|c| c.api_listen)
|
||||
.unwrap_or_else(|| "127.0.0.1:4200".to_string())
|
||||
} else {
|
||||
"127.0.0.1:4200".to_string()
|
||||
}
|
||||
};
|
||||
if !json {
|
||||
println!();
|
||||
}
|
||||
@@ -2056,19 +2143,24 @@ decay_rate = 0.05
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "daemon", "status": "warn"}));
|
||||
|
||||
// Check if port 4200 is available
|
||||
match std::net::TcpListener::bind("127.0.0.1:4200") {
|
||||
// Check if the configured port is available
|
||||
let bind_addr = if api_listen.starts_with("0.0.0.0") {
|
||||
api_listen.replacen("0.0.0.0", "127.0.0.1", 1)
|
||||
} else {
|
||||
api_listen.clone()
|
||||
};
|
||||
match std::net::TcpListener::bind(&bind_addr) {
|
||||
Ok(_) => {
|
||||
if !json {
|
||||
ui::check_ok("Port 4200 is available");
|
||||
ui::check_ok(&format!("Port {api_listen} is available"));
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "port_4200", "status": "ok"}));
|
||||
checks.push(serde_json::json!({"check": "port", "status": "ok", "address": api_listen}));
|
||||
}
|
||||
Err(_) => {
|
||||
if !json {
|
||||
ui::check_warn("Port 4200 is in use by another process");
|
||||
ui::check_warn(&format!("Port {api_listen} is in use by another process"));
|
||||
}
|
||||
checks.push(serde_json::json!({"check": "port_4200", "status": "warn"}));
|
||||
checks.push(serde_json::json!({"check": "port", "status": "warn", "address": api_listen}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2288,8 +2380,8 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// --- Check 11: .env keys vs config api_key_env consistency ---
|
||||
if let Some(ref h) = home {
|
||||
let openfang_dir = h.join(".openfang");
|
||||
{
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let config_path = openfang_dir.join("config.toml");
|
||||
if config_path.exists() {
|
||||
let config_str = std::fs::read_to_string(&config_path).unwrap_or_default();
|
||||
@@ -2314,8 +2406,8 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// --- Check 12: Config deserialization into KernelConfig ---
|
||||
if let Some(ref h) = home {
|
||||
let openfang_dir = h.join(".openfang");
|
||||
{
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let config_path = openfang_dir.join("config.toml");
|
||||
if config_path.exists() {
|
||||
if !json {
|
||||
@@ -2419,10 +2511,7 @@ decay_rate = 0.05
|
||||
if !json {
|
||||
println!("\n Skills:");
|
||||
}
|
||||
let skills_dir = home
|
||||
.as_ref()
|
||||
.map(|h| h.join(".openfang").join("skills"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("skills"));
|
||||
let skills_dir = cli_openfang_home().join("skills");
|
||||
let mut skill_reg = openfang_skills::registry::SkillRegistry::new(skills_dir.clone());
|
||||
skill_reg.load_bundled();
|
||||
let bundled_count = skill_reg.count();
|
||||
@@ -2456,12 +2545,20 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// Check for prompt injection issues in skill definitions
|
||||
// Only flag Critical-severity warnings (Warning-level hits are expected
|
||||
// in bundled skills that mention shell commands in educational context).
|
||||
let skills = skill_reg.list();
|
||||
let mut injection_warnings = 0;
|
||||
for skill in &skills {
|
||||
if let Some(ref prompt) = skill.manifest.prompt_context {
|
||||
let warnings = openfang_skills::verify::SkillVerifier::scan_prompt_content(prompt);
|
||||
if !warnings.is_empty() {
|
||||
let has_critical = warnings.iter().any(|w| {
|
||||
matches!(
|
||||
w.severity,
|
||||
openfang_skills::verify::WarningSeverity::Critical
|
||||
)
|
||||
});
|
||||
if has_critical {
|
||||
injection_warnings += 1;
|
||||
if !json {
|
||||
ui::check_warn(&format!(
|
||||
@@ -2483,11 +2580,11 @@ decay_rate = 0.05
|
||||
}
|
||||
|
||||
// --- Check 14: Extension registry health ---
|
||||
if let Some(ref h) = home {
|
||||
{
|
||||
if !json {
|
||||
println!("\n Extensions:");
|
||||
}
|
||||
let openfang_dir = h.join(".openfang");
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let mut ext_registry =
|
||||
openfang_extensions::registry::IntegrationRegistry::new(&openfang_dir);
|
||||
ext_registry.load_bundled();
|
||||
@@ -3098,12 +3195,7 @@ fn cmd_migrate(args: MigrateArgs) {
|
||||
}
|
||||
});
|
||||
|
||||
let target_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Error: Could not determine home directory");
|
||||
std::process::exit(1);
|
||||
})
|
||||
.join(".openfang");
|
||||
let target_dir = cli_openfang_home();
|
||||
|
||||
println!("Migrating from {} ({})...", source, source_dir.display());
|
||||
if args.dry_run {
|
||||
@@ -3794,6 +3886,52 @@ fn cmd_channel_toggle(channel: &str, enable: bool) {
|
||||
// Hand commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cmd_hand_install(path: &str) {
|
||||
let base = require_daemon("hand install");
|
||||
let dir = std::path::Path::new(path);
|
||||
let toml_path = dir.join("HAND.toml");
|
||||
let skill_path = dir.join("SKILL.md");
|
||||
|
||||
if !toml_path.exists() {
|
||||
eprintln!(
|
||||
"Error: No HAND.toml found in {}",
|
||||
dir.canonicalize()
|
||||
.unwrap_or_else(|_| dir.to_path_buf())
|
||||
.display()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let toml_content = std::fs::read_to_string(&toml_path).unwrap_or_else(|e| {
|
||||
eprintln!("Error reading {}: {e}", toml_path.display());
|
||||
std::process::exit(1);
|
||||
});
|
||||
let skill_content = std::fs::read_to_string(&skill_path).unwrap_or_default();
|
||||
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/install"))
|
||||
.json(&serde_json::json!({
|
||||
"toml_content": toml_content,
|
||||
"skill_content": skill_content,
|
||||
}))
|
||||
.send(),
|
||||
);
|
||||
|
||||
if let Some(err) = body.get("error").and_then(|v| v.as_str()) {
|
||||
eprintln!("Error: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Installed hand: {} ({})",
|
||||
body["name"].as_str().unwrap_or("?"),
|
||||
body["id"].as_str().unwrap_or("?"),
|
||||
);
|
||||
println!("Use `openfang hand activate {}` to start it.", body["id"].as_str().unwrap_or("?"));
|
||||
}
|
||||
|
||||
fn cmd_hand_list() {
|
||||
let base = require_daemon("hand list");
|
||||
let client = daemon_client();
|
||||
@@ -3952,6 +4090,87 @@ fn cmd_hand_info(id: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
fn cmd_hand_check_deps(id: &str) {
|
||||
let base = require_daemon("hand check-deps");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/{id}/check-deps"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&body).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_install_deps(id: &str) {
|
||||
let base = require_daemon("hand install-deps");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/{id}/install-deps"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
ui::success(&format!("Dependencies installed for hand '{id}'."));
|
||||
if let Some(results) = body.get("results") {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(results).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_pause(id: &str) {
|
||||
let base = require_daemon("hand pause");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/instances/{id}/pause"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
ui::success(&format!("Hand instance '{id}' paused."));
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_hand_resume(id: &str) {
|
||||
let base = require_daemon("hand resume");
|
||||
let client = daemon_client();
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/hands/instances/{id}/resume"))
|
||||
.send(),
|
||||
);
|
||||
if body.get("error").is_some() {
|
||||
ui::error(&format!(
|
||||
"Failed: {}",
|
||||
body["error"].as_str().unwrap_or("?")
|
||||
));
|
||||
} else {
|
||||
ui::success(&format!("Hand instance '{id}' resumed."));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider / API key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -4254,8 +4473,9 @@ fn cmd_config_set(key: &str, value: &str) {
|
||||
let new_value = if let Some(existing) = tbl.get(last_key) {
|
||||
match existing {
|
||||
toml::Value::Integer(_) => value
|
||||
.parse::<i64>()
|
||||
.map(toml::Value::Integer)
|
||||
.parse::<u64>()
|
||||
.map(|v| toml::Value::Integer(v as i64))
|
||||
.or_else(|_| value.parse::<i64>().map(toml::Value::Integer))
|
||||
.unwrap_or_else(|_| toml::Value::String(value.to_string())),
|
||||
toml::Value::Float(_) => value
|
||||
.parse::<f64>()
|
||||
@@ -4268,7 +4488,18 @@ fn cmd_config_set(key: &str, value: &str) {
|
||||
_ => toml::Value::String(value.to_string()),
|
||||
}
|
||||
} else {
|
||||
toml::Value::String(value.to_string())
|
||||
// No existing value — infer type from the string content
|
||||
if let Ok(b) = value.parse::<bool>() {
|
||||
toml::Value::Boolean(b)
|
||||
} else if let Ok(i) = value.parse::<u64>() {
|
||||
toml::Value::Integer(i as i64)
|
||||
} else if let Ok(i) = value.parse::<i64>() {
|
||||
toml::Value::Integer(i)
|
||||
} else if let Ok(f) = value.parse::<f64>() {
|
||||
toml::Value::Float(f)
|
||||
} else {
|
||||
toml::Value::String(value.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
tbl.insert(last_key.to_string(), new_value);
|
||||
@@ -4427,6 +4658,9 @@ fn cmd_quick_chat(config: Option<PathBuf>, agent: Option<String>) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn openfang_home() -> PathBuf {
|
||||
if let Ok(home) = std::env::var("OPENFANG_HOME") {
|
||||
return PathBuf::from(home);
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Error: Could not determine home directory");
|
||||
@@ -5149,16 +5383,40 @@ fn cmd_cron_list(json: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str) {
|
||||
fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<&str>) {
|
||||
let base = require_daemon("cron create");
|
||||
let client = daemon_client();
|
||||
|
||||
// Use explicit name if provided, otherwise derive from agent + prompt
|
||||
let name = if let Some(n) = explicit_name {
|
||||
n.to_string()
|
||||
} else {
|
||||
let short_prompt: String = prompt
|
||||
.split_whitespace()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||||
.take(64)
|
||||
.collect();
|
||||
format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt })
|
||||
};
|
||||
|
||||
let body = daemon_json(
|
||||
client
|
||||
.post(format!("{base}/api/cron/jobs"))
|
||||
.json(&serde_json::json!({
|
||||
"agent_id": agent,
|
||||
"cron_expr": spec,
|
||||
"prompt": prompt,
|
||||
"name": name,
|
||||
"schedule": {
|
||||
"kind": "cron",
|
||||
"expr": spec
|
||||
},
|
||||
"action": {
|
||||
"kind": "agent_turn",
|
||||
"message": prompt
|
||||
}
|
||||
}))
|
||||
.send(),
|
||||
);
|
||||
@@ -5245,9 +5503,7 @@ fn cmd_sessions(agent: Option<&str>, json: bool) {
|
||||
}
|
||||
|
||||
fn cmd_logs(lines: usize, follow: bool) {
|
||||
let log_path = dirs::home_dir()
|
||||
.map(|h| h.join(".openfang").join("tui.log"))
|
||||
.unwrap_or_else(|| PathBuf::from("tui.log"));
|
||||
let log_path = cli_openfang_home().join("tui.log");
|
||||
|
||||
if !log_path.exists() {
|
||||
ui::error_with_fix(
|
||||
@@ -5777,13 +6033,7 @@ fn cmd_system_version(json: bool) {
|
||||
}
|
||||
|
||||
fn cmd_reset(confirm: bool) {
|
||||
let openfang_dir = match dirs::home_dir() {
|
||||
Some(h) => h.join(".openfang"),
|
||||
None => {
|
||||
ui::error("Could not determine home directory");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let openfang_dir = cli_openfang_home();
|
||||
|
||||
if !openfang_dir.exists() {
|
||||
println!(
|
||||
@@ -5818,14 +6068,7 @@ fn cmd_reset(confirm: bool) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cmd_uninstall(confirm: bool, keep_config: bool) {
|
||||
let home = match dirs::home_dir() {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
ui::error("Could not determine home directory");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let openfang_dir = home.join(".openfang");
|
||||
let openfang_dir = cli_openfang_home();
|
||||
let exe_path = std::env::current_exe().ok();
|
||||
|
||||
// Step 1: Show what will be removed
|
||||
@@ -5851,11 +6094,15 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
|
||||
println!(" • Remove binary: {}", exe.display());
|
||||
}
|
||||
// Check cargo bin path
|
||||
let cargo_bin = home.join(".cargo").join("bin").join(if cfg!(windows) {
|
||||
"openfang.exe"
|
||||
} else {
|
||||
"openfang"
|
||||
});
|
||||
let cargo_bin = dirs::home_dir()
|
||||
.unwrap_or_else(std::env::temp_dir)
|
||||
.join(".cargo")
|
||||
.join("bin")
|
||||
.join(if cfg!(windows) {
|
||||
"openfang.exe"
|
||||
} else {
|
||||
"openfang"
|
||||
});
|
||||
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
|
||||
println!(" • Remove cargo binary: {}", cargo_bin.display());
|
||||
}
|
||||
@@ -5889,12 +6136,13 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
|
||||
}
|
||||
|
||||
// Step 4: Remove auto-start entries
|
||||
remove_autostart_entries(&home);
|
||||
let user_home = dirs::home_dir().unwrap_or_else(std::env::temp_dir);
|
||||
remove_autostart_entries(&user_home);
|
||||
|
||||
// Step 5: Clean PATH from shell configs
|
||||
if let Some(ref exe) = exe_path {
|
||||
if let Some(bin_dir) = exe.parent() {
|
||||
clean_path_entries(&home, &bin_dir.to_string_lossy());
|
||||
clean_path_entries(&user_home, &bin_dir.to_string_lossy());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,16 @@ pub fn discover_template_dirs() -> Vec<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
// Installed templates
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let agents = home.join(".openfang").join("agents");
|
||||
// Installed templates (respects OPENFANG_HOME)
|
||||
let of_home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
PathBuf::from(h)
|
||||
} else if let Some(home) = dirs::home_dir() {
|
||||
home.join(".openfang")
|
||||
} else {
|
||||
std::env::temp_dir().join(".openfang")
|
||||
};
|
||||
{
|
||||
let agents = of_home.join("agents");
|
||||
if agents.is_dir() && !dirs.contains(&agents) {
|
||||
dirs.push(agents);
|
||||
}
|
||||
|
||||
@@ -220,6 +220,8 @@ impl StandaloneChat {
|
||||
}
|
||||
ChatAction::SendMessage(msg) => self.send_message(msg),
|
||||
ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
|
||||
ChatAction::OpenModelPicker => self.open_model_picker(),
|
||||
ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,12 +269,13 @@ impl StandaloneChat {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/model \u{2014} show current model",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent & quit",
|
||||
"/exit \u{2014} end chat session",
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent & quit",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
@@ -294,8 +297,14 @@ impl StandaloneChat {
|
||||
self.chat.push_message(Role::System, s.join("\n"));
|
||||
}
|
||||
"/model" => {
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Model: {}", self.chat.model_label));
|
||||
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
||||
if args.is_empty() {
|
||||
// No argument: open the model picker
|
||||
self.open_model_picker();
|
||||
} else {
|
||||
// With argument: switch directly
|
||||
self.switch_model(args);
|
||||
}
|
||||
}
|
||||
"/clear" => {
|
||||
let name = self.chat.agent_name.clone();
|
||||
@@ -364,6 +373,160 @@ impl StandaloneChat {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model picker helpers ──────────────────────────────────────────────────
|
||||
|
||||
fn open_model_picker(&mut self) {
|
||||
use super::screens::chat::ModelEntry;
|
||||
|
||||
let models = match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
let client = crate::daemon_client();
|
||||
match client.get(format!("{base_url}/api/models")).send() {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>() {
|
||||
Ok(body) => body["models"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter(|m| m["available"].as_bool().unwrap_or(false))
|
||||
.map(|m| ModelEntry {
|
||||
id: m["id"].as_str().unwrap_or("").to_string(),
|
||||
display_name: m["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
let catalog = kernel.model_catalog.read().unwrap();
|
||||
catalog
|
||||
.available_models()
|
||||
.into_iter()
|
||||
.map(|e| ModelEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
provider: e.provider.clone(),
|
||||
tier: format!("{:?}", e.tier),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Backend::None => Vec::new(),
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
self.chat
|
||||
.push_message(Role::System, "No models available.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.chat.model_picker_models = models;
|
||||
self.chat.model_picker_filter.clear();
|
||||
self.chat.model_picker_idx = 0;
|
||||
self.chat.show_model_picker = true;
|
||||
}
|
||||
|
||||
fn switch_model(&mut self, model_id: &str) {
|
||||
// Skip if already on this model
|
||||
if self.chat.model_label.ends_with(model_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
if let Some(ref agent_id) = self.agent_id_daemon {
|
||||
let client = crate::daemon_client();
|
||||
let url = format!("{base_url}/api/agents/{agent_id}/model");
|
||||
match client
|
||||
.put(&url)
|
||||
.json(&serde_json::json!({"model": model_id}))
|
||||
.send()
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
// Re-fetch agent to get updated provider/model
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/agents/{agent_id}"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Failed to switch to {model_id}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
if let Some(id) = self.agent_id_inprocess {
|
||||
let provider = kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap()
|
||||
.find_model(model_id)
|
||||
.map(|e| e.provider.clone());
|
||||
let result = if let Some(ref prov) = provider {
|
||||
kernel.registry.update_model_and_provider(
|
||||
id,
|
||||
model_id.to_string(),
|
||||
prov.clone(),
|
||||
)
|
||||
} else {
|
||||
kernel.registry.update_model(id, model_id.to_string())
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let prov_label = provider.unwrap_or_else(|| {
|
||||
kernel
|
||||
.registry
|
||||
.get(id)
|
||||
.map(|e| e.manifest.model.provider.clone())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
});
|
||||
self.chat.model_label = format!("{prov_label}/{model_id}");
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend::None => {
|
||||
self.chat
|
||||
.push_message(Role::System, "No backend connected.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent resolution helpers ─────────────────────────────────────────────
|
||||
|
||||
fn enter_chat_daemon(&mut self, id: String, name: String) {
|
||||
|
||||
@@ -1364,6 +1364,8 @@ impl App {
|
||||
}
|
||||
chat::ChatAction::SendMessage(msg) => self.send_message(msg),
|
||||
chat::ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
|
||||
chat::ChatAction::OpenModelPicker => self.open_model_picker(),
|
||||
chat::ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1848,6 +1850,159 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Model picker ────────────────────────────────────────────────────────
|
||||
|
||||
fn open_model_picker(&mut self) {
|
||||
let models = match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
let client = crate::daemon_client();
|
||||
match client.get(format!("{base_url}/api/models")).send() {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>() {
|
||||
Ok(body) => body["models"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter(|m| m["available"].as_bool().unwrap_or(false))
|
||||
.map(|m| chat::ModelEntry {
|
||||
id: m["id"].as_str().unwrap_or("").to_string(),
|
||||
display_name: m["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tier: m["tier"]
|
||||
.as_str()
|
||||
.unwrap_or("Balanced")
|
||||
.to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
let catalog = kernel.model_catalog.read().unwrap();
|
||||
catalog
|
||||
.available_models()
|
||||
.into_iter()
|
||||
.map(|e| chat::ModelEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
provider: e.provider.clone(),
|
||||
tier: format!("{:?}", e.tier),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Backend::None => Vec::new(),
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
self.chat
|
||||
.push_message(chat::Role::System, "No models available.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.chat.model_picker_models = models;
|
||||
self.chat.model_picker_filter.clear();
|
||||
self.chat.model_picker_idx = 0;
|
||||
self.chat.show_model_picker = true;
|
||||
}
|
||||
|
||||
fn switch_model(&mut self, model_id: &str) {
|
||||
if self.chat.model_label.ends_with(model_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match (&self.backend, &self.chat_target) {
|
||||
(Backend::Daemon { base_url }, Some(target)) => {
|
||||
if let Some(ref agent_id) = target.agent_id_daemon {
|
||||
let client = crate::daemon_client();
|
||||
let url = format!("{base_url}/api/agents/{agent_id}/model");
|
||||
match client
|
||||
.put(&url)
|
||||
.json(&serde_json::json!({"model": model_id}))
|
||||
.send()
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/agents/{agent_id}"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Failed to switch to {model_id}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(Backend::InProcess { kernel }, Some(target)) => {
|
||||
if let Some(id) = target.agent_id_inprocess {
|
||||
let provider = kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap()
|
||||
.find_model(model_id)
|
||||
.map(|e| e.provider.clone());
|
||||
let result = if let Some(ref prov) = provider {
|
||||
kernel.registry.update_model_and_provider(
|
||||
id,
|
||||
model_id.to_string(),
|
||||
prov.clone(),
|
||||
)
|
||||
} else {
|
||||
kernel.registry.update_model(id, model_id.to_string())
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let prov_label = provider.unwrap_or_else(|| {
|
||||
kernel
|
||||
.registry
|
||||
.get(id)
|
||||
.map(|e| e.manifest.model.provider.clone())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
});
|
||||
self.chat.model_label = format!("{prov_label}/{model_id}");
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.chat
|
||||
.push_message(chat::Role::System, "No backend connected.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Slash commands ──────────────────────────────────────────────────────
|
||||
|
||||
fn handle_slash_command(&mut self, cmd: &str) {
|
||||
@@ -1858,13 +2013,14 @@ impl App {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/model \u{2014} show current model",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
@@ -1989,10 +2145,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
"/model" => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Model: {}", self.chat.model_label),
|
||||
);
|
||||
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
||||
if args.is_empty() {
|
||||
self.open_model_picker();
|
||||
} else {
|
||||
self.switch_model(args);
|
||||
}
|
||||
}
|
||||
"/hands" => match &self.backend {
|
||||
Backend::InProcess { kernel } => {
|
||||
|
||||
@@ -5,9 +5,18 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Padding, Paragraph};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Model entry for the picker.
|
||||
#[derive(Clone)]
|
||||
pub struct ModelEntry {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub provider: String,
|
||||
pub tier: String,
|
||||
}
|
||||
|
||||
/// Tool call metadata for rich rendering.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolInfo {
|
||||
@@ -68,6 +77,14 @@ pub struct ChatState {
|
||||
pub staged_messages: Vec<String>,
|
||||
/// Accumulates ToolInputDelta text for the current tool call.
|
||||
pub tool_input_buf: String,
|
||||
/// Model picker overlay state.
|
||||
pub show_model_picker: bool,
|
||||
/// Available models for the picker.
|
||||
pub model_picker_models: Vec<ModelEntry>,
|
||||
/// Filter text for model search.
|
||||
pub model_picker_filter: String,
|
||||
/// Selected index in the filtered model list.
|
||||
pub model_picker_idx: usize,
|
||||
}
|
||||
|
||||
pub enum ChatAction {
|
||||
@@ -75,6 +92,10 @@ pub enum ChatAction {
|
||||
SendMessage(String),
|
||||
Back,
|
||||
SlashCommand(String),
|
||||
/// Open the model picker (fetch models first).
|
||||
OpenModelPicker,
|
||||
/// Switch to a specific model by id.
|
||||
SwitchModel(String),
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
@@ -97,6 +118,10 @@ impl ChatState {
|
||||
status_msg: None,
|
||||
staged_messages: Vec::new(),
|
||||
tool_input_buf: String::new(),
|
||||
show_model_picker: false,
|
||||
model_picker_models: Vec::new(),
|
||||
model_picker_filter: String::new(),
|
||||
model_picker_idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +140,9 @@ impl ChatState {
|
||||
self.status_msg = None;
|
||||
self.staged_messages.clear();
|
||||
self.tool_input_buf.clear();
|
||||
self.show_model_picker = false;
|
||||
self.model_picker_filter.clear();
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
|
||||
/// Push a completed message into history.
|
||||
@@ -205,11 +233,81 @@ impl ChatState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return filtered models based on the current picker filter.
|
||||
pub fn filtered_models(&self) -> Vec<&ModelEntry> {
|
||||
if self.model_picker_filter.is_empty() {
|
||||
return self.model_picker_models.iter().collect();
|
||||
}
|
||||
let f = self.model_picker_filter.to_lowercase();
|
||||
self.model_picker_models
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.id.to_lowercase().contains(&f)
|
||||
|| m.display_name.to_lowercase().contains(&f)
|
||||
|| m.provider.to_lowercase().contains(&f)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> ChatAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.show_model_picker {
|
||||
self.show_model_picker = false;
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
return ChatAction::Back;
|
||||
}
|
||||
|
||||
// Ctrl+M: toggle model picker
|
||||
if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.is_streaming {
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
if self.show_model_picker {
|
||||
self.show_model_picker = false;
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
return ChatAction::OpenModelPicker;
|
||||
}
|
||||
|
||||
// Model picker mode: intercept all keys
|
||||
if self.show_model_picker {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_model_picker = false;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
self.model_picker_idx = self.model_picker_idx.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let max = self.filtered_models().len().saturating_sub(1);
|
||||
if self.model_picker_idx < max {
|
||||
self.model_picker_idx += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let filtered = self.filtered_models();
|
||||
if let Some(entry) = filtered.get(self.model_picker_idx) {
|
||||
let model_id = entry.id.clone();
|
||||
self.show_model_picker = false;
|
||||
self.model_picker_filter.clear();
|
||||
self.model_picker_idx = 0;
|
||||
return ChatAction::SwitchModel(model_id);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.model_picker_filter.pop();
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.model_picker_filter.push(c);
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
|
||||
// When streaming, allow typing + staging messages, scrolling, and Esc
|
||||
if self.is_streaming {
|
||||
match key.code {
|
||||
@@ -361,13 +459,136 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut ChatState) {
|
||||
f.render_widget(Paragraph::new(input_line), chunks[2]);
|
||||
|
||||
// ── Hints ────────────────────────────────────────────────────────────────
|
||||
let hints = if state.is_streaming {
|
||||
let hints = if state.show_model_picker {
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] Select [Esc] Close [type] Filter"
|
||||
} else if state.is_streaming {
|
||||
" [Enter] Stage [\u{2191}\u{2193}] Scroll [Esc] Stop"
|
||||
} else {
|
||||
" [Enter] Send [\u{2191}\u{2193}/PgUp/PgDn] Scroll [Esc] Back"
|
||||
" [Enter] Send [Ctrl+M] Models [\u{2191}\u{2193}] Scroll [Esc] Back"
|
||||
};
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(hints, theme::hint_style())]));
|
||||
f.render_widget(hints, chunks[3]);
|
||||
|
||||
// ── Model picker overlay ────────────────────────────────────────────────
|
||||
if state.show_model_picker {
|
||||
draw_model_picker(f, inner, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
let filtered = state.filtered_models();
|
||||
|
||||
// Center a popup — width ~50 cols, height capped at area
|
||||
if area.height < 6 || area.width < 20 {
|
||||
return; // Too small to show picker
|
||||
}
|
||||
let popup_w = area.width.clamp(30, 54);
|
||||
let popup_h = (filtered.len() as u16 + 4)
|
||||
.clamp(5, area.height.saturating_sub(2));
|
||||
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
|
||||
let popup_area = Rect::new(x, y, popup_w, popup_h);
|
||||
|
||||
// Clear background
|
||||
f.render_widget(Clear, popup_area);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Switch Model ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
f.render_widget(block, popup_area);
|
||||
|
||||
if inner.height < 2 || inner.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout: search bar | model list
|
||||
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
|
||||
|
||||
// Search bar
|
||||
let search_line = Line::from(vec![
|
||||
Span::styled("/ ", theme::dim_style()),
|
||||
Span::raw(&state.model_picker_filter),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(search_line), chunks[0]);
|
||||
|
||||
// Model list
|
||||
let visible_h = chunks[1].height as usize;
|
||||
let total = filtered.len();
|
||||
|
||||
if total == 0 {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" No models match",
|
||||
theme::dim_style(),
|
||||
)])),
|
||||
chunks[1],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll window: keep selected item visible
|
||||
let scroll_start = if state.model_picker_idx >= visible_h {
|
||||
state.model_picker_idx - visible_h + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let max_name = (chunks[1].width as usize).saturating_sub(14);
|
||||
for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) {
|
||||
let selected = i == state.model_picker_idx;
|
||||
let indicator = if selected { "\u{25b6} " } else { " " };
|
||||
|
||||
let name = if entry.display_name.is_empty() {
|
||||
&entry.id
|
||||
} else {
|
||||
&entry.display_name
|
||||
};
|
||||
let name_display = if name.len() > max_name && max_name > 1 {
|
||||
let truncated = openfang_types::truncate_str(name, max_name.saturating_sub(1));
|
||||
format!("{truncated}\u{2026}")
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
let tier_style = match entry.tier.to_lowercase().as_str() {
|
||||
"frontier" => Style::default().fg(theme::PURPLE),
|
||||
"smart" => Style::default().fg(theme::BLUE),
|
||||
"balanced" => Style::default().fg(theme::GREEN),
|
||||
"fast" => Style::default().fg(theme::YELLOW),
|
||||
_ => theme::dim_style(),
|
||||
};
|
||||
|
||||
let bg = if selected {
|
||||
Style::default()
|
||||
.fg(theme::TEXT_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme::TEXT_SECONDARY)
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(indicator, Style::default().fg(theme::ACCENT)),
|
||||
Span::styled(name_display, bg),
|
||||
Span::raw(" "),
|
||||
Span::styled(entry.tier.to_lowercase(), tier_style),
|
||||
]));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), chunks[1]);
|
||||
}
|
||||
|
||||
fn draw_messages(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
|
||||
@@ -825,8 +825,11 @@ fn handle_migration_key(
|
||||
if yes {
|
||||
state.migration_phase = MigrationPhase::Running;
|
||||
let source_dir = state.openclaw_path.clone().unwrap_or_default();
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
let target_dir = home.join(".openfang");
|
||||
let target_dir = if let Ok(h) = std::env::var("OPENFANG_HOME") {
|
||||
PathBuf::from(h)
|
||||
} else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(".openfang")
|
||||
};
|
||||
let tx = migrate_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let options = openfang_migrate::MigrateOptions {
|
||||
@@ -945,15 +948,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
@@ -344,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());
|
||||
@@ -368,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();
|
||||
@@ -390,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();
|
||||
@@ -409,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();
|
||||
@@ -427,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();
|
||||
@@ -452,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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -20,7 +20,9 @@ use openfang_runtime::agent_loop::{
|
||||
use openfang_runtime::audit::AuditLog;
|
||||
use openfang_runtime::drivers;
|
||||
use openfang_runtime::kernel_handle::{self, KernelHandle};
|
||||
use openfang_runtime::llm_driver::{CompletionRequest, DriverConfig, LlmDriver, StreamEvent};
|
||||
use openfang_runtime::llm_driver::{
|
||||
CompletionRequest, CompletionResponse, DriverConfig, LlmDriver, LlmError, StreamEvent,
|
||||
};
|
||||
use openfang_runtime::python_runtime::{self, PythonConfig};
|
||||
use openfang_runtime::routing::ModelRouter;
|
||||
use openfang_runtime::sandbox::{SandboxConfig, WasmSandbox};
|
||||
@@ -39,6 +41,22 @@ use std::sync::{Arc, OnceLock, Weak};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// The main OpenFang kernel — coordinates all subsystems.
|
||||
/// Stub LLM driver used when no providers are configured.
|
||||
/// Returns a helpful error so the dashboard still boots and users can configure providers.
|
||||
struct StubDriver;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmDriver for StubDriver {
|
||||
async fn complete(&self, _request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
Err(LlmError::MissingApiKey(
|
||||
"No LLM provider configured. Set an API key (e.g. GROQ_API_KEY) and restart, \
|
||||
configure a provider via the dashboard, \
|
||||
or use Ollama for local models (no API key needed)."
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OpenFangKernel {
|
||||
/// Kernel configuration.
|
||||
pub config: KernelConfig,
|
||||
@@ -532,51 +550,73 @@ impl OpenFangKernel {
|
||||
let driver_config = DriverConfig {
|
||||
provider: config.default_model.provider.clone(),
|
||||
api_key: std::env::var(&config.default_model.api_key_env).ok(),
|
||||
base_url: config.default_model.base_url.clone(),
|
||||
base_url: config
|
||||
.default_model
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| config.provider_urls.get(&config.default_model.provider).cloned()),
|
||||
};
|
||||
let primary_driver = drivers::create_driver(&driver_config)
|
||||
.map_err(|e| KernelError::BootFailed(format!("LLM driver init failed: {e}")))?;
|
||||
// Primary driver failure is non-fatal: the dashboard should remain accessible
|
||||
// even if the LLM provider is misconfigured. Users can fix config via dashboard.
|
||||
let primary_result = drivers::create_driver(&driver_config);
|
||||
let mut driver_chain: Vec<Arc<dyn LlmDriver>> = Vec::new();
|
||||
|
||||
// If fallback providers are configured, wrap the primary driver in a FallbackDriver
|
||||
let driver: Arc<dyn LlmDriver> = if !config.fallback_providers.is_empty() {
|
||||
let mut chain: Vec<Arc<dyn LlmDriver>> = vec![primary_driver.clone()];
|
||||
for fb in &config.fallback_providers {
|
||||
let fb_config = DriverConfig {
|
||||
provider: fb.provider.clone(),
|
||||
api_key: if fb.api_key_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
std::env::var(&fb.api_key_env).ok()
|
||||
},
|
||||
base_url: fb.base_url.clone(),
|
||||
};
|
||||
match drivers::create_driver(&fb_config) {
|
||||
Ok(d) => {
|
||||
info!(
|
||||
provider = %fb.provider,
|
||||
model = %fb.model,
|
||||
"Fallback provider configured"
|
||||
);
|
||||
chain.push(d);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
provider = %fb.provider,
|
||||
error = %e,
|
||||
"Fallback provider init failed — skipped"
|
||||
);
|
||||
}
|
||||
match &primary_result {
|
||||
Ok(d) => driver_chain.push(d.clone()),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
provider = %config.default_model.provider,
|
||||
error = %e,
|
||||
"Primary LLM driver init failed — dashboard will still be accessible"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add fallback providers to the chain
|
||||
for fb in &config.fallback_providers {
|
||||
let fb_config = DriverConfig {
|
||||
provider: fb.provider.clone(),
|
||||
api_key: if fb.api_key_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
std::env::var(&fb.api_key_env).ok()
|
||||
},
|
||||
base_url: fb
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| config.provider_urls.get(&fb.provider).cloned()),
|
||||
};
|
||||
match drivers::create_driver(&fb_config) {
|
||||
Ok(d) => {
|
||||
info!(
|
||||
provider = %fb.provider,
|
||||
model = %fb.model,
|
||||
"Fallback provider configured"
|
||||
);
|
||||
driver_chain.push(d);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
provider = %fb.provider,
|
||||
error = %e,
|
||||
"Fallback provider init failed — skipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
if chain.len() > 1 {
|
||||
Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::new(
|
||||
chain,
|
||||
))
|
||||
} else {
|
||||
primary_driver
|
||||
}
|
||||
}
|
||||
|
||||
// Use the chain, or create a stub driver if everything failed
|
||||
let driver: Arc<dyn LlmDriver> = if driver_chain.len() > 1 {
|
||||
Arc::new(openfang_runtime::drivers::fallback::FallbackDriver::new(
|
||||
driver_chain,
|
||||
))
|
||||
} else if let Some(single) = driver_chain.into_iter().next() {
|
||||
single
|
||||
} else {
|
||||
primary_driver
|
||||
// All drivers failed — use a stub that returns a helpful error.
|
||||
// The kernel boots, dashboard is accessible, users can fix their config.
|
||||
warn!("No LLM drivers available — agents will return errors until a provider is configured");
|
||||
Arc::new(StubDriver) as Arc<dyn LlmDriver>
|
||||
};
|
||||
|
||||
// Initialize metering engine (shares the same SQLite connection as the memory substrate)
|
||||
@@ -648,7 +688,7 @@ impl OpenFangKernel {
|
||||
}
|
||||
|
||||
// Initialize hand registry (curated autonomous packages)
|
||||
let mut hand_registry = openfang_hands::registry::HandRegistry::new();
|
||||
let hand_registry = openfang_hands::registry::HandRegistry::new();
|
||||
let hand_count = hand_registry.load_bundled();
|
||||
if hand_count > 0 {
|
||||
info!("Loaded {hand_count} bundled hand(s)");
|
||||
@@ -897,6 +937,61 @@ impl OpenFangKernel {
|
||||
let agent_id = entry.id;
|
||||
let name = entry.name.clone();
|
||||
|
||||
// Check if TOML on disk is newer/different — if so, update from file
|
||||
let mut entry = entry;
|
||||
let toml_path = kernel
|
||||
.config
|
||||
.home_dir
|
||||
.join("agents")
|
||||
.join(&name)
|
||||
.join("agent.toml");
|
||||
if toml_path.exists() {
|
||||
match std::fs::read_to_string(&toml_path) {
|
||||
Ok(toml_str) => {
|
||||
match toml::from_str::<openfang_types::agent::AgentManifest>(
|
||||
&toml_str,
|
||||
) {
|
||||
Ok(disk_manifest) => {
|
||||
// Compare key fields to detect changes
|
||||
let changed = disk_manifest.name != entry.manifest.name
|
||||
|| disk_manifest.description != entry.manifest.description
|
||||
|| disk_manifest.model.system_prompt != entry.manifest.model.system_prompt
|
||||
|| disk_manifest.model.provider != entry.manifest.model.provider
|
||||
|| disk_manifest.model.model != entry.manifest.model.model
|
||||
|| disk_manifest.capabilities.tools != entry.manifest.capabilities.tools;
|
||||
if changed {
|
||||
info!(
|
||||
agent = %name,
|
||||
"Agent TOML on disk differs from DB, updating"
|
||||
);
|
||||
entry.manifest = disk_manifest;
|
||||
// Persist the update back to DB
|
||||
if let Err(e) = kernel.memory.save_agent(&entry) {
|
||||
warn!(
|
||||
agent = %name,
|
||||
"Failed to persist TOML update: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
agent = %name,
|
||||
path = %toml_path.display(),
|
||||
"Invalid agent TOML on disk, using DB version: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
agent = %name,
|
||||
"Failed to read agent TOML: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-grant capabilities
|
||||
let caps = manifest_to_capabilities(&entry.manifest);
|
||||
kernel.capabilities.grant(agent_id, caps);
|
||||
@@ -923,23 +1018,24 @@ impl OpenFangKernel {
|
||||
);
|
||||
|
||||
// Apply default_model to restored agents (same logic as spawn)
|
||||
if restored_entry.manifest.model.api_key_env.is_none()
|
||||
&& restored_entry.manifest.model.base_url.is_none()
|
||||
{
|
||||
let dm = &kernel.config.default_model;
|
||||
let is_default_provider = restored_entry.manifest.model.provider.is_empty()
|
||||
|| restored_entry.manifest.model.provider == "anthropic";
|
||||
|| restored_entry.manifest.model.provider == "default";
|
||||
let is_default_model = restored_entry.manifest.model.model.is_empty()
|
||||
|| restored_entry.manifest.model.model == "claude-sonnet-4-20250514";
|
||||
|| restored_entry.manifest.model.model == "default";
|
||||
if is_default_provider && is_default_model {
|
||||
let dm = &kernel.config.default_model;
|
||||
if !dm.provider.is_empty() {
|
||||
restored_entry.manifest.model.provider = dm.provider.clone();
|
||||
}
|
||||
if !dm.model.is_empty() {
|
||||
restored_entry.manifest.model.model = dm.model.clone();
|
||||
}
|
||||
if dm.base_url.is_some() {
|
||||
restored_entry.manifest.model.base_url = dm.base_url.clone();
|
||||
if !dm.api_key_env.is_empty() && restored_entry.manifest.model.api_key_env.is_none() {
|
||||
restored_entry.manifest.model.api_key_env = Some(dm.api_key_env.clone());
|
||||
}
|
||||
if dm.base_url.is_some() && restored_entry.manifest.model.base_url.is_none() {
|
||||
restored_entry.manifest.model.base_url.clone_from(&dm.base_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -959,6 +1055,33 @@ impl OpenFangKernel {
|
||||
}
|
||||
}
|
||||
|
||||
// If no agents exist (fresh install), spawn a default assistant
|
||||
if kernel.registry.list().is_empty() {
|
||||
info!("No agents found — spawning default assistant");
|
||||
let dm = &kernel.config.default_model;
|
||||
let manifest = AgentManifest {
|
||||
name: "assistant".to_string(),
|
||||
description: "General-purpose assistant".to_string(),
|
||||
model: openfang_types::agent::ModelConfig {
|
||||
provider: dm.provider.clone(),
|
||||
model: dm.model.clone(),
|
||||
system_prompt: "You are a helpful AI assistant.".to_string(),
|
||||
api_key_env: if dm.api_key_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(dm.api_key_env.clone())
|
||||
},
|
||||
base_url: dm.base_url.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
match kernel.spawn_agent(manifest) {
|
||||
Ok(id) => info!(id = %id, "Default assistant spawned"),
|
||||
Err(e) => warn!("Failed to spawn default assistant: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate routing configs against model catalog
|
||||
for entry in kernel.registry.list() {
|
||||
if let Some(ref routing_config) = entry.manifest.routing {
|
||||
@@ -1008,28 +1131,35 @@ impl OpenFangKernel {
|
||||
info!(agent = %name, id = %agent_id, exec_mode = ?manifest.exec_policy.as_ref().map(|p| &p.mode), "Agent exec_policy resolved");
|
||||
|
||||
// Overlay kernel default_model onto agent if agent didn't explicitly choose.
|
||||
// Only override when the agent has empty (unset) provider/model fields.
|
||||
// This preserves explicit model choices like provider="groq", model="llama-3.3-70b".
|
||||
if manifest.model.api_key_env.is_none() && manifest.model.base_url.is_none() {
|
||||
// Check hot-reloaded override first, fall back to boot-time config
|
||||
let override_guard = self
|
||||
.default_model_override
|
||||
.read()
|
||||
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
|
||||
let dm = override_guard
|
||||
.as_ref()
|
||||
.unwrap_or(&self.config.default_model);
|
||||
let is_default_provider = manifest.model.provider.is_empty();
|
||||
let is_default_model = manifest.model.model.is_empty();
|
||||
// Treat empty or "default" as "use the kernel's configured default_model".
|
||||
// This allows bundled agents to defer to the user's configured provider/model,
|
||||
// even if the agent manifest specifies an api_key_env (which is just a hint
|
||||
// about which env var to check, not a hard lock on provider/model).
|
||||
{
|
||||
let is_default_provider =
|
||||
manifest.model.provider.is_empty() || manifest.model.provider == "default";
|
||||
let is_default_model =
|
||||
manifest.model.model.is_empty() || manifest.model.model == "default";
|
||||
if is_default_provider && is_default_model {
|
||||
// Check hot-reloaded override first, fall back to boot-time config
|
||||
let override_guard = self
|
||||
.default_model_override
|
||||
.read()
|
||||
.unwrap_or_else(|e: std::sync::PoisonError<_>| e.into_inner());
|
||||
let dm = override_guard
|
||||
.as_ref()
|
||||
.unwrap_or(&self.config.default_model);
|
||||
if !dm.provider.is_empty() {
|
||||
manifest.model.provider = dm.provider.clone();
|
||||
}
|
||||
if !dm.model.is_empty() {
|
||||
manifest.model.model = dm.model.clone();
|
||||
}
|
||||
if dm.base_url.is_some() {
|
||||
manifest.model.base_url = dm.base_url.clone();
|
||||
if !dm.api_key_env.is_empty() && manifest.model.api_key_env.is_none() {
|
||||
manifest.model.api_key_env = Some(dm.api_key_env.clone());
|
||||
}
|
||||
if dm.base_url.is_some() && manifest.model.base_url.is_none() {
|
||||
manifest.model.base_url.clone_from(&dm.base_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1987,7 +2117,16 @@ impl OpenFangKernel {
|
||||
routed_model = %routed_model,
|
||||
"Model routing applied"
|
||||
);
|
||||
manifest.model.model = routed_model;
|
||||
manifest.model.model = routed_model.clone();
|
||||
// Also update provider if the routed model belongs to a different provider
|
||||
if let Ok(cat) = self.model_catalog.read() {
|
||||
if let Some(entry) = cat.find_model(&routed_model) {
|
||||
if entry.provider != manifest.model.provider {
|
||||
info!(old = %manifest.model.provider, new = %entry.provider, "Model routing changed provider");
|
||||
manifest.model.provider = entry.provider.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let driver = self.resolve_driver(&manifest)?;
|
||||
@@ -2760,6 +2899,15 @@ impl OpenFangKernel {
|
||||
max_iterations: max_iter,
|
||||
..Default::default()
|
||||
}),
|
||||
// Autonomous hands must run in Continuous mode so the background loop picks them up.
|
||||
// Reactive (default) only fires on incoming messages, so autonomous hands would be inert.
|
||||
schedule: if def.agent.max_iterations.is_some() {
|
||||
ScheduleMode::Continuous {
|
||||
check_interval_secs: 60,
|
||||
}
|
||||
} else {
|
||||
ScheduleMode::default()
|
||||
},
|
||||
skills: def.skills.clone(),
|
||||
mcp_servers: def.mcp_servers.clone(),
|
||||
// Hands are curated packages — if they declare shell_exec, grant full exec access
|
||||
@@ -2784,10 +2932,23 @@ impl OpenFangKernel {
|
||||
manifest.model.system_prompt, resolved.prompt_block
|
||||
);
|
||||
}
|
||||
if !resolved.env_vars.is_empty() {
|
||||
// Collect env vars from settings + from requires (api_key/env_var requirements)
|
||||
let mut allowed_env = resolved.env_vars;
|
||||
for req in &def.requires {
|
||||
match req.requirement_type {
|
||||
openfang_hands::RequirementType::ApiKey
|
||||
| openfang_hands::RequirementType::EnvVar => {
|
||||
if !req.check_value.is_empty() && !allowed_env.contains(&req.check_value) {
|
||||
allowed_env.push(req.check_value.clone());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !allowed_env.is_empty() {
|
||||
manifest.metadata.insert(
|
||||
"hand_allowed_env".to_string(),
|
||||
serde_json::to_value(&resolved.env_vars).unwrap_or_default(),
|
||||
serde_json::to_value(&allowed_env).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2799,6 +2960,13 @@ impl OpenFangKernel {
|
||||
);
|
||||
}
|
||||
|
||||
// If an agent with this hand's name already exists, remove it first
|
||||
let existing = self.registry.list().into_iter().find(|e| e.name == def.agent.name);
|
||||
if let Some(old) = existing {
|
||||
info!(agent = %old.name, id = %old.id, "Removing existing hand agent for reactivation");
|
||||
let _ = self.kill_agent(old.id);
|
||||
}
|
||||
|
||||
// Spawn the agent
|
||||
let agent_id = self.spawn_agent(manifest)?;
|
||||
|
||||
@@ -3125,18 +3293,30 @@ impl OpenFangKernel {
|
||||
/// `Continuous`, `Periodic`, or `Proactive` schedules.
|
||||
pub fn start_background_agents(self: &Arc<Self>) {
|
||||
let agents = self.registry.list();
|
||||
let mut started = 0u32;
|
||||
let mut bg_agents: Vec<(openfang_types::agent::AgentId, String, ScheduleMode)> =
|
||||
Vec::new();
|
||||
|
||||
for entry in &agents {
|
||||
if matches!(entry.manifest.schedule, ScheduleMode::Reactive) {
|
||||
continue;
|
||||
}
|
||||
self.start_background_for_agent(entry.id, &entry.name, &entry.manifest.schedule);
|
||||
started += 1;
|
||||
bg_agents.push((entry.id, entry.name.clone(), entry.manifest.schedule.clone()));
|
||||
}
|
||||
|
||||
if started > 0 {
|
||||
info!("Started {started} background agent loop(s)");
|
||||
if !bg_agents.is_empty() {
|
||||
let count = bg_agents.len();
|
||||
let kernel = Arc::clone(self);
|
||||
// Stagger agent startup to prevent rate-limit storm on shared providers.
|
||||
// Each agent gets a 500ms delay before the next one starts.
|
||||
tokio::spawn(async move {
|
||||
for (i, (id, name, schedule)) in bg_agents.into_iter().enumerate() {
|
||||
kernel.start_background_for_agent(id, &name, &schedule);
|
||||
if i > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
info!("Started {count} background agent loop(s) (staggered)");
|
||||
});
|
||||
}
|
||||
|
||||
// Start heartbeat monitor for agent health checking
|
||||
@@ -3564,7 +3744,7 @@ impl OpenFangKernel {
|
||||
}
|
||||
|
||||
/// Start the background loop / register triggers for a single agent.
|
||||
fn start_background_for_agent(
|
||||
pub fn start_background_for_agent(
|
||||
self: &Arc<Self>,
|
||||
agent_id: AgentId,
|
||||
name: &str,
|
||||
@@ -3698,10 +3878,14 @@ impl OpenFangKernel {
|
||||
let base_url = if has_custom_url {
|
||||
manifest.model.base_url.clone()
|
||||
} else if agent_provider == default_provider {
|
||||
self.config.default_model.base_url.clone()
|
||||
self.config
|
||||
.default_model
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| self.config.provider_urls.get(agent_provider.as_str()).cloned())
|
||||
} else {
|
||||
// Let create_driver() use the target provider's default base URL
|
||||
None
|
||||
// Check provider_urls before falling back to hardcoded defaults
|
||||
self.config.provider_urls.get(agent_provider.as_str()).cloned()
|
||||
};
|
||||
|
||||
let driver_config = DriverConfig {
|
||||
@@ -3727,7 +3911,10 @@ impl OpenFangKernel {
|
||||
.api_key_env
|
||||
.as_ref()
|
||||
.and_then(|env| std::env::var(env).ok()),
|
||||
base_url: fb.base_url.clone(),
|
||||
base_url: fb
|
||||
.base_url
|
||||
.clone()
|
||||
.or_else(|| self.config.provider_urls.get(&fb.provider).cloned()),
|
||||
};
|
||||
match drivers::create_driver(&config) {
|
||||
Ok(d) => chain.push((d, fb.model.clone())),
|
||||
@@ -4517,7 +4704,7 @@ fn infer_provider_from_model(model: &str) -> Option<String> {
|
||||
|
||||
/// A well-known agent ID used for shared memory operations across agents.
|
||||
/// This is a fixed UUID so all agents read/write to the same namespace.
|
||||
fn shared_memory_agent_id() -> AgentId {
|
||||
pub fn shared_memory_agent_id() -> AgentId {
|
||||
AgentId(uuid::Uuid::from_bytes([
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x01,
|
||||
@@ -4907,6 +5094,24 @@ impl KernelHandle for OpenFangKernel {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn hand_install(
|
||||
&self,
|
||||
toml_content: &str,
|
||||
skill_content: &str,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let def = self
|
||||
.hand_registry
|
||||
.install_from_content(toml_content, skill_content)
|
||||
.map_err(|e| format!("{e}"))?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": def.id,
|
||||
"name": def.name,
|
||||
"description": def.description,
|
||||
"category": format!("{:?}", def.category),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn hand_activate(
|
||||
&self,
|
||||
hand_id: &str,
|
||||
@@ -4933,8 +5138,8 @@ impl KernelHandle for OpenFangKernel {
|
||||
.ok_or_else(|| format!("No active instance found for hand '{hand_id}'"))?;
|
||||
|
||||
let def = self.hand_registry.get_definition(hand_id);
|
||||
let def_name = def.map(|d| d.name.clone()).unwrap_or_default();
|
||||
let def_icon = def.map(|d| d.icon.clone()).unwrap_or_default();
|
||||
let def_name = def.as_ref().map(|d| d.name.clone()).unwrap_or_default();
|
||||
let def_icon = def.as_ref().map(|d| d.icon.clone()).unwrap_or_default();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"hand_id": hand_id,
|
||||
|
||||
@@ -391,6 +391,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 +443,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") {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -305,32 +305,44 @@ fn convert_response(resp: GeminiResponse) -> Result<CompletionResponse, LlmError
|
||||
let mut content = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
if let Some(gemini_content) = candidate.content {
|
||||
for part in gemini_content.parts {
|
||||
match part {
|
||||
GeminiPart::Text { text } => {
|
||||
if !text.is_empty() {
|
||||
content.push(ContentBlock::Text { text });
|
||||
match candidate.content {
|
||||
Some(gemini_content) => {
|
||||
for part in gemini_content.parts {
|
||||
match part {
|
||||
GeminiPart::Text { text } => {
|
||||
if !text.is_empty() {
|
||||
content.push(ContentBlock::Text { text });
|
||||
}
|
||||
}
|
||||
GeminiPart::FunctionCall { function_call } => {
|
||||
let id = format!("call_{}", uuid::Uuid::new_v4().simple());
|
||||
content.push(ContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: function_call.name.clone(),
|
||||
input: function_call.args.clone(),
|
||||
});
|
||||
tool_calls.push(ToolCall {
|
||||
id,
|
||||
name: function_call.name,
|
||||
input: function_call.args,
|
||||
});
|
||||
}
|
||||
GeminiPart::InlineData { .. } | GeminiPart::FunctionResponse { .. } => {
|
||||
// Shouldn't normally appear in responses, ignore
|
||||
}
|
||||
}
|
||||
GeminiPart::FunctionCall { function_call } => {
|
||||
let id = format!("call_{}", uuid::Uuid::new_v4().simple());
|
||||
content.push(ContentBlock::ToolUse {
|
||||
id: id.clone(),
|
||||
name: function_call.name.clone(),
|
||||
input: function_call.args.clone(),
|
||||
});
|
||||
tool_calls.push(ToolCall {
|
||||
id,
|
||||
name: function_call.name,
|
||||
input: function_call.args,
|
||||
});
|
||||
}
|
||||
GeminiPart::InlineData { .. } | GeminiPart::FunctionResponse { .. } => {
|
||||
// Shouldn't normally appear in responses, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let reason = candidate
|
||||
.finish_reason
|
||||
.as_deref()
|
||||
.unwrap_or("unknown");
|
||||
warn!(finish_reason = %reason, "Gemini returned candidate with no content");
|
||||
return Err(LlmError::Parse(format!(
|
||||
"Gemini returned empty response (finish_reason: {reason})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini uses "STOP" for both end-of-turn and function calls,
|
||||
|
||||
@@ -17,8 +17,8 @@ use openfang_types::model_catalog::{
|
||||
FIREWORKS_BASE_URL, GEMINI_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL, LMSTUDIO_BASE_URL,
|
||||
MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL, OPENAI_BASE_URL,
|
||||
OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, XAI_BASE_URL,
|
||||
ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL,
|
||||
XAI_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -173,6 +173,11 @@ fn provider_defaults(provider: &str) -> Option<ProviderDefaults> {
|
||||
api_key_env: "QIANFAN_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
"volcengine" | "doubao" => Some(ProviderDefaults {
|
||||
base_url: VOLCENGINE_BASE_URL,
|
||||
api_key_env: "VOLCENGINE_API_KEY",
|
||||
key_required: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -360,6 +365,7 @@ pub fn known_providers() -> &'static [&'static str] {
|
||||
"zhipu",
|
||||
"zhipu_coding",
|
||||
"qianfan",
|
||||
"volcengine",
|
||||
"codex",
|
||||
"claude-code",
|
||||
]
|
||||
@@ -455,9 +461,10 @@ mod tests {
|
||||
assert!(providers.contains(&"zhipu"));
|
||||
assert!(providers.contains(&"zhipu_coding"));
|
||||
assert!(providers.contains(&"qianfan"));
|
||||
assert!(providers.contains(&"volcengine"));
|
||||
assert!(providers.contains(&"codex"));
|
||||
assert!(providers.contains(&"claude-code"));
|
||||
assert_eq!(providers.len(), 29);
|
||||
assert_eq!(providers.len(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,7 +33,12 @@ impl OpenAIDriver {
|
||||
struct OaiRequest {
|
||||
model: String,
|
||||
messages: Vec<OaiMessage>,
|
||||
max_tokens: u32,
|
||||
/// Classic token limit field (used by most models).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<u32>,
|
||||
/// New token limit field required by GPT-5 and o-series reasoning models.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_completion_tokens: Option<u32>,
|
||||
temperature: f32,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
tools: Vec<OaiTool>,
|
||||
@@ -43,6 +48,16 @@ struct OaiRequest {
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
/// Returns true if a model uses `max_completion_tokens` instead of `max_tokens`.
|
||||
fn uses_completion_tokens(model: &str) -> bool {
|
||||
let m = model.to_lowercase();
|
||||
m.starts_with("gpt-5")
|
||||
|| m.starts_with("gpt5")
|
||||
|| m.starts_with("o1")
|
||||
|| m.starts_with("o3")
|
||||
|| m.starts_with("o4")
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OaiMessage {
|
||||
role: String,
|
||||
@@ -276,10 +291,16 @@ impl LlmDriver for OpenAIDriver {
|
||||
Some(serde_json::json!("auto"))
|
||||
};
|
||||
|
||||
let (mt, mct) = if uses_completion_tokens(&request.model) {
|
||||
(None, Some(request.max_tokens))
|
||||
} else {
|
||||
(Some(request.max_tokens), None)
|
||||
};
|
||||
let mut oai_request = OaiRequest {
|
||||
model: request.model.clone(),
|
||||
messages: oai_messages,
|
||||
max_tokens: request.max_tokens,
|
||||
max_tokens: mt,
|
||||
max_completion_tokens: mct,
|
||||
temperature: request.temperature,
|
||||
tools: oai_tools,
|
||||
tool_choice,
|
||||
@@ -339,16 +360,31 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
|
||||
if status == 400
|
||||
&& body.contains("max_tokens")
|
||||
&& (body.contains("unsupported_parameter")
|
||||
|| body.contains("max_completion_tokens"))
|
||||
&& oai_request.max_tokens.is_some()
|
||||
&& attempt < max_retries
|
||||
{
|
||||
let val = oai_request.max_tokens.unwrap();
|
||||
warn!(model = %oai_request.model, "Switching to max_completion_tokens for this model");
|
||||
oai_request.max_tokens = None;
|
||||
oai_request.max_completion_tokens = Some(val);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-cap max_tokens when model rejects our value (e.g. Groq Maverick limit 8192)
|
||||
if status == 400 && body.contains("max_tokens") && attempt < max_retries {
|
||||
// Extract the limit from error: "must be less than or equal to `8192`"
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(oai_request.max_tokens / 2);
|
||||
warn!(
|
||||
old = oai_request.max_tokens,
|
||||
new = cap,
|
||||
"Auto-capping max_tokens to model limit"
|
||||
);
|
||||
oai_request.max_tokens = cap;
|
||||
let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096);
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2);
|
||||
warn!(old = current, new = cap, "Auto-capping max_tokens to model limit");
|
||||
if oai_request.max_completion_tokens.is_some() {
|
||||
oai_request.max_completion_tokens = Some(cap);
|
||||
} else {
|
||||
oai_request.max_tokens = Some(cap);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -555,10 +591,16 @@ impl LlmDriver for OpenAIDriver {
|
||||
Some(serde_json::json!("auto"))
|
||||
};
|
||||
|
||||
let (mt, mct) = if uses_completion_tokens(&request.model) {
|
||||
(None, Some(request.max_tokens))
|
||||
} else {
|
||||
(Some(request.max_tokens), None)
|
||||
};
|
||||
let mut oai_request = OaiRequest {
|
||||
model: request.model.clone(),
|
||||
messages: oai_messages,
|
||||
max_tokens: request.max_tokens,
|
||||
max_tokens: mt,
|
||||
max_completion_tokens: mct,
|
||||
temperature: request.temperature,
|
||||
tools: oai_tools,
|
||||
tool_choice,
|
||||
@@ -620,15 +662,31 @@ impl LlmDriver for OpenAIDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// GPT-5 / o-series: switch from max_tokens to max_completion_tokens
|
||||
if status == 400
|
||||
&& body.contains("max_tokens")
|
||||
&& (body.contains("unsupported_parameter")
|
||||
|| body.contains("max_completion_tokens"))
|
||||
&& oai_request.max_tokens.is_some()
|
||||
&& attempt < max_retries
|
||||
{
|
||||
let val = oai_request.max_tokens.unwrap();
|
||||
warn!(model = %oai_request.model, "Switching to max_completion_tokens for this model (stream)");
|
||||
oai_request.max_tokens = None;
|
||||
oai_request.max_completion_tokens = Some(val);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-cap max_tokens when model rejects our value
|
||||
if status == 400 && body.contains("max_tokens") && attempt < max_retries {
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(oai_request.max_tokens / 2);
|
||||
warn!(
|
||||
old = oai_request.max_tokens,
|
||||
new = cap,
|
||||
"Auto-capping max_tokens (stream)"
|
||||
);
|
||||
oai_request.max_tokens = cap;
|
||||
let current = oai_request.max_tokens.or(oai_request.max_completion_tokens).unwrap_or(4096);
|
||||
let cap = extract_max_tokens_limit(&body).unwrap_or(current / 2);
|
||||
warn!(old = current, new = cap, "Auto-capping max_tokens (stream)");
|
||||
if oai_request.max_completion_tokens.is_some() {
|
||||
oai_request.max_completion_tokens = Some(cap);
|
||||
} else {
|
||||
oai_request.max_tokens = Some(cap);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,16 @@ pub trait KernelHandle: Send + Sync {
|
||||
Err("Hands system not available".to_string())
|
||||
}
|
||||
|
||||
/// Install a Hand from TOML content.
|
||||
async fn hand_install(
|
||||
&self,
|
||||
toml_content: &str,
|
||||
skill_content: &str,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let _ = (toml_content, skill_content);
|
||||
Err("Hands system not available".to_string())
|
||||
}
|
||||
|
||||
/// Activate a Hand — spawns a specialized autonomous agent.
|
||||
async fn hand_activate(
|
||||
&self,
|
||||
|
||||
@@ -32,7 +32,7 @@ pub struct McpServerConfig {
|
||||
}
|
||||
|
||||
fn default_timeout() -> u64 {
|
||||
30
|
||||
60
|
||||
}
|
||||
|
||||
/// Transport type for MCP server connections.
|
||||
@@ -393,7 +393,31 @@ impl McpConnection {
|
||||
return Err("MCP command path contains '..': rejected".to_string());
|
||||
}
|
||||
|
||||
let mut cmd = tokio::process::Command::new(command);
|
||||
// On Windows, npm/npx install as .cmd batch wrappers. Detect and adapt.
|
||||
let resolved_command: String = if cfg!(windows) {
|
||||
// If the user already specified .cmd/.bat, use as-is
|
||||
if command.ends_with(".cmd") || command.ends_with(".bat") {
|
||||
command.to_string()
|
||||
} else {
|
||||
// Check if the .cmd variant exists on PATH
|
||||
let cmd_variant = format!("{command}.cmd");
|
||||
let has_cmd = std::env::var("PATH")
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.any(|dir| {
|
||||
std::path::Path::new(dir).join(&cmd_variant).exists()
|
||||
});
|
||||
if has_cmd {
|
||||
cmd_variant
|
||||
} else {
|
||||
command.to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
command.to_string()
|
||||
};
|
||||
|
||||
let mut cmd = tokio::process::Command::new(&resolved_command);
|
||||
cmd.args(args);
|
||||
cmd.stdin(Stdio::piped());
|
||||
cmd.stdout(Stdio::piped());
|
||||
@@ -410,10 +434,41 @@ impl McpConnection {
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
// On Windows, npm/node need APPDATA, USERPROFILE, LOCALAPPDATA, and SystemRoot
|
||||
if cfg!(windows) {
|
||||
for var in &[
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"USERPROFILE",
|
||||
"SystemRoot",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"HOME",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
cmd.env(var, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn MCP server '{command}': {e}"))?;
|
||||
.map_err(|e| format!("Failed to spawn MCP server '{resolved_command}': {e}"))?;
|
||||
|
||||
// Log stderr in background for debugging MCP server issues
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let cmd_name = resolved_command.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let reader = tokio::io::BufReader::new(stderr);
|
||||
let mut lines = reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
tracing::debug!(mcp_server = %cmd_name, "stderr: {line}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
|
||||
@@ -9,8 +9,8 @@ use openfang_types::model_catalog::{
|
||||
GEMINI_BASE_URL, GITHUB_COPILOT_BASE_URL, GROQ_BASE_URL, HUGGINGFACE_BASE_URL,
|
||||
LMSTUDIO_BASE_URL, MINIMAX_BASE_URL, MISTRAL_BASE_URL, MOONSHOT_BASE_URL, OLLAMA_BASE_URL,
|
||||
OPENAI_BASE_URL, OPENROUTER_BASE_URL, PERPLEXITY_BASE_URL, QIANFAN_BASE_URL, QWEN_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, XAI_BASE_URL,
|
||||
ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
REPLICATE_BASE_URL, SAMBANOVA_BASE_URL, TOGETHER_BASE_URL, VLLM_BASE_URL, VOLCENGINE_BASE_URL,
|
||||
XAI_BASE_URL, ZHIPU_BASE_URL, ZHIPU_CODING_BASE_URL,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -601,6 +601,16 @@ fn builtin_providers() -> Vec<ProviderInfo> {
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
// ── Volcano Engine (Doubao) ──────────────────────────────────
|
||||
ProviderInfo {
|
||||
id: "volcengine".into(),
|
||||
display_name: "Volcano Engine (Doubao)".into(),
|
||||
api_key_env: "VOLCENGINE_API_KEY".into(),
|
||||
base_url: VOLCENGINE_BASE_URL.into(),
|
||||
key_required: true,
|
||||
auth_status: AuthStatus::Missing,
|
||||
model_count: 0,
|
||||
},
|
||||
// ── AWS Bedrock ──────────────────────────────────────────────
|
||||
ProviderInfo {
|
||||
id: "bedrock".into(),
|
||||
@@ -648,8 +658,8 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("gpt5", "gpt-5.2"),
|
||||
("gpt5-mini", "gpt-5-mini"),
|
||||
("flash", "gemini-2.5-flash"),
|
||||
("gemini-flash", "gemini-2.5-flash"),
|
||||
("gemini-pro", "gemini-3.1-pro"),
|
||||
("gemini-pro", "gemini-3.1-pro-preview"),
|
||||
("gemini-flash", "gemini-3-flash-preview"),
|
||||
("deepseek", "deepseek-chat"),
|
||||
("llama", "llama-3.3-70b-versatile"),
|
||||
("llama-70b", "llama-3.3-70b-versatile"),
|
||||
@@ -663,10 +673,11 @@ fn builtin_aliases() -> HashMap<String, String> {
|
||||
("mistral-nemo", "open-mistral-nemo"),
|
||||
("pixtral", "pixtral-large-latest"),
|
||||
// xAI aliases
|
||||
("grok", "grok-4"),
|
||||
("grok", "grok-4-0709"),
|
||||
("grok-4", "grok-4-0709"),
|
||||
("grok-mini", "grok-2-mini"),
|
||||
("grok3", "grok-3"),
|
||||
("grok-fast", "grok-4.1-fast"),
|
||||
("grok-fast", "grok-4-1-fast-reasoning"),
|
||||
// Perplexity alias
|
||||
("sonar", "sonar-pro"),
|
||||
// AI21 aliases
|
||||
@@ -1039,8 +1050,8 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
// Google Gemini (10)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "gemini-3.1-pro".into(),
|
||||
display_name: "Gemini 3.1 Pro".into(),
|
||||
id: "gemini-3.1-pro-preview".into(),
|
||||
display_name: "Gemini 3.1 Pro Preview".into(),
|
||||
provider: "gemini".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
context_window: 1_048_576,
|
||||
@@ -1052,16 +1063,29 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec!["gemini-pro".into()],
|
||||
},
|
||||
// gemini-3-flash removed: model doesn't exist. Use gemini-2.5-flash instead.
|
||||
ModelCatalogEntry {
|
||||
id: "gemini-3-deep-think".into(),
|
||||
display_name: "Gemini 3 Deep Think".into(),
|
||||
id: "gemini-3-flash-preview".into(),
|
||||
display_name: "Gemini 3 Flash Preview".into(),
|
||||
provider: "gemini".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 1_048_576,
|
||||
max_output_tokens: 65_536,
|
||||
input_cost_per_m: 2.50,
|
||||
output_cost_per_m: 15.0,
|
||||
input_cost_per_m: 0.15,
|
||||
output_cost_per_m: 0.60,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["gemini-flash".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "gemini-3.1-flash-lite-preview".into(),
|
||||
display_name: "Gemini 3.1 Flash Lite Preview".into(),
|
||||
provider: "gemini".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 1_048_576,
|
||||
max_output_tokens: 8_192,
|
||||
input_cost_per_m: 0.04,
|
||||
output_cost_per_m: 0.15,
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
@@ -2174,10 +2198,10 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// xAI (6)
|
||||
// xAI (9)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "grok-4".into(),
|
||||
id: "grok-4-0709".into(),
|
||||
display_name: "Grok 4".into(),
|
||||
provider: "xai".into(),
|
||||
tier: ModelTier::Frontier,
|
||||
@@ -2188,11 +2212,39 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["grok".into()],
|
||||
aliases: vec!["grok".into(), "grok-4".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "grok-4.1-fast".into(),
|
||||
display_name: "Grok 4.1 Fast".into(),
|
||||
id: "grok-4-fast-reasoning".into(),
|
||||
display_name: "Grok 4 Fast Reasoning".into(),
|
||||
provider: "xai".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 256_000,
|
||||
max_output_tokens: 32_768,
|
||||
input_cost_per_m: 1.0,
|
||||
output_cost_per_m: 5.0,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "grok-4-fast-non-reasoning".into(),
|
||||
display_name: "Grok 4 Fast Non-Reasoning".into(),
|
||||
provider: "xai".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 256_000,
|
||||
max_output_tokens: 32_768,
|
||||
input_cost_per_m: 1.0,
|
||||
output_cost_per_m: 5.0,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "grok-4-1-fast-reasoning".into(),
|
||||
display_name: "Grok 4.1 Fast Reasoning".into(),
|
||||
provider: "xai".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 2_000_000,
|
||||
@@ -2204,6 +2256,20 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
supports_streaming: true,
|
||||
aliases: vec!["grok-fast".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "grok-4-1-fast-non-reasoning".into(),
|
||||
display_name: "Grok 4.1 Fast Non-Reasoning".into(),
|
||||
provider: "xai".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 2_000_000,
|
||||
max_output_tokens: 32_768,
|
||||
input_cost_per_m: 0.20,
|
||||
output_cost_per_m: 0.50,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec![],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "grok-3".into(),
|
||||
display_name: "Grok 3".into(),
|
||||
@@ -2694,6 +2760,65 @@ fn builtin_models() -> Vec<ModelCatalogEntry> {
|
||||
aliases: vec![],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Volcano Engine / Doubao (4)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
id: "doubao-seed-1-6-251015".into(),
|
||||
display_name: "Doubao Seed 1.6 Pro".into(),
|
||||
provider: "volcengine".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 262_144,
|
||||
max_output_tokens: 16_384,
|
||||
input_cost_per_m: 0.80,
|
||||
output_cost_per_m: 2.00,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["doubao".into(), "doubao-pro".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "doubao-seed-2-0-lite".into(),
|
||||
display_name: "Doubao Seed 2.0 Lite".into(),
|
||||
provider: "volcengine".into(),
|
||||
tier: ModelTier::Balanced,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 16_384,
|
||||
input_cost_per_m: 0.30,
|
||||
output_cost_per_m: 0.60,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["doubao-lite".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "doubao-seed-2-0-mini".into(),
|
||||
display_name: "Doubao Seed 2.0 Mini".into(),
|
||||
provider: "volcengine".into(),
|
||||
tier: ModelTier::Fast,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 16_384,
|
||||
input_cost_per_m: 0.10,
|
||||
output_cost_per_m: 0.10,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["doubao-mini".into()],
|
||||
},
|
||||
ModelCatalogEntry {
|
||||
id: "doubao-seed-code".into(),
|
||||
display_name: "Doubao Seed Code".into(),
|
||||
provider: "volcengine".into(),
|
||||
tier: ModelTier::Smart,
|
||||
context_window: 131_072,
|
||||
max_output_tokens: 16_384,
|
||||
input_cost_per_m: 0.50,
|
||||
output_cost_per_m: 1.00,
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
supports_streaming: true,
|
||||
aliases: vec!["doubao-code".into()],
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// AWS Bedrock (8)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
ModelCatalogEntry {
|
||||
@@ -2900,7 +3025,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_catalog_has_providers() {
|
||||
let catalog = ModelCatalog::new();
|
||||
assert_eq!(catalog.list_providers().len(), 30);
|
||||
assert_eq!(catalog.list_providers().len(), 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3020,7 +3145,7 @@ mod tests {
|
||||
assert!(aliases.len() >= 20);
|
||||
assert_eq!(aliases.get("sonnet").unwrap(), "claude-sonnet-4-6");
|
||||
// New aliases
|
||||
assert_eq!(aliases.get("grok").unwrap(), "grok-4");
|
||||
assert_eq!(aliases.get("grok").unwrap(), "grok-4-0709");
|
||||
assert_eq!(aliases.get("jamba").unwrap(), "jamba-1.5-large");
|
||||
}
|
||||
|
||||
@@ -3028,7 +3153,7 @@ mod tests {
|
||||
fn test_find_grok_by_alias() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let entry = catalog.find_model("grok").unwrap();
|
||||
assert_eq!(entry.id, "grok-4");
|
||||
assert_eq!(entry.id, "grok-4-0709");
|
||||
assert_eq!(entry.provider, "xai");
|
||||
}
|
||||
|
||||
@@ -3049,9 +3174,12 @@ mod tests {
|
||||
fn test_xai_models() {
|
||||
let catalog = ModelCatalog::new();
|
||||
let xai = catalog.models_by_provider("xai");
|
||||
assert_eq!(xai.len(), 6);
|
||||
assert!(xai.iter().any(|m| m.id == "grok-4"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-4.1-fast"));
|
||||
assert_eq!(xai.len(), 9);
|
||||
assert!(xai.iter().any(|m| m.id == "grok-4-0709"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-4-fast-reasoning"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-4-fast-non-reasoning"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-4-1-fast-reasoning"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-4-1-fast-non-reasoning"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-3"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-3-mini"));
|
||||
assert!(xai.iter().any(|m| m.id == "grok-2"));
|
||||
|
||||
@@ -215,6 +215,9 @@ const TOOL_CALL_BEHAVIOR: &str = "\
|
||||
- Prefer action over narration. If you can answer by using a tool, do it.
|
||||
- When executing multiple sequential tool calls, batch them — don't output reasoning between each call.
|
||||
- If a tool returns useful results, present the KEY information, not the raw output.
|
||||
- When web_fetch or web_search returns content, you MUST include the relevant data in your response. \
|
||||
Quote specific facts, numbers, or passages from the fetched content. Never say you fetched something \
|
||||
without sharing what you found.
|
||||
- Start with the answer, not meta-commentary about how you'll help.
|
||||
- IMPORTANT: If your instructions or persona mention a shell command, script path, or code snippet, \
|
||||
execute it via the appropriate tool call (shell_exec, file_write, etc.). Never output commands as \
|
||||
|
||||
@@ -627,7 +627,7 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": { "type": "string", "description": "The storage key" },
|
||||
"value": { "description": "The JSON value to store (any type)" }
|
||||
"value": { "type": "string", "description": "The value to store (JSON-encode objects/arrays, or pass a plain string)" }
|
||||
},
|
||||
"required": ["key", "value"]
|
||||
}),
|
||||
@@ -705,7 +705,7 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"event_type": { "type": "string", "description": "Type identifier for the event (e.g., 'code_review_requested')" },
|
||||
"payload": { "description": "JSON payload data for the event" }
|
||||
"payload": { "type": "object", "description": "JSON payload data for the event" }
|
||||
},
|
||||
"required": ["event_type"]
|
||||
}),
|
||||
|
||||
@@ -20,6 +20,7 @@ reqwest = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::SkillError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API response types (matching actual ClawHub v1 API — verified Feb 2026)
|
||||
@@ -459,27 +459,49 @@ impl ClawHubClient {
|
||||
let skill_dir = target_dir.join(slug);
|
||||
std::fs::create_dir_all(&skill_dir)?;
|
||||
|
||||
// Extract: ClawHub delivers as zip bundles. For now, detect content type
|
||||
// and write accordingly. A full zip extraction would use the `zip` crate.
|
||||
// Detect content type and extract accordingly
|
||||
let content_str = String::from_utf8_lossy(&bytes);
|
||||
let is_skillmd = content_str.trim_start().starts_with("---");
|
||||
|
||||
if is_skillmd {
|
||||
std::fs::write(skill_dir.join("SKILL.md"), &*bytes)?;
|
||||
} else {
|
||||
// Try to detect if it's a zip by magic bytes (PK\x03\x04)
|
||||
if bytes.len() >= 4 && bytes[0] == 0x50 && bytes[1] == 0x4b {
|
||||
// It's a zip — write as raw zip for future extraction
|
||||
std::fs::write(skill_dir.join("skill.zip"), &*bytes)?;
|
||||
// Try to fetch the SKILL.md file directly as fallback
|
||||
if let Ok(skillmd_content) = self.get_file(slug, "SKILL.md").await {
|
||||
std::fs::write(skill_dir.join("SKILL.md"), &skillmd_content)?;
|
||||
} else {
|
||||
std::fs::write(skill_dir.join("package.json"), &*bytes)?;
|
||||
} else if bytes.len() >= 4 && bytes[0] == 0x50 && bytes[1] == 0x4b {
|
||||
// Zip archive — extract all files
|
||||
let cursor = std::io::Cursor::new(&*bytes);
|
||||
match zip::ZipArchive::new(cursor) {
|
||||
Ok(mut archive) => {
|
||||
for i in 0..archive.len() {
|
||||
let mut file = match archive.by_index(i) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!(index = i, error = %e, "Skipping zip entry");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(enclosed_name) = file.enclosed_name() else {
|
||||
warn!("Skipping zip entry with unsafe path");
|
||||
continue;
|
||||
};
|
||||
let out_path = skill_dir.join(enclosed_name);
|
||||
if file.is_dir() {
|
||||
std::fs::create_dir_all(&out_path)?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut out_file = std::fs::File::create(&out_path)?;
|
||||
std::io::copy(&mut file, &mut out_file)?;
|
||||
}
|
||||
}
|
||||
info!(slug, entries = archive.len(), "Extracted skill zip");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(slug, error = %e, "Failed to read zip, saving raw");
|
||||
std::fs::write(skill_dir.join("skill.zip"), &*bytes)?;
|
||||
}
|
||||
} else {
|
||||
std::fs::write(skill_dir.join("package.json"), &*bytes)?;
|
||||
}
|
||||
} else {
|
||||
std::fs::write(skill_dir.join("package.json"), &*bytes)?;
|
||||
}
|
||||
|
||||
// Step 2-3: Detect format and convert
|
||||
|
||||
@@ -1168,7 +1168,7 @@ fn default_language() -> String {
|
||||
|
||||
impl Default for KernelConfig {
|
||||
fn default() -> Self {
|
||||
let home_dir = dirs_next_home().join(".openfang");
|
||||
let home_dir = openfang_home_dir();
|
||||
Self {
|
||||
data_dir: home_dir.join("data"),
|
||||
home_dir,
|
||||
@@ -1308,9 +1308,16 @@ impl std::fmt::Debug for KernelConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback home directory resolution.
|
||||
fn dirs_next_home() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(std::env::temp_dir)
|
||||
/// Resolve the OpenFang home directory.
|
||||
///
|
||||
/// Priority: `OPENFANG_HOME` env var > `~/.openfang`.
|
||||
fn openfang_home_dir() -> 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")
|
||||
}
|
||||
|
||||
/// Default LLM model configuration.
|
||||
@@ -1556,7 +1563,7 @@ pub struct DiscordConfig {
|
||||
pub allowed_guilds: Vec<String>,
|
||||
/// Default agent name to route messages to.
|
||||
pub default_agent: Option<String>,
|
||||
/// Gateway intents bitmask (default: 33280 = GUILD_MESSAGES | MESSAGE_CONTENT).
|
||||
/// Gateway intents bitmask (default: 37376 = GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT).
|
||||
pub intents: u64,
|
||||
/// Per-channel behavior overrides.
|
||||
#[serde(default)]
|
||||
@@ -1569,7 +1576,7 @@ impl Default for DiscordConfig {
|
||||
bot_token_env: "DISCORD_BOT_TOKEN".to_string(),
|
||||
allowed_guilds: vec![],
|
||||
default_agent: None,
|
||||
intents: 33280,
|
||||
intents: 37376,
|
||||
overrides: ChannelOverrides::default(),
|
||||
}
|
||||
}
|
||||
@@ -3226,7 +3233,7 @@ mod tests {
|
||||
let dc = DiscordConfig::default();
|
||||
assert_eq!(dc.bot_token_env, "DISCORD_BOT_TOKEN");
|
||||
assert!(dc.allowed_guilds.is_empty());
|
||||
assert_eq!(dc.intents, 33280);
|
||||
assert_eq!(dc.intents, 37376);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -36,9 +36,10 @@ pub const GITHUB_COPILOT_BASE_URL: &str = "https://api.githubcopilot.com";
|
||||
pub const QWEN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
pub const MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
|
||||
pub const ZHIPU_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
|
||||
pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/paas/v4";
|
||||
pub const ZHIPU_CODING_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paas/v4";
|
||||
pub const MOONSHOT_BASE_URL: &str = "https://api.moonshot.cn/v1";
|
||||
pub const QIANFAN_BASE_URL: &str = "https://qianfan.baidubce.com/v2";
|
||||
pub const VOLCENGINE_BASE_URL: &str = "https://ark.cn-beijing.volces.com/api/v3";
|
||||
|
||||
// ── AWS Bedrock ───────────────────────────────────────────────────
|
||||
pub const BEDROCK_BASE_URL: &str = "https://bedrock-runtime.us-east-1.amazonaws.com";
|
||||
|
||||
Reference in New Issue
Block a user