Compare commits

...
212 Commits
Author SHA1 Message Date
jaberjaber23 618e83714c fix: version bump to 0.5.5, SSRF allowlist, Ollama context, embedding detection
- Bump workspace version and Tauri config to 0.5.5 (fixes users stuck on 0.5.1)
- Add ssrf_allowed_hosts config for self-hosted K8s environments (Jerry Jaz)
- Raise Ollama discovered model defaults to 128K context / 16K output (Cureator)
- Expand embedding auto-detection: OpenAI, Groq, Mistral, Together, Fireworks, Cohere, then local providers (Thunder Guardian)

All tests passing. 9 files changed, 272 insertions.
2026-03-30 21:30:48 +03:00
jaberjaber23 64631a31e6 fix: resolve 5 bugs + close 1 resolved (#771, #811, #752, #772, #661)
- #771: Fix Qwen tool_calls orphaning after context overflow. Added safe drain boundaries
  in compactor and context_overflow to avoid splitting tool pairs. Added missing
  validate_and_repair call in streaming loop.
- #811: LINE webhook signature now uses raw request bytes (not re-serialized JSON) for
  HMAC. Channel secret is trimmed. Debug logging added for mismatches.
- #752: Local skill install now hot-reloads kernel via POST /api/skills/reload. TUI skill
  list fixed to parse wrapper object. ClawHub install also triggers reload.
- #772: exec_policy mode=full now bypasses approval gate for shell_exec tools. Non-shell
  tools like file_delete still respect approval settings.
- #661: Closed as resolved by #770 splice() reactivity fix and #836 tool ID fix.

All tests passing. 10 files changed, 436 insertions.
2026-03-28 00:44:12 +03:00
jaberjaber23 9fef6d6c91 fix: resolve 5 bugs + close 1 resolved (#875, #872, #867, #824, #833, #766)
- #875: Install script uses robust sed parsing instead of fragile cut for version detection
- #872: Session endpoint returns full tool results (removed 2000-char truncation)
- #867: agent_send/agent_spawn get 600s timeout (was 120s), regular tools keep 120s
- #824: Doctor workspace skills count uses direct return value from load_workspace_skills
- #833: Model switching respects provider via new find_model_for_provider() lookup
- #766: Closed as resolved by combined heartbeat fixes (v0.5.3 + merged PRs)

All tests passing. Live tested with daemon.
2026-03-27 22:42:24 +03:00
Jaber Jaber f98bc330d4 Merge pull request #859 from RightNow-AI/dependabot/cargo/governor-0.10.4
build(deps): bump governor from 0.8.1 to 0.10.4
2026-03-27 22:04:59 +03:00
Jaber Jaber 86694dd926 Merge pull request #862 from RightNow-AI/dependabot/cargo/toml-0.9.12spec-1.1.0
Bump toml from 0.8.2 to 0.9.12+spec-1.1.0
2026-03-27 22:04:55 +03:00
dependabot[bot] f8da17719e Bump governor from 0.8.1 to 0.10.4
Bumps [governor](https://github.com/boinkor-net/governor) from 0.8.1 to 0.10.4.
- [Release notes](https://github.com/boinkor-net/governor/releases)
- [Changelog](https://github.com/boinkor-net/governor/blob/master/release.toml)
- [Commits](https://github.com/boinkor-net/governor/compare/v0.8.1...v0.10.4)

---
updated-dependencies:
- dependency-name: governor
  dependency-version: 0.10.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 19:02:27 +00:00
Jaber Jaber a72e6087d8 Merge pull request #665 from tianrking/main
feat(channels): add MQTT pub/sub channel adapter
2026-03-27 22:00:33 +03:00
Jaber Jaber 3a64e322ad Merge pull request #778 from BaseDatum/feat/rmcp-protocol
feat: replace hand-rolled MCP transport with rmcp SDK
2026-03-27 22:00:18 +03:00
Sky Moore 9e2853a5f8 fix: resolve CI failures after rebase on upstream/main
- Add missing budget_config field to AppState in all 3 test files
- Fix redundant closures and unwrap_or_else in openfang-memory semantic.rs
- Fix needless_borrow in openfang-api routes.rs (toml::from_str)
- Update parse_researcher_hand test to match new max_iterations = 25
- Update tar to 0.4.45 to fix RUSTSEC-2026-0067 and RUSTSEC-2026-0068
- Apply cargo fmt fixes in ws.rs and feishu.rs
2026-03-27 18:40:33 +00:00
Sky Moore feecb60442 fix: use specific capability-denial assertion to avoid OS error false positive on Linux CI 2026-03-27 16:47:37 +00:00
Sky Moore e53e238e81 fix: cargo fmt and update rustls-webpki to 0.103.10 (RUSTSEC-2026-0049) 2026-03-27 16:47:35 +00:00
Sky Moore 991aea85ee feat: use rmcp for mcp protocol instead of hand rolled
Replace the custom JSON-RPC + stdio/SSE transport layer with the rmcp
SDK (crate 'rmcp').  This gives us spec-compliant Streamable-HTTP
transport, automatic Mcp-Session-Id tracking, SSE stream parsing, and
content-type negotiation out of the box while deleting ~300 lines of
hand-rolled plumbing.

Key changes:
- Add rmcp dependency with transport feature
- Replace McpTransportHandle enum with rmcp RunningService
- Replace manual JSON-RPC send_request/send_notification with rmcp client calls
- Add custom HTTP headers support for authenticated remote MCP servers
- Simplify tool discovery and invocation through rmcp's typed API
2026-03-27 16:47:32 +00:00
w0x7ce bfbf0bb892 feat(channels): add MQTT pub/sub channel adapter
Add generic MQTT 3.1.1/5.0 support for IoT and messaging integration:

- MqttConfig with broker_url, TLS, QoS, auth via env vars
- MqttAdapter implementing ChannelAdapter trait
- Support for text and JSON {"text": "..."} payloads
- Command messages via /command args syntax
- Auto-reconnect with exponential backoff
- Message chunking for long responses

Configuration example:
  [channels.mqtt]
  broker_url = "tcp://broker.hivemq.com:1883"
  subscribe_topic = "openfang/inbox"
  publish_topic = "openfang/outbox"
2026-03-27 22:08:18 +08:00
Jaber Jaber b6cb4cc2d9 Merge pull request #657 from xinuxZ/feat/feishu-websocket-receive-mode
feat(feishu): add WebSocket receive mode with protobuf framing
2026-03-27 16:44:31 +03:00
Jaber Jaber 827481633c Merge pull request #662 from lizekai-hash/feat/langchain-code-reviewer
feat(agents): add LangChain code review agent with A2A protocol
2026-03-27 16:44:22 +03:00
Jaber Jaber ad780b9cb4 Merge pull request #667 from bobbiejaxn/feat/http-memory-backend
feat: HTTP memory backend for SemanticStore
2026-03-27 16:44:17 +03:00
Jaber Jaber 4582ed16b0 Merge pull request #659 from zamal-db/feat/vertex-ai-oauth-v2
feat(drivers): add Vertex AI driver with OAuth authentication
2026-03-27 16:44:09 +03:00
Jaber Jaber f56505258d Merge pull request #673 from vnz/feat/cron-run-now
Implement "Run Now" for cron jobs
2026-03-27 16:42:47 +03:00
Jaber Jaber ddd1536bcb Merge pull request #702 from yaroslavyaroslav/codex/tlg-chat-enhancements
Expose Telegram slash commands via setMyCommands
2026-03-27 16:42:42 +03:00
Jaber Jaber 9fa5234061 Merge pull request #705 from apestchanker/fix/claude-code-system-prompt
fix(claude-code): pass system prompt via --system-prompt flag instead…
2026-03-27 16:42:33 +03:00
Jaber Jaber e21efa61ef Merge pull request #685 from Fail-Safe/fix/researcher-hand-defaults
fix: make heartbeat interval configurable and reduce researcher max_iterations
2026-03-27 16:39:31 +03:00
Jaber Jaber 3f72c5d918 Merge pull request #701 from Fail-Safe/fix/agent-modal-ui
fix: improve agent detail modal layout and fallback chain display
2026-03-27 16:39:23 +03:00
Jaber Jaber c286b88d54 Merge pull request #703 from Fail-Safe/fix/heartbeat-startup-false-positive
fix: reset last_active on agent restore to prevent heartbeat false-positives on startup
2026-03-27 16:39:19 +03:00
Jaber Jaber 22c08c2325 Merge pull request #668 from lc-soft/fix-runtime-page-style
fix runtime page stat card layout
2026-03-27 16:39:13 +03:00
Jaber Jaber f6493e8843 Merge pull request #682 from Fail-Safe/fix/tool-filter-case-insensitive
fix: make tool allowlist/blocklist matching case-insensitive
2026-03-27 16:39:08 +03:00
Jaber Jaber 1d2bfff8ea Merge pull request #680 from Fail-Safe/fix/docs-search-provider-duck-duck-go
fix(docs): correct search_provider value for DuckDuckGo
2026-03-27 16:39:02 +03:00
Jaber Jaber b967852891 Merge pull request #690 from lc-soft/fix-list-style
fix list style in message bubble
2026-03-27 16:38:59 +03:00
Jaber Jaber d95d9583b0 Merge pull request #696 from Abhishek21k/fix(#660)/notion-api-token-fix
Fix Notion MCP server env var name (NOTION_API_KEY → NOTION_TOKEN)
2026-03-27 16:38:53 +03:00
Jaber Jaber 8c0cce3ac5 Merge pull request #737 from octo-patch/feature/add-minimax-m2.7
feat: add MiniMax-M2.7 as new flagship model
2026-03-27 16:25:35 +03:00
Jaber Jaber f036bd54e3 Merge pull request #710 from Reaster0/fix/fallback-default-provider-resolution
fix(kernel): resolve "default" provider in fallback_models before driver init
2026-03-27 16:25:30 +03:00
Jaber Jaber 77da90f3f8 Merge pull request #709 from Fail-Safe/fix/touch-agent-before-llm-call
fix: stamp last_active before LLM call to prevent mid-iteration heartbeat timeouts
2026-03-27 16:25:26 +03:00
Jaber Jaber b0b6f84492 Merge pull request #762 from lc-soft/fix/mobile-menu-btn-overlap
fix: resolve page-header overlap and overflow
2026-03-27 16:25:23 +03:00
Jaber Jaber 0da8e32a51 Merge pull request #870 from lc-soft/fix/wizard-provider-api-key-test
Clean fix for provider reset during API key test. Reviewed and approved.
2026-03-27 16:16:49 +03:00
Liu 7410faa96d fix(wizard): prevent provider reset to first item during API_KEY test 2026-03-27 10:57:40 +08:00
Jaber Jaber e880dfa3e7 Merge pull request #777 from ANierbeck/main
Expose all agent templates in the web interface
2026-03-27 05:37:24 +03:00
Jaber Jaber 7791b3f170 Merge pull request #768 from voidborne-d/fix/matrix-self-message-loop
fix(matrix): prevent bot self-reply loop with user_id mismatch and event dedup
2026-03-27 05:37:19 +03:00
Jaber Jaber e58039c83e Merge pull request #789 from pbranchu/fix/mcp-response-matching
Fix MCP bridge dropping tool results when servers send notifications
2026-03-27 05:37:15 +03:00
Jaber Jaber 9b0a7d2f61 Merge pull request #790 from pbranchu/fix/sender-identity
Prepend sender identity to channel messages for agent context
2026-03-27 05:37:10 +03:00
Jaber Jaber 86fe4929e9 Merge pull request #775 from pbranchu/config-heartbeat-timeout
Expose heartbeat default_timeout_secs in config.toml
2026-03-27 05:36:32 +03:00
Jaber Jaber 9993718d9c Merge pull request #779 from Mohl/fix/streamable-http-mcp
fix(mcp): handle Streamable HTTP MCP responses with SSE framing
2026-03-27 05:36:28 +03:00
Jaber Jaber 0bf2f61ab1 Merge pull request #782 from rager306/fix/safe-budget-mutation
fix: replace unsafe Arc mutation in PUT /api/budget with RwLock
2026-03-27 05:36:24 +03:00
Jaber Jaber 51eff0d75f Merge pull request #783 from rager306/fix/csp-nonce
fix: replace unsafe-inline CSP with per-request nonce
2026-03-27 05:36:21 +03:00
Jaber Jaber a30cce129e Merge pull request #788 from pbranchu/fix/gemini-empty-parts
Fix Gemini driver crash on content entries without parts
2026-03-27 05:35:02 +03:00
Jaber Jaber 54885d8a1c Merge pull request #765 from felix307253927/pr-main-0320
fix: Fix the issue of duplicate tool calls with identical arguments i…
2026-03-27 05:34:59 +03:00
Jaber Jaber 617b4f81d8 Merge pull request #764 from felix307253927/pr-main-320
fix: The command succeeded, yet the model keeps calling it repeatedly.
2026-03-27 05:34:55 +03:00
Jaber Jaber a0f829383c Merge pull request #776 from felix307253927/pr-main-321
fix: Empty string IDs are overwritten, leading to inconsistencies in …
2026-03-27 05:34:51 +03:00
Jaber Jaber 6083c24484 Merge pull request #801 from b4iterdev/main
feat: add statically compiled native-tls to binary
2026-03-27 05:34:48 +03:00
Jaber Jaber 1964545f35 Merge pull request #814 from szponeczek/feat/infisical-sync-hand-clean
Adds infisical-sync hand. Declarative only, strong security posture.
2026-03-27 04:53:23 +03:00
Jaber Jaber fc7e971d7e Merge pull request #806 from ilteoood/main
Adds NVIDIA NIM support to CLI wizard.
2026-03-27 04:53:20 +03:00
Jaber Jaber 86309c8e40 Merge pull request #838 from turbolego/fix_clippy_linting_errors
Trivial lint and clippy fixes.
2026-03-27 04:52:53 +03:00
Jaber Jaber 282ad3a960 Merge pull request #832 from felix307253927/pr-main-324
Fixes unicode filename upload via multipart/form-data.
2026-03-27 04:52:50 +03:00
Jaber Jaber d6f857eee2 Merge pull request #830 from lc-soft/fix/tool-input-json-format
Clean 3-line fix for object-type tool input in formatToolJson.
2026-03-27 04:52:46 +03:00
Jaber Jaber 751b420b39 Merge pull request #796 from pbranchu/fix/gemini-turn-sanitization
Fixes Gemini INVALID_ARGUMENT crash after message trimming.
2026-03-27 04:51:28 +03:00
Jaber Jaber 5b2be80399 Merge pull request #803 from jam676767/fix/claude-code-empty-response-295
Critical fix for claude-code driver deadlock and empty responses.
2026-03-27 04:51:26 +03:00
Jaber Jaber 6ae8dd4cfd Merge pull request #860 from RightNow-AI/dependabot/cargo/clap_complete-4.6.0
Minor version bump. CI passes.
2026-03-27 04:50:13 +03:00
Jaber Jaber 25a66df41b Merge pull request #861 from RightNow-AI/dependabot/cargo/openssl-0.10.76
Security patch. Reviewed and approved.
2026-03-27 04:50:10 +03:00
dependabot[bot] 5212730773 Bump toml from 0.8.2 to 0.9.12+spec-1.1.0
Bumps [toml](https://github.com/toml-rs/toml) from 0.8.2 to 0.9.12+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.2...toml-v0.9.12)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 0.9.12+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 01:10:12 +00:00
jaberjaber23andClaude Opus 4.6 9b7496947b fix: resolve 7 more bugs (#825, #828, #856, #770, #774, #851/#808, #785)
- #825: Doctor now surfaces blocked workspace skills count in injection scan
- #828: Skill install detects Git URLs (https://, git@) and clones before install
- #856: Custom model names preserved — user-defined models take priority over builtins
- #770: Dashboard WS streaming now triggers Alpine.js reactivity via splice()
- #774: tool_use.input always normalized to JSON object (fixes Anthropic API errors)
- #851/#808: Global skills loaded for all agents; workspace skills properly override globals
- #785: Gemini streaming SSE parser handles \r\n line endings (fixes empty response loop)

All 2,186 tests passing. Live tested with daemon.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 04:04:17 +03:00
dependabot[bot] 895e94ccac Bump openssl from 0.10.75 to 0.10.76
Bumps [openssl](https://github.com/rust-openssl/rust-openssl) from 0.10.75 to 0.10.76.
- [Release notes](https://github.com/rust-openssl/rust-openssl/releases)
- [Commits](https://github.com/rust-openssl/rust-openssl/compare/openssl-v0.10.75...openssl-v0.10.76)

---
updated-dependencies:
- dependency-name: openssl
  dependency-version: 0.10.76
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 08:19:40 +00:00
dependabot[bot] da9d8a84f1 Bump clap_complete from 4.5.66 to 4.6.0
Bumps [clap_complete](https://github.com/clap-rs/clap) from 4.5.66 to 4.6.0.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.66...clap_complete-v4.6.0)

---
updated-dependencies:
- dependency-name: clap_complete
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 08:19:25 +00:00
jaberjaber23 e7b9143423 fix: resolve 6 more bugs (#845, #844, #823, #767, #802, #816)
- #845: Model fallback chain now retries with fallback_models on ModelNotFound
- #844: Heartbeat skips idle agents that never received a message (no more crash loops)
- #823: Doctor --json outputs clean JSON to stdout, tracing to stderr, BrokenPipe handled
- #767: Workflows page scrollable with flex layout fix
- #802: Model dropdown handles object options (no more [object Object] for Ollama)
- #816: Spawn wizard provider dropdown loads dynamically from /api/providers (43 providers)

All 829+ tests passing. Live tested with daemon.
2026-03-26 05:26:57 +03:00
jaberjaber23 604e4ea7e3 fix: resolve 6 open bugs (#834, #805, #820, #848, #826, #836)
- #834: Remove 3 decommissioned Groq models (gemma2-9b-it, llama-3.2-1b/3b-preview)
- #805: Ollama streaming parser now checks both reasoning_content and reasoning fields
- #820: Browser Hand checks python3 before python, fix optional dep logic
- #848: Hand continuous interval changed from 60s to 3600s to prevent credit waste
- #826: Doctor command no longer reports all_ok when provider key is rejected
- #836: WebSocket tool events now include tool call ID for concurrent call correlation

All 825+ tests passing. Verified live with daemon.
2026-03-26 03:33:07 +03:00
turbolego c926372d81 lint fixes for 'cargo clippy --workspace --all-targets -- -D warnings' and 'cargo test --workspace' 2026-03-24 22:23:06 +01:00
Felix da12f47369 fix: Fix the error when uploading files with Unicode characters in filenames 2026-03-24 19:33:12 +08:00
Liu b7c81965a1 fix: format object-type tool input correctly in formatToolJson 2026-03-24 17:05:44 +08:00
Claw Kowalski f65dc775eb fix(hands): remove deployment-specific language from infisical-sync 2026-03-23 15:01:29 -04:00
Claw Kowalski c59041a09d fix(hands): workspaceId → projectId in list query string 2026-03-23 14:59:47 -04:00
Claw Kowalski 715f37effc feat(hands): add infisical-sync Hand
- Implement create-with-PATCH-on-conflict push pattern (POST 409 → PATCH)
- Migrate push and delete endpoints from deprecated v3 to v4 API
- Replace workspaceId with projectId in push/delete API calls
2026-03-23 14:50:22 -04:00
Matteo Pietro Dazzi 570e1941b2 Merge pull request #1 from ilteoood/copilot/implement-nvidia-provider-functionality
feat: add NVIDIA NIM to CLI provider selection wizards
2026-03-23 09:47:36 +01:00
copilot-swe-agent[bot]andilteoood 4c700c8d2d fix: use existing nvidia/llama-3.1-nemotron-70b-instruct as default model
Use a model that already exists in the catalog instead of the
non-existent meta/llama-3.3-70b-instruct.

Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ilteoood/openfang/sessions/541bf2ad-f8d8-488c-84a2-4ca71e66730f
2026-03-23 08:36:36 +00:00
copilot-swe-agent[bot]andilteoood 5ae554ed51 feat: add NVIDIA NIM to CLI provider selection wizards
Add NVIDIA NIM as a selectable provider in both the setup wizard
and init wizard CLI screens, using NVIDIA_API_KEY env var and
meta/llama-3.3-70b-instruct as the default model.

Closes #787

Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ilteoood/openfang/sessions/541bf2ad-f8d8-488c-84a2-4ca71e66730f
2026-03-23 08:35:33 +00:00
jam 62b697c90e fix(claude-code): extract assistant text from nested message.content in stream()
Claude CLI ≥2.x emits type=assistant events where the response text is
inside message.content[{"type":"text","text":"..."}] rather than a flat
content string. The old handler only checked event.content, so every
token was silently dropped and streaming always returned an empty response.

The handler now checks the flat content field first (backward-compatible),
then falls back to joining all text blocks from message.content[].

Refs: RightNow-AI/openfang#295
2026-03-23 09:35:22 +01:00
jam 4b3b602457 fix(claude-code): inject HOME and null stdin in stream() subprocess
Mirror the same environment fixes applied to complete(): inject HOME so
the CLI locates ~/.claude/credentials when running as a service, and set
stdin to null so the process does not block on interactive input.

Refs: RightNow-AI/openfang#295
2026-03-23 09:35:05 +01:00
jam d7bd5c6636 fix(claude-code): prevent pipe-buffer deadlock in complete() via concurrent drain
When complete() called child.wait() before reading stdout/stderr, large
responses (>64 KB) caused a deadlock: the subprocess blocked on write()
because the OS pipe buffer was full, and wait() never returned.

Fix by spawning two tokio tasks to drain stdout/stderr concurrently with
child.wait(), then collecting after the process exits.

Also inject HOME from home_dir() so the CLI finds ~/.claude/credentials
when OpenFang runs as a service, and set stdin to null so the CLI does
not stall waiting for interactive input.

Refs: RightNow-AI/openfang#295
2026-03-23 09:34:55 +01:00
jam 66e6eb2509 fix(claude-code): add message field to ClaudeStreamEvent for nested assistant content
Newer Claude CLI versions (≥2.x) emit assistant responses inside a nested
`message.content[].text` structure in stream-json events, rather than a
flat `content` string.

Add ClaudeMessageBlock and ClaudeAssistantMessage structs, plus a new
`message` field on ClaudeStreamEvent, so the stream handler can extract
text from both layouts.

Refs: RightNow-AI/openfang#295
2026-03-23 09:33:35 +01:00
jamandClaude 1365fc9635 fix(claude-code): add #[serde(default)] to ClaudeJsonOutput.result
Without this attribute, serde treats a missing `result` field as a
deserialization error even though `Option<T>` implies the field is
optional.  Some Claude CLI versions emit the response in `content` or
`text` rather than `result`; the silent parse failure caused the
driver to fall through to a plain-text read which could be empty,
triggering the "model returned an empty response" guard in the agent
loop.

Closes #295.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-23 09:31:37 +01:00
b4iterdev 78669863b7 feat: add statically compiled native-tls to binary 2026-03-23 14:32:48 +07:00
Philippe BranchuandClaude Opus 4.6 316bbe11c3 Add tests for sanitize_gemini_turns
- test_sanitize_drops_orphaned_function_call
- test_sanitize_keeps_valid_function_call_response_pair
- test_sanitize_drops_orphaned_function_response
- test_sanitize_merges_consecutive_same_role
- test_sanitize_empty_input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:43:03 +00:00
Philippe BranchuandClaude Opus 4.6 ff00499e8e Fix Gemini INVALID_ARGUMENT crash after message trimming
Add sanitize_gemini_turns() to enforce Gemini's strict turn-ordering
constraints after message history is trimmed. This merges consecutive
same-role turns, drops orphaned functionCall/functionResponse parts,
and removes empty turns. Also adds #[serde(default)] on GeminiContent.parts
and fixes two tests that were missing required ToolResult messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:31:40 +00:00
Philippe BranchuandClaude Opus 4.6 1a5ae4e3ce Prepend sender identity to channel messages for agent context
The bridge now prefixes messages with [From: Name <email>] so agents
know who is speaking. Essential for multi-user rooms and for agents
that need to act on behalf of specific users (e.g., checking the
correct email account or calendar).

Updated bridge integration tests to match the new format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:49:37 +00:00
Philippe BranchuandClaude Opus 4.6 bf9066a602 Fix MCP bridge dropping tool results from servers that send notifications
Read response lines until finding a JSON-RPC response matching the
request ID. Previously, the bridge read one line and assumed it was
the response, causing "No result from MCP tools/call" when MCP
servers send notifications or log lines before the actual result.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:49:28 +00:00
Philippe BranchuandClaude Opus 4.6 acf51e02a8 Fix Gemini driver crash on content entries without parts
Add #[serde(default)] to GeminiContent.parts so responses with
empty or missing parts arrays deserialize as empty Vec instead
of failing with "missing field parts".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:47:56 +00:00
copilot-swe-agent[bot]andilteoood 1c9d53df11 chore: remove generated linux-schema.json build artifact
Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ilteoood/openfang/sessions/7944f50c-bf8e-47ca-a936-cc6c562e36ca
2026-03-22 15:23:09 +00:00
copilot-swe-agent[bot]andilteoood b298c4c273 feat: add NVIDIA NIM provider with ZeroClaw-recommended models
- Add NVIDIA_API_KEY to .env.example
- Add 3 new ZeroClaw-recommended models: meta/llama-3.3-70b-instruct,
  nvidia/llama-3.3-nemotron-super-49b-v1.5,
  nvidia/llama-3.1-nemotron-ultra-253b-v1
- Add nemotron, nemotron-super, nemotron-ultra aliases
- Add NVIDIA NIM provider section to docs/providers.md (provider #21)
- Add NVIDIA NIM models to Model Catalog table
- Add aliases to Aliases table
- Add NVIDIA NIM to Environment Variables Summary
- Update provider/model/alias counts

Closes #787

Co-authored-by: ilteoood <6383527+ilteoood@users.noreply.github.com>
Agent-Logs-Url: https://github.com/ilteoood/openfang/sessions/7944f50c-bf8e-47ca-a936-cc6c562e36ca
2026-03-22 15:22:34 +00:00
copilot-swe-agent[bot] f407a41a98 Initial plan 2026-03-22 15:02:53 +00:00
rager306 173c843107 fix: replace unsafe-inline CSP with per-request nonce
The dashboard CSP uses 'unsafe-inline' for script-src, which permits
any inline <script> block to execute — including attacker-injected
scripts if any endpoint reflects user input (agent names, message
content, channel descriptions, etc.).

Replace with a per-request cryptographic nonce (UUID v4):
- webchat_page generates a unique nonce on every request
- All <script> tags embed the nonce at compile time via __NONCE__ placeholder
- CSP becomes: script-src 'self' 'nonce-{nonce}' 'unsafe-eval'
  ('unsafe-eval' is still required for Alpine.js x-data expressions)
- API endpoints receive a strict default-src 'none'; frame-ancestors 'none'
  policy instead of the permissive dashboard policy

This is a standard CSP Level 2 hardening; all modern browsers support nonces.
2026-03-22 16:18:15 +07:00
rager306 b3787e07ea fix: replace unsafe Arc mutation in update_budget with RwLock
PUT /api/budget casts &Arc<AppState> to *mut KernelConfig and mutates
the budget fields through a raw pointer. This is unsound: AppState is
shared across Tokio worker threads, so two concurrent PUT /api/budget
requests cause a data race on the same memory location.

Replace with Arc<tokio::sync::RwLock<BudgetConfig>> stored on AppState,
initialized from kernel.config.budget at startup. All readers use
.read().await and all writers use .write().await. No unsafe code remains
in the budget update path.

Fixes: data race / undefined behaviour under concurrent budget updates
2026-03-22 16:16:09 +07:00
Alaundo 935c8cad88 fix(mcp): handle Streamable HTTP MCP responses with SSE framing
MCP servers using Streamable HTTP (e.g., Hindsight) wrap JSON-RPC
responses in SSE framing (event: message\ndata: {...}\n\n). The SSE
transport handler expected raw JSON, causing 'Invalid MCP SSE JSON-RPC
response' errors when connecting to these servers.

Extract the JSON payload from SSE data: lines before deserializing.
Falls back to raw body parsing for servers that return plain JSON.

Fixes connection to MCP servers implementing the Streamable HTTP
transport (MCP spec 2025-03-26).
2026-03-21 20:15:54 +01:00
anierbeck d95270da5a Fix agent template spawning
- Ensure all templates have manifest_toml field
- Use spawnFromTemplate for templates with manifest_toml
- Fix spawnBuiltin to handle missing fields gracefully
- Update HTML template to call correct spawn method
2026-03-21 18:16:16 +01:00
anierbeck 7a2211d0f4 Expose agent templates in web interface
- Replace hardcoded list of 6 templates with all 30+ available templates
- Add category information to templates
- Combine static and dynamic templates with static templates displayed first
- Add loading and error states for template list
- Fix showDetail method for agent configuration
2026-03-21 17:03:09 +01:00
Felix e14885fa80 fix: Empty string IDs are overwritten, leading to inconsistencies in certain models. 2026-03-21 21:02:59 +08:00
pbranchuandClaude Opus 4.6 ccbaf90a24 Expose heartbeat default_timeout_secs in config.toml
Add a [heartbeat] section to KernelConfig so users can tune the
inactivity timeout that determines when agents are marked unresponsive.
Reactive agents (hands) that sit idle between infrequent requests were
getting marked as crashed after the hardcoded 180s default, causing
the first request after idle to fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:45:47 +00:00
anierbeck f66c2525cb Expose agent templates in web interface
- Modified agents.js to fetch templates dynamically from /api/templates endpoint
- Updated API endpoint to include category information for templates
- Added category mapping logic for proper template categorization
- Updated HTML template to handle loading and error states
- Implemented fallback to hardcoded templates if API fails

This change replaces the hardcoded list of 6 templates with all 32 available
agent templates from the agents/ directory, making the web interface more
dynamic and maintainable.
2026-03-21 12:14:48 +01:00
d 🔹 842d932ae6 fix(matrix): prevent bot self-reply loop with user_id mismatch and event dedup
Two fixes for the Matrix bot stuck in infinite reply loop (#757):

1. Use validated user ID from /whoami instead of config value for
   self-message filtering. Matrix server delegation or casing
   differences can cause the configured user_id to not match the
   sender field in timeline events, so the bot processes its own
   replies and enters an infinite loop.

2. Add event ID dedup set to prevent re-processing the same event
   on sync token races or reconnects. This is a defense-in-depth
   measure that also protects against edge cases where /sync returns
   overlapping event windows.

Fixes #757
2026-03-20 16:13:22 +00:00
Felix 37d1c822f2 fix: Fix the issue of duplicate tool calls with identical arguments in certain scenarios. 2026-03-20 19:31:37 +08:00
Felix 865fd28704 fix: The command succeeded, yet the model keeps calling it repeatedly. 2026-03-20 18:22:08 +08:00
Liu 43a92a764f fix: resolve page-header overlap and overflow 2026-03-20 16:07:40 +08:00
PR Bot 17f783073e fix: correct MiniMax M2.7 model specifications per official docs
- Pricing: $0.30/$1.20 per 1M tokens (was $1.10/$4.40)
- Context window: 204,800 tokens (was 1,048,576)
- Max output: 131,072 tokens (was 16,384)
- Vision: false — M2.7 is text-only (was true)
2026-03-20 13:11:34 +08:00
jaberjaber23 db86ff4ce3 bump v0.5.1 2026-03-20 03:48:59 +03:00
Jaber Jaber ee042769e2 Merge pull request #711 from Reaster0/fix/matrix-configurable-auto-accept-invites
fix(matrix): make auto_accept_invites configurable, default to false
2026-03-20 03:13:33 +03:00
Jaber Jaber 41ffb8537a Merge pull request #742 from RightNow-AI/dependabot/cargo/zip-4.6.1
Bump zip from 2.4.2 to 4.6.1
2026-03-20 03:13:22 +03:00
Jaber Jaber 80658c94e3 Merge pull request #744 from RightNow-AI/dependabot/cargo/roxmltree-0.21.1
Bump roxmltree from 0.20.0 to 0.21.1
2026-03-20 03:13:11 +03:00
Jaber Jaber c35301e155 Merge pull request #740 from RightNow-AI/dependabot/github_actions/docker/setup-buildx-action-4
Bump docker/setup-buildx-action from 3 to 4
2026-03-20 03:13:02 +03:00
Jaber Jaber 14f0421e7b Merge pull request #741 from RightNow-AI/dependabot/github_actions/docker/build-push-action-7
Bump docker/build-push-action from 6 to 7
2026-03-20 03:12:52 +03:00
Jaber Jaber 3f772b5b27 Merge pull request #713 from CastleOneX/pr/approvals-visibility
Fix invisible approval requests in dashboard
2026-03-20 03:12:42 +03:00
Jaber Jaber a12547081a Merge pull request #714 from CastleOneX/pr/provider-model-normalization
Normalize provider-backed model updates
2026-03-20 03:12:32 +03:00
Jaber Jaber 63f4befe80 Merge pull request #748 from lc-soft/fix/katex-load
Load KaTeX on demand to prevent first-paint blocking
2026-03-20 03:12:21 +03:00
Jaber Jaber 0f25386e2e Merge pull request #750 from lc-soft/fix/settings-page-error
fix: settingsLoading -> loading
2026-03-20 03:12:11 +03:00
jaberjaber23 7f752dde99 bump v0.5.0 2026-03-20 00:46:15 +03:00
jaberjaber23 93ef98a429 bug fixes 2026-03-20 00:33:20 +03:00
Liu b71bd801fb fix(api): settingsLoading -> loading 2026-03-19 19:29:09 +08:00
Liu 9badeb243e fix(api): load KaTeX on demand to prevent first-paint blocking 2026-03-19 19:12:25 +08:00
dependabot[bot] 9a683ec511 Bump roxmltree from 0.20.0 to 0.21.1
Bumps [roxmltree](https://github.com/RazrFalcon/roxmltree) from 0.20.0 to 0.21.1.
- [Changelog](https://github.com/RazrFalcon/roxmltree/blob/master/CHANGELOG.md)
- [Commits](https://github.com/RazrFalcon/roxmltree/compare/v0.20.0...v0.21.1)

---
updated-dependencies:
- dependency-name: roxmltree
  dependency-version: 0.21.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 08:18:28 +00:00
dependabot[bot] eaa89defd1 Bump zip from 2.4.2 to 4.6.1
Bumps [zip](https://github.com/zip-rs/zip2) from 2.4.2 to 4.6.1.
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v2.4.2...v4.6.1)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 4.6.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 08:17:54 +00:00
dependabot[bot] d245059a01 Bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 08:17:10 +00:00
dependabot[bot] c30bf3e557 Bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 08:17:06 +00:00
PR Bot dd95f24980 feat: add MiniMax-M2.7 as new flagship model and update default alias
- Add MiniMax-M2.7 model entry (Frontier tier, 1M context, vision+tools)
- Update default 'minimax' alias to resolve to MiniMax-M2.7
- Add 'minimax-m2.7' alias for explicit model selection
- Add M2.7 pricing in metering (same as M2.5: $1.10/$4.40 per 1M tokens)
- Update model catalog tests for M2.7 as new default
- Increment MiniMax model count from 6 to 7
2026-03-19 11:48:22 +08:00
vnzandClaude Opus 4.6 1cf36241e4 Apply rustfmt to kernel.rs (fixes CI format check)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 2ab31f3d3e Apply rustfmt to changed files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 2915cb2113 Fix failed manual run pushing next_run and premature last_run in UI
- record_failure() now only recomputes next_run when the job is already
  overdue (next_run <= now), preserving the scheduled fire time when a
  manual run fails before the job's natural next_run
- Remove premature job.last_run update in scheduler.js — the job runs
  asynchronously so last_run should only reflect the server-side
  completion timestamp on the next data refresh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 7b1057df0c Add complete JSON response examples to cron endpoint docs
- GET /api/cron/jobs: show actual {jobs: [...], total} wrapper and
  document the ?agent_id query filter
- POST /api/cron/jobs: fix status code to 201 Created, show the actual
  {result: "<stringified-json>"} response shape
- GET /api/cron/jobs/{id}/status: show full JobMeta structure with
  nested job object, one_shot, last_status, consecutive_errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 0b99ac4071 Replace racy get_job + reserve_run with atomic try_claim_for_run
The previous sequence — get_job (read lock), check enabled, reserve_run
(write lock) — had a TOCTOU window where another request could disable
or delete the job between the check and the reservation.

Replace with CronScheduler::try_claim_for_run() which holds a single
DashMap write lock for the existence check, enabled guard, and next_run
advancement. Returns a typed ClaimError (NotFound | Disabled) so the
route handler maps directly to HTTP status codes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
vnzandClaude Opus 4.6 19260945bd Implement "Run Now" for cron jobs
Add POST /api/cron/jobs/{id}/run endpoint that triggers a cron job
immediately without waiting for its next scheduled fire time. The job
executes asynchronously in the background and its status can be polled
via the existing /status endpoint.

Key changes:
- Extract per-job execution logic from the inline cron tick loop into
  a reusable `cron_run_job()` method on OpenFangKernel, called by both
  the background scheduler and the new API endpoint
- Add `reserve_run()` on CronScheduler to pre-advance next_run for
  overdue jobs before spawning manual runs, preventing duplicate
  execution from the scheduler tick (only advances when next_run <= now
  to avoid skipping imminent scheduled runs)
- Fix dashboard scheduler.js to call the correct cron API endpoint
  instead of the legacy /api/schedules/ path
- Document all cron/scheduler endpoints in api-reference.md

Partially addresses upstream issue #634.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 04:24:39 +01:00
jaberjaber23 93ea832394 bump v0.4.9 2026-03-19 02:04:30 +03:00
jaberjaber23 38d42c4d9b image pipeline 2026-03-19 01:34:31 +03:00
jaberjaber23 2d02ba22fb lockfile sync 2026-03-19 01:03:35 +03:00
jaberjaber23 91d8734198 community docs 2026-03-19 00:26:41 +03:00
jaberjaber23 b676b2975a bump v0.4.8 2026-03-19 00:04:29 +03:00
jaberjaber23 44f37711cb bug fixes 2026-03-18 23:00:36 +03:00
jaberjaber23 cea4c3f452 bug fixes 2026-03-18 22:55:36 +03:00
Irwin 12ab5f1a93 Normalize provider-backed model updates
Resolve catalog display names and aliases to canonical model IDs when spawning or updating agents, keep provider-specific api_key_env hints in sync when switching providers, and route explicit provider model changes through kernel model normalization instead of directly mutating the registry. This fixes cases like xAI agents carrying stale OpenAI auth hints or UI labels such as 'Grok 4.20' being treated as raw model IDs.

(cherry picked from commit 51ee6a5a927208ab0301a29fd2e40b29c9dfaf7d)
2026-03-18 16:37:54 +13:00
Irwin a5bc9f916a Fix invisible approval requests in dashboard
Keep a bounded recent approval history instead of dropping timed-out or resolved requests on the floor, return recent approvals from /api/approvals, and make the dashboard poll and badge pending approvals so shell_exec prompts do not disappear before the user ever sees them.

(cherry picked from commit 78dd9f99cc835e85e452889bf6cfced5137f8a4a)
2026-03-18 16:34:34 +13:00
jaberjaber23 ad472d657e bug fixes 2026-03-18 06:23:12 +03:00
jaberjaber23 b4383b1626 bug fixes 2026-03-18 06:20:37 +03:00
jaberjaber23 ea287093c4 bug fixes 2026-03-18 05:58:22 +03:00
jaberjaber23 3688d86ef8 bug fixes 2026-03-18 05:49:42 +03:00
jaberjaber23 9f9903797e bump v0.4.5 2026-03-18 05:42:11 +03:00
jaberjaber23 3cd8847a95 bug fixes 2026-03-18 05:37:32 +03:00
jaberjaber23 88bb55c8f2 bug fixes 2026-03-18 05:08:12 +03:00
reasterandClaude Opus 4.6 935f3fac8e fix(matrix): make auto_accept_invites configurable, default to false
MatrixAdapter hardcoded `auto_accept_invites: true`, meaning any
Matrix-connected instance would blindly join every room it was invited
to. This is a security concern for public-facing homeservers — a
malicious user could invite the bot into an arbitrary room and interact
with the agent without the operator's consent.

Changes:
- Add `auto_accept_invites: bool` to `MatrixConfig` in openfang-types,
  with `#[serde(default)]` defaulting to `false`.
- Thread the field through `MatrixAdapter::new()` instead of hardcoding.
- Wire it in `channel_bridge.rs` from `mx_config.auto_accept_invites`.
- Update tests to pass the new parameter.

Operators who want the old behaviour can set:
```toml
[channels.matrix]
auto_accept_invites = true
```

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:43:47 +01:00
reasterandClaude Opus 4.6 ad90d417cc fix(kernel): resolve "default" provider in fallback_models before driver init
The fallback model loop passed `provider = "default"` verbatim to
`create_driver()`, which only recognises real provider names (ollama,
openai, anthropic, …).  The primary model overlay at spawn_agent()
already resolves "default" → kernel config, but fallback_models was
skipped, causing every bundled agent with a "default" fallback to log:

    Fallback driver 'default' failed to init: Unknown provider 'default'

This meant agents had zero fallback drivers, silently degrading
resilience for anyone whose config.toml sets a non-standard default
provider (e.g. ollama pointing at a local proxy).

Changes:
- Mirror the primary-model overlay logic for fallback entries:
  resolve provider, model, api_key_env, and base_url from
  `config.default_model` when the fallback specifies "default" or empty.
- Inherit `base_url` from default_model before falling back to
  `lookup_provider_url()`, so custom endpoints propagate correctly.
- Use resolved values in `strip_provider_prefix()` and warn messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:42:09 +01:00
Alex Li 4eae7502d2 fix(claude-code): pass system prompt via --system-prompt flag instead of inlining in -p 2026-03-17 18:22:24 -03:00
Yaroslav Yashin d6326d8967 Expose Telegram slash commands 2026-03-17 22:53:49 +02:00
Abhishek Kumar 8ec3766da3 fix(notion): fixed the notion api call to mcp 2026-03-17 16:36:57 +05:30
Liu 90fc171e26 fix list style in message bubble 2026-03-17 17:06:43 +08:00
Mark BakerandClaude Sonnet 4.6 972a52ff9c fix: make heartbeat interval configurable and reduce researcher max_iterations
Two related issues with autonomous Hand agents:

1. heartbeat_interval_secs was hardcoded at 30s (the AutonomousConfig default)
   for all Hands, with no way to override it from HAND.toml. For agents that
   make long LLM calls, 30s causes false-positive recovery triggers during
   normal operation. Add heartbeat_interval_secs to HandAgentConfig so each
   Hand can declare an appropriate interval.

2. The researcher Hand shipped with max_iterations = 80 and a system prompt
   instructing exhaustive research (50+ sources). This combination was designed
   for cloud LLMs with 200K context windows. On any model with a 32K or smaller
   context window, 80 iterations × growing history guarantees context overflow
   before the task completes. Reduce to 25, which is sufficient for thorough
   research within a 32K budget.

researcher/HAND.toml changes:
- max_iterations: 80 → 25
- heartbeat_interval_secs: 120 (new field; 30s default was triggering false
  recovery during normal multi-minute LLM calls)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:00:00 -04:00
Mark BakerandClaude Sonnet 4.6 6b78838416 fix: improve agent detail modal layout and fallback chain display
- Widen agent detail modal from 600px to 700px to better accommodate
  longer model names and the fallback chain editor
- Restructure the Fallbacks section in the Info tab: content div is now
  a column flex container (gap:6px) with margin-left:16px to create a
  clear visual column between the label and its content
- Prevent long provider/model badge strings from overflowing the right
  edge of the modal (word-break:break-all; white-space:normal on badge)
- Add flex-shrink:0 to the × delete button so it never gets squashed
  when a badge is long
- Wrap the "+ Add" button in a div so it stays left-aligned (column
  flex would otherwise stretch a bare button to full width)
- Replace margin-top with gap-based spacing on the fallback edit form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:00:00 -04:00
Mark BakerandClaude Sonnet 4.6 13051b2f06 fix: reset last_active on agent restore to prevent heartbeat false-positives
When agents are loaded from persistent storage on daemon startup, their
last_active timestamp reflects when they were last active before the
previous shutdown. If the daemon was down for longer than the heartbeat
timeout (default 180 s), the first heartbeat tick immediately marks every
restored agent as unresponsive and triggers crash recovery — even though
all agents just started and haven't had a chance to run.

Fix: stamp last_active = Utc::now() alongside the state = Running reset
in the restore loop. This is consistent with how new agent spawns work
(they also set last_active to now) and gives each restored agent a clean
baseline from which the heartbeat can accurately track responsiveness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:00:00 -04:00
Mark BakerandClaude Sonnet 4.6 aed4bf62ae fix: stamp last_active before LLM call to prevent mid-iteration heartbeat timeouts
Slow local models (e.g. 27B quantised MLX models) can take 3–4+ minutes
per iteration, well beyond the default 180s heartbeat timeout. Because
last_active was only updated at the end of an iteration — never during it —
the heartbeat monitor would flag the agent as unresponsive mid-call and
initiate crash/recovery while the loop was still running correctly.

Changes:
- Add `touch()` to `AgentRegistry`: refreshes `last_active` with no other
  side-effects.
- Add `touch_agent(&self, agent_id: &str)` to `KernelHandle` trait with a
  default no-op, so existing mock implementations require no changes.
- Implement `touch_agent` on `OpenFangKernel`: parses the UUID and
  delegates to `registry.touch()`.
- Call `kernel.touch_agent(agent_id)` at the top of each agent loop
  iteration, immediately before the `call_with_retry` LLM call. This
  resets the inactivity clock at the start of every iteration rather than
  only at completion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:00:00 -04:00
Liu a039e395f5 fix runtime page stat card layout 2026-03-16 11:44:22 +00:00
Your NameandClaude Opus 4.6 67b30c1549 feat: HTTP memory backend for shared memory infrastructure
Route SemanticStore remember/recall operations to the memory-api gateway
(PostgreSQL + pgvector + Jina AI embeddings) when backend=http is configured.

- Add backend, http_url, http_token_env fields to MemoryConfig
- Create http_client module with MemoryApiClient (reqwest::blocking)
- Add HTTP dispatch to SemanticStore with graceful SQLite fallback
- Wire MemoryConfig through MemorySubstrate::open() and kernel boot
- Add reqwest as optional dependency behind http-memory feature flag

Sessions, KV store, and knowledge graph remain local SQLite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:44:06 +01:00
Tsukimaru Oshawott 40bdf4316b feat(agents): add LangChain code review agent with A2A protocol
Add a Python-based code review agent powered by LangChain that
integrates with OpenFang via the A2A (Agent-to-Agent) protocol.

- agent.py: Core review logic with structured Chinese SYSTEM_PROMPT
  covering 6 dimensions (correctness, security, performance,
  maintainability, testing, style) and 4 severity levels
- server.py: FastAPI server exposing A2A-compatible endpoints
  (/.well-known/agent.json and /a2a JSON-RPC)
- workflow.json: OpenFang workflow definition for the review pipeline
- config.example.toml: Example A2A config for ~/.openfang/config.toml
- Supports OpenAI, DeepSeek, and Ollama backends

Made-with: Cursor
2026-03-16 14:33:41 +08:00
at384 e3c05a9d47 feat(drivers): add Vertex AI driver with OAuth authentication
Rebased on latest main (f1ca527) after codebase changes. This is a
fresh submission after PR #22 was closed as stale.

## Why This Feature

Enables enterprise GCP deployments using existing service accounts
instead of requiring separate Gemini API keys. Many organizations
already have GCP infrastructure and prefer OAuth-based auth.

## What's New

- VertexAIDriver with full streaming support
- OAuth 2.0 token caching (50 min TTL) with auto-refresh via gcloud
- Auto-detection of project_id from service account JSON
- Security: tokens stored with Zeroizing<String>
- Provider aliases: vertex-ai, vertex, google-vertex
- Compatible with new ContentBlock::provider_metadata field

## Testing

- 6 unit tests passing
- Clippy clean (no warnings)
- End-to-end tested with real GCP service account + gemini-2.0-flash
- Both streaming and non-streaming paths verified

## Usage

export GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json
# Set provider=vertex-ai, model=gemini-2.0-flash in config.toml
2026-03-16 06:50:35 +01:00
Mark BakerandClaude Sonnet 4.6 6ab77612f5 fix: make tool allowlist/blocklist matching case-insensitive
Tool names stored via the dashboard can arrive in any case (e.g. uppercase
FILE_READ vs registered name file_read). The previous case-sensitive
comparison caused allowlisted tools to silently match nothing, giving the
agent an empty effective tool set with no error or warning.

Normalise both sides with to_lowercase() so the filter works regardless of
how the names were entered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:00:00 -04:00
Mark BakerandClaude Sonnet 4.6 a3073007a1 fix(docs): correct search_provider value for DuckDuckGo
The docs listed `duckduckgo` as the config value but the actual serde
deserialization produces `duck_duck_go` — serde's rename_all = "snake_case"
on the DuckDuckGo enum variant inserts underscores at each word boundary.

Updated all three occurrences in configuration.md to match the real value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:00:00 -04:00
xinuxz a95fb4a96b feat(feishu): add WebSocket receive mode with protobuf framing
Add WebSocket long-connection receive mode for the Feishu/Lark adapter
as an alternative to webhook callbacks. WebSocket mode is enabled by
default, requiring no public IP or domain.

- FeishuConnectionMode enum (Webhook/WebSocket) with mode dispatch
- Protobuf binary frame parsing (prost) based on Feishu pbbp2 protocol
- Auto-reconnect, ping/pong heartbeat, ACK, multi-part payload combine
- handle_data_frame reuses parse_event() pipeline (dedup, group filter)
- FeishuMode config enum with bridge-layer adapter creation per mode
2026-03-16 10:37:01 +08:00
jaberjaber23 f1ca52714d feature batch 2026-03-15 20:23:30 +03:00
Evan HuandClaude Opus 4.6 77ed954d18 wecom channel adapter
* feat: Add WeCom (WeChat Work) channel adapter

- Add wecom.rs channel adapter implementation
- Add WeComConfig in config.rs
- Register WeCom adapter in channel_bridge.rs

WeCom channel supports:
- Inbound messages via callback webhook
- Outbound messages via WeCom API
- Access token caching and auto-refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle WeCom callbacks and preserve hand extension tools

* fix: render WeCom replies as plain text

* fix: resolve clippy warnings in wecom adapter

- Remove unused WECOM_API_HOST constant
- Fix needless borrow in send_text call
- Replace assert_eq!(bool, true) with assert!()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt for wecom-related files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: cargo fmt --all

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: upgrade quinn-proto and add cargo audit ignore list

- Upgrade quinn-proto 0.11.13 → 0.11.14 (RUSTSEC-2026-0037 DoS fix)
- Add .cargo/audit.toml to ignore unmaintainable transitive deps
  (tauri GTK3 bindings, time pinned by mac-notification-sys, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 19:50:43 +03:00
jaberjaber23 4fa2f9474b bug fixes 2026-03-15 19:39:39 +03:00
jaberjaber23 75c9c80679 bug fixes 2026-03-15 18:26:53 +03:00
jaberjaber23 3eaa9e02c9 community fixes 2026-03-15 17:48:09 +03:00
Tilman Baumann f165263296 test merge
* Feat: Add Nix support

Adding Nix support. Nixos modules may follow...

Run directly with `nix run github:RightNow-AI/openfang`

There are a bunch of flake outputs (based on cargo workspace)
Focus on these:
* openfang-cli (default)
* openfang-desktop

* nix: cmake depdencency was introduced via llama

* Follow upstream style
2026-03-15 17:40:22 +03:00
NextDoorLaoHuang-HFandroot c122e1ddd7 test merge
* Improve OpenClaw provider alias migration compatibility

* Fix local provider env mapping regression in migration

* test(migrate): cover json5 default_model provider/env mapping

* test(migrate): add JSON5 agent provider mapping integration tests

* test(migrate): add legacy YAML provider alias integration coverage

* fix(migrate): harden JSON5 provider catalog resolution

* chore(migrate): scope split_model_ref helper to tests

---------

Co-authored-by: root <root@LAPTOP-NGAQG9OH.localdomain>
2026-03-15 17:40:09 +03:00
pluginmdandClaude Opus 4.6 d2ea030f03 test merge
Merge lark.rs features (dedup, encryption, group filtering, rich text parsing)
into feishu.rs with FeishuRegion toggle (cn/intl). Single [channels.feishu]
config handles both domestic Feishu and international Lark via region field.

- Expand FeishuConfig: region, webhook_path, verification_token, encrypt_key_env, bot_names
- Add FeishuRegion enum with domain switching (open.feishu.cn / open.larksuite.com)
- Add AES-256-CBC event decryption, message/event dedup, group chat filtering
- Update channel_bridge.rs wiring for full config
- Update routes.rs ChannelMeta with new UI fields (region basic, rest advanced)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 17:39:56 +03:00
tuzkierandWang Hanbin eb87e3fd42 test merge
Adds a WebSocket-based DingTalk Stream channel adapter as an alternative
to the existing webhook-based DingTalk adapter.

DingTalk Stream Mode uses a long-lived WebSocket connection to the
DingTalk Gateway, eliminating the need for a public webhook endpoint.

Changes:
- `openfang-types`: add `DingTalkStreamConfig` struct and wire into
  `ChannelsConfig` alongside the existing `DingTalkConfig`
- `openfang-channels`: implement `DingTalkStreamAdapter` (WebSocket
  connection management, ping/pong, token refresh, send via batchSend API)
- `openfang-api`: register `dingtalk_stream` in the channel registry,
  `is_channel_configured`, and `channel_config_values`
- `openfang-api`: wire adapter startup in `channel_bridge.rs`
- `openfang-cli`: add `dingtalk_stream` entry to the TUI channels list

Configuration:
```toml
[channels.dingtalk_stream]
app_key_env = "DINGTALK_APP_KEY"      # Enterprise Internal App Key
app_secret_env = "DINGTALK_APP_SECRET" # Enterprise Internal App Secret
robot_code_env = "DINGTALK_ROBOT_CODE" # optional, defaults to app_key
```

Requires an Enterprise Internal App in the DingTalk Open Platform with
Stream Mode enabled. No public endpoint needed.

Made-with: Cursor

Co-authored-by: Wang Hanbin <wanghb@best-inc.com>
2026-03-15 17:39:52 +03:00
6d742e9081 test merge
* feat: heartbeat auto-recovery for crashed agents

Extend the heartbeat monitor to detect and automatically recover crashed
agents, reducing operator intervention for 24/7 autonomous deployments:

- Add RecoveryTracker: per-agent failure count with configurable cooldown
- Heartbeat now monitors both Running and Crashed agents
- Crashed agents auto-recover up to max_recovery_attempts (default 3)
- After exhausting attempts, agents are marked Terminated
- Unresponsive Running agents marked Crashed for next-cycle recovery
- Increase default timeout from 60s to 180s (browser/LLM tasks need time)
- Add HeartbeatStatus.state field for downstream consumers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: claude code driver — PID tracking and message timeout

Add subprocess lifecycle management to prevent hung CLI processes from
blocking agents indefinitely:

- Track active subprocess PIDs in a concurrent DashMap for external monitoring
- Enforce configurable message timeout (default 300s) with automatic process kill
- Return proper LlmError::Api on non-zero exit in streaming mode (was silently ignored)
- Add with_timeout(), active_pids(), pid_map() public methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: /restart endpoint — manual per-agent recovery without daemon bounce

POST /api/agents/{id}/restart and /api/agents/{id}/start both:
- Cancel any active task via stop_agent_run()
- Reset agent state to Running (updates last_active)
- Return JSON with previous state and whether a task was cancelled

Enables operators to recover individual crashed/stuck agents through the
API without restarting the entire daemon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: ZiLLA Dev <dev@zilla.wtf>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 17:39:26 +03:00
Victor Duarte 317b947608 docker runtimes 2026-03-15 17:27:13 +03:00
jaberjaber23 1cb8b989d1 community fixes 2026-03-15 16:59:21 +03:00
psumotek bde1f5414c channel agent reresolution
When an agent is restarted, its UUID changes but the channel bridge still
holds the old UUID from startup. This causes "Agent not found" errors.

This fix stores the agent *name* alongside the cached UUID at bridge
startup and, on "Agent not found" errors, re-resolves the name to a
fresh UUID via find_agent_by_name(), updates the cache, and retries the
message — all transparently to the end user.

Changes:
- router.rs: add channel_default_names DashMap, set_channel_default_with_name(),
  channel_default_name(), update_channel_default()
- channel_bridge.rs: use set_channel_default_with_name() at startup
- bridge.rs: add try_reresolution() helper, integrate retry logic into
  dispatch_message() and dispatch_with_blocks() error paths with proper
  lifecycle_reactions guards and sanitize_agent_error() usage
2026-03-15 16:55:08 +03:00
TJUEZandTJUEZ d15207fa51 shell skill runtime
Add Shell runtime type to SkillRuntime enum and implement
execute_shell function for running Bash scripts as skills.

This allows skills to use Bash script files, which many
existing skills rely on.

Ref: RightNow-AI/openfang/issues/620

Co-authored-by: TJUEZ <tjuez@email.com>
2026-03-15 16:54:57 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e808ca0d08 bump mailparse
Bumps [mailparse](https://github.com/staktrace/mailparse) from 0.15.0 to 0.16.1.
- [Commits](https://github.com/staktrace/mailparse/compare/v0.15.0...v0.16.1)

---
updated-dependencies:
- dependency-name: mailparse
  dependency-version: 0.16.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:54:06 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c554adae0d bump ci action
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:53:33 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 590121d5f3 bump ci action
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:53:31 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 58a7a07942 bump ci action
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-15 16:53:28 +03:00
Nahuel Gonzalez 72b87dfaa9 chromium no-sandbox root
Chromium refuses to launch without --no-sandbox when the process is
running as UID 0. This causes the browser hand to fail immediately with
'Chromium exited before printing DevTools URL' on any server-based
OpenFang installation that runs as root (the default install).

Added is_running_as_root() which reads /proc/self/status on Linux to
detect UID 0 without requiring a libc dependency, with a fallback to
the HOME env var for other Unix systems. When root is detected,
--no-sandbox is appended to the Chromium launch args automatically.
2026-03-15 15:30:29 +03:00
Frankandtsubasakong b8fb6987e0 tool error guidance
Co-authored-by: tsubasakong <185121705+tsubasakong@users.noreply.github.com>
2026-03-15 15:08:19 +03:00
Vincent LeraitreandClaude Opus 4.6 dec081a326 slack unfurl links
* Add unfurl_links config for Slack channel

Add unfurl_links: bool (default true) to SlackConfig to control
Slack's automatic URL preview expansion. When set to false, links
in agent messages are not unfurled, keeping output compact.

Applied to SlackAdapter's chat.postMessage payload via unfurl_links
and unfurl_media parameters, affecting both real-time and cron
delivery paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Rename test per review: clarify it tests explicit true, not default

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:44:53 +03:00
Mark BandClaude Sonnet 4.6 52647b2996 agent rename fix
* feat: add release-fast Cargo profile for faster dev builds

Introduces a `release-fast` profile that inherits from `release` but
uses thin LTO and 8 codegen units instead of full LTO + 1, cutting
link time significantly while remaining fast enough for integration
testing. Documents usage in CONTRIBUTING.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: allow renaming an agent to its current name

AgentRegistry::update_name was calling name_index.contains_key()
without excluding the agent being renamed. Renaming to the same name
always returned AgentAlreadyExists instead of succeeding silently.

Fix: only error when a *different* agent owns the target name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 14:44:22 +03:00
Frank 905cfd2c21 docs link fix 2026-03-15 14:43:55 +03:00
jaberjaber23 7ad7489860 community fixes 2026-03-15 14:43:10 +03:00
Jones Fernandes 77c4c9add8 whatsapp setup docs 2026-03-15 14:34:31 +03:00
jaberjaber23 07019f764e community fixes 2026-03-15 06:28:23 +03:00
jaberjaber23 80eed53305 community fixes 2026-03-15 06:23:04 +03:00
Evan Hu 14c4c1d1f5 stable hand agent IDs
* fix: use fixed agent ID for hand agents based on hand_id

This ensures triggers and cron jobs continue to work after daemon restart,
as hand agents now have stable IDs instead of generating a new UUID each time.

Changes:
- Add AgentId::from_string() method for deterministic ID generation
- Modify spawn_agent_with_parent() to accept optional fixed_id
- Use hand_id-based fixed ID in activate_hand()

See: #519

* remove: remove serena local config from commit

* chore: ignore .serena directory
2026-03-15 06:18:48 +03:00
jaberjaber23 fa7dd277e6 community fixes 2026-03-15 06:16:06 +03:00
jaberjaber23 0e589e3f9a community fixes 2026-03-15 06:12:33 +03:00
Daniel.Chung 59703d50d6 codex id_token
Signed-off-by: zhong <zdianjiang@gmail.com>
2026-03-15 06:05:52 +03:00
Sky Moore 5413269943 async session save
* fix: use async save_session to avoid blocking tokio runtime

save_session() was synchronous, holding a Mutex<Connection> on the
tokio worker thread during SQLite writes. On pods with 1 CPU core
(1 tokio worker thread), this starved the entire runtime — including
health check endpoints — causing K8s to mark the pod not-ready and
return 504 on all subsequent requests.

Add save_session_async() that wraps the SQLite write in
spawn_blocking, matching the pattern already used by other memory
operations (recall, remember, etc.). Update all 12 call sites in
the agent loop.

* fix: move health check DB query to spawn_blocking and add SSE keep_alive

The health endpoint called structured_get() synchronously on the tokio
async runtime, acquiring the shared std::sync::Mutex<Connection> on a
worker thread. When the agent loop held this mutex during session saves,
the health check blocked the tokio thread, starving the SSE stream and
causing Kubernetes probe timeouts.

- Health and health_detail now run the DB check via spawn_blocking
- SSE message/stream endpoint now includes keep_alive to flush periodic
  heartbeats even during contention

* feat: add hands upsert API for idempotent hand definition updates

Add upsert_from_content() to HandRegistry that overwrites existing
definitions instead of rejecting duplicates. Exposed as POST
/api/hands/upsert for use by the shard manager to keep hand definitions
up to date across pod restarts.

* fix: websocket streaming delays

* fix: get response immediately
2026-03-15 06:05:19 +03:00
mdrissel c5582ceb1e docker build args
Adds LTO and CODEGEN_UNITS arguments that default to optimized prod settings but can be overridden (e.g., LTO=false, CODEGEN_UNITS=16) by developers for faster iteration.
2026-03-15 06:04:37 +03:00
Mark BandClaude Sonnet 4.6 36dc62745c mastodon polling fix
The polling loop was updating last_notification_id on every iteration,
leaving it set to the oldest (smallest) ID in the batch after the loop
completed. On the next poll, since_id was set to that oldest ID, causing
Mastodon to return all previously seen notifications again.

Re-delivered notifications caused the bot to respond to the same user
mention repeatedly. Combined with api_post_status chaining each response
chunk as a reply to the previous chunk, this produced long self-reply
threads that appeared to be the bot conversing with itself.

Fix: capture the first (newest) notification ID before processing the
batch, so since_id always advances correctly on each poll cycle.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 06:04:16 +03:00
JingyiQiu cb6e6909d4 telegram formatting 2026-03-15 06:01:45 +03:00
jaberjaber23 b3be3f4940 community fixes 2026-03-15 05:58:00 +03:00
Mark BandClaude Sonnet 4.6 7505007d8c release-fast profile
Introduces a `release-fast` profile that inherits from `release` but
uses thin LTO and 8 codegen units instead of full LTO + 1, cutting
link time significantly while remaining fast enough for integration
testing. Documents usage in CONTRIBUTING.md.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 05:57:33 +03:00
jaberjaber23 fdd6c1a1f7 vault wiring 2026-03-15 05:48:09 +03:00
jaberjaber23 135c37fbf7 community fixes 2026-03-15 01:25:15 +03:00
jaberjaber23 a7a96a7b0f community fixes 2026-03-15 00:18:34 +03:00
jaberjaber23 52bacf0946 community fixes 2026-03-14 22:49:43 +03:00
jaberjaber23 d55e1b8545 community batch v0.4.0 2026-03-12 23:33:19 +03:00
jaberjaber23 0c059d1dc1 bump v0.3.49 2026-03-12 18:34:16 +03:00
jaberjaber23 b6b8b4ebe1 fix community issues 2026-03-12 16:42:39 +03:00
jaberjaber23 14f4845170 trader dashboard 2026-03-12 06:20:46 +03:00
jaberjaber23 be8a589986 bump v0.3.47 2026-03-12 01:23:35 +03:00
jaberjaber23 951e8d0feb fix 11 issues 2026-03-12 01:22:45 +03:00
jaberjaber23 98f8d1ca79 fix community PRs (inspired by #438 @pandego, #433 @ozekimasaki, #417 @f-liva, #392 @cryptonahue, #410 @hobostay, #413 @castorinop, #275 @woodcoal, #464 @citadelgrad, #419 @shipdocs, #480 @skeltavik, #439 @modship) 2026-03-11 03:25:16 +03:00
jaberjaber23 24f5717ae9 fix streaming-think, cron-orphans 2026-03-10 18:24:33 +03:00
jaberjaber23 f10eefdc0e fix 6 issues 2026-03-10 17:28:06 +03:00
jaberjaber23 edd0fed518 fix 7 issues 2026-03-10 16:53:04 +03:00
jaberjaber23 86b50070e8 fix gemini-schema 2026-03-10 04:09:26 +03:00
jaberjaber23 c6aab08faa fix temperature, free-models 2026-03-10 04:06:24 +03:00
jaberjaber23 62ec09d0ae bump version 2026-03-10 02:16:05 +03:00
jaberjaber23 f6f9cf7e9f fix claude-code 2026-03-10 02:14:49 +03:00
jaberjaber23 56aeb499c9 fix tool-schema 2026-03-10 01:41:33 +03:00
202 changed files with 35224 additions and 3533 deletions
+34
View File
@@ -0,0 +1,34 @@
# Ignored advisories — all are transitive dependencies we cannot upgrade directly.
#
# time 0.3.45: pinned by mac-notification-sys (tauri dependency), awaiting upstream fix
# GTK3/glib/pango/etc: tauri uses gtk-rs GTK3 bindings which are unmaintained
# paste, proc-macro-error, fxhash: unmaintained transitive deps
# lexical-core: unmaintained, pulled by tauri dep chain
# serde_cbor: unmaintained, pulled by tao (tauri)
# cocoa/cocoa-foundation: unmaintained, pulled by tauri/tao
[advisories]
ignore = [
"RUSTSEC-2026-0009", # time DoS — pinned by mac-notification-sys
"RUSTSEC-2024-0370", # proc-macro-error unmaintained
"RUSTSEC-2024-0411", # gtk-rs GTK3 unmaintained (gdk-pixbuf)
"RUSTSEC-2024-0412", # gtk-rs GTK3 unmaintained (gdk)
"RUSTSEC-2024-0413", # gtk-rs GTK3 unmaintained (atk)
"RUSTSEC-2024-0414", # gtk-rs GTK3 unmaintained (pango)
"RUSTSEC-2024-0415", # gtk-rs GTK3 unmaintained (gio)
"RUSTSEC-2024-0416", # gtk-rs GTK3 unmaintained (atk-sys)
"RUSTSEC-2024-0417", # gtk-rs GTK3 unmaintained (gdk-pixbuf-sys)
"RUSTSEC-2024-0418", # gtk-rs GTK3 unmaintained (gdk-sys)
"RUSTSEC-2024-0419", # gtk-rs GTK3 unmaintained (gtk3-macros)
"RUSTSEC-2024-0420", # gtk-rs GTK3 unmaintained (pango-sys)
"RUSTSEC-2024-0429", # gtk-rs GTK3 unmaintained (gtk-sys)
"RUSTSEC-2024-0436", # paste unmaintained
"RUSTSEC-2025-0057", # fxhash unmaintained
"RUSTSEC-2025-0075", # glib unmaintained
"RUSTSEC-2025-0080", # cocoa unmaintained
"RUSTSEC-2025-0081", # cocoa-foundation unmaintained
"RUSTSEC-2025-0098", # lexical-core unmaintained
"RUSTSEC-2025-0100", # gio-sys unmaintained
"RUSTSEC-2026-0002", # serde_cbor unmaintained
"RUSTSEC-2023-0086", # lexopt unmaintained (if present)
]
+6 -6
View File
@@ -20,7 +20,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
@@ -45,7 +45,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
@@ -67,7 +67,7 @@ jobs:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
@@ -87,7 +87,7 @@ jobs:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
@@ -97,7 +97,7 @@ jobs:
name: Security Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install cargo-audit
@@ -109,7 +109,7 @@ jobs:
name: Secrets Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install trufflehog
+7 -7
View File
@@ -49,7 +49,7 @@ jobs:
runs-on: ${{ matrix.platform.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install system deps (Linux)
if: runner.os == 'Linux'
@@ -162,7 +162,7 @@ jobs:
archive: zip
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -212,22 +212,22 @@ jobs:
name: Docker Image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU (for arm64 emulation)
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Extract version
id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
- name: Build and push (multi-arch)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
push: true
+2
View File
@@ -34,6 +34,7 @@ BUILD_LOG.md
# OS
.DS_Store
._*
Thumbs.db
# IDE & tools
@@ -43,3 +44,4 @@ Thumbs.db
*.swp
*.swo
*~
.serena/
+10
View File
@@ -56,6 +56,16 @@ Tests that require a real LLM key will skip gracefully if the env var is absent.
cargo build --workspace
```
### Fast Release Build (for development)
The default `--release` profile uses full LTO and single-codegen-unit, which produces the smallest/fastest binary but is slow to compile. For iterating locally, use the `release-fast` profile instead:
```bash
cargo build --profile release-fast -p openfang-cli
```
This cuts link time significantly (thin LTO, 8 codegen units, `opt-level=2`) while still producing a binary fast enough to run integration tests against. Use `--release` only for final binaries or CI.
### Run All Tests
```bash
Generated
+763 -219
View File
File diff suppressed because it is too large Load Diff
+29 -9
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.3.38"
version = "0.5.5"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/RightNow-AI/openfang"
@@ -32,7 +32,7 @@ tokio-stream = "0.1"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
toml = "0.9"
rmp-serde = "1"
# Error handling
@@ -52,7 +52,7 @@ chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
# IDs
uuid = { version = "1", features = ["v4", "serde"] }
uuid = { version = "1", features = ["v4", "v5", "serde"] }
# Database
rusqlite = { version = "0.31", features = ["bundled", "serde_json"] }
@@ -62,7 +62,7 @@ clap = { version = "4", features = ["derive"] }
clap_complete = "4"
# HTTP client (for LLM drivers)
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart", "rustls-tls", "gzip", "deflate", "brotli"] }
# Async trait
async-trait = "0.1"
@@ -75,6 +75,7 @@ bytes = "1"
# Futures
futures = "0.3"
prost = "0.13"
# WebSocket client (for Discord/Slack gateway)
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
@@ -84,7 +85,7 @@ url = "2"
wasmtime = "41"
# HTTP server (for API daemon)
axum = { version = "0.8", features = ["ws"] }
axum = { version = "0.8", features = ["ws", "multipart"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "compression-br"] }
@@ -102,6 +103,9 @@ walkdir = "2"
# Security
sha2 = "0.10"
sha1 = "0.10"
aes = "0.8"
cbc = "0.1"
hmac = "0.12"
hex = "0.4"
subtle = "2"
@@ -110,7 +114,7 @@ rand = "0.8"
zeroize = { version = "1", features = ["derive"] }
# Rate limiting
governor = "0.8"
governor = "0.10"
# Interactive CLI
ratatui = "0.29"
@@ -126,17 +130,26 @@ html-escape = "0.2"
# Lightweight regex
regex-lite = "0.1"
# MCP SDK (official Rust implementation)
rmcp = { version = "1.2", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "reqwest"] }
# Socket options (SO_REUSEADDR)
socket2 = "0.5"
# Zip archive extraction
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "4", default-features = false, features = ["deflate"] }
# Email (SMTP + IMAP)
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
imap = "2"
native-tls = "0.2"
mailparse = "0.15"
native-tls = { version = "0.2", features = ["vendored"] }
mailparse = "0.16"
# MQTT client
rumqttc = "0.24"
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
openssl = { version = "0.10", features = ["vendored"] }
# Testing
tokio-test = "0.4"
@@ -147,3 +160,10 @@ lto = true
codegen-units = 1
strip = true
opt-level = 3
[profile.release-fast]
inherits = "release"
lto = "thin"
codegen-units = 8
opt-level = 2
strip = false
+16 -2
View File
@@ -7,10 +7,24 @@ COPY crates ./crates
COPY xtask ./xtask
COPY agents ./agents
COPY packages ./packages
# Optional build args for dev environments to speed up compilation
# Example: docker build --build-arg LTO=false --build-arg CODEGEN_UNITS=16 .
ARG LTO=true
ARG CODEGEN_UNITS=1
ENV CARGO_PROFILE_RELEASE_LTO=${LTO} \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=${CODEGEN_UNITS}
RUN cargo build --release --bin openfang
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
FROM rust:1-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
python3 \
python3-pip \
python3-venv \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/openfang /usr/local/bin/
COPY --from=builder /build/agents /opt/openfang/agents
EXPOSE 4200
+91
View File
@@ -264,6 +264,97 @@ Connect your agents to every platform your users are on.
Each adapter supports per-channel model overrides, DM/group policies, rate limiting, and output formatting.
---
## WhatsApp Web Gateway (QR Code)
Connect your personal WhatsApp account to OpenFang via QR code — just like WhatsApp Web. No Meta Business account required.
### Prerequisites
- **Node.js >= 18** installed ([download](https://nodejs.org/))
- OpenFang installed and initialized
### Setup
**1. Install the gateway dependencies:**
```bash
cd packages/whatsapp-gateway
npm install
```
**2. Configure `config.toml`:**
```toml
[channels.whatsapp]
mode = "web"
default_agent = "assistant"
```
**3. Set the gateway URL (choose one):**
Add to your shell profile for persistence:
```bash
# macOS / Linux
echo 'export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"' >> ~/.zshrc
source ~/.zshrc
```
Or set it inline when starting the gateway:
```bash
export WHATSAPP_WEB_GATEWAY_URL="http://127.0.0.1:3009"
```
**4. Start the gateway:**
```bash
node packages/whatsapp-gateway/index.js
```
The gateway listens on port `3009` by default. Override with `WHATSAPP_GATEWAY_PORT`.
**5. Start OpenFang:**
```bash
openfang start
# Dashboard at http://localhost:4200
```
**6. Scan the QR code:**
Open the dashboard → **Channels****WhatsApp**. A QR code will appear. Scan it with your phone:
> **WhatsApp** → **Settings** → **Linked Devices** → **Link a Device**
Once scanned, the status changes to `connected` and incoming messages are routed to your configured agent.
### Gateway Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `WHATSAPP_WEB_GATEWAY_URL` | Gateway URL for OpenFang to connect to | _(empty = disabled)_ |
| `WHATSAPP_GATEWAY_PORT` | Port the gateway listens on | `3009` |
| `OPENFANG_URL` | OpenFang API URL the gateway reports to | `http://127.0.0.1:4200` |
| `OPENFANG_DEFAULT_AGENT` | Agent that handles incoming messages | `assistant` |
### Gateway API Endpoints
| Method | Route | Description |
|--------|-------|-------------|
| `POST` | `/login/start` | Generate QR code (returns base64 PNG) |
| `GET` | `/login/status` | Connection status (`disconnected`, `qr_ready`, `connected`) |
| `POST` | `/message/send` | Send a message (`{ "to": "5511999999999", "text": "Hello" }`) |
| `GET` | `/health` | Health check |
### Alternative: WhatsApp Cloud API
For production workloads, use the [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api) with a Meta Business account. See the [Cloud API configuration docs](https://openfang.sh/docs/channels/whatsapp).
---
## 27 LLM Providers — 123+ Models
+3
View File
@@ -76,3 +76,6 @@ memory_read = ["*"]
memory_write = ["self.*", "shared.*"]
agent_message = ["*"]
shell = ["python *", "cargo *", "git *", "npm *"]
[autonomous]
max_iterations = 100
@@ -0,0 +1 @@
__pycache__/
+187
View File
@@ -0,0 +1,187 @@
"""
LangChain Code Review Agent — core review logic.
Supports OpenAI, Ollama, and any LangChain-compatible LLM.
"""
import os
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
SYSTEM_PROMPT = """\
You are a principal-level code reviewer with 15+ years of production experience \
across multiple languages (Python, Rust, TypeScript, Java, Go, C/C++).
You receive code snippets, diffs, or pull request descriptions and produce a \
structured, actionable review report.
You MUST respond in **中文**, but keep code snippets, variable names, and \
technical terms in their original language.
# ── 审核维度(按优先级排序) ──────────────────────────────
## 1. 正确性 (Correctness)
- 逻辑错误、off-by-one、边界条件
- 空指针 / None / undefined 未处理
- 错误处理不完整(吞异常、漏 catch、panic 路径)
- 并发问题:竞态条件、死锁、数据竞争
- 类型安全:隐式转换、溢出、精度丢失
- 资源泄漏:未关闭的文件/连接/锁
## 2. 安全性 (Security)
- SQL / NoSQL / OS 命令注入
- XSS、CSRF、SSRF
- 硬编码密钥、token、密码
- 不安全的反序列化
- 路径穿越(Path Traversal
- 缺少输入校验 / 输出编码
- 权限检查缺失或绕过
- 敏感数据明文日志
## 3. 性能 (Performance)
- 算法复杂度不合理(O(n²) 可优化为 O(n))
- 不必要的内存分配 / 拷贝
- N+1 查询、缺少批量操作
- 阻塞 I/O 在异步上下文中
- 缺少缓存 / 索引
- 热路径上的正则编译 / 反射
## 4. 可维护性 (Maintainability)
- 命名不清晰、缩写歧义
- 函数过长(>50行建议拆分)
- 重复代码(DRY 违反)
- 职责不单一(SRP 违反)
- 缺少必要注释(复杂业务逻辑、非显而易见的决策)
- 魔法数字 / 字符串
- 耦合过紧、依赖方向不合理
## 5. 测试 (Testing)
- 关键路径缺少单元测试
- 测试覆盖了 happy path 但遗漏了 edge case
- 测试中有硬编码依赖(时间、文件路径、网络)
- Mock 过度导致测试失去意义
## 6. 风格 (Style)
- 不符合语言惯例(Pythonic、Rust idiom 等)
- 格式不一致(应由 formatter 处理的除外)
- 不必要的复杂写法
# ── 严重级别 ──────────────────────────────────────────
| 级别 | 含义 | 是否阻塞合并 |
|------|------|-------------|
| 🔴 **[必须修复]** | 存在 bug、安全漏洞或数据丢失风险 | 是 |
| 🟡 **[建议修复]** | 不影响功能但会影响可维护性或性能 | 否,但强烈建议 |
| 🔵 **[小建议]** | 风格、命名等微小改进 | 否 |
| 🟢 **[亮点]** | 写得好的地方,值得肯定 | — |
# ── 输出格式 ──────────────────────────────────────────
严格按以下 Markdown 格式输出:
```
## 📋 总结
**结论**: [✅ 通过 / ⚠️ 需要修改 / 💬 仅评论]
**概述**: [1-2 句话总体评价]
**发现统计**: 🔴 X 个必须修复 | 🟡 X 个建议修复 | 🔵 X 个小建议 | 🟢 X 个亮点
---
## 🔍 详细发现
### 🔴 [必须修复] 问题标题
- **位置**: `文件名` 第 X-Y 行
- **问题**: 具体描述
- **原因**: 为什么这是个问题,可能造成什么后果
- **修复建议**:
(给出修复后的代码)
### 🟡 [建议修复] 问题标题
...
### 🔵 [小建议] 问题标题
...
### 🟢 [亮点] 优点标题
- **位置**: `文件名` 第 X-Y 行
- **说明**: 为什么这段代码写得好
---
## 📊 评分
| 维度 | 分数 | 说明 |
|------|------|------|
| 正确性 | X/10 | 一句话说明 |
| 安全性 | X/10 | 一句话说明 |
| 性能 | X/10 | 一句话说明 |
| 可维护性 | X/10 | 一句话说明 |
| 测试 | X/10 | 一句话说明 |
| **综合** | **X/10** | 一句话总结 |
```
# ── 审核原则 ──────────────────────────────────────────
1. **先肯定,再指出问题** — 不要只挑毛病,好的代码也要指出来
2. **解释 WHY,不仅是 WHAT** — 每个问题都要说清楚「为什么不好」和「可能导致什么后果」
3. **给出具体修复代码** — 不要只说"这里有问题",要给出改好后的写法
4. **区分严重级别** — 不要把小问题标成必须修复,也不要把严重 bug 标成小建议
5. **尊重作者** — 用建设性的语气,避免 "这是错的" 这种措辞,用 "这里可以改进为..."
6. **不纠结格式** — 如果项目有 formatter/linter,格式问题跳过
7. **关注变更本身** — 如果是 diff,只审核变更的部分,不要评论未修改的代码
8. **没有代码时** — 直接要求提交代码,不要编造审核结果"""
def _build_llm():
"""Build the LLM based on environment configuration."""
use_ollama = os.getenv("USE_OLLAMA", "").lower() in ("1", "true", "yes")
if use_ollama:
from langchain_ollama import ChatOllama
model = os.getenv("OLLAMA_MODEL", "qwen2.5")
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
return ChatOllama(model=model, base_url=base_url, temperature=0.2)
provider = os.getenv("LLM_PROVIDER", "openai").lower()
if provider == "deepseek":
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
temperature=0.2,
max_tokens=4096,
)
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
temperature=0.2,
max_tokens=4096,
)
class CodeReviewAgent:
"""LangChain-based code review agent."""
def __init__(self):
self.llm = _build_llm()
self.prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
("human", "{input}"),
])
self.chain = self.prompt | self.llm | StrOutputParser()
def review(self, code_or_diff: str) -> str:
"""
Review the given code or diff.
Args:
code_or_diff: Source code, git diff, or PR description to review.
Returns:
Structured review report as markdown text.
"""
if not code_or_diff.strip():
return "No code provided. Please submit code or a diff to review."
return self.chain.invoke({"input": code_or_diff})
@@ -0,0 +1,10 @@
# Add this section to your ~/.openfang/config.toml
# to register the LangChain code review agent.
[a2a]
enabled = true
listen_path = "/a2a"
[[a2a.external_agents]]
name = "langchain-code-reviewer"
url = "http://127.0.0.1:9100"
@@ -0,0 +1,6 @@
langchain>=0.3
langchain-openai>=0.3
langchain-core>=0.3
langchain-ollama>=0.3
fastapi>=0.115
uvicorn>=0.34
+226
View File
@@ -0,0 +1,226 @@
"""
LangChain Code Review Agent — A2A-compatible server.
Exposes a code review agent via Google's A2A protocol so that
OpenFang workflows can call it as an external agent.
Start:
OPENAI_API_KEY=sk-xxx python server.py
# or with Ollama (no key needed):
USE_OLLAMA=1 python server.py
Endpoints:
GET /.well-known/agent.json — A2A Agent Card
POST /a2a — JSON-RPC task endpoint
"""
import os
import uuid
import asyncio
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import uvicorn
from agent import CodeReviewAgent
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "9100"))
BASE_URL = os.getenv("BASE_URL", f"http://127.0.0.1:{PORT}")
app = FastAPI(title="LangChain Code Review Agent")
agent = CodeReviewAgent()
# In-memory task store
tasks: dict[str, dict] = {}
# ---------------------------------------------------------------------------
# A2A Agent Card
# ---------------------------------------------------------------------------
AGENT_CARD = {
"name": "langchain-code-reviewer",
"description": (
"LangChain-powered code review agent. "
"Analyzes code for bugs, security issues, performance problems, "
"and style violations. Returns structured review with severity levels."
),
"url": f"{BASE_URL}/a2a",
"version": "0.1.0",
"capabilities": {
"streaming": False,
"pushNotifications": False,
"stateTransitionHistory": True,
},
"skills": [
{
"id": "code-review",
"name": "Code Review",
"description": "Review code for correctness, security, performance, and style",
"tags": ["code", "review", "security", "quality"],
"examples": [
"Review this Python function for bugs",
"Check this Rust code for security issues",
"Analyze this PR diff for performance problems",
],
},
{
"id": "pr-review",
"name": "Pull Request Review",
"description": "Review a git diff / pull request",
"tags": ["pr", "diff", "git"],
"examples": [
"Review this PR diff",
"Analyze these changes",
],
},
],
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
}
@app.get("/.well-known/agent.json")
async def agent_card():
return JSONResponse(content=AGENT_CARD)
# ---------------------------------------------------------------------------
# A2A JSON-RPC Endpoint
# ---------------------------------------------------------------------------
@app.post("/a2a")
async def a2a_endpoint(request: Request):
body = await request.json()
jsonrpc = body.get("jsonrpc", "2.0")
req_id = body.get("id", 1)
method = body.get("method", "")
params = body.get("params", {})
if method == "tasks/send":
return await handle_tasks_send(jsonrpc, req_id, params)
elif method == "tasks/get":
return handle_tasks_get(jsonrpc, req_id, params)
elif method == "tasks/cancel":
return handle_tasks_cancel(jsonrpc, req_id, params)
else:
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
})
async def handle_tasks_send(jsonrpc: str, req_id: int, params: dict):
message = params.get("message", {})
session_id = params.get("sessionId")
task_id = str(uuid.uuid4())
text_parts = [
p["text"] for p in message.get("parts", []) if p.get("type") == "text"
]
user_input = "\n".join(text_parts)
task = {
"id": task_id,
"sessionId": session_id,
"status": {"state": "working", "message": None},
"messages": [message],
"artifacts": [],
}
tasks[task_id] = task
try:
review_result = await asyncio.to_thread(agent.review, user_input)
agent_message = {
"role": "agent",
"parts": [{"type": "text", "text": review_result}],
}
task["messages"].append(agent_message)
task["status"] = {"state": "completed", "message": None}
task["artifacts"] = [
{
"name": "code-review-report",
"description": "Structured code review report",
"parts": [{"type": "text", "text": review_result}],
"index": 0,
"lastChunk": True,
}
]
except Exception as e:
task["status"] = {"state": "failed", "message": str(e)}
task["messages"].append({
"role": "agent",
"parts": [{"type": "text", "text": f"Review failed: {e}"}],
})
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"result": task,
})
def handle_tasks_get(jsonrpc: str, req_id: int, params: dict):
task_id = params.get("id", "")
task = tasks.get(task_id)
if task is None:
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"error": {"code": -32000, "message": f"Task not found: {task_id}"},
})
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"result": task,
})
def handle_tasks_cancel(jsonrpc: str, req_id: int, params: dict):
task_id = params.get("id", "")
task = tasks.get(task_id)
if task is None:
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"error": {"code": -32000, "message": f"Task not found: {task_id}"},
})
task["status"] = {"state": "cancelled", "message": None}
return JSONResponse(content={
"jsonrpc": jsonrpc,
"id": req_id,
"result": task,
})
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------
@app.get("/health")
async def health():
return {"status": "ok", "agent": "langchain-code-reviewer", "tasks": len(tasks)}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if __name__ == "__main__":
print(f"Starting LangChain Code Review Agent on {HOST}:{PORT}")
print(f"Agent Card: {BASE_URL}/.well-known/agent.json")
print(f"A2A endpoint: {BASE_URL}/a2a")
uvicorn.run(app, host=HOST, port=PORT)
@@ -0,0 +1,23 @@
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "langchain-code-review-pipeline",
"description": "Code review pipeline: uses LangChain external agent for deep review, then OpenFang Writer agent to format the final report.",
"created_at": "2026-03-16T00:00:00Z",
"steps": [
{
"name": "review-code",
"agent": { "name": "a2a-proxy" },
"prompt_template": "Use the a2a_send tool to send the following code to the external agent for code review. Set agent_name to langchain-code-reviewer and set message to the code below. Return the complete review result:\n\n{{input}}",
"mode": "sequential",
"timeout_secs": 300,
"output_var": "review_result"
},
{
"name": "format-report",
"agent": { "name": "Writer" },
"prompt_template": "Format the following code review into a clean, professional report. Preserve all severity levels and scores. Add a brief executive summary at the top:\n\n{{review_result}}",
"mode": "sequential",
"timeout_secs": 120
}
]
}
+3
View File
@@ -33,6 +33,9 @@ governor = { workspace = true }
tokio-stream = { workspace = true }
subtle = { workspace = true }
base64 = { workspace = true }
sha2 = { workspace = true }
hmac = { workspace = true }
hex = { workspace = true }
socket2 = { workspace = true }
reqwest = { workspace = true }
+201 -27
View File
@@ -30,6 +30,7 @@ use openfang_channels::messenger::MessengerAdapter;
use openfang_channels::reddit::RedditAdapter;
use openfang_channels::revolt::RevoltAdapter;
use openfang_channels::viber::ViberAdapter;
use openfang_types::config::FeishuMode;
// Wave 4
use openfang_channels::flock::FlockAdapter;
use openfang_channels::guilded::GuildedAdapter;
@@ -43,19 +44,24 @@ use openfang_channels::webex::WebexAdapter;
// Wave 5
use async_trait::async_trait;
use openfang_channels::dingtalk::DingTalkAdapter;
use openfang_channels::dingtalk_stream::DingTalkStreamAdapter;
use openfang_channels::discourse::DiscourseAdapter;
use openfang_channels::gitter::GitterAdapter;
use openfang_channels::gotify::GotifyAdapter;
use openfang_channels::linkedin::LinkedInAdapter;
use openfang_channels::mumble::MumbleAdapter;
use openfang_channels::mqtt::MqttAdapter;
use openfang_channels::ntfy::NtfyAdapter;
use openfang_channels::webhook::WebhookAdapter;
use openfang_channels::wecom::WeComAdapter;
use openfang_kernel::OpenFangKernel;
use openfang_types::agent::AgentId;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{error, info, warn};
use openfang_runtime::str_utils::safe_truncate_str;
/// Wraps `OpenFangKernel` to implement `ChannelBridgeHandle`.
pub struct KernelBridgeAdapter {
kernel: Arc<OpenFangKernel>,
@@ -70,6 +76,10 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.send_message(agent_id, message)
.await
.map_err(|e| format!("{e}"))?;
// Silent/NO_REPLY responses should not be forwarded to channels
if result.silent {
return Ok(String::new());
}
Ok(result.response)
}
@@ -82,7 +92,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
let text: String = blocks
.iter()
.filter_map(|b| match b {
openfang_types::message::ContentBlock::Text { text } => Some(text.as_str()),
openfang_types::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
@@ -378,7 +388,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| t.agent_id.to_string());
let status = if t.enabled { "on" } else { "off" };
let id_short = &t.id.0.to_string()[..8];
let id_str = t.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
msg.push_str(&format!(
" [{}] {} -> {} ({:?}) fires:{} [{}]\n",
id_short,
@@ -417,7 +428,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.kernel
.triggers
.register(agent.id, pattern, prompt.to_string(), 0);
let id_short = &trigger_id.0.to_string()[..8];
let id_str = trigger_id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Trigger created [{id_short}] for agent '{agent_name}'.")
}
@@ -432,7 +444,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
1 => {
let t = matched[0];
if self.kernel.triggers.remove(t.id) {
format!("Trigger [{}] removed.", &t.id.0.to_string()[..8])
let id_str = t.id.0.to_string();
format!("Trigger [{}] removed.", safe_truncate_str(&id_str, 8))
} else {
"Failed to remove trigger.".to_string()
}
@@ -455,7 +468,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| job.agent_id.to_string());
let status = if job.enabled { "on" } else { "off" };
let id_short = &job.id.0.to_string()[..8];
let id_str = job.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let sched = match &job.schedule {
openfang_types::scheduler::CronSchedule::Cron { expr, .. } => expr.clone(),
openfang_types::scheduler::CronSchedule::Every { every_secs } => {
@@ -477,6 +491,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
msg
}
#[allow(dead_code)]
async fn manage_schedule_text(&self, action: &str, args: &[String]) -> String {
match action {
"add" => {
@@ -515,7 +530,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
match self.kernel.cron_scheduler.add_job(job, false) {
Ok(id) => {
let id_short = &id.0.to_string()[..8];
let id_str = id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] created: '{cron_expr}' -> {agent_name}: \"{message}\"")
}
Err(e) => format!("Failed to create job: {e}"),
@@ -537,7 +553,12 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
let j = matched[0];
match self.kernel.cron_scheduler.remove_job(j.id) {
Ok(_) => {
format!("Job [{}] '{}' removed.", &j.id.0.to_string()[..8], j.name)
let id_str = j.id.0.to_string();
format!(
"Job [{}] '{}' removed.",
safe_truncate_str(&id_str, 8),
j.name
)
}
Err(e) => format!("Failed to remove job: {e}"),
}
@@ -566,10 +587,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
openfang_types::scheduler::CronAction::SystemEvent { text } => {
text.clone()
}
openfang_types::scheduler::CronAction::WorkflowRun {
workflow_id,
input,
..
} => {
format!(
"Run workflow {workflow_id}{}",
input
.as_deref()
.map(|i| format!(" with input: {i}"))
.unwrap_or_default()
)
}
};
match self.kernel.send_message(j.agent_id, &message).await {
Ok(result) => {
let id_short = &j.id.0.to_string()[..8];
let id_str = j.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] ran:\n{}", result.response)
}
Err(e) => format!("Failed to run job: {e}"),
@@ -589,7 +624,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
}
let mut msg = format!("Pending approvals ({}):\n", pending.len());
for req in &pending {
let id_short = &req.id.to_string()[..8];
let id_str = req.id.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds();
let age = if age_secs >= 60 {
format!("{}m", age_secs / 60)
@@ -630,10 +666,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
) {
Ok(_) => {
let verb = if approve { "Approved" } else { "Rejected" };
let id_str = req.id.to_string();
format!(
"{} [{}] {}{}",
verb,
&req.id.to_string()[..8],
safe_truncate_str(&id_str, 8),
req.tool_name,
req.agent_id
)
@@ -673,7 +710,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
));
}
self.kernel
.set_agent_model(agent_id, model)
.set_agent_model(agent_id, model, None)
.map_err(|e| format!("{e}"))?;
// Read back resolved model+provider from registry
let entry = self
@@ -763,12 +800,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
// Wave 5
"mumble" => channels.mumble.as_ref().map(|c| c.overrides.clone()),
"dingtalk" => channels.dingtalk.as_ref().map(|c| c.overrides.clone()),
"dingtalk_stream" => channels
.dingtalk_stream
.as_ref()
.map(|c| c.overrides.clone()),
"discourse" => channels.discourse.as_ref().map(|c| c.overrides.clone()),
"gitter" => channels.gitter.as_ref().map(|c| c.overrides.clone()),
"ntfy" => channels.ntfy.as_ref().map(|c| c.overrides.clone()),
"gotify" => channels.gotify.as_ref().map(|c| c.overrides.clone()),
"webhook" => channels.webhook.as_ref().map(|c| c.overrides.clone()),
"linkedin" => channels.linkedin.as_ref().map(|c| c.overrides.clone()),
"wecom" => channels.wecom.as_ref().map(|c| c.overrides.clone()),
"mqtt" => channels.mqtt.as_ref().map(|c| c.overrides.clone()),
_ => None,
}
}
@@ -810,6 +853,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
recipient: &str,
success: bool,
error: Option<&str>,
thread_id: Option<&str>,
) {
let receipt = if success {
openfang_kernel::DeliveryTracker::sent_receipt(channel, recipient)
@@ -822,9 +866,13 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
};
self.kernel.delivery_tracker.record(agent_id, receipt);
// Persist last channel for cron CronDelivery::LastChannel
// Persist last channel for cron CronDelivery::LastChannel.
// Include thread_id when present so forum-topic context survives restarts.
if success {
let kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
let mut kv_val = serde_json::json!({"channel": channel, "recipient": recipient});
if let Some(tid) = thread_id {
kv_val["thread_id"] = serde_json::json!(tid);
}
let _ = self
.kernel
.memory
@@ -895,7 +943,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
return "OFP peer network is disabled. Set network_enabled = true in config.toml."
.to_string();
}
match &self.kernel.peer_registry {
match self.kernel.peer_registry.get() {
Some(registry) => {
let peers = registry.all_peers();
if peers.is_empty() {
@@ -970,16 +1018,39 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
}
}
/// 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) {
/// Resolve a token: if the value looks like an actual secret (contains `:`,
/// starts with `xoxb-`, `xapp-`, `sk-`, etc.), use it directly.
/// Otherwise treat it as an env var name and look it up.
fn read_token(env_var_or_token: &str, adapter_name: &str) -> Option<String> {
// Heuristic: actual tokens contain `:` (Telegram, Discord) or start with
// known prefixes. Env var names are uppercase ASCII identifiers.
let looks_like_token = env_var_or_token.contains(':')
|| env_var_or_token.starts_with("xoxb-")
|| env_var_or_token.starts_with("xapp-")
|| env_var_or_token.starts_with("sk-")
|| env_var_or_token.starts_with("Bearer ")
|| env_var_or_token.len() > 80; // Long random strings are tokens, not env var names
if looks_like_token {
warn!(
"{adapter_name}: config field contains what looks like an actual token \
rather than an env var name — using it directly. \
Tip: store the token in an env var and use the var name instead for security."
);
return Some(env_var_or_token.to_string());
}
match std::env::var(env_var_or_token) {
Ok(t) if !t.is_empty() => Some(t),
Ok(_) => {
warn!("{adapter_name} bot token env var '{env_var}' is empty, skipping");
warn!("{adapter_name} token env var '{env_var_or_token}' is set but empty, skipping");
None
}
Err(_) => {
warn!("{adapter_name} bot token env var '{env_var}' not set, skipping");
warn!(
"{adapter_name} token env var '{env_var_or_token}' not set, skipping. \
Set it with: export {env_var_or_token}=<your-token>"
);
None
}
}
@@ -1039,6 +1110,7 @@ pub async fn start_channel_bridge_with_config(
// Wave 5
|| config.mumble.is_some()
|| config.dingtalk.is_some()
|| config.dingtalk_stream.is_some()
|| config.discourse.is_some()
|| config.gitter.is_some()
|| config.ntfy.is_some()
@@ -1094,6 +1166,9 @@ pub async fn start_channel_bridge_with_config(
app_token,
bot_token,
sl_config.allowed_channels.clone(),
sl_config.auto_thread_reply,
sl_config.thread_ttl_hours,
sl_config.unfurl_links,
));
adapters.push((adapter, sl_config.default_agent.clone()));
}
@@ -1103,7 +1178,9 @@ pub async fn start_channel_bridge_with_config(
// WhatsApp — supports Cloud API mode (access token) or Web/QR mode (gateway URL)
if let Some(ref wa_config) = config.whatsapp {
let cloud_token = read_token(&wa_config.access_token_env, "WhatsApp");
let gateway_url = std::env::var(&wa_config.gateway_url_env).ok().filter(|u| !u.is_empty());
let gateway_url = std::env::var(&wa_config.gateway_url_env)
.ok()
.filter(|u| !u.is_empty());
if cloud_token.is_some() || gateway_url.is_some() {
let token = cloud_token.unwrap_or_default();
@@ -1145,6 +1222,7 @@ pub async fn start_channel_bridge_with_config(
mx_config.user_id.clone(),
token,
mx_config.allowed_rooms.clone(),
mx_config.auto_accept_invites,
));
adapters.push((adapter, mx_config.default_agent.clone()));
}
@@ -1351,11 +1429,27 @@ pub async fn start_channel_bridge_with_config(
// Feishu/Lark
if let Some(ref fs_config) = config.feishu {
if let Some(secret) = read_token(&fs_config.app_secret_env, "Feishu") {
let adapter = Arc::new(FeishuAdapter::new(
fs_config.app_id.clone(),
secret,
fs_config.webhook_port,
));
let region = openfang_channels::feishu::FeishuRegion::parse_region(&fs_config.region);
let encrypt_key = fs_config
.encrypt_key_env
.as_ref()
.and_then(|env| read_token(env, "Feishu encrypt_key"));
let adapter = match fs_config.mode {
FeishuMode::Webhook => Arc::new(FeishuAdapter::with_config(
fs_config.app_id.clone(),
secret,
fs_config.webhook_port,
region,
Some(fs_config.webhook_path.clone()),
fs_config.verification_token.clone(),
encrypt_key,
fs_config.bot_names.clone(),
)),
FeishuMode::Websocket => Arc::new(FeishuAdapter::new_websocket(
fs_config.app_id.clone(),
secret,
)),
};
adapters.push((adapter, fs_config.default_agent.clone()));
}
}
@@ -1368,6 +1462,21 @@ pub async fn start_channel_bridge_with_config(
}
}
// WeCom/WeChat Work
if let Some(ref wc_config) = config.wecom {
if let Some(secret) = read_token(&wc_config.secret_env, "WeCom") {
let adapter = Arc::new(WeComAdapter::with_verification(
wc_config.corp_id.clone(),
wc_config.agent_id.clone(),
secret,
wc_config.webhook_port,
wc_config.encoding_aes_key.clone(),
wc_config.token.clone(),
));
adapters.push((adapter, wc_config.default_agent.clone()));
}
}
// ── Wave 4 ──────────────────────────────────────────────────
// Nextcloud Talk
@@ -1474,7 +1583,7 @@ pub async fn start_channel_bridge_with_config(
}
}
// DingTalk
// DingTalk (webhook mode)
if let Some(ref dt_config) = config.dingtalk {
if let Some(token) = read_token(&dt_config.access_token_env, "DingTalk") {
let secret = read_token(&dt_config.secret_env, "DingTalk (secret)").unwrap_or_default();
@@ -1483,6 +1592,21 @@ pub async fn start_channel_bridge_with_config(
}
}
// DingTalk (stream mode)
if let Some(ref ds_config) = config.dingtalk_stream {
if let Some(app_key) = read_token(&ds_config.app_key_env, "DingTalk Stream (app_key)") {
if let Some(app_secret) =
read_token(&ds_config.app_secret_env, "DingTalk Stream (app_secret)")
{
let robot_code =
read_token(&ds_config.robot_code_env, "DingTalk Stream (robot_code)")
.unwrap_or_else(|| app_key.clone());
let adapter = Arc::new(DingTalkStreamAdapter::new(app_key, app_secret, robot_code));
adapters.push((adapter, ds_config.default_agent.clone()));
}
}
}
// Discourse
if let Some(ref dc_config) = config.discourse {
if let Some(api_key) = read_token(&dc_config.api_key_env, "Discourse") {
@@ -1556,6 +1680,25 @@ pub async fn start_channel_bridge_with_config(
}
}
// MQTT
if let Some(ref mq_config) = config.mqtt {
let username = read_token(&mq_config.username_env, "MQTT (username)");
let password = read_token(&mq_config.password_env, "MQTT (password)");
let adapter = Arc::new(MqttAdapter::new(
mq_config.broker_url.clone(),
mq_config.client_id.clone(),
mq_config.subscribe_topic.clone(),
mq_config.publish_topic.clone(),
username,
password,
mq_config.use_tls,
mq_config.keep_alive_secs,
mq_config.clean_session,
mq_config.qos,
));
adapters.push((adapter, mq_config.default_agent.clone()));
}
if adapters.is_empty() {
return (None, Vec::new());
}
@@ -1587,7 +1730,7 @@ pub async fn start_channel_bridge_with_config(
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
adapter.name()
);
router.set_channel_default(channel_key, agent_id);
router.set_channel_default_with_name(channel_key, agent_id, name.clone());
// First configured default also becomes system-wide fallback
if !system_default_set {
router.set_default(agent_id);
@@ -1760,4 +1903,35 @@ mod tests {
assert!(config.channels.webhook.is_none());
assert!(config.channels.linkedin.is_none());
}
#[test]
fn test_feishu_bridge_mode_defaults_to_websocket() {
let config: openfang_types::config::KernelConfig = toml::from_str(
r#"
[channels.feishu]
app_id = "cli_test"
app_secret_env = "FEISHU_APP_SECRET"
"#,
)
.unwrap();
let feishu = config.channels.feishu.expect("feishu config should exist");
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
}
#[test]
fn test_feishu_bridge_mode_supports_websocket() {
let config: openfang_types::config::KernelConfig = toml::from_str(
r#"
[channels.feishu]
app_id = "cli_test"
app_secret_env = "FEISHU_APP_SECRET"
mode = "websocket"
"#,
)
.unwrap();
let feishu = config.channels.feishu.expect("feishu config should exist");
assert_eq!(feishu.mode, openfang_types::config::FeishuMode::Websocket);
}
}
+1
View File
@@ -9,6 +9,7 @@ pub mod openai_compat;
pub mod rate_limiter;
pub mod routes;
pub mod server;
pub mod session_auth;
pub mod stream_chunker;
pub mod stream_dedup;
pub mod types;
+54 -11
View File
@@ -43,14 +43,24 @@ pub async fn request_logging(request: Request<Body>, next: Next) -> Response<Bod
response
}
/// Authentication state passed to the auth middleware.
#[derive(Clone)]
pub struct AuthState {
pub api_key: String,
pub auth_enabled: bool,
pub session_secret: String,
}
/// Bearer token authentication middleware.
///
/// When `api_key` is non-empty (after trimming), requests to non-public
/// endpoints must include `Authorization: Bearer <api_key>`.
/// If the key is empty or whitespace-only, auth is disabled entirely
/// (public/local development mode).
///
/// When dashboard auth is enabled, session cookies are also accepted.
pub async fn auth(
axum::extract::State(api_key): axum::extract::State<String>,
axum::extract::State(auth_state): axum::extract::State<AuthState>,
request: Request<Body>,
next: Next,
) -> Response<Body> {
@@ -114,7 +124,10 @@ pub async fn auth(
|| (path == "/api/workflows" && is_get)
|| path == "/api/logs/stream" // SSE stream, read-only
|| (path.starts_with("/api/cron/") && is_get)
|| path.starts_with("/api/providers/github-copilot/oauth/");
|| path.starts_with("/api/providers/github-copilot/oauth/")
|| path == "/api/auth/login"
|| path == "/api/auth/logout"
|| (path == "/api/auth/check" && is_get);
if is_public {
return next.run(request).await;
@@ -123,10 +136,11 @@ pub async fn auth(
// If no API key configured (empty, whitespace-only, or missing), skip auth
// entirely. Users who don't set api_key accept that all endpoints are open.
// To secure the dashboard, set a non-empty api_key in config.toml.
let api_key = api_key.trim();
if api_key.is_empty() {
let api_key_trimmed = auth_state.api_key.trim().to_string();
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
return next.run(request).await;
}
let api_key = api_key_trimmed.as_str();
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
let bearer_token = request
@@ -172,6 +186,17 @@ pub async fn auth(
return next.run(request).await;
}
// Check session cookie (dashboard login sessions)
if auth_state.auth_enabled {
if let Some(token) = extract_session_cookie(&request) {
if crate::session_auth::verify_session_token(&token, &auth_state.session_secret)
.is_some()
{
return next.run(request).await;
}
}
}
// Determine error message: was a credential provided but wrong, or missing entirely?
let credential_provided = header_auth.is_some() || query_auth.is_some();
let error_msg = if credential_provided {
@@ -189,6 +214,21 @@ pub async fn auth(
.unwrap_or_default()
}
/// Extract the `openfang_session` cookie value from a request.
fn extract_session_cookie(request: &Request<Body>) -> Option<String> {
request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|c| {
c.trim()
.strip_prefix("openfang_session=")
.map(|v| v.to_string())
})
})
}
/// Security headers middleware — applied to ALL API responses.
pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Body> {
let mut response = next.run(request).await;
@@ -196,13 +236,16 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
headers.insert("x-content-type-options", "nosniff".parse().unwrap());
headers.insert("x-frame-options", "DENY".parse().unwrap());
headers.insert("x-xss-protection", "1; mode=block".parse().unwrap());
// All JS/CSS is bundled inline — only external resource is Google Fonts.
headers.insert(
"content-security-policy",
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; font-src 'self' https://fonts.gstatic.com; media-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
.parse()
.unwrap(),
);
// The dashboard handler (webchat_page) sets its own nonce-based CSP.
// For all other responses (API endpoints), apply a strict default.
if !headers.contains_key("content-security-policy") {
headers.insert(
"content-security-policy",
"default-src 'none'; frame-ancestors 'none'"
.parse()
.unwrap(),
);
}
headers.insert(
"referrer-policy",
"strict-origin-when-cross-origin".parse().unwrap(),
+6 -5
View File
@@ -202,9 +202,10 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
let blocks: Vec<ContentBlock> = parts
.iter()
.filter_map(|part| match part {
OaiContentPart::Text { text } => {
Some(ContentBlock::Text { text: text.clone() })
}
OaiContentPart::Text { text } => Some(ContentBlock::Text {
text: text.clone(),
provider_metadata: None,
}),
OaiContentPart::ImageUrl { image_url } => {
// Parse data URI: data:{media_type};base64,{data}
if let Some(rest) = image_url.url.strip_prefix("data:") {
@@ -322,7 +323,7 @@ pub async fn chat_completions(
let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
match state
.kernel
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle))
.send_message_with_handle(agent_id, &last_user_msg, Some(kernel_handle), None, None)
.await
{
Ok(result) => {
@@ -378,7 +379,7 @@ async fn stream_response(
let (mut rx, _handle) = state
.kernel
.send_message_streaming(agent_id, message, Some(kernel_handle))
.send_message_streaming(agent_id, message, Some(kernel_handle), None, None, None)
.map_err(|e| format!("Streaming setup failed: {e}"))?;
let (tx, stream_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(64);
+1
View File
@@ -29,6 +29,7 @@ pub fn operation_cost(method: &str, path: &str) -> NonZeroU32 {
("POST", p) if p.contains("/run") => NonZeroU32::new(100).unwrap(),
("POST", "/api/skills/install") => NonZeroU32::new(50).unwrap(),
("POST", "/api/skills/uninstall") => NonZeroU32::new(10).unwrap(),
("POST", "/api/skills/reload") => NonZeroU32::new(5).unwrap(),
("POST", "/api/migrate") => NonZeroU32::new(100).unwrap(),
("PUT", p) if p.contains("/update") => NonZeroU32::new(10).unwrap(),
_ => NonZeroU32::new(5).unwrap(),
File diff suppressed because it is too large Load Diff
+59 -22
View File
@@ -45,12 +45,13 @@ pub async fn build_router(
let state = Arc::new(AppState {
kernel: kernel.clone(),
started_at: Instant::now(),
peer_registry: kernel.peer_registry.as_ref().map(|r| Arc::new(r.clone())),
peer_registry: kernel.peer_registry.get().map(|r| Arc::new(r.clone())),
bridge_manager: tokio::sync::Mutex::new(bridge),
channels_config: tokio::sync::RwLock::new(channels_config),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(kernel.config.budget.clone())),
});
// CORS: allow localhost origins by default. If API key is set, the API
@@ -105,12 +106,25 @@ pub async fn build_router(
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
let api_key = state.kernel.config.api_key.trim().to_string();
let auth_state = crate::middleware::AuthState {
api_key: api_key.clone(),
auth_enabled: state.kernel.config.auth.enabled,
session_secret: if !api_key.is_empty() {
api_key.clone()
} else if state.kernel.config.auth.enabled {
state.kernel.config.auth.password_hash.clone()
} else {
String::new()
},
};
let gcra_limiter = rate_limiter::create_rate_limiter();
let app = Router::new()
.route("/", axum::routing::get(webchat::webchat_page))
.route("/logo.png", axum::routing::get(webchat::logo_png))
.route("/favicon.ico", axum::routing::get(webchat::favicon_ico))
.route("/manifest.json", axum::routing::get(webchat::manifest_json))
.route("/sw.js", axum::routing::get(webchat::sw_js))
.route(
"/api/metrics",
axum::routing::get(routes::prometheus_metrics),
@@ -128,13 +142,23 @@ pub async fn build_router(
)
.route(
"/api/agents/{id}",
axum::routing::get(routes::get_agent).delete(routes::kill_agent).patch(routes::patch_agent),
axum::routing::get(routes::get_agent)
.delete(routes::kill_agent)
.patch(routes::patch_agent),
)
.route(
"/api/agents/{id}/mode",
axum::routing::put(routes::set_agent_mode),
)
.route("/api/profiles", axum::routing::get(routes::list_profiles))
.route(
"/api/agents/{id}/restart",
axum::routing::post(routes::restart_agent),
)
.route(
"/api/agents/{id}/start",
axum::routing::post(routes::restart_agent),
)
.route(
"/api/agents/{id}/message",
axum::routing::post(routes::send_message),
@@ -288,6 +312,12 @@ pub async fn build_router(
"/api/workflows",
axum::routing::get(routes::list_workflows).post(routes::create_workflow),
)
.route(
"/api/workflows/{id}",
axum::routing::get(routes::get_workflow)
.put(routes::update_workflow)
.delete(routes::delete_workflow),
)
.route(
"/api/workflows/{id}/run",
axum::routing::post(routes::run_workflow),
@@ -306,6 +336,10 @@ pub async fn build_router(
"/api/skills/uninstall",
axum::routing::post(routes::uninstall_skill),
)
.route(
"/api/skills/reload",
axum::routing::post(routes::reload_skills),
)
.route(
"/api/marketplace/search",
axum::routing::get(routes::marketplace_search),
@@ -337,6 +371,10 @@ pub async fn build_router(
"/api/hands/install",
axum::routing::post(routes::install_hand),
)
.route(
"/api/hands/upsert",
axum::routing::post(routes::upsert_hand),
)
.route(
"/api/hands/active",
axum::routing::get(routes::list_active_hands),
@@ -356,8 +394,7 @@ pub async fn build_router(
)
.route(
"/api/hands/{hand_id}/settings",
axum::routing::get(routes::get_hand_settings)
.put(routes::update_hand_settings),
axum::routing::get(routes::get_hand_settings).put(routes::update_hand_settings),
)
.route(
"/api/hands/instances/{id}/pause",
@@ -414,14 +451,11 @@ pub async fn build_router(
"/api/comms/events/stream",
axum::routing::get(routes::comms_events_stream),
)
.route(
"/api/comms/send",
axum::routing::post(routes::comms_send),
)
.route(
"/api/comms/task",
axum::routing::post(routes::comms_task),
)
.route("/api/comms/send", axum::routing::post(routes::comms_send))
.route("/api/comms/task", axum::routing::post(routes::comms_task));
// Split into a second router chunk to stay within axum's type nesting limit.
let app = app
// Tools endpoint
.route("/api/tools", axum::routing::get(routes::list_tools))
// Config endpoints
@@ -466,8 +500,7 @@ pub async fn build_router(
)
.route(
"/api/budget/agents/{id}",
axum::routing::get(routes::agent_budget_status)
.put(routes::update_agent_budget),
axum::routing::get(routes::agent_budget_status).put(routes::update_agent_budget),
)
// Session endpoints
.route("/api/sessions", axum::routing::get(routes::list_sessions))
@@ -558,6 +591,10 @@ pub async fn build_router(
"/api/cron/jobs/{id}/status",
axum::routing::get(routes::cron_job_status),
)
.route(
"/api/cron/jobs/{id}/run",
axum::routing::post(routes::run_cron_job),
)
// Webhook trigger endpoints (external event injection)
.route("/hooks/wake", axum::routing::post(routes::webhook_wake))
.route("/hooks/agent", axum::routing::post(routes::webhook_agent))
@@ -671,8 +708,12 @@ pub async fn build_router(
"/v1/models",
axum::routing::get(crate::openai_compat::list_models),
)
// Dashboard authentication endpoints
.route("/api/auth/login", axum::routing::post(routes::auth_login))
.route("/api/auth/logout", axum::routing::post(routes::auth_logout))
.route("/api/auth/check", axum::routing::get(routes::auth_check))
.layer(axum::middleware::from_fn_with_state(
api_key,
auth_state,
middleware::auth,
))
.layer(axum::middleware::from_fn_with_state(
@@ -789,8 +830,7 @@ pub async fn run_daemon(
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(1024)?;
let listener =
tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
let listener = tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
// Run server with graceful shutdown.
// SECURITY: `into_make_service_with_connect_info` injects the peer
@@ -921,11 +961,8 @@ fn is_daemon_responding(addr: &str) -> bool {
.or_else(|| addr.strip_prefix("https://"))
.unwrap_or(addr);
if let Ok(sock_addr) = addr_only.parse::<std::net::SocketAddr>() {
std::net::TcpStream::connect_timeout(
&sock_addr,
std::time::Duration::from_millis(500),
)
.is_ok()
std::net::TcpStream::connect_timeout(&sock_addr, std::time::Duration::from_millis(500))
.is_ok()
} else {
// Fallback: try connecting to hostname
std::net::TcpStream::connect(addr_only)
+109
View File
@@ -0,0 +1,109 @@
//! Stateless session token authentication for the dashboard.
//! Tokens are HMAC-SHA256 signed and contain username + expiry.
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Create a session token: base64(username:expiry_unix:hmac_hex)
pub fn create_session_token(username: &str, secret: &str, ttl_hours: u64) -> String {
use base64::Engine;
let expiry = chrono::Utc::now().timestamp() + (ttl_hours as i64 * 3600);
let payload = format!("{username}:{expiry}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key");
mac.update(payload.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
base64::engine::general_purpose::STANDARD.encode(format!("{payload}:{signature}"))
}
/// Verify a session token. Returns the username if valid and not expired.
pub fn verify_session_token(token: &str, secret: &str) -> Option<String> {
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(token)
.ok()?;
let decoded_str = String::from_utf8(decoded).ok()?;
let parts: Vec<&str> = decoded_str.splitn(3, ':').collect();
if parts.len() != 3 {
return None;
}
let (username, expiry_str, provided_sig) = (parts[0], parts[1], parts[2]);
let expiry: i64 = expiry_str.parse().ok()?;
if chrono::Utc::now().timestamp() > expiry {
return None;
}
let payload = format!("{username}:{expiry_str}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(payload.as_bytes());
let expected_sig = hex::encode(mac.finalize().into_bytes());
use subtle::ConstantTimeEq;
if provided_sig.len() != expected_sig.len() {
return None;
}
if provided_sig
.as_bytes()
.ct_eq(expected_sig.as_bytes())
.into()
{
Some(username.to_string())
} else {
None
}
}
/// Hash a password with SHA256 for config storage.
pub fn hash_password(password: &str) -> String {
use sha2::Digest;
hex::encode(Sha256::digest(password.as_bytes()))
}
/// Verify a password against a stored SHA256 hash (constant-time).
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
let computed = hash_password(password);
use subtle::ConstantTimeEq;
if computed.len() != stored_hash.len() {
return false;
}
computed.as_bytes().ct_eq(stored_hash.as_bytes()).into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_password() {
let hash = hash_password("secret123");
assert!(verify_password("secret123", &hash));
assert!(!verify_password("wrong", &hash));
}
#[test]
fn test_create_and_verify_token() {
let token = create_session_token("admin", "my-secret", 1);
let user = verify_session_token(&token, "my-secret");
assert_eq!(user, Some("admin".to_string()));
}
#[test]
fn test_token_wrong_secret() {
let token = create_session_token("admin", "my-secret", 1);
let user = verify_session_token(&token, "wrong-secret");
assert_eq!(user, None);
}
#[test]
fn test_token_invalid_base64() {
let user = verify_session_token("not-valid-base64!!!", "secret");
assert_eq!(user, None);
}
#[test]
fn test_password_hash_length_mismatch() {
assert!(!verify_password("x", "short"));
}
}
+6
View File
@@ -42,6 +42,12 @@ pub struct MessageRequest {
/// Optional file attachments (uploaded via /upload endpoint).
#[serde(default)]
pub attachments: Vec<AttachmentRef>,
/// Sender identity (e.g. WhatsApp phone number, Telegram user ID).
#[serde(default)]
pub sender_id: Option<String>,
/// Sender display name.
#[serde(default)]
pub sender_name: Option<String>,
}
/// Response from sending a message.
+72 -16
View File
@@ -15,7 +15,13 @@
use axum::http::header;
use axum::response::IntoResponse;
/// Nonce placeholder in compile-time HTML, replaced at request time.
const NONCE_PLACEHOLDER: &str = "__NONCE__";
/// Compile-time ETag based on the crate version.
/// Not used for the dashboard page (nonce prevents caching) but retained
/// for potential future use by static asset handlers.
#[allow(dead_code)]
const ETAG: &str = concat!("\"openfang-", env!("CARGO_PKG_VERSION"), "\"");
/// Embedded logo PNG for single-binary deployment.
@@ -46,20 +52,65 @@ pub async fn favicon_ico() -> impl IntoResponse {
)
}
/// GET / — Serve the OpenFang Dashboard single-page application.
///
/// Returns the full SPA with ETag header based on package version for caching.
pub async fn webchat_page() -> impl IntoResponse {
/// Embedded PWA manifest for installable web app support.
const MANIFEST_JSON: &str = include_str!("../static/manifest.json");
/// Embedded service worker for PWA support.
const SW_JS: &str = include_str!("../static/sw.js");
/// GET /manifest.json — Serve the PWA web app manifest.
pub async fn manifest_json() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::ETAG, ETAG),
(
header::CACHE_CONTROL,
"public, max-age=3600, must-revalidate",
),
(header::CONTENT_TYPE, "application/manifest+json"),
(header::CACHE_CONTROL, "public, max-age=86400, immutable"),
],
WEBCHAT_HTML,
MANIFEST_JSON,
)
}
/// GET /sw.js — Serve the PWA service worker.
pub async fn sw_js() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
SW_JS,
)
}
/// GET / — Serve the OpenFang Dashboard single-page application.
///
/// Generates a unique CSP nonce on every request and injects it into both
/// the `<script>` tags and the `Content-Security-Policy` header. This
/// replaces `'unsafe-inline'` so only our own scripts execute.
pub async fn webchat_page() -> impl IntoResponse {
let nonce = uuid::Uuid::new_v4().to_string();
let html = WEBCHAT_HTML.replace(NONCE_PLACEHOLDER, &nonce);
let csp = format!(
"default-src 'self'; \
script-src 'self' 'nonce-{nonce}' 'unsafe-eval'; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com; \
img-src 'self' data: blob:; \
connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:*; \
font-src 'self' https://fonts.gstatic.com; \
media-src 'self' blob:; \
frame-src 'self' blob:; \
object-src 'none'; \
base-uri 'self'; \
form-action 'self'"
);
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string()),
(
header::HeaderName::from_static("content-security-policy"),
csp,
),
(header::CACHE_CONTROL, "no-store".to_string()),
],
html,
)
}
@@ -81,21 +132,26 @@ const WEBCHAT_HTML: &str = concat!(
include_str!("../static/vendor/github-dark.min.css"),
"\n</style>\n",
include_str!("../static/index_body.html"),
// Vendor libs: marked + highlight first (used by app.js)
"<script>\n",
// Vendor libs: marked + highlight first (used by app.js), then Chart.js
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/marked.min.js"),
"\n</script>\n",
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/highlight.min.js"),
"\n</script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/chart.umd.min.js"),
"\n</script>\n",
// App code
"<script>\n",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/js/api.js"),
"\n",
include_str!("../static/js/app.js"),
"\n",
include_str!("../static/js/pages/overview.js"),
"\n",
include_str!("../static/js/katex.js"),
"\n",
include_str!("../static/js/pages/chat.js"),
"\n",
include_str!("../static/js/pages/agents.js"),
@@ -129,7 +185,7 @@ const WEBCHAT_HTML: &str = concat!(
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",
"<script nonce=\"__NONCE__\">\n",
include_str!("../static/vendor/alpine.min.js"),
"\n</script>\n",
"</body></html>"
+157 -74
View File
@@ -439,6 +439,7 @@ async fn handle_text_message(
// Resolve file attachments into image content blocks
let mut has_images = false;
let mut ws_content_blocks: Option<Vec<openfang_types::message::ContentBlock>> = None;
if let Some(attachments) = parsed["attachments"].as_array() {
let refs: Vec<crate::types::AttachmentRef> = attachments
.iter()
@@ -448,11 +449,7 @@ async fn handle_text_message(
let image_blocks = crate::routes::resolve_attachments(&refs);
if !image_blocks.is_empty() {
has_images = true;
crate::routes::inject_attachments_into_session(
&state.kernel,
agent_id,
image_blocks,
);
ws_content_blocks = Some(image_blocks);
}
}
}
@@ -502,16 +499,30 @@ async fn handle_text_message(
// Send message to agent with streaming
let kernel_handle: Arc<dyn KernelHandle> =
state.kernel.clone() as Arc<dyn KernelHandle>;
match state
.kernel
.send_message_streaming(agent_id, &content, Some(kernel_handle))
{
match state.kernel.send_message_streaming(
agent_id,
&content,
Some(kernel_handle),
None,
None,
ws_content_blocks,
) {
Ok((mut rx, handle)) => {
// Forward stream events to WebSocket with debouncing
// Forward stream events to WebSocket with debouncing.
//
// The stream_task also accumulates the full response text and
// captures ContentComplete usage data. This lets us send the
// `response` event immediately when the stream channel closes
// (after `drop(phase_cb)` in the kernel), WITHOUT waiting for
// post-processing (canonical session writes, JSONL, compaction)
// that happens in the kernel task after the loop.
let sender_stream = Arc::clone(sender);
let verbose_clone = Arc::clone(verbose);
let stream_task = tokio::spawn(async move {
let mut text_buffer = String::new();
let mut accumulated_text = String::new();
let mut stream_usage: Option<openfang_types::message::TokenUsage> = None;
let mut is_silent = false;
let far_future = tokio::time::Instant::now() + Duration::from_secs(86400);
let mut flush_deadline = far_future;
@@ -535,7 +546,15 @@ async fn handle_text_message(
break;
}
Some(ev) => {
// Capture ContentComplete for immediate response
if let StreamEvent::ContentComplete { usage, .. } = &ev {
stream_usage = Some(*usage);
// Don't forward — handled below
continue;
}
if let StreamEvent::TextDelta { ref text } = ev {
accumulated_text.push_str(text);
text_buffer.push_str(text);
if text_buffer.len() >= DEBOUNCE_CHARS {
let _ = flush_text_buffer(
@@ -600,14 +619,62 @@ async fn handle_text_message(
}
}
}
// Check if the agent signalled NO_REPLY via the stream
// (PhaseChange with a "silent" marker — currently the
// kernel sets result.silent after the loop, so we detect
// it from empty accumulated text when ContentComplete
// had no text deltas at all).
if accumulated_text.is_empty() && stream_usage.is_some() {
is_silent = true;
}
(accumulated_text, stream_usage, is_silent)
});
// Wait for the agent loop to complete
match handle.await {
Ok(Ok(result)) => {
// Cancel the stream forwarder (should be done by now)
stream_task.abort();
// Wait for the stream to finish (fast — closes as soon as
// drop(phase_cb) runs after the agent loop). This does NOT
// wait for post-processing.
let stream_result = stream_task.await;
// Spawn the kernel task in the background for cleanup
// (canonical session writes, JSONL mirror, compaction).
// We don't need its result for the response event.
let sender_bg = Arc::clone(sender);
tokio::spawn(async move {
match handle.await {
Ok(Err(e)) => {
warn!("Agent post-processing failed: {e}");
let user_msg = classify_streaming_error(&e);
let _ = send_json(
&sender_bg,
&serde_json::json!({
"type": "error",
"content": user_msg,
}),
)
.await;
}
Err(e) => {
warn!("Agent task panicked: {e}");
let _ = send_json(
&sender_bg,
&serde_json::json!({
"type": "error",
"content": "Internal error occurred",
}),
)
.await;
}
Ok(Ok(_)) => {
// Post-processing completed successfully — nothing to send
}
}
});
// Send the response immediately from stream data
match stream_result {
Ok((accumulated_text, stream_usage, is_silent)) => {
// Send typing lifecycle: stop
let _ = send_json(
sender,
@@ -618,43 +685,36 @@ async fn handle_text_message(
)
.await;
// NO_REPLY: agent intentionally chose not to reply
if result.silent {
let usage = stream_usage.unwrap_or_default();
if is_silent {
let _ = send_json(
sender,
&serde_json::json!({
"type": "silent_complete",
"input_tokens": result.total_usage.input_tokens,
"output_tokens": result.total_usage.output_tokens,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
}),
)
.await;
return;
}
// Strip <think>...</think> blocks from model output
// (e.g. MiniMax, DeepSeek reasoning tokens)
let cleaned_response = strip_think_tags(&result.response);
// Strip <think>...</think> blocks
let cleaned = strip_think_tags(&accumulated_text);
// Guard: ensure we never send an empty response
let content = if cleaned_response.trim().is_empty() {
let content = if cleaned.trim().is_empty() {
format!(
"[The agent completed processing but returned no text response. ({} in / {} out | {} iter)]",
result.total_usage.input_tokens,
result.total_usage.output_tokens,
result.iterations,
"[The agent completed processing but returned no text response. ({} in / {} out)]",
usage.input_tokens, usage.output_tokens,
)
} else {
cleaned_response
cleaned
};
// Estimate context pressure from last call
let per_call = if result.iterations > 0 {
result.total_usage.input_tokens / result.iterations as u64
} else {
result.total_usage.input_tokens
};
let ctx_pct = (per_call as f64 / 200_000.0 * 100.0).min(100.0);
// Estimate context pressure
let ctx_pct =
(usage.input_tokens as f64 / 200_000.0 * 100.0).min(100.0);
let pressure = if ctx_pct > 85.0 {
"critical"
} else if ctx_pct > 70.0 {
@@ -670,38 +730,17 @@ async fn handle_text_message(
&serde_json::json!({
"type": "response",
"content": content,
"input_tokens": result.total_usage.input_tokens,
"output_tokens": result.total_usage.output_tokens,
"iterations": result.iterations,
"cost_usd": result.cost_usd,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"iterations": 0, // Not available from stream; handle updates later if needed
"cost_usd": null,
"context_pressure": pressure,
}),
)
.await;
}
Ok(Err(e)) => {
stream_task.abort();
warn!("Agent message failed: {e}");
let _ = send_json(
sender,
&serde_json::json!({
"type": "typing", "state": "stop",
}),
)
.await;
let user_msg = classify_streaming_error(&e);
let _ = send_json(
sender,
&serde_json::json!({
"type": "error",
"content": user_msg,
}),
)
.await;
}
Err(e) => {
stream_task.abort();
warn!("Agent task panicked: {e}");
warn!("Stream task panicked: {e}");
let _ = send_json(
sender,
&serde_json::json!({
@@ -809,7 +848,7 @@ async fn handle_command(
serde_json::json!({"type": "error", "content": "Agent not found"})
}
} else {
match state.kernel.set_agent_model(agent_id, args) {
match state.kernel.set_agent_model(agent_id, args, None) {
Ok(()) => {
if let Some(entry) = state.kernel.registry.get(agent_id) {
let model = &entry.manifest.model.model;
@@ -912,7 +951,7 @@ async fn handle_command(
let msg = if !state.kernel.config.network_enabled {
"OFP network disabled.".to_string()
} else {
match &state.kernel.peer_registry {
match state.kernel.peer_registry.get() {
Some(registry) => {
let peers = registry.all_peers();
if peers.is_empty() {
@@ -959,11 +998,14 @@ async fn handle_command(
fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_json::Value> {
match event {
StreamEvent::TextDelta { .. } => None, // Handled by debounce buffer
StreamEvent::ToolUseStart { name, .. } => Some(serde_json::json!({
StreamEvent::ToolUseStart { id, name, .. } => Some(serde_json::json!({
"type": "tool_start",
"id": id,
"tool": name,
})),
StreamEvent::ToolUseEnd { name, input, .. } if name == "canvas_present" => {
StreamEvent::ToolUseEnd {
id, name, input, ..
} if name == "canvas_present" => {
let html = input.get("html").and_then(|v| v.as_str()).unwrap_or("");
let title = input
.get("title")
@@ -971,12 +1013,15 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
.unwrap_or("Canvas");
Some(serde_json::json!({
"type": "canvas",
"id": id,
"canvas_id": uuid::Uuid::new_v4().to_string(),
"html": html,
"title": title,
}))
}
StreamEvent::ToolUseEnd { name, input, .. } => match verbose {
StreamEvent::ToolUseEnd {
id, name, input, ..
} => match verbose {
VerboseLevel::Off => None,
VerboseLevel::On => {
let input_preview: String = serde_json::to_string(input)
@@ -986,6 +1031,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
.collect();
Some(serde_json::json!({
"type": "tool_end",
"id": id,
"tool": name,
"input": input_preview,
}))
@@ -998,18 +1044,21 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
.collect();
Some(serde_json::json!({
"type": "tool_end",
"id": id,
"tool": name,
"input": input_preview,
}))
}
},
StreamEvent::ToolExecutionResult {
id,
name,
result_preview,
is_error,
} => match verbose {
VerboseLevel::Off => Some(serde_json::json!({
"type": "tool_result",
"id": id,
"tool": name,
"is_error": is_error,
})),
@@ -1017,6 +1066,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
let truncated: String = result_preview.chars().take(200).collect();
Some(serde_json::json!({
"type": "tool_result",
"id": id,
"tool": name,
"result": truncated,
"is_error": is_error,
@@ -1024,6 +1074,7 @@ fn map_stream_event(event: &StreamEvent, verbose: VerboseLevel) -> Option<serde_
}
VerboseLevel::Full => Some(serde_json::json!({
"type": "tool_result",
"id": id,
"tool": name,
"result": result_preview,
"is_error": is_error,
@@ -1119,6 +1170,9 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
let status = extract_status_code(&inner);
let classified = llm_errors::classify_error(&inner, status);
// Build a user-facing message. The classified.sanitized_message now
// includes a redacted excerpt of the raw error (issue #493 fix), so we
// use it as the base and only override for cases that need extra context.
match classified.category {
llm_errors::LlmErrorCategory::ContextOverflow => {
"Context is full. Try /compact or /new.".to_string()
@@ -1126,24 +1180,36 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
llm_errors::LlmErrorCategory::RateLimit => {
if let Some(delay_ms) = classified.suggested_delay_ms {
let secs = (delay_ms / 1000).max(1);
format!("Provider rate limited. Wait ~{secs}s and try again.")
format!("Rate limited. Wait ~{secs}s and try again.")
} else {
"Provider rate limited. Wait a moment and try again.".to_string()
"Rate limited. Wait a moment and try again.".to_string()
}
}
llm_errors::LlmErrorCategory::Billing => {
"Check provider account status (billing issue detected).".to_string()
format!("Billing issue. {}", classified.sanitized_message)
}
llm_errors::LlmErrorCategory::Auth => {
// Show the actual error detail so users can diagnose (issue #493).
// The sanitized_message already redacts secrets.
classified.sanitized_message.clone()
}
llm_errors::LlmErrorCategory::Auth => "Verify your API key in config.".to_string(),
llm_errors::LlmErrorCategory::ModelNotFound => {
if inner.contains("localhost:11434") || inner.contains("ollama") {
"Model not found on Ollama. Run `ollama pull <model>` to download it, then try again. Use /model to see options.".to_string()
"Model not found on Ollama. Run `ollama pull <model>` first. Use /model to see options.".to_string()
} else {
"Model unavailable. Use /model to see options or check your provider configuration.".to_string()
format!(
"{}. Use /model to see options.",
classified.sanitized_message
)
}
}
llm_errors::LlmErrorCategory::Format => {
"LLM request failed. Check your API key and model configuration in Settings.".to_string()
// Claude Code CLI errors have actionable messages — pass them through
if inner.contains("Claude Code CLI") || inner.contains("claude auth") {
classified.raw_message.clone()
} else {
classified.sanitized_message.clone()
}
}
_ => classified.sanitized_message,
}
@@ -1151,6 +1217,14 @@ fn classify_streaming_error(err: &openfang_kernel::error::KernelError) -> String
/// Try to extract an HTTP status code from an error string.
fn extract_status_code(s: &str) -> Option<u16> {
// "API error (NNN):" — the format produced by LlmError::Api Display impl
if let Some(idx) = s.find("API error (") {
let after = &s[idx + 11..];
let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(code) = num.parse::<u16>() {
return Some(code);
}
}
// "status: NNN"
if let Some(idx) = s.find("status: ") {
let after = &s[idx + 8..];
@@ -1269,6 +1343,15 @@ mod tests {
);
assert_eq!(extract_status_code("StatusCode(401)"), Some(401));
assert_eq!(extract_status_code("some random error"), None);
// LlmError::Api Display format (issue #493 fix)
assert_eq!(
extract_status_code("LLM driver error: API error (403): quota exceeded"),
Some(403)
);
assert_eq!(
extract_status_code("API error (401): invalid api key"),
Some(401)
);
}
#[test]
@@ -647,6 +647,11 @@ mark.search-highlight {
color: var(--text);
}
.message-bubble.markdown-body ul,
.message-bubble.markdown-body ol {
padding-left: 2em;
}
.copy-btn {
position: absolute;
top: 6px;
@@ -1274,8 +1279,11 @@ mark.search-highlight {
/* Utility */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.grid { display: grid; }
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
@@ -1283,6 +1291,7 @@ mark.search-highlight {
.mt-4 { margin-top: 16px; }
.mb-2 { margin-bottom: 8px; }
.mb-4 { margin-bottom: 16px; }
.mb-6 { margin-bottom: 24px; }
.text-dim { color: var(--text-dim); }
.text-sm { font-size: 11px; }
.text-xs { font-size: 10px; }
@@ -3238,3 +3247,206 @@ mark.search-highlight {
.comms-event-row:hover { background: var(--bg-hover); }
.comms-event-time { min-width: 50px; text-align: right; }
.comms-event-detail { margin-left: auto; }
/*
Trader Dashboard
*/
.trader-dashboard {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
width: 96vw;
max-width: 1200px;
max-height: 92vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.trader-dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--bg-card);
z-index: 10;
border-radius: 12px 12px 0 0;
}
.trader-dashboard-body {
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* KPI Cards */
.trader-kpi-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 10px;
}
@media (max-width: 900px) {
.trader-kpi-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 540px) {
.trader-kpi-row { grid-template-columns: repeat(2, 1fr); }
}
.trader-kpi-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
text-align: center;
}
.trader-kpi-label {
font-size: 0.7rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.trader-kpi-value {
font-size: 1.15rem;
font-weight: 700;
color: var(--text);
font-family: var(--font-mono);
}
.kpi-positive { color: var(--success) !important; }
.kpi-negative { color: var(--error) !important; }
/* Chart Rows */
.trader-chart-row {
display: flex;
gap: 12px;
}
@media (max-width: 768px) {
.trader-chart-row { flex-direction: column; }
}
.trader-chart-panel {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
min-width: 0;
}
.trader-chart-title {
font-size: 0.75rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
font-weight: 600;
}
.trader-chart-wrap {
position: relative;
width: 100%;
min-height: 180px;
}
.trader-chart-wrap canvas {
width: 100% !important;
height: 100% !important;
}
.trader-chart-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-size: 0.85rem;
}
/* Heatmap Table */
.trader-heatmap-wrap {
overflow-x: auto;
}
.trader-heatmap-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.trader-heatmap-table th {
text-align: left;
padding: 6px 10px;
color: var(--text-dim);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.trader-heatmap-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle);
}
.heatmap-positive { color: var(--success); font-weight: 600; }
.heatmap-negative { color: var(--error); font-weight: 600; }
/* Signal Badges */
.signal-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.3px;
}
.signal-strong_buy, .signal-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.signal-sell, .signal-strong_sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
.signal-hold { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
/* Confidence Bar */
.confidence-bar-wrap {
display: flex;
align-items: center;
gap: 6px;
min-width: 100px;
}
.confidence-bar {
height: 6px;
border-radius: 3px;
transition: width 0.3s ease;
}
.conf-high { background: var(--success); }
.conf-mid { background: var(--warning); }
.conf-low { background: var(--error); }
.confidence-label {
font-size: 0.7rem;
color: var(--text-dim);
min-width: 32px;
font-family: var(--font-mono);
}
/* Trades Table */
.trader-trades-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.trader-trades-table th {
text-align: left;
padding: 6px 10px;
color: var(--text-dim);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.trader-trades-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-mono);
font-size: 0.78rem;
}
.trade-side-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 0.68rem;
font-weight: 700;
}
.trade-buy { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.trade-sell { background: rgba(239, 68, 68, 0.15); color: var(--error); }
+18 -1
View File
@@ -243,6 +243,14 @@
z-index: 99;
}
.mobile-menu-btn {
position: fixed !important;
top: 12px;
left: 16px;
z-index: 98;
padding: 6px 10px !important;
}
/* Wide desktop — larger card grids */
@media (min-width: 1400px) {
.card-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
@@ -277,6 +285,8 @@
left: -300px;
}
.mobile-menu-btn { display: flex !important; }
/* Offset header content so it does not overlap the fixed mobile menu button. */
.page-header > :first-child { margin-left: 52px; }
}
@media (min-width: 769px) {
@@ -285,7 +295,14 @@
/* Mobile small screen */
@media (max-width: 480px) {
.page-header { flex-direction: column; gap: 8px; align-items: flex-start; padding: 12px 16px; }
.page-header {
gap: 8px;
padding: 12px 16px;
flex-wrap: wrap;
}
.page-header h2 {
line-height: 44px;
}
.page-body { padding: 12px; }
.stats-row { flex-wrap: wrap; }
.stat-card { min-width: 80px; flex: 1 1 40%; }
+321 -63
View File
@@ -1,13 +1,28 @@
<body x-data="app" :data-theme="theme">
<!-- API Key Auth Prompt -->
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '' }">
<!-- Auth Prompt (API Key or Username/Password) -->
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '', loginUser: '', loginPass: '' }">
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
<!-- Session login mode -->
<template x-if="$store.app.authMode === 'session'">
<div>
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">Sign In</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">Enter your dashboard credentials.</p>
<input type="text" x-model="loginUser" placeholder="Username" autocomplete="username" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.5rem">
<input type="password" x-model="loginPass" placeholder="Password" autocomplete="current-password" @keydown.enter="$store.app.sessionLogin(loginUser, loginPass)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
<button @click="$store.app.sessionLogin(loginUser, loginPass)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Sign In</button>
</div>
</template>
<!-- API key mode -->
<template x-if="$store.app.authMode === 'apikey'">
<div>
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 0.5rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
<p style="color:var(--text-dim,#666);font-size:0.75rem;margin:0 0 1rem">Add <code style="color:var(--accent-light,#a78bfa);background:var(--bg,#111);padding:1px 4px;border-radius:2px">api_key = "your-key"</code> at the <strong>top</strong> of <code>~/.openfang/config.toml</code> (not under any [section]).</p>
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
</div>
</template>
</div>
</div>
@@ -96,6 +111,7 @@
<a class="nav-item" :class="{ active: page === 'approvals' }" @click="navigate('approvals')" :aria-current="page === 'approvals' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></span>
<span class="nav-label">Approvals</span>
<span class="badge badge-warn" x-show="$store.app.pendingApprovalCount > 0" x-text="$store.app.pendingApprovalCount"></span>
</a>
<a class="nav-item" :class="{ active: page === 'comms' }" @click="navigate('comms')" :aria-current="page === 'comms' ? 'page' : false">
<span class="nav-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/></svg></span>
@@ -171,6 +187,10 @@
</div>
<div class="sidebar-footer">
<div x-show="$store.app.sessionUser" style="padding:4px 16px;display:flex;align-items:center;justify-content:space-between">
<span class="text-xs text-dim" x-text="$store.app.sessionUser" style="letter-spacing:0.5px"></span>
<button @click="$store.app.sessionLogout()" class="btn btn-ghost btn-sm" style="font-size:11px;padding:2px 8px;opacity:0.7" title="Sign out">Logout</button>
</div>
<div class="sidebar-label text-xs text-dim" style="padding:0 16px 4px;letter-spacing:0.5px">Ctrl+K agents | Ctrl+N new</div>
</div>
<div class="sidebar-toggle" @click="toggleSidebar()" x-text="sidebarCollapsed ? '\u276F' : '\u276E'"></div>
@@ -181,7 +201,7 @@
<!-- Main Content -->
<main class="main-content">
<!-- Mobile menu button -->
<button class="mobile-menu-btn btn btn-ghost" @click="mobileMenuOpen = !mobileMenuOpen" style="position:fixed;top:8px;left:8px;z-index:98;padding:6px 10px">
<button class="mobile-menu-btn btn btn-ghost" @click="mobileMenuOpen = !mobileMenuOpen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
@@ -736,7 +756,7 @@
<span class="text-xs" style="color:var(--danger)" x-text="formatRecordingTime()"></span>
</div>
<textarea id="msg-input" rows="1" :placeholder="recording ? 'Recording... release to send' : 'Message OpenFang... (/ for commands)'"
@keydown.enter.prevent="if(!$event.isComposing && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.enter.prevent="if(!$event.isComposing && $event.keyCode !== 229 && !$event.shiftKey){if(showModelPicker && filteredModelPicker.length){pickModel(filteredModelPicker[modelPickerIdx].id)}else if(showSlashMenu && filteredSlashCommands.length){executeSlashCommand(filteredSlashCommands[slashIdx].cmd)}else{sendMessage()}}"
@keydown.escape="showSlashMenu = false; showModelPicker = false"
@keydown.arrow-up.prevent="if(showModelPicker){modelPickerIdx = Math.max(0, modelPickerIdx - 1)}else if(showSlashMenu){slashIdx = Math.max(0, slashIdx - 1)}"
@keydown.arrow-down.prevent="if(showModelPicker){modelPickerIdx = Math.min(filteredModelPicker.length - 1, modelPickerIdx + 1)}else if(showSlashMenu){slashIdx = Math.min(filteredSlashCommands.length - 1, slashIdx + 1)}"
@@ -769,7 +789,7 @@
<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])">
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="!$event.isComposing && $event.keyCode !== 229 && filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
<select x-model="modelSwitcherProviderFilter" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;padding:2px 6px;cursor:pointer;font-family:var(--font-mono);flex-shrink:0">
<option value="">All</option>
<template x-for="pn in switcherProviders" :key="pn">
@@ -792,7 +812,7 @@
<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-item-name" x-text="m.provider + ':' + (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">
@@ -861,7 +881,7 @@
<div class="text-sm font-bold mb-2" style="color:var(--text-dim);letter-spacing:0.5px;font-size:11px;text-transform:uppercase" x-text="agents.length ? 'Or Start a New Agent' : 'Start Chatting'"></div>
<div class="card-grid">
<template x-for="t in builtinTemplates" :key="t.name">
<div class="card" style="cursor:pointer" @click="spawnBuiltin(t)">
<div class="card" style="cursor:pointer" @click="t.manifest_toml ? spawnFromTemplate(t) : spawnBuiltin(t)">
<div class="flex justify-between items-center mb-1">
<div class="card-header" style="margin:0;font-size:14px;font-weight:600" x-text="t.name"></div>
<span class="badge badge-dim" x-text="t.category"></span>
@@ -878,7 +898,7 @@
<!-- Agent detail modal with tabs (Info / Files / Config) -->
<template x-if="showDetailModal && detailAgent">
<div class="modal-overlay" @click.self="showDetailModal = false" @keydown.escape.window="showDetailModal = false">
<div class="modal" style="max-width:600px">
<div class="modal" style="max-width:700px">
<div class="modal-header">
<h3>
<span x-show="detailAgent.identity && detailAgent.identity.emoji" x-text="detailAgent.identity && detailAgent.identity.emoji" style="margin-right:6px"></span>
@@ -903,7 +923,23 @@
</select>
</div>
<div class="detail-row" x-show="detailAgent.profile"><span class="detail-label">Profile</span><span class="detail-value" style="text-transform:capitalize" x-text="detailAgent.profile || '-'"></span></div>
<div class="detail-row"><span class="detail-label">Provider</span><span class="detail-value" x-text="detailAgent.model_provider"></span></div>
<div class="detail-row"><span class="detail-label">Provider</span>
<template x-if="!editingProvider">
<span>
<span class="detail-value" x-text="detailAgent.model_provider"></span>
<button class="btn btn-ghost btn-sm" style="margin-left:8px;padding:2px 8px;font-size:11px" @click="editingProvider = true; newProviderValue = detailAgent.model_provider">Change</button>
</span>
</template>
<template x-if="editingProvider">
<span class="flex gap-1" style="align-items:center">
<input class="form-input" style="width:160px;font-size:12px" x-model="newProviderValue" placeholder="provider" @keydown.enter="changeProvider()" @keydown.escape="editingProvider = false">
<button class="btn btn-primary btn-sm" @click="changeProvider()" :disabled="modelSaving" style="padding:2px 10px">
<span x-show="!modelSaving">Save</span><span x-show="modelSaving">...</span>
</button>
<button class="btn btn-ghost btn-sm" @click="editingProvider = false" style="padding:2px 8px">Cancel</button>
</span>
</template>
</div>
<div class="detail-row"><span class="detail-label">Model</span>
<template x-if="!editingModel">
<span>
@@ -926,13 +962,13 @@
<!-- Fallback Model Chain -->
<div class="detail-row" style="align-items:flex-start">
<span class="detail-label">Fallbacks</span>
<div style="flex:1">
<div style="flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;margin-left:16px">
<template x-if="detailAgent._fallbacks && detailAgent._fallbacks.length > 0">
<div>
<div style="display:flex;flex-direction:column;gap:4px">
<template x-for="(fb, idx) in detailAgent._fallbacks" :key="idx">
<div class="flex gap-1 items-center" style="margin-bottom:4px">
<span class="badge" style="font-size:11px;font-family:var(--font-mono)" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger)" @click="removeFallback(idx)">&times;</button>
<div class="flex gap-1 items-center" style="min-width:0">
<span class="badge" style="font-size:11px;font-family:var(--font-mono);word-break:break-all;white-space:normal" x-text="(idx+1) + '. ' + fb.provider + '/' + fb.model"></span>
<button class="btn btn-ghost btn-sm" style="padding:1px 4px;font-size:10px;color:var(--danger);flex-shrink:0" @click="removeFallback(idx)">&times;</button>
</div>
</template>
</div>
@@ -941,10 +977,12 @@
<span class="text-dim" style="font-size:12px">None — add a fallback chain</span>
</template>
<template x-if="!editingFallback">
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px;margin-top:4px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
<div>
<button class="btn btn-ghost btn-sm" style="padding:2px 8px;font-size:11px" @click="editingFallback = true; newFallbackValue = ''">+ Add</button>
</div>
</template>
<template x-if="editingFallback">
<div class="flex gap-1 mt-1" style="align-items:center">
<div class="flex gap-1" style="align-items:center">
<input class="form-input" style="width:220px;font-size:12px" x-model="newFallbackValue" placeholder="provider/model" @keydown.enter="addFallback()" @keydown.escape="editingFallback = false">
<button class="btn btn-primary btn-sm" @click="addFallback()" style="padding:2px 10px;font-size:11px">Add</button>
<button class="btn btn-ghost btn-sm" @click="editingFallback = false" style="padding:2px 8px;font-size:11px">Cancel</button>
@@ -1091,7 +1129,7 @@
<div x-show="spawnStep === 1">
<div class="form-group">
<label>Agent Name</label>
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="nextStep()">
<input class="form-input" x-model="spawnForm.name" placeholder="my-agent" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) nextStep()">
</div>
<div class="form-group">
<label>Emoji</label>
@@ -1119,12 +1157,26 @@
<div class="form-group">
<label>Provider</label>
<select class="form-select" x-model="spawnForm.provider">
<option value="anthropic">Anthropic</option><option value="openai">OpenAI</option>
<option value="groq">Groq</option><option value="ollama">Ollama</option>
<option value="google">Google</option><option value="mistral">Mistral</option>
<option value="xai">xAI</option><option value="deepseek">DeepSeek</option>
<option value="cerebras">Cerebras</option><option value="sambanova">SambaNova</option>
<option value="together">Together</option>
<template x-if="spawnProvidersLoading">
<option disabled>Loading providers…</option>
</template>
<template x-if="!spawnProvidersLoading && spawnProviders.length === 0">
<option disabled>No providers available</option>
</template>
<template x-if="!spawnProvidersLoading && spawnProviders.filter(p => !p.is_local).length > 0">
<optgroup label="Cloud">
<template x-for="p in spawnProviders.filter(p => !p.is_local)" :key="p.id">
<option :value="p.id" x-text="p.display_name"></option>
</template>
</optgroup>
</template>
<template x-if="!spawnProvidersLoading && spawnProviders.filter(p => p.is_local).length > 0">
<optgroup label="Local">
<template x-for="p in spawnProviders.filter(p => p.is_local)" :key="p.id">
<option :value="p.id" x-text="p.display_name"></option>
</template>
</optgroup>
</template>
</select>
</div>
<div class="form-group">
@@ -1225,7 +1277,7 @@
<!-- Page: Approvals -->
<template x-if="page === 'approvals'">
<div x-data="approvalsPage" x-init="loadData()">
<div x-data="approvalsPage()" x-init="init()">
<div class="page-header">
<h2>Execution Approvals</h2>
<div class="flex items-center gap-2">
@@ -1247,6 +1299,7 @@
<button class="filter-pill" :class="{ active: filterStatus === 'pending' }" @click="filterStatus = 'pending'">Pending</button>
<button class="filter-pill" :class="{ active: filterStatus === 'approved' }" @click="filterStatus = 'approved'">Approved</button>
<button class="filter-pill" :class="{ active: filterStatus === 'rejected' }" @click="filterStatus = 'rejected'">Rejected</button>
<button class="filter-pill" :class="{ active: filterStatus === 'expired' }" @click="filterStatus = 'expired'">Expired</button>
</div>
<div x-show="filtered.length === 0" class="empty-state">
<h4>No approvals</h4>
@@ -1289,7 +1342,7 @@
<!-- Tab: List -->
<template x-if="wfTab === 'list'">
<div x-data="workflowsPage">
<div x-data="workflowsPage" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden">
<div class="page-body" x-init="loadWorkflows()">
<div x-show="loading" class="loading-state"><div class="spinner"></div><span>Loading workflows...</span></div>
<div x-show="!loading && loadError" class="error-state">
@@ -1320,7 +1373,9 @@
<td class="text-xs" x-text="new Date(wf.created_at).toLocaleDateString()"></td>
<td>
<button class="btn btn-primary btn-sm" @click="showRunModal(wf)">Run</button>
<button class="btn btn-ghost btn-sm" @click="showEditModal(wf)">Edit</button>
<button class="btn btn-ghost btn-sm" @click="viewRuns(wf)">History</button>
<button class="btn btn-danger btn-sm" @click="deleteWorkflow(wf)">Delete</button>
</td>
</tr>
</template>
@@ -1385,6 +1440,39 @@
</div>
</div>
</template>
<!-- Edit modal -->
<template x-if="editModal">
<div class="modal-overlay" @click.self="editModal = null" @keydown.escape.window="editModal = null">
<div class="modal">
<div class="modal-header"><h3 x-text="'Edit: ' + editModal.name"></h3><button class="modal-close" @click="editModal = null">&times;</button></div>
<div class="form-group"><label>Name</label><input class="form-input" x-model="editWf.name" placeholder="Workflow name"></div>
<div class="form-group"><label>Description</label><input class="form-input" x-model="editWf.description" placeholder="What does this workflow do?"></div>
<div class="mb-4">
<div class="form-group" style="margin:0"><label>Steps</label></div>
<div class="text-xs text-dim mb-2">Each step runs an agent. Use <code style="color:var(--accent)">{{input}}</code> in prompts to pass the previous step's output.</div>
<template x-for="(step, i) in editWf.steps" :key="i">
<div class="card mt-2" style="padding:10px">
<div class="flex gap-2 items-center">
<span class="text-xs text-dim font-bold" x-text="'#' + (i+1)" style="width:24px"></span>
<input class="form-input" style="flex:1" x-model="step.name" placeholder="Step name">
<input class="form-input" style="flex:1" x-model="step.agent_name" placeholder="Agent name">
<select class="form-select" style="width:120px" x-model="step.mode">
<option value="sequential">Sequential</option>
<option value="fan_out">Fan Out</option>
<option value="conditional">Conditional</option>
<option value="loop">Loop</option>
</select>
<button class="btn btn-danger btn-sm" @click="editWf.steps.splice(i,1)">&times;</button>
</div>
<input class="form-input mt-2" x-model="step.prompt" placeholder="Prompt template (use {{input}})">
</div>
</template>
<button class="btn btn-ghost btn-sm mt-2" @click="editWf.steps.push({name:'',agent_name:'',mode:'sequential',prompt:'{{input}}'})">+ Add Step</button>
</div>
<button class="btn btn-primary btn-block" @click="saveWorkflow()">Save Changes</button>
</div>
</div>
</template>
</div>
</div>
</template>
@@ -2172,7 +2260,7 @@
<!-- Search bar with live search and clear button -->
<div class="search-input mb-4" style="position:relative">
<span style="color:var(--text-muted)"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg></span>
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
<input placeholder="Search ClawHub skills... (type to search)" x-model="clawhubSearch" @input="onSearchInput()" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) searchClawHub()" @keydown.escape="clearSearch()" x-ref="clawhubSearchInput">
<button x-show="clawhubSearch" @click="clearSearch()" class="search-clear-btn" title="Clear search (Esc)">&times;</button>
</div>
@@ -2223,7 +2311,7 @@
<div class="flex gap-3 items-center">
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
</div>
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || isSkillInstalled(skill.slug)" x-text="isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || skill.installed || isSkillInstalled(skill.slug)" x-text="skill.installed || isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
</div>
</div>
</template>
@@ -2246,7 +2334,7 @@
<span class="text-xs text-dim" x-show="skill.stars" x-text="skill.stars + ' stars'"></span>
<span class="text-xs text-dim" x-show="skill.version" x-text="'v' + skill.version"></span>
</div>
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || isSkillInstalled(skill.slug)" x-text="isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
<button class="btn btn-primary btn-sm" @click.stop="installFromClawHub(skill.slug)" :disabled="installingSlug === skill.slug || skill.installed || isSkillInstalled(skill.slug)" x-text="skill.installed || isSkillInstalled(skill.slug) ? 'Installed' : installingSlug === skill.slug ? 'Installing...' : 'Install'"></button>
</div>
</div>
</template>
@@ -2530,6 +2618,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Actions -->
<div class="flex gap-2 mt-3">
<button class="btn btn-ghost btn-sm" @click="loadStats(inst)">Stats</button>
<template x-if="isTraderHand(inst)">
<button class="btn btn-primary btn-sm" @click="openDashboard(inst)">Dashboard</button>
</template>
<template x-if="isBrowserHand(inst)">
<button class="btn btn-ghost btn-sm" @click="openBrowserViewer(inst)">View Browser</button>
</template>
@@ -2642,9 +2733,9 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- ═══ Step 1: Dependencies ═══ -->
<div class="hand-wizard-body" x-show="setupStep === 1">
<template x-for="req in (setupWizard.requirements || [])" :key="req.key">
<div class="dep-card" :class="req.satisfied ? 'dep-met' : 'dep-missing'">
<div class="dep-card" :class="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'dep-met' : 'dep-missing'">
<div class="dep-card-header">
<div class="dep-status-icon" :class="[req.satisfied ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="req.satisfied ? '\u2713' : '\u2717'"></div>
<div class="dep-status-icon" :class="[(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? 'met' : 'missing', setupChecking ? 'checking' : '']" x-text="(req.satisfied || (req.type === 'ApiKey' && apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== '')) ? '\u2713' : '\u2717'"></div>
<span class="dep-card-title" x-text="req.label"></span>
<template x-if="req.install && req.install.estimated_time">
<span class="dep-time-badge" x-text="req.install.estimated_time"></span>
@@ -2687,24 +2778,32 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</ol>
</template>
<!-- API Key: numbered steps + signup link -->
<!-- API Key: input field + numbered steps + signup link -->
<template x-if="req.type === 'ApiKey' && req.install">
<div>
<template x-if="req.install.steps && req.install.steps.length">
<ol class="api-key-steps">
<template x-for="step in req.install.steps" :key="step">
<li x-text="step"></li>
</template>
</ol>
</template>
<template x-if="req.install.env_example">
<div class="install-block" style="margin-top:8px">
<div class="install-cmd">
<code x-text="req.install.env_example"></code>
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
<div style="margin-bottom:10px">
<label class="text-xs text-dim" style="display:block;margin-bottom:4px" x-text="'Paste your ' + req.label + ':'"></label>
<input type="password" class="form-input" x-model="apiKeyInputs[req.key]" :placeholder="req.label" style="width:100%;font-family:var(--font-mono);font-size:12px">
<div class="text-xs" style="margin-top:4px;color:var(--green)" x-show="apiKeyInputs[req.key] && apiKeyInputs[req.key].trim() !== ''">&check; Token entered</div>
</div>
<details style="margin-bottom:8px">
<summary class="text-xs text-dim" style="cursor:pointer;user-select:none">Or set as environment variable</summary>
<template x-if="req.install.steps && req.install.steps.length">
<ol class="api-key-steps">
<template x-for="step in req.install.steps" :key="step">
<li x-text="step"></li>
</template>
</ol>
</template>
<template x-if="req.install.env_example">
<div class="install-block" style="margin-top:8px">
<div class="install-cmd">
<code x-text="req.install.env_example"></code>
<button class="copy-btn" :class="{ copied: clipboardMsg === req.install.env_example }" @click="copyToClipboard(req.install.env_example)" x-text="clipboardMsg === req.install.env_example ? 'Copied!' : 'Copy'"></button>
</div>
</div>
</div>
</template>
</template>
</details>
<div class="flex gap-2 mt-2">
<template x-if="req.install.signup_url">
<a :href="req.install.signup_url" target="_blank" rel="noopener" class="btn btn-primary btn-sm">Get API Key &rarr;</a>
@@ -2937,6 +3036,152 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</template>
<!-- Trader Dashboard Modal -->
<template x-if="dashboardOpen">
<div class="modal-overlay" @click.self="closeDashboard()" @keydown.escape.window="closeDashboard()">
<div class="trader-dashboard">
<!-- Header -->
<div class="trader-dashboard-header">
<div class="flex items-center gap-2">
<span style="font-size:1.4rem">&#x1F4C8;</span>
<div>
<div style="font-weight:600;font-size:1.1rem" x-text="dashboardData ? (dashboardData.agent_name || 'Trading Hand') : 'Trading Hand'"></div>
<div class="text-xs text-dim">Live Trading Dashboard</div>
</div>
</div>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm" @click="refreshDashboard()">Refresh</button>
<button class="modal-close" @click="closeDashboard()">&times;</button>
</div>
</div>
<!-- Loading -->
<div x-show="dashboardLoading" class="text-center" style="padding:60px 0">
<div class="spinner"></div>
<div class="text-dim mt-2">Loading dashboard data...</div>
</div>
<!-- Dashboard Content -->
<div class="trader-dashboard-body" x-show="!dashboardLoading && dashboardData">
<!-- KPI Row -->
<div class="trader-kpi-row">
<div class="trader-kpi-card">
<div class="trader-kpi-label">Portfolio Value</div>
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.portfolio_value || '-') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Total P&amp;L</div>
<div class="trader-kpi-value" :class="dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('+') ? 'kpi-positive' : (dashboardData && dashboardData.total_pnl && dashboardData.total_pnl.startsWith('-') ? 'kpi-negative' : '')" x-text="dashboardData ? (dashboardData.total_pnl || '-') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Win Rate</div>
<div class="trader-kpi-value" x-text="dashboardData && dashboardData.win_rate ? (dashboardData.win_rate + '%') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Sharpe Ratio</div>
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.sharpe_ratio || '-') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Max Drawdown</div>
<div class="trader-kpi-value kpi-negative" x-text="dashboardData && dashboardData.max_drawdown ? (dashboardData.max_drawdown + '%') : '-'"></div>
</div>
<div class="trader-kpi-card">
<div class="trader-kpi-label">Trades</div>
<div class="trader-kpi-value" x-text="dashboardData ? (dashboardData.trades_count || '0') : '0'"></div>
</div>
</div>
<!-- Charts Row 1: Equity Curve + Daily P&L -->
<div class="trader-chart-row">
<div class="trader-chart-panel" style="flex:2">
<div class="trader-chart-title">Equity Curve</div>
<div class="trader-chart-wrap">
<canvas id="traderEquityChart"></canvas>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.equity_curve || !dashboardData.equity_curve.length">No equity data yet</div>
</div>
</div>
<div class="trader-chart-panel" style="flex:1">
<div class="trader-chart-title">Daily P&amp;L</div>
<div class="trader-chart-wrap">
<canvas id="traderPnlChart"></canvas>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.daily_pnl || !dashboardData.daily_pnl.length">No P&amp;L data yet</div>
</div>
</div>
</div>
<!-- Charts Row 2: Signal Radar + Watchlist Heatmap -->
<div class="trader-chart-row">
<div class="trader-chart-panel" style="flex:1;max-width:320px">
<div class="trader-chart-title">Signal Radar</div>
<div class="trader-chart-wrap" style="max-height:280px">
<canvas id="traderRadarChart"></canvas>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.signal_radar">No signal data yet</div>
</div>
</div>
<div class="trader-chart-panel" style="flex:2">
<div class="trader-chart-title">Watchlist Heatmap</div>
<div class="trader-heatmap-wrap" x-show="dashboardData && dashboardData.watchlist_heatmap && dashboardData.watchlist_heatmap.length">
<table class="trader-heatmap-table">
<thead>
<tr><th>Ticker</th><th>Change</th><th>Signal</th><th>Confidence</th></tr>
</thead>
<tbody>
<template x-for="item in (dashboardData ? dashboardData.watchlist_heatmap || [] : [])" :key="item.ticker">
<tr>
<td style="font-weight:600" x-text="item.ticker"></td>
<td :class="item.change_pct >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(item.change_pct >= 0 ? '+' : '') + item.change_pct + '%'"></td>
<td><span class="signal-badge" :class="'signal-' + (item.signal || 'hold').toLowerCase()" x-text="item.signal || 'HOLD'"></span></td>
<td>
<div class="confidence-bar-wrap">
<div class="confidence-bar" :style="'width:' + (item.confidence || 0) + '%'" :class="item.confidence >= 70 ? 'conf-high' : (item.confidence >= 40 ? 'conf-mid' : 'conf-low')"></div>
<span class="confidence-label" x-text="(item.confidence || 0) + '%'"></span>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.watchlist_heatmap || !dashboardData.watchlist_heatmap.length">No watchlist data yet</div>
</div>
</div>
<!-- Recent Trades Table -->
<div class="trader-chart-panel">
<div class="trader-chart-title">Recent Trades</div>
<div x-show="dashboardData && dashboardData.recent_trades && dashboardData.recent_trades.length">
<table class="trader-trades-table">
<thead>
<tr><th>Date</th><th>Ticker</th><th>Side</th><th>Price</th><th>Qty</th><th>P&amp;L</th></tr>
</thead>
<tbody>
<template x-for="trade in (dashboardData ? dashboardData.recent_trades || [] : [])" :key="trade.date + trade.ticker">
<tr>
<td class="text-dim" x-text="trade.date"></td>
<td style="font-weight:600" x-text="trade.ticker"></td>
<td><span class="trade-side-badge" :class="trade.side === 'BUY' ? 'trade-buy' : 'trade-sell'" x-text="trade.side"></span></td>
<td x-text="'$' + Number(trade.price || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
<td x-text="trade.qty"></td>
<td :class="(trade.pnl || 0) >= 0 ? 'heatmap-positive' : 'heatmap-negative'" x-text="(trade.pnl >= 0 ? '+$' : '-$') + Math.abs(trade.pnl || 0).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2})"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="trader-chart-empty" x-show="!dashboardData || !dashboardData.recent_trades || !dashboardData.recent_trades.length">No trades yet</div>
</div>
</div>
<!-- No data state -->
<div x-show="!dashboardLoading && !dashboardData" class="text-center" style="padding:60px 0">
<div style="font-size:2rem;margin-bottom:8px">&#x1F4C8;</div>
<div class="text-dim">Could not load dashboard data.</div>
<button class="btn btn-ghost btn-sm mt-3" @click="refreshDashboard()">Retry</button>
</div>
</div>
</div>
</template>
<!-- Activation result toast -->
<div x-show="activateResult" x-transition class="info-card" style="position:fixed;bottom:24px;right:24px;z-index:200;max-width:360px" @click="activateResult = null">
<div class="flex items-center gap-2">
@@ -3123,7 +3368,7 @@ 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 x-show="!filteredModels.length && !loading" style="text-align:center;padding:32px 16px">
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">&#x1F916;</div>
<h3 style="margin:0 0 4px;font-size:14px" x-text="models.length ? 'No models match your search' : 'No models available'"></h3>
<p class="text-xs text-dim" x-text="models.length ? 'Try a different search term or clear filters.' : 'Configure an LLM provider to see available models.'"></p>
@@ -3157,7 +3402,7 @@ 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 x-show="!filteredTools.length && !loading" style="text-align:center;padding:32px 16px">
<div style="font-size:32px;margin-bottom:8px;opacity:0.5">&#x1F527;</div>
<h3 style="margin:0 0 4px;font-size:14px" x-text="tools.length ? 'No tools match your search' : 'No tools available'"></h3>
<p class="text-xs text-dim" x-text="tools.length ? 'Try a different search term.' : 'Tools will appear once agents are configured.'"></p>
@@ -3206,7 +3451,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div class="card mb-4">
<div class="card-header" style="text-transform:capitalize" x-text="section.replace(/_/g, ' ')"></div>
<div class="detail-grid" style="margin-top:12px">
<template x-for="field in fields" :key="section + '.' + field.name">
<template x-for="field in (fields.fields || [])" :key="section + '.' + field.name">
<div class="detail-row" style="align-items:center">
<span class="detail-label" x-text="field.label || field.name"></span>
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
@@ -3226,8 +3471,8 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<select class="form-select" style="width:180px"
:value="configValues[section] && configValues[section][field.name]"
@change="configValues[section] = configValues[section] || {}; configValues[section][field.name] = $event.target.value; markConfigDirty(section, field.name)">
<template x-for="opt in field.options" :key="opt">
<option :value="opt" x-text="opt" :selected="configValues[section] && configValues[section][field.name] === opt"></option>
<template x-for="opt in field.options" :key="typeof opt === 'object' ? opt.id : opt">
<option :value="typeof opt === 'object' ? opt.id : opt" x-text="typeof opt === 'object' ? (opt.name || opt.display_name || opt.id) : opt" :selected="configValues[section] && configValues[section][field.name] === (typeof opt === 'object' ? opt.id : opt)"></option>
</template>
</select>
</template>
@@ -3458,7 +3703,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div x-show="tab === 'budget'" x-data="{
budgetData: null, agentRanking: [], budgetLoading: true,
editMode: false,
editHourly: '', editDaily: '', editMonthly: '', editAlert: '',
editHourly: '', editDaily: '', editMonthly: '', editAlert: '', editTokenLimit: '',
saving: false,
async loadBudget() {
this.budgetLoading = true;
@@ -3477,6 +3722,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
this.editDaily = this.budgetData.daily_limit || 0;
this.editMonthly = this.budgetData.monthly_limit || 0;
this.editAlert = ((this.budgetData.alert_threshold || 0.8) * 100).toFixed(0);
this.editTokenLimit = this.budgetData.default_max_llm_tokens_per_hour || 0;
this.editMode = true;
},
async saveBudget() {
@@ -3488,12 +3734,14 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
if (+this.editMonthly !== this.budgetData.monthly_limit) body.max_monthly_usd = +this.editMonthly;
let alertVal = (+this.editAlert) / 100;
if (Math.abs(alertVal - this.budgetData.alert_threshold) > 0.001) body.alert_threshold = alertVal;
if (+this.editTokenLimit !== (this.budgetData.default_max_llm_tokens_per_hour || 0)) body.default_max_llm_tokens_per_hour = +this.editTokenLimit;
await OpenFangAPI.put('/api/budget', body);
this.editMode = false;
await this.loadBudget();
} catch(e) { OpenFangToast.error('Failed to save: ' + (e.message || e)); }
this.saving = false;
},
fmtTokens(v) { return v > 0 ? (v >= 1000000 ? (v/1000000).toFixed(1)+'M' : v >= 1000 ? (v/1000).toFixed(0)+'K' : v) : 'per-agent'; },
pctColor(pct) { return pct >= 0.8 ? '#ef4444' : pct >= 0.5 ? '#eab308' : '#22c55e'; },
fmtUsd(v) { return v > 0 ? '$' + v.toFixed(4) : 'unlimited'; }
}" x-init="loadBudget()">
@@ -3533,9 +3781,12 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
</div>
</div>
</div>
<div class="text-xs text-dim mb-3" x-show="budgetData.alert_threshold > 0 && !editMode">
<div class="text-xs text-dim mb-1" x-show="budgetData.alert_threshold > 0 && !editMode">
Alert threshold: <span x-text="(budgetData.alert_threshold * 100).toFixed(0) + '%'"></span> of any limit
</div>
<div class="text-xs text-dim mb-3" x-show="!editMode">
Hourly token limit (per agent): <span x-text="fmtTokens(budgetData.default_max_llm_tokens_per_hour || 0)"></span>
</div>
<!-- Edit limits form -->
<div x-show="editMode" class="card" style="margin:12px 0;padding:12px;border:1px solid var(--accent);border-radius:6px">
@@ -3557,7 +3808,11 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<input type="number" step="5" min="0" max="100" x-model="editAlert" class="input" style="width:100%;margin-top:2px" placeholder="80">
</div>
</div>
<div class="text-xs text-dim">Set to 0 for unlimited. Changes apply immediately (in-memory, not persisted to config.toml).</div>
<div style="margin-bottom:8px">
<label class="text-xs text-dim">Hourly Token Limit (per agent, 0 = use per-agent values)</label>
<input type="number" step="100000" min="0" x-model="editTokenLimit" class="input" style="width:260px;margin-top:2px" placeholder="0 = per-agent default">
</div>
<div class="text-xs text-dim">Set to 0 for unlimited/per-agent default. Changes apply immediately (in-memory, not persisted to config.toml).</div>
<button class="btn btn-sm mt-2" @click="editMode = false" style="margin-right:8px">Cancel</button>
</div>
@@ -3565,7 +3820,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<h4 style="margin-top:16px;margin-bottom:8px">Top Spenders (Today)</h4>
<div class="table-wrap" x-show="agentRanking.length">
<table>
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th></tr></thead>
<thead><tr><th>Agent</th><th>Today</th><th>Hourly Limit</th><th>Daily Limit</th><th>Monthly Limit</th><th>Token Limit/hr</th></tr></thead>
<tbody>
<template x-for="a in agentRanking" :key="a.agent_id">
<tr>
@@ -3574,6 +3829,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<td class="text-dim" x-text="fmtUsd(a.hourly_limit)"></td>
<td class="text-dim" x-text="fmtUsd(a.daily_limit)"></td>
<td class="text-dim" x-text="fmtUsd(a.monthly_limit)"></td>
<td class="text-dim" x-text="fmtTokens(a.max_llm_tokens_per_hour || 0)"></td>
</tr>
</template>
</tbody>
@@ -4058,7 +4314,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div x-data="logsPage">
<div class="page-header">
<h2>Logs</h2>
<div class="flex gap-2 items-center" x-show="tab === 'live'">
<div class="flex gap-2 items-center flex-wrap" x-show="tab === 'live'">
<!-- Connection status indicator -->
<span class="live-indicator" :class="connectionClass">
<span class="live-dot"></span>
@@ -4565,7 +4821,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<div class="card" style="border-left:3px solid var(--accent)">
<div class="form-group" style="margin-bottom:8px">
<label>Agent Name</label>
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="createAgent()">
<input class="form-input" type="text" x-model="agentName" placeholder="my-assistant" style="max-width:320px" @keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) createAgent()">
</div>
<div class="text-xs text-dim" x-text="'Will use ' + templates[selectedTemplate].provider + ' / ' + templates[selectedTemplate].model + ' with ' + profileInfo(templates[selectedTemplate].profile).label + ' profile'"></div>
<div class="mt-2">
@@ -4611,7 +4867,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Input -->
<div style="display:flex;gap:8px;margin-top:12px">
<input class="form-input" type="text" x-model="tryItInput" placeholder="Type a message..."
@keydown.enter="sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
@keydown.enter="if(!$event.isComposing && $event.keyCode !== 229) sendTryItMessage(tryItInput)" :disabled="tryItSending" style="flex:1">
<button class="btn btn-primary btn-sm" @click="sendTryItMessage(tryItInput)" :disabled="tryItSending || !tryItInput.trim()">Send</button>
</div>
</div>
@@ -4734,7 +4990,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<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="grid grid-cols-4 gap-4 mb-6">
<div class="card stat-card">
<div class="stat-label">Uptime</div>
<div class="stat-value" x-text="uptime"></div>
@@ -4797,3 +5053,5 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]</pre>
<!-- Toast notification container -->
<div id="toast-container" class="toast-container" aria-live="polite"></div>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
@@ -6,6 +6,8 @@
<title>OpenFang Dashboard</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#6366f1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
+23 -12
View File
@@ -224,9 +224,12 @@ var OpenFangAPI = (function() {
try {
var url = WS_BASE + '/api/agents/' + agentId + '/ws';
if (_authToken) url += '?token=' + encodeURIComponent(_authToken);
_ws = new WebSocket(url);
var socket = new WebSocket(url);
_ws = socket;
_ws.onopen = function() {
socket.onopen = function() {
// Guard: ignore if this socket was superseded by a newer connection
if (_ws !== socket) return;
_wsConnected = true;
_reconnectAttempts = 0;
setConnectionState('connected');
@@ -237,14 +240,20 @@ var OpenFangAPI = (function() {
if (_wsCallbacks.onOpen) _wsCallbacks.onOpen();
};
_ws.onmessage = function(e) {
socket.onmessage = function(e) {
try {
var data = JSON.parse(e.data);
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
} catch(err) { /* ignore parse errors */ }
} catch(parseErr) {
return; // Ignore malformed JSON frames
}
// Dispatch outside try/catch so handler errors are not swallowed
if (_wsCallbacks.onMessage) _wsCallbacks.onMessage(data);
};
_ws.onclose = function(e) {
socket.onclose = function(e) {
// Guard: only update state if this is still the active socket.
// A superseded socket closing must not null-out the new connection.
if (_ws !== socket) return;
_wsConnected = false;
_ws = null;
if (_wsAgentId && _reconnectAttempts < MAX_RECONNECT && e.code !== 1000) {
@@ -265,7 +274,9 @@ var OpenFangAPI = (function() {
if (_wsCallbacks.onClose) _wsCallbacks.onClose();
};
_ws.onerror = function() {
socket.onerror = function() {
// Guard: ignore errors from superseded sockets
if (_ws !== socket) return;
_wsConnected = false;
if (_wsCallbacks.onError) _wsCallbacks.onError();
};
@@ -297,15 +308,15 @@ var OpenFangAPI = (function() {
function getToken() { return _authToken; }
function upload(agentId, file) {
var hdrs = {
'Content-Type': file.type || 'application/octet-stream',
'X-Filename': file.name
};
var hdrs = {};
if (_authToken) hdrs['Authorization'] = 'Bearer ' + _authToken;
var form = new FormData();
form.append('file', file);
form.append('filename', file.name);
return fetch(BASE + '/api/agents/' + agentId + '/upload', {
method: 'POST',
headers: hdrs,
body: file
body: form
}).then(function(r) {
if (!r.ok) throw new Error('Upload failed');
return r.json();
+105 -6
View File
@@ -24,9 +24,43 @@ function escapeHtml(text) {
function renderMarkdown(text) {
if (!text) return '';
if (typeof marked !== 'undefined') {
var html = marked.parse(text);
// Protect LaTeX blocks from marked.js mangling (underscores, backslashes, etc.)
var latexBlocks = [];
var protected_ = text;
// Protect display math $$...$$ first (greedy across lines)
protected_ = protected_.replace(/\$\$([\s\S]+?)\$\$/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
// Protect inline math $...$ (single line, not empty, not starting/ending with space)
protected_ = protected_.replace(/\$([^\s$](?:[^$]*[^\s$])?)\$/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
// Protect \[...\] display math
protected_ = protected_.replace(/\\\[([\s\S]+?)\\\]/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
// Protect \(...\) inline math
protected_ = protected_.replace(/\\\(([\s\S]+?)\\\)/g, function(match) {
var idx = latexBlocks.length;
latexBlocks.push(match);
return '\x00LATEX' + idx + '\x00';
});
var html = marked.parse(protected_);
// Restore LaTeX blocks
for (var i = 0; i < latexBlocks.length; i++) {
html = html.replace('\x00LATEX' + i + '\x00', latexBlocks[i]);
}
// Add copy buttons to code blocks
html = html.replace(/<pre><code/g, '<pre><button class="copy-btn" onclick="copyCode(this)">Copy</button><code');
// Open external links in new tab
html = html.replace(/<a\s+href="(https?:\/\/[^"]*)"(?![^>]*target=)([^>]*)>/gi, '<a href="$1" target="_blank" rel="noopener"$2>');
return html;
}
return escapeHtml(text);
@@ -100,10 +134,14 @@ document.addEventListener('alpine:init', function() {
lastError: '',
version: '0.1.0',
agentCount: 0,
pendingApprovalCount: 0,
lastPendingApprovalSignature: '',
pendingAgent: null,
focusMode: localStorage.getItem('openfang-focus') === 'true',
showOnboarding: false,
showAuthPrompt: false,
authMode: 'apikey',
sessionUser: null,
toggleFocusMode() {
this.focusMode = !this.focusMode;
@@ -118,6 +156,23 @@ document.addEventListener('alpine:init', function() {
} catch(e) { /* silent */ }
},
async refreshApprovals() {
try {
var data = await OpenFangAPI.get('/api/approvals');
var approvals = Array.isArray(data) ? data : (data.approvals || []);
var pending = approvals.filter(function(a) { return a.status === 'pending'; });
var signature = pending
.map(function(a) { return a.id; })
.sort()
.join(',');
if (pending.length > 0 && signature !== this.lastPendingApprovalSignature && typeof OpenFangToast !== 'undefined') {
OpenFangToast.warn('An agent is waiting for approval. Open Approvals to review.');
}
this.pendingApprovalCount = pending.length;
this.lastPendingApprovalSignature = signature;
} catch(e) { /* silent */ }
},
async checkStatus() {
try {
var s = await OpenFangAPI.get('/api/status');
@@ -155,16 +210,33 @@ document.addEventListener('alpine:init', function() {
async checkAuth() {
try {
// Use a protected endpoint (not in the public allowlist) to detect
// whether the server requires an API key.
// First check if session-based auth is configured
var authInfo = await OpenFangAPI.get('/api/auth/check');
if (authInfo.mode === 'none') {
// No session auth — fall back to API key detection
this.authMode = 'apikey';
this.sessionUser = null;
} else if (authInfo.mode === 'session') {
this.authMode = 'session';
if (authInfo.authenticated) {
this.sessionUser = authInfo.username;
this.showAuthPrompt = false;
return;
}
// Session auth enabled but not authenticated — show login prompt
this.showAuthPrompt = true;
return;
}
} catch(e) { /* ignore — fall through to API key check */ }
// API key mode detection
try {
await OpenFangAPI.get('/api/tools');
this.showAuthPrompt = false;
} catch(e) {
if (e.message && (e.message.indexOf('Not authorized') >= 0 || e.message.indexOf('401') >= 0 || e.message.indexOf('Missing Authorization') >= 0 || e.message.indexOf('Unauthorized') >= 0)) {
// Only show prompt if we don't already have a saved key
var saved = localStorage.getItem('openfang-api-key');
if (saved) {
// Saved key might be stale — clear it and show prompt
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
}
@@ -181,6 +253,29 @@ document.addEventListener('alpine:init', function() {
this.refreshAgents();
},
async sessionLogin(username, password) {
try {
var result = await OpenFangAPI.post('/api/auth/login', { username: username, password: password });
if (result.status === 'ok') {
this.sessionUser = result.username;
this.showAuthPrompt = false;
this.refreshAgents();
} else {
OpenFangToast.error(result.error || 'Login failed');
}
} catch(e) {
OpenFangToast.error(e.message || 'Login failed');
}
},
async sessionLogout() {
try {
await OpenFangAPI.post('/api/auth/logout');
} catch(e) { /* ignore */ }
this.sessionUser = null;
this.showAuthPrompt = true;
},
clearApiKey() {
OpenFangAPI.setAuthToken('');
localStorage.removeItem('openfang-api-key');
@@ -274,9 +369,13 @@ function app() {
// Initial data load
this.pollStatus();
Alpine.store('app').refreshApprovals();
Alpine.store('app').checkOnboarding();
Alpine.store('app').checkAuth();
setInterval(function() { self.pollStatus(); }, 5000);
setInterval(function() {
self.pollStatus();
Alpine.store('app').refreshApprovals();
}, 5000);
},
navigate(p) {
+84
View File
@@ -0,0 +1,84 @@
// On-demand KaTeX loader and renderer for chat messages.
var KATEX_VERSION = '0.16.21';
var KATEX_CSS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.css';
var KATEX_JS_URL = 'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/katex.min.js';
var KATEX_AUTORENDER_URL =
'https://cdn.jsdelivr.net/npm/katex@' + KATEX_VERSION + '/dist/contrib/auto-render.min.js';
var katexLoadPromise = null;
function hasLatexDelimiters(text) {
if (!text) return false;
return /\$\$|\\\[|\\\(|\$(?=\S)[^$\n]+\$/.test(text);
}
function loadScript(url) {
return new Promise(function (resolve, reject) {
var script = document.createElement('script');
script.src = url;
script.async = true;
script.onload = function () {
resolve();
};
script.onerror = function () {
reject(new Error('Failed to load script: ' + url));
};
document.head.appendChild(script);
});
}
function ensureKatexLoaded() {
if (typeof renderMathInElement === 'function') return Promise.resolve(true);
if (katexLoadPromise) return katexLoadPromise;
katexLoadPromise = new Promise(function (resolve) {
var cssId = 'openfang-katex-css';
if (!document.getElementById(cssId)) {
var link = document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href = KATEX_CSS_URL;
document.head.appendChild(link);
}
loadScript(KATEX_JS_URL)
.then(function () {
return loadScript(KATEX_AUTORENDER_URL);
})
.then(function () {
resolve(typeof renderMathInElement === 'function');
})
.catch(function () {
katexLoadPromise = null;
resolve(false);
});
});
return katexLoadPromise;
}
// Render LaTeX math in the chat message container using KaTeX auto-render.
// Call this after new messages are inserted into the DOM.
function renderLatex(el) {
var target = el || document.getElementById('messages');
if (!target) return;
if (!hasLatexDelimiters(target.textContent || '')) return;
ensureKatexLoaded().then(function (ok) {
if (!ok || typeof renderMathInElement !== 'function') return;
try {
renderMathInElement(target, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
],
throwOnError: false,
trust: false,
});
} catch (e) {
/* KaTeX render error — ignore gracefully */
}
});
}
+146 -106
View File
@@ -1,6 +1,21 @@
// OpenFang Agents Page — Multi-step spawn wizard, detail view with tabs, file editor, personality presets
'use strict';
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n""").
* Backslashes are escaped, and runs of 3+ consecutive double-quotes are
* broken up so the TOML parser never sees an unintended closing delimiter.
*/
function tomlMultilineEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
}
/** Escape a string for use inside a TOML basic (single-line) string ("...").
* Backslashes, double-quotes, and common control chars are escaped.
*/
function tomlBasicEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function agentsPage() {
return {
tab: 'agents',
@@ -25,6 +40,8 @@ function agentsPage() {
},
// -- Multi-step wizard state --
spawnProviders: [], // populated from /api/providers on wizard open
spawnProvidersLoading: false,
spawnStep: 1,
spawnIdentity: { emoji: '', color: '#FF5C00', archetype: '' },
selectedPreset: '',
@@ -62,6 +79,8 @@ function agentsPage() {
// -- Model switch --
editingModel: false,
newModelValue: '',
editingProvider: false,
newProviderValue: '',
modelSaving: false,
// -- Fallback chain --
editingFallback: false,
@@ -75,98 +94,12 @@ function agentsPage() {
selectedCategory: 'All',
searchQuery: '',
builtinTemplates: [
{
name: 'General Assistant',
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.'
},
{
name: 'Code Helper',
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.'
},
{
name: 'Researcher',
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
category: 'Research',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'research',
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.'
},
{
name: 'Writer',
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
category: 'Writing',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.'
},
{
name: 'Data Analyst',
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.'
},
{
name: 'DevOps Engineer',
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'automation',
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.'
},
{
name: 'Customer Support',
description: 'A professional, empathetic agent for handling customer inquiries and resolving issues.',
category: 'Business',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'messaging',
system_prompt: 'You are a professional customer support representative. Be empathetic, patient, and solution-oriented. Acknowledge concerns before offering solutions. Escalate complex issues appropriately.'
},
{
name: 'Tutor',
description: 'A patient educational agent that explains concepts step-by-step and adapts to the learner\'s level.',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a patient and encouraging tutor. Explain concepts step by step, starting from fundamentals. Use analogies and examples. Check understanding before moving on. Adapt to the learner\'s pace.'
},
{
name: 'API Designer',
description: 'An agent specialized in RESTful API design, OpenAPI specs, and integration architecture.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are an API design expert. Help users design clean, consistent RESTful APIs following best practices. Cover endpoint naming, request/response schemas, error handling, and versioning.'
},
{
name: 'Meeting Notes',
description: 'Summarizes meeting transcripts into structured notes with action items and key decisions.',
category: 'Business',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'minimal',
system_prompt: 'You are a meeting summarizer. When given a meeting transcript or notes, produce a structured summary with: key decisions, action items (with owners), discussion highlights, and follow-up questions.'
}
],
builtinTemplates: [],
// Load templates from API
async init() {
await this.loadTemplates();
},
// ── Profile Descriptions ──
profileDescriptions: {
@@ -263,6 +196,7 @@ function agentsPage() {
this.loadError = '';
try {
await Alpine.store('app').refreshAgents();
await this.loadTemplates();
} catch(e) {
this.loadError = e.message || 'Could not load agents. Is the daemon running?';
}
@@ -300,10 +234,73 @@ function agentsPage() {
OpenFangAPI.get('/api/templates'),
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
]);
this.tplTemplates = results[0].templates || [];
// Combine static and dynamic templates
this.builtinTemplates = [
{
name: 'General Assistant',
description: 'A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations.',
category: 'General',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.',
manifest_toml: 'name = "General Assistant"\ndescription = "A versatile conversational agent that can help with everyday tasks, answer questions, and provide recommendations."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a helpful, friendly assistant. Provide clear, accurate, and concise responses. Ask clarifying questions when needed.\n"""'
},
{
name: 'Code Helper',
description: 'A programming-focused agent that writes, reviews, and debugs code across multiple languages.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.',
manifest_toml: 'name = "Code Helper"\ndescription = "A programming-focused agent that writes, reviews, and debugs code across multiple languages."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are an expert programmer. Help users write clean, efficient code. Explain your reasoning. Follow best practices and conventions for the language being used.\n"""'
},
{
name: 'Researcher',
description: 'An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries.',
category: 'Research',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'research',
system_prompt: 'You are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.',
manifest_toml: 'name = "Researcher"\ndescription = "An analytical agent that breaks down complex topics, synthesizes information, and provides cited summaries."\nmodule = "builtin:chat"\nprofile = "research"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a research analyst. Break down complex topics into clear explanations. Provide structured analysis with key findings. Cite sources when available.\n"""'
},
{
name: 'Writer',
description: 'A creative writing agent that helps with drafting, editing, and improving written content of all kinds.',
category: 'Writing',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'full',
system_prompt: 'You are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.',
manifest_toml: 'name = "Writer"\ndescription = "A creative writing agent that helps with drafting, editing, and improving written content of all kinds."\nmodule = "builtin:chat"\nprofile = "full"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a skilled writer and editor. Help users create polished content. Adapt your tone and style to match the intended audience. Offer constructive suggestions for improvement.\n"""'
},
{
name: 'Data Analyst',
description: 'A data-focused agent that helps analyze datasets, create queries, and interpret statistical results.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'coding',
system_prompt: 'You are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.',
manifest_toml: 'name = "Data Analyst"\ndescription = "A data-focused agent that helps analyze datasets, create queries, and interpret statistical results."\nmodule = "builtin:chat"\nprofile = "coding"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a data analysis expert. Help users understand their data, write SQL/Python queries, and interpret results. Present findings clearly with actionable insights.\n"""'
},
{
name: 'DevOps Engineer',
description: 'A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting.',
category: 'Development',
provider: 'groq',
model: 'llama-3.3-70b-versatile',
profile: 'automation',
system_prompt: 'You are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.',
manifest_toml: 'name = "DevOps Engineer"\ndescription = "A systems-focused agent for CI/CD, infrastructure, Docker, and deployment troubleshooting."\nmodule = "builtin:chat"\nprofile = "automation"\n\n[model]\nprovider = "groq"\nmodel = "llama-3.3-70b-versatile"\nsystem_prompt = """\nYou are a DevOps engineer. Help with CI/CD pipelines, Docker, Kubernetes, infrastructure as code, and deployment. Prioritize reliability and security.\n"""'
},
...results[0].templates || []
];
this.tplProviders = results[1].providers || [];
} catch(e) {
this.tplTemplates = [];
this.builtinTemplates = [];
this.tplLoadError = e.message || 'Could not load templates.';
}
this.tplLoading = false;
@@ -378,7 +375,7 @@ function agentsPage() {
},
// ── Multi-step wizard navigation ──
openSpawnWizard() {
async openSpawnWizard() {
this.showSpawnModal = true;
this.spawnStep = 1;
this.spawnMode = 'wizard';
@@ -386,8 +383,26 @@ function agentsPage() {
this.selectedPreset = '';
this.soulContent = '';
this.spawnForm.name = '';
this.spawnForm.provider = 'groq';
this.spawnForm.model = 'llama-3.3-70b-versatile';
this.spawnForm.systemPrompt = 'You are a helpful assistant.';
this.spawnForm.profile = 'full';
// Fetch status defaults and dynamic provider list concurrently
this.spawnProvidersLoading = true;
try {
var results = await Promise.all([
OpenFangAPI.get('/api/status').catch(function() { return {}; }),
OpenFangAPI.get('/api/providers').catch(function() { return { providers: [] }; })
]);
var status = results[0];
var provData = results[1];
if (status.default_provider) this.spawnForm.provider = status.default_provider;
if (status.default_model) this.spawnForm.model = status.default_model;
this.spawnProviders = provData.providers || [];
} catch(e) {
this.spawnProviders = [];
}
this.spawnProvidersLoading = false;
},
nextStep() {
@@ -411,7 +426,7 @@ function agentsPage() {
var f = this.spawnForm;
var si = this.spawnIdentity;
var lines = [
'name = "' + f.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"',
'name = "' + tomlBasicEscape(f.name) + '"',
'module = "builtin:chat"'
];
if (f.profile && f.profile !== 'custom') {
@@ -420,7 +435,7 @@ function agentsPage() {
lines.push('', '[model]');
lines.push('provider = "' + f.provider + '"');
lines.push('model = "' + f.model + '"');
lines.push('system_prompt = """\n' + f.systemPrompt.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"') + '\n"""');
lines.push('system_prompt = """\n' + tomlMultilineEscape(f.systemPrompt) + '\n"""');
if (f.profile === 'custom') {
lines.push('', '[capabilities]');
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
@@ -563,15 +578,20 @@ function agentsPage() {
},
// -- Template methods --
async spawnFromTemplate(name) {
async spawnFromTemplate(template) {
try {
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(name));
if (data.manifest_toml) {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: data.manifest_toml });
var manifestToml = template.manifest_toml;
if (!manifestToml) {
// If template doesn't have manifest_toml, fetch it from the API
var data = await OpenFangAPI.get('/api/templates/' + encodeURIComponent(template.name));
manifestToml = data.manifest_toml;
}
if (manifestToml) {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: manifestToml });
if (res.agent_id) {
OpenFangToast.success('Agent "' + (res.name || name) + '" spawned from template');
OpenFangToast.success('Agent "' + (res.name || template.name) + '" spawned from template');
await Alpine.store('app').refreshAgents();
this.chatWithAgent({ id: res.agent_id, name: res.name || name, model_provider: '?', model_name: '?' });
this.chatWithAgent({ id: res.agent_id, name: res.name || template.name, model_provider: '?', model_name: '?' });
}
}
} catch(e) {
@@ -613,6 +633,26 @@ function agentsPage() {
this.modelSaving = false;
},
// ── Provider switch ──
async changeProvider() {
if (!this.detailAgent || !this.newProviderValue.trim()) return;
this.modelSaving = true;
try {
var combined = this.newProviderValue.trim() + '/' + this.detailAgent.model_name;
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: combined });
OpenFangToast.success('Provider changed to ' + (resp && resp.provider ? resp.provider : this.newProviderValue.trim()));
this.editingProvider = false;
await Alpine.store('app').refreshAgents();
var agents = Alpine.store('app').agents;
for (var i = 0; i < agents.length; i++) {
if (agents[i].id === this.detailAgent.id) { this.detailAgent = agents[i]; break; }
}
} catch(e) {
OpenFangToast.error('Failed to change provider: ' + e.message);
}
this.modelSaving = false;
},
// ── Fallback model chain ──
async addFallback() {
if (!this.detailAgent || !this.newFallbackValue.trim()) return;
@@ -698,12 +738,12 @@ function agentsPage() {
},
async spawnBuiltin(t) {
var toml = 'name = "' + t.name + '"\n';
toml += 'description = "' + t.description.replace(/"/g, '\\"') + '"\n';
var toml = 'name = "' + tomlBasicEscape(t.name) + '"\n';
toml += 'description = "' + tomlBasicEscape(t.description) + '"\n';
toml += 'module = "builtin:chat"\n';
toml += 'profile = "' + t.profile + '"\n\n';
toml += '[model]\nprovider = "' + t.provider + '"\nmodel = "' + t.model + '"\n';
toml += 'system_prompt = """\n' + t.system_prompt + '\n"""\n';
toml += 'system_prompt = """\n' + tomlMultilineEscape(t.system_prompt) + '\n"""\n';
try {
var res = await OpenFangAPI.post('/api/agents', { manifest_toml: toml });
@@ -7,6 +7,22 @@ function approvalsPage() {
filterStatus: 'all',
loading: true,
loadError: '',
refreshTimer: null,
init() {
var self = this;
this.loadData();
this.refreshTimer = setInterval(function() {
self.loadData();
}, 5000);
},
destroy() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
},
get filtered() {
var f = this.filterStatus;
+62 -18
View File
@@ -530,7 +530,7 @@ function chatPage() {
id: (t.name || 'tool') + '-hist-' + idx,
name: t.name || 'unknown',
running: false,
expanded: false,
expanded: true,
input: t.input || '',
result: t.result || '',
is_error: !!t.is_error
@@ -622,8 +622,12 @@ function chatPage() {
this.scrollToBottom();
this._resetTypingTimeout();
} else if (data.level) {
var lastThink = this.messages[this.messages.length - 1];
if (lastThink && lastThink.thinking) lastThink.text = 'Thinking (' + data.level + ')...';
var thinkIdx = this.messages.length - 1;
var lastThink = thinkIdx >= 0 ? this.messages[thinkIdx] : null;
if (lastThink && lastThink.thinking) {
lastThink.text = 'Thinking (' + data.level + ')...';
this.messages.splice(thinkIdx, 1, lastThink);
}
}
break;
@@ -636,9 +640,11 @@ function chatPage() {
}
this._resetTypingTimeout();
} else if (data.state === 'tool') {
var typingMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
var toolTypIdx = this.messages.length - 1;
var typingMsg = toolTypIdx >= 0 ? this.messages[toolTypIdx] : null;
if (typingMsg && (typingMsg.thinking || typingMsg.streaming)) {
typingMsg.text = 'Using ' + (data.tool || 'tool') + '...';
this.messages.splice(toolTypIdx, 1, typingMsg);
}
this._resetTypingTimeout();
} else if (data.state === 'stop') {
@@ -648,26 +654,45 @@ function chatPage() {
case 'phase':
// Show tool/phase progress so the user sees the agent is working
var phaseMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
var phaseIdx = this.messages.length - 1;
var phaseMsg = phaseIdx >= 0 ? this.messages[phaseIdx] : null;
if (phaseMsg && (phaseMsg.thinking || phaseMsg.streaming)) {
var detail = data.detail || data.phase || 'Working...';
// Context warning: show prominently
// Skip phases that have no user-meaningful display text — "streaming"
// and "done" are lifecycle signals, not status to show in the chat bubble.
if (data.phase === 'streaming' || data.phase === 'done') {
break;
}
// Context warning: show prominently as a separate system message
if (data.phase === 'context_warning') {
this.messages.push({ id: ++msgId, role: 'system', text: detail, meta: '', tools: [] });
var cwDetail = data.detail || 'Context limit reached.';
this.messages.push({ id: ++msgId, role: 'system', text: cwDetail, meta: '', tools: [] });
} else if (data.phase === 'thinking' && this.thinkingMode === 'stream') {
// Stream reasoning tokens to a collapsible panel
if (!phaseMsg._reasoning) phaseMsg._reasoning = '';
phaseMsg._reasoning += (detail || '') + '\n';
phaseMsg._reasoning += (data.detail || '') + '\n';
phaseMsg.text = '<details><summary>Reasoning...</summary>\n\n' + phaseMsg._reasoning + '</details>';
} else {
phaseMsg.text = detail;
this.messages.splice(phaseIdx, 1, phaseMsg);
} else if (phaseMsg.thinking) {
// Only update text on messages still in thinking state (not yet
// receiving streamed content) to avoid overwriting accumulated text.
var phaseDetail;
if (data.phase === 'tool_use') {
phaseDetail = 'Using ' + (data.detail || 'tool') + '...';
} else if (data.phase === 'thinking') {
phaseDetail = 'Thinking...';
} else {
phaseDetail = data.detail || 'Working...';
}
phaseMsg.text = phaseDetail;
this.messages.splice(phaseIdx, 1, phaseMsg);
}
}
this.scrollToBottom();
break;
case 'text_delta':
var last = this.messages.length ? this.messages[this.messages.length - 1] : null;
var lastIdx = this.messages.length - 1;
var last = lastIdx >= 0 ? this.messages[lastIdx] : null;
if (last && last.streaming) {
if (last.thinking) { last.text = ''; last.thinking = false; }
// If we already detected a text-based tool call, skip further text
@@ -688,7 +713,7 @@ function chatPage() {
id: toolMatch[1] + '-txt-' + Date.now(),
name: toolMatch[1],
running: true,
expanded: false,
expanded: true,
input: inputMatch ? inputMatch[1].replace(/<\/function>?\s*$/, '').trim() : '',
result: '',
is_error: false
@@ -696,6 +721,10 @@ function chatPage() {
}
}
this.tokenCount = Math.round(last.text.length / 4);
// Force Alpine reactivity: splice-in-place so x-for re-renders
// this item. Direct property mutation on array elements may not
// trigger DOM updates from async WebSocket callbacks.
this.messages.splice(lastIdx, 1, last);
} else {
this.messages.push({ id: ++msgId, role: 'agent', text: data.content, meta: '', streaming: true, tools: [] });
}
@@ -703,17 +732,20 @@ function chatPage() {
break;
case 'tool_start':
var lastMsg = this.messages.length ? this.messages[this.messages.length - 1] : null;
var tsIdx = this.messages.length - 1;
var lastMsg = tsIdx >= 0 ? this.messages[tsIdx] : null;
if (lastMsg && lastMsg.streaming) {
if (!lastMsg.tools) lastMsg.tools = [];
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: false, input: '', result: '', is_error: false });
lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: true, input: '', result: '', is_error: false });
this.messages.splice(tsIdx, 1, lastMsg);
}
this.scrollToBottom();
break;
case 'tool_end':
// Tool call parsed by LLM — update tool card with input params
var lastMsg2 = this.messages.length ? this.messages[this.messages.length - 1] : null;
var teIdx = this.messages.length - 1;
var lastMsg2 = teIdx >= 0 ? this.messages[teIdx] : null;
if (lastMsg2 && lastMsg2.tools) {
for (var ti = lastMsg2.tools.length - 1; ti >= 0; ti--) {
if (lastMsg2.tools[ti].name === data.tool && lastMsg2.tools[ti].running) {
@@ -721,12 +753,14 @@ function chatPage() {
break;
}
}
this.messages.splice(teIdx, 1, lastMsg2);
}
break;
case 'tool_result':
// Tool execution completed — update tool card with result
var lastMsg3 = this.messages.length ? this.messages[this.messages.length - 1] : null;
var trIdx = this.messages.length - 1;
var lastMsg3 = trIdx >= 0 ? this.messages[trIdx] : null;
if (lastMsg3 && lastMsg3.tools) {
for (var ri = lastMsg3.tools.length - 1; ri >= 0; ri--) {
if (lastMsg3.tools[ri].name === data.tool && lastMsg3.tools[ri].running) {
@@ -755,6 +789,7 @@ function chatPage() {
break;
}
}
this.messages.splice(trIdx, 1, lastMsg3);
}
this.scrollToBottom();
break;
@@ -1031,10 +1066,16 @@ function chatPage() {
});
},
_latexTimer: null,
scrollToBottom() {
var self = this;
var el = document.getElementById('messages');
if (el) self.$nextTick(function() { el.scrollTop = el.scrollHeight; });
if (el) self.$nextTick(function() {
el.scrollTop = el.scrollHeight;
// Debounce LaTeX rendering to avoid running on every streaming token
if (self._latexTimer) clearTimeout(self._latexTimer);
self._latexTimer = setTimeout(function() { renderLatex(el); }, 150);
});
},
addFiles(files) {
@@ -1104,6 +1145,9 @@ function chatPage() {
formatToolJson: function(text) {
if (!text) return '';
if (typeof text === 'object') {
return JSON.stringify(text, null, 2);
}
try { return JSON.stringify(JSON.parse(text), null, 2); }
catch(e) { return text; }
},
+453 -3
View File
@@ -18,6 +18,15 @@ function handsPage() {
browserViewerOpen: false,
_browserPollTimer: null,
// ── Trader Dashboard State ────────────────────────────────────────────
dashboardOpen: false,
dashboardLoading: false,
dashboardData: null,
_dashboardInst: null,
_chartEquity: null,
_chartPnl: null,
_chartRadar: null,
// ── Setup Wizard State ──────────────────────────────────────────────
setupWizard: null,
setupStep: 1,
@@ -27,6 +36,7 @@ function handsPage() {
_clipboardTimer: null,
detectedPlatform: 'linux',
installPlatforms: {},
apiKeyInputs: {},
async loadData() {
this.loading = true;
@@ -101,11 +111,15 @@ function handsPage() {
} else {
this._detectClientPlatform();
}
// Initialize per-requirement platform selections
// Initialize per-requirement platform selections and API key inputs
this.installPlatforms = {};
this.apiKeyInputs = {};
if (data.requirements) {
for (var j = 0; j < data.requirements.length; j++) {
this.installPlatforms[data.requirements[j].key] = this.detectedPlatform;
if (data.requirements[j].type === 'ApiKey') {
this.apiKeyInputs[data.requirements[j].key] = '';
}
}
}
this.setupWizard = data;
@@ -274,7 +288,10 @@ function handsPage() {
if (!this.setupWizard || !this.setupWizard.requirements) return 0;
var count = 0;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
if (this.setupWizard.requirements[i].satisfied) count++;
var req = this.setupWizard.requirements[i];
if (req.satisfied) { count++; continue; }
// Count API key reqs as met if user entered a value
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') count++;
}
return count;
},
@@ -285,7 +302,34 @@ function handsPage() {
},
get setupAllReqsMet() {
return this.setupReqsTotal > 0 && this.setupReqsMet === this.setupReqsTotal;
if (!this.setupWizard || !this.setupWizard.requirements) return false;
if (this.setupReqsTotal === 0) return false;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.satisfied) continue;
// API key reqs are satisfied if the user entered a value in the input
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') continue;
return false;
}
return true;
},
getSettingKeyForReq(req) {
// Find the matching setting key for an API key requirement.
// Convention: setting key is the lowercase version of the requirement key.
if (!this.setupWizard || !this.setupWizard.settings) return null;
var lowerKey = req.key.toLowerCase();
for (var i = 0; i < this.setupWizard.settings.length; i++) {
if (this.setupWizard.settings[i].key === lowerKey) return lowerKey;
}
// Fallback: try matching by check_value lowercased
if (req.check_value) {
var lowerCheck = req.check_value.toLowerCase();
for (var j = 0; j < this.setupWizard.settings.length; j++) {
if (this.setupWizard.settings[j].key === lowerCheck) return lowerCheck;
}
}
return null;
},
get setupHasReqs() {
@@ -297,6 +341,10 @@ function handsPage() {
},
setupNextStep() {
// When leaving step 1, sync API key inputs into settings values
if (this.setupStep === 1) {
this._syncApiKeysToSettings();
}
if (this.setupStep === 1 && this.setupHasSettings) {
this.setupStep = 2;
} else if (this.setupStep === 1) {
@@ -306,6 +354,19 @@ function handsPage() {
}
},
_syncApiKeysToSettings() {
if (!this.setupWizard || !this.setupWizard.requirements) return;
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
var settingKey = this.getSettingKeyForReq(req);
if (settingKey) {
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
}
}
}
},
setupPrevStep() {
if (this.setupStep === 3 && this.setupHasSettings) {
this.setupStep = 2;
@@ -323,11 +384,24 @@ function handsPage() {
this.setupChecking = false;
this.clipboardMsg = null;
this.installPlatforms = {};
this.apiKeyInputs = {};
},
async launchHand() {
if (!this.setupWizard) return;
var handId = this.setupWizard.id;
// Sync API key inputs from step 1 into settings values
if (this.setupWizard.requirements) {
for (var i = 0; i < this.setupWizard.requirements.length; i++) {
var req = this.setupWizard.requirements[i];
if (req.type === 'ApiKey' && this.apiKeyInputs[req.key] && this.apiKeyInputs[req.key].trim() !== '') {
var settingKey = this.getSettingKeyForReq(req);
if (settingKey) {
this.settingsValues[settingKey] = this.apiKeyInputs[req.key].trim();
}
}
}
}
var config = {};
for (var key in this.settingsValues) {
config[key] = this.settingsValues[key];
@@ -499,6 +573,382 @@ function handsPage() {
this.stopBrowserPolling();
this.browserViewerOpen = false;
this.browserViewer = null;
},
// ── Trader Dashboard ──────────────────────────────────────────────────
isTraderHand(inst) {
return inst.hand_id === 'trader';
},
async openDashboard(inst) {
this._dashboardInst = inst;
this.dashboardOpen = true;
this.dashboardLoading = true;
this.dashboardData = null;
await this._fetchDashboardData(inst);
this.dashboardLoading = false;
// Render charts after DOM update
var self = this;
setTimeout(function() { self._renderCharts(); }, 60);
},
async refreshDashboard() {
if (!this._dashboardInst) return;
this.dashboardLoading = true;
await this._fetchDashboardData(this._dashboardInst);
this.dashboardLoading = false;
var self = this;
setTimeout(function() { self._renderCharts(); }, 60);
},
closeDashboard() {
this.dashboardOpen = false;
this._destroyCharts();
this.dashboardData = null;
this._dashboardInst = null;
},
async _fetchDashboardData(inst) {
var data = {
agent_name: inst.agent_name || inst.hand_id,
portfolio_value: null,
total_pnl: null,
win_rate: null,
sharpe_ratio: null,
max_drawdown: null,
trades_count: null,
equity_curve: [],
daily_pnl: [],
watchlist_heatmap: [],
signal_radar: null,
recent_trades: []
};
// Fetch basic stats from the hand stats endpoint
try {
var stats = await OpenFangAPI.get('/api/hands/instances/' + inst.instance_id + '/stats');
var m = stats.metrics || {};
if (m['Portfolio Value']) data.portfolio_value = this._metricVal(m['Portfolio Value']);
if (m['Total P&L']) data.total_pnl = this._metricVal(m['Total P&L']);
if (m['Win Rate']) data.win_rate = this._metricVal(m['Win Rate']);
if (m['Sharpe Ratio']) data.sharpe_ratio = this._metricVal(m['Sharpe Ratio']);
if (m['Max Drawdown']) data.max_drawdown = this._metricVal(m['Max Drawdown']);
if (m['Trades Executed']) data.trades_count = this._metricVal(m['Trades Executed']);
} catch(e) {
// Stats endpoint might fail — continue with KV data
}
// Fetch rich chart data from agent memory KV
var agentId = inst.agent_id || 'shared';
var kvKeys = [
'trader_hand_equity_curve',
'trader_hand_daily_pnl',
'trader_hand_watchlist_heatmap',
'trader_hand_signal_radar',
'trader_hand_recent_trades',
'trader_hand_portfolio_value',
'trader_hand_total_pnl',
'trader_hand_win_rate',
'trader_hand_sharpe_ratio',
'trader_hand_max_drawdown',
'trader_hand_trades_count'
];
for (var i = 0; i < kvKeys.length; i++) {
try {
var resp = await OpenFangAPI.get('/api/memory/agents/' + agentId + '/kv/' + kvKeys[i]);
if (resp && resp.value !== null && resp.value !== undefined) {
var val = resp.value;
this._applyKvToData(data, kvKeys[i], val);
}
} catch(e) {
// Key might not exist yet — that's fine
}
}
this.dashboardData = data;
},
_metricVal(metric) {
if (!metric) return null;
var v = metric.value;
if (v === null || v === undefined) return null;
// Values come as JSON values — could be string, number, etc.
if (typeof v === 'string') return v;
return String(v);
},
_applyKvToData(data, key, val) {
// Values from KV can be strings (JSON-encoded) or already parsed
var parsed = val;
if (typeof val === 'string') {
try { parsed = JSON.parse(val); } catch(e) { parsed = val; }
}
switch(key) {
case 'trader_hand_portfolio_value':
if (!data.portfolio_value) data.portfolio_value = String(parsed);
break;
case 'trader_hand_total_pnl':
if (!data.total_pnl) data.total_pnl = String(parsed);
break;
case 'trader_hand_win_rate':
if (!data.win_rate) data.win_rate = String(parsed);
break;
case 'trader_hand_sharpe_ratio':
if (!data.sharpe_ratio) data.sharpe_ratio = String(parsed);
break;
case 'trader_hand_max_drawdown':
if (!data.max_drawdown) data.max_drawdown = String(parsed);
break;
case 'trader_hand_trades_count':
if (!data.trades_count) data.trades_count = String(parsed);
break;
case 'trader_hand_equity_curve':
if (Array.isArray(parsed)) data.equity_curve = parsed;
break;
case 'trader_hand_daily_pnl':
if (Array.isArray(parsed)) data.daily_pnl = parsed;
break;
case 'trader_hand_watchlist_heatmap':
if (Array.isArray(parsed)) data.watchlist_heatmap = parsed;
break;
case 'trader_hand_signal_radar':
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data.signal_radar = parsed;
break;
case 'trader_hand_recent_trades':
if (Array.isArray(parsed)) data.recent_trades = parsed;
break;
}
},
_destroyCharts() {
if (this._chartEquity) { this._chartEquity.destroy(); this._chartEquity = null; }
if (this._chartPnl) { this._chartPnl.destroy(); this._chartPnl = null; }
if (this._chartRadar) { this._chartRadar.destroy(); this._chartRadar = null; }
},
_renderCharts() {
if (typeof Chart === 'undefined') return;
this._destroyCharts();
if (!this.dashboardData) return;
var d = this.dashboardData;
// Detect theme
var isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
var gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
var textColor = isDark ? '#8A8380' : '#6B6560';
var accentColor = '#FF5C00';
var successColor = isDark ? '#4ADE80' : '#22C55E';
var errorColor = '#EF4444';
// ── Equity Curve ──
if (d.equity_curve && d.equity_curve.length > 0) {
var eqCanvas = document.getElementById('traderEquityChart');
if (eqCanvas) {
var labels = [];
var values = [];
for (var i = 0; i < d.equity_curve.length; i++) {
labels.push(d.equity_curve[i].date || '');
values.push(parseFloat(d.equity_curve[i].value) || 0);
}
// Determine gradient
var eqCtx = eqCanvas.getContext('2d');
var gradient = eqCtx.createLinearGradient(0, 0, 0, eqCanvas.parentElement.clientHeight || 180);
gradient.addColorStop(0, isDark ? 'rgba(255, 92, 0, 0.25)' : 'rgba(255, 92, 0, 0.15)');
gradient.addColorStop(1, 'rgba(255, 92, 0, 0)');
this._chartEquity = new Chart(eqCtx, {
type: 'line',
data: {
labels: labels,
datasets: [{
data: values,
borderColor: accentColor,
backgroundColor: gradient,
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: d.equity_curve.length > 20 ? 0 : 3,
pointHoverRadius: 5,
pointBackgroundColor: accentColor
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) {
return '$' + ctx.parsed.y.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
}
}
},
scales: {
x: {
grid: { color: gridColor },
ticks: { color: textColor, maxTicksLimit: 8, font: { size: 10 } }
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { size: 10 },
callback: function(v) { return '$' + v.toLocaleString(); }
}
}
}
}
});
}
}
// ── Daily P&L Bar Chart ──
if (d.daily_pnl && d.daily_pnl.length > 0) {
var pnlCanvas = document.getElementById('traderPnlChart');
if (pnlCanvas) {
var pnlLabels = [];
var pnlValues = [];
var pnlColors = [];
for (var j = 0; j < d.daily_pnl.length; j++) {
pnlLabels.push(d.daily_pnl[j].date || '');
var pnlVal = parseFloat(d.daily_pnl[j].pnl) || 0;
pnlValues.push(pnlVal);
pnlColors.push(pnlVal >= 0 ? successColor : errorColor);
}
this._chartPnl = new Chart(pnlCanvas.getContext('2d'), {
type: 'bar',
data: {
labels: pnlLabels,
datasets: [{
data: pnlValues,
backgroundColor: pnlColors,
borderRadius: 3,
borderSkipped: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) {
var v = ctx.parsed.y;
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: textColor, maxTicksLimit: 7, font: { size: 10 } }
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
font: { size: 10 },
callback: function(v) {
return (v >= 0 ? '+$' : '-$') + Math.abs(v).toLocaleString();
}
}
}
}
}
});
}
}
// ── Signal Radar Chart ──
if (d.signal_radar) {
var radarCanvas = document.getElementById('traderRadarChart');
if (radarCanvas) {
var radarLabels = [];
var radarValues = [];
var keys = ['technical', 'fundamental', 'sentiment', 'macro'];
var displayLabels = ['Technical', 'Fundamental', 'Sentiment', 'Macro'];
for (var k = 0; k < keys.length; k++) {
radarLabels.push(displayLabels[k]);
radarValues.push(parseFloat(d.signal_radar[keys[k]]) || 0);
}
this._chartRadar = new Chart(radarCanvas.getContext('2d'), {
type: 'radar',
data: {
labels: radarLabels,
datasets: [{
data: radarValues,
borderColor: accentColor,
backgroundColor: isDark ? 'rgba(255, 92, 0, 0.2)' : 'rgba(255, 92, 0, 0.12)',
borderWidth: 2,
pointBackgroundColor: accentColor,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: isDark ? '#1a1a1a' : '#fff',
titleColor: textColor,
bodyColor: isDark ? '#e0e0e0' : '#333',
borderColor: gridColor,
borderWidth: 1,
padding: 10,
callbacks: {
label: function(ctx) { return ctx.parsed.r + '/100'; }
}
}
},
scales: {
r: {
min: 0,
max: 100,
beginAtZero: true,
grid: { color: gridColor },
angleLines: { color: gridColor },
pointLabels: {
color: textColor,
font: { size: 11, weight: '600' }
},
ticks: {
color: textColor,
backdropColor: 'transparent',
stepSize: 25,
font: { size: 9 }
}
}
}
}
});
}
}
}
};
}
@@ -201,15 +201,17 @@ function schedulerPage() {
async runNow(job) {
this.runningJobId = job.id;
try {
var result = await OpenFangAPI.post('/api/schedules/' + job.id + '/run', {});
if (result.status === 'completed') {
OpenFangToast.success('Schedule "' + (job.name || 'job') + '" executed successfully');
job.last_run = new Date().toISOString();
var result = await OpenFangAPI.post('/api/cron/jobs/' + job.id + '/run', {});
if (result.status === 'triggered' || result.status === 'completed') {
OpenFangToast.success('Job "' + (job.name || 'job') + '" triggered');
// Don't update job.last_run here — the job runs asynchronously in the
// background. The real last_run is set by the server on completion and
// will appear on the next data refresh.
} else {
OpenFangToast.error('Schedule run failed: ' + (result.error || 'Unknown error'));
OpenFangToast.error('Run failed: ' + (result.error || 'Unknown error'));
}
} catch(e) {
OpenFangToast.error('Run Now is not yet available for cron jobs');
OpenFangToast.error('Run failed: ' + (e.message || e));
}
this.runningJobId = '';
},
@@ -401,8 +401,12 @@ function settingsPage() {
var key = this.providerKeyInputs[provider.id];
if (!key || !key.trim()) { OpenFangToast.error('Please enter an API key'); return; }
try {
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
OpenFangToast.success('API key saved for ' + provider.display_name);
var resp = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/key', { key: key.trim() });
if (resp && resp.switched_default) {
OpenFangToast.warning(resp.message || 'Default provider was switched to ' + provider.display_name);
} else {
OpenFangToast.success('API key saved for ' + provider.display_name);
}
this.providerKeyInputs[provider.id] = '';
await this.loadProviders();
await this.loadModels();
+22 -12
View File
@@ -1,6 +1,16 @@
// OpenFang Setup Wizard — First-run guided setup (Provider + Agent + Channel)
'use strict';
/** Escape a string for use inside TOML triple-quoted strings ("""\n...\n"""). */
function wizardTomlMultilineEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"""/g, '""\\"');
}
/** Escape a string for use inside a TOML basic (single-line) string ("..."). */
function wizardTomlBasicEscape(s) {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
}
function wizardPage() {
return {
step: 1,
@@ -244,6 +254,15 @@ function wizardPage() {
this.error = '';
try {
await this.loadProviders();
// Pre-select first unconfigured provider, or first one
var unconfigured = this.providers.filter(function(p) {
return p.auth_status !== 'configured' && p.api_key_env;
});
if (unconfigured.length > 0) {
this.selectedProvider = unconfigured[0].id;
} else if (this.providers.length > 0) {
this.selectedProvider = this.providers[0].id;
}
} catch(e) {
this.error = e.message || 'Could not load setup data.';
}
@@ -303,15 +322,6 @@ function wizardPage() {
try {
var data = await OpenFangAPI.get('/api/providers');
this.providers = data.providers || [];
// Pre-select first unconfigured provider, or first one
var unconfigured = this.providers.filter(function(p) {
return p.auth_status !== 'configured' && p.api_key_env;
});
if (unconfigured.length > 0) {
this.selectedProvider = unconfigured[0].id;
} else if (this.providers.length > 0) {
this.selectedProvider = this.providers[0].id;
}
} catch(e) { this.providers = []; }
},
@@ -462,12 +472,12 @@ function wizardPage() {
}
var toml = '[agent]\n';
toml += 'name = "' + name.replace(/"/g, '\\"') + '"\n';
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
toml += 'name = "' + wizardTomlBasicEscape(name) + '"\n';
toml += 'description = "' + wizardTomlBasicEscape(tpl.description) + '"\n';
toml += 'profile = "' + tpl.profile + '"\n\n';
toml += '[model]\nprovider = "' + provider + '"\n';
toml += 'model = "' + model + '"\n';
toml += 'system_prompt = """\n' + tpl.system_prompt + '\n"""\n';
toml += 'system_prompt = """\n' + wizardTomlMultilineEscape(tpl.system_prompt) + '\n"""\n';
this.creatingAgent = true;
try {
@@ -13,6 +13,8 @@ function workflowsPage() {
loading: true,
loadError: '',
newWf: { name: '', description: '', steps: [{ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' }] },
editModal: null,
editWf: { name: '', description: '', steps: [] },
// -- Workflows methods --
async loadWorkflows() {
@@ -74,6 +76,57 @@ function workflowsPage() {
} catch(e) {
OpenFangToast.error('Failed to load run history: ' + e.message);
}
},
async deleteWorkflow(wf) {
if (!confirm('Delete workflow "' + wf.name + '"? This cannot be undone.')) return;
try {
await OpenFangAPI.delete('/api/workflows/' + wf.id);
OpenFangToast.success('Workflow "' + wf.name + '" deleted');
await this.loadWorkflows();
} catch(e) {
OpenFangToast.error('Failed to delete workflow: ' + e.message);
}
},
async showEditModal(wf) {
try {
var full = await OpenFangAPI.get('/api/workflows/' + wf.id);
this.editWf = {
name: full.name || '',
description: full.description || '',
steps: (full.steps || []).map(function(s) {
return {
name: s.name || '',
agent_name: (s.agent && s.agent.name) || '',
mode: s.mode || 'sequential',
prompt: s.prompt_template || '{{input}}'
};
})
};
if (this.editWf.steps.length === 0) {
this.editWf.steps.push({ name: '', agent_name: '', mode: 'sequential', prompt: '{{input}}' });
}
this.editModal = wf;
} catch(e) {
OpenFangToast.error('Failed to load workflow: ' + e.message);
}
},
async saveWorkflow() {
if (!this.editModal) return;
var steps = this.editWf.steps.map(function(s) {
return { name: s.name || 'step', agent_name: s.agent_name, mode: s.mode, prompt: s.prompt || '{{input}}' };
});
try {
var wfName = this.editWf.name;
await OpenFangAPI.put('/api/workflows/' + this.editModal.id, { name: wfName, description: this.editWf.description, steps: steps });
this.editModal = null;
OpenFangToast.success('Workflow "' + wfName + '" updated');
await this.loadWorkflows();
} catch(e) {
OpenFangToast.error('Failed to update workflow: ' + e.message);
}
}
};
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "OpenFang Agent OS",
"short_name": "OpenFang",
"description": "Open-source Agent Operating System",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#6366f1",
"icons": [
{"src": "/logo.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo.png", "sizes": "512x512", "type": "image/png"}
]
}
+3
View File
@@ -0,0 +1,3 @@
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
File diff suppressed because one or more lines are too long
@@ -78,6 +78,7 @@ async fn start_test_server_with_provider(
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
@@ -707,9 +708,21 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let api_key_state = state.kernel.config.api_key.clone();
let api_key = state.kernel.config.api_key.trim().to_string();
let auth_state = middleware::AuthState {
api_key: api_key.clone(),
auth_enabled: state.kernel.config.auth.enabled,
session_secret: if !api_key.is_empty() {
api_key.clone()
} else if state.kernel.config.auth.enabled {
state.kernel.config.auth.password_hash.clone()
} else {
String::new()
},
};
let app = Router::new()
.route("/api/health", axum::routing::get(routes::health))
@@ -753,7 +766,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer {
)
.route("/api/shutdown", axum::routing::post(routes::shutdown))
.layer(axum::middleware::from_fn_with_state(
api_key_state,
auth_state,
middleware::auth,
))
.layer(axum::middleware::from_fn(middleware::request_logging))
@@ -115,6 +115,7 @@ async fn test_full_daemon_lifecycle() {
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
@@ -240,6 +241,7 @@ async fn test_server_immediate_responsiveness() {
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
+1
View File
@@ -59,6 +59,7 @@ async fn start_test_server() -> TestServer {
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
+7
View File
@@ -14,6 +14,7 @@ chrono = { workspace = true }
dashmap = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }
prost = { workspace = true }
reqwest = { workspace = true }
tokio-stream = { workspace = true }
tracing = { workspace = true }
@@ -24,14 +25,20 @@ zeroize = { workspace = true }
axum = { workspace = true }
hmac = { workspace = true }
sha2 = { workspace = true }
sha1 = { workspace = true }
aes = "0.8"
cbc = "0.1"
base64 = { workspace = true }
hex = { workspace = true }
html-escape = { workspace = true }
regex-lite = "0.1"
roxmltree = "0.21"
lettre = { workspace = true }
imap = { workspace = true }
native-tls = { workspace = true }
mailparse = { workspace = true }
rumqttc = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,600 @@
//! DingTalk Stream channel adapter.
//!
//! Uses DingTalk Stream Mode (WebSocket long-connection) instead of the
//! legacy webhook approach. The webhook adapter in `dingtalk.rs` is preserved
//! for backwards compatibility.
//!
//! Protocol:
//! 1. POST /v1.0/oauth2/accessToken → get access token
//! 2. POST /v1.0/gateway/connections/open → get WebSocket URL
//! 3. Connect via WebSocket, handle ping/pong and EVENT messages
//! 4. Outbound: POST /v1.0/robot/oToMessages/batchSend
use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use chrono::Utc;
use futures::{SinkExt, Stream, StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{mpsc, watch};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tracing::{error, info, warn};
const API_BASE: &str = "https://api.dingtalk.com";
const MAX_MESSAGE_LEN: usize = 20000;
// ─── Adapter ─────────────────────────────────────────────────────────────────
pub struct DingTalkStreamAdapter {
app_key: String,
app_secret: String,
robot_code: String,
client: reqwest::Client,
token_cache: Arc<Mutex<TokenCache>>,
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
}
impl DingTalkStreamAdapter {
pub fn new(app_key: String, app_secret: String, robot_code: String) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
app_key,
app_secret,
robot_code,
client: reqwest::Client::new(),
token_cache: Arc::new(Mutex::new(TokenCache::default())),
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
}
}
async fn get_token(&self) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
{
let c = self.token_cache.lock().unwrap();
if !c.token.is_empty() && c.expire_at > now + 300 {
return Ok(c.token.clone());
}
}
let resp: serde_json::Value = self
.client
.post(format!("{API_BASE}/v1.0/oauth2/accessToken"))
.json(&serde_json::json!({
"appKey": self.app_key,
"appSecret": self.app_secret,
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let token = resp["accessToken"]
.as_str()
.ok_or("missing accessToken")?
.to_string();
let expire_in = resp["expireIn"].as_u64().unwrap_or(7200);
{
let mut c = self.token_cache.lock().unwrap();
c.token = token.clone();
c.expire_at = now + expire_in;
}
Ok(token)
}
async fn send_to_ids(
&self,
user_ids: &[&str],
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let token = self
.get_token()
.await
.map_err(|e| -> Box<dyn std::error::Error> { e })?;
let (msg_key, _msg_param) = match &content {
ChannelContent::Text(t) => (
"sampleText",
serde_json::json!({ "content": t }).to_string(),
),
_ => (
"sampleText",
serde_json::json!({ "content": "(unsupported content type)" }).to_string(),
),
};
let text = match &content {
ChannelContent::Text(t) => t.as_str(),
_ => "(unsupported)",
};
let chunks = split_message(text, MAX_MESSAGE_LEN);
for chunk in &chunks {
let param = serde_json::json!({ "content": chunk }).to_string();
let body = serde_json::json!({
"robotCode": self.robot_code,
"userIds": user_ids,
"msgKey": msg_key,
"msgParam": param,
});
let resp = self
.client
.post(format!("{API_BASE}/v1.0/robot/oToMessages/batchSend"))
.header("x-acs-dingtalk-access-token", &token)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let err_body = resp.text().await.unwrap_or_default();
return Err(format!("DingTalk batchSend error {status}: {err_body}").into());
}
if chunks.len() > 1 {
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
Ok(())
}
}
#[async_trait]
impl ChannelAdapter for DingTalkStreamAdapter {
fn name(&self) -> &str {
"dingtalk_stream"
}
fn channel_type(&self) -> ChannelType {
ChannelType::Custom("dingtalk_stream".to_string())
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let app_key = self.app_key.clone();
let app_secret = self.app_secret.clone();
let client = self.client.clone();
let token_cache = Arc::clone(&self.token_cache);
let mut shutdown_rx = self.shutdown_rx.clone();
info!("DingTalk Stream adapter starting WebSocket connection");
tokio::spawn(async move {
let mut attempt: u32 = 0;
loop {
if *shutdown_rx.borrow() {
info!("DingTalk Stream: shutdown requested");
break;
}
// 1. Get access token
let token =
match get_access_token(&client, &app_key, &app_secret, &token_cache).await {
Ok(t) => t,
Err(e) => {
warn!("DingTalk Stream: token fetch failed: {e}");
attempt += 1;
tokio::time::sleep(backoff(attempt)).await;
continue;
}
};
// 2. Get WebSocket endpoint
let ws_url = match get_ws_endpoint(&client, &app_key, &app_secret, &token).await {
Ok(u) => u,
Err(e) => {
warn!("DingTalk Stream: endpoint fetch failed: {e}");
attempt += 1;
tokio::time::sleep(backoff(attempt)).await;
continue;
}
};
info!(
"DingTalk Stream: connecting to {}...",
&ws_url[..ws_url.len().min(60)]
);
// 3. Connect
let ws_stream = match connect_async(&ws_url).await {
Ok((ws, _)) => ws,
Err(e) => {
warn!("DingTalk Stream: WS connect failed: {e}");
attempt += 1;
tokio::time::sleep(backoff(attempt)).await;
continue;
}
};
info!("DingTalk Stream: connected");
attempt = 0;
let (mut sink, mut source) = ws_stream.split();
// 4. Message loop
loop {
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!("DingTalk Stream: graceful shutdown");
return;
}
}
msg = source.next() => {
match msg {
None => { warn!("DingTalk Stream: connection closed"); break; }
Some(Err(e)) => { warn!("DingTalk Stream: WS error: {e}"); break; }
Some(Ok(Message::Text(text))) => {
handle_frame(&text, &mut sink, &tx).await;
}
Some(Ok(Message::Ping(d))) => { let _ = sink.send(Message::Pong(d)).await; }
Some(Ok(Message::Close(_))) => { info!("DingTalk Stream: close frame"); break; }
_ => {}
}
}
}
}
// Reconnect
attempt += 1;
let delay = backoff(attempt);
info!("DingTalk Stream: reconnecting in {delay:?}");
tokio::time::sleep(delay).await;
}
});
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn send(
&self,
user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let uid = &user.platform_id;
if uid.is_empty() {
return Err("DingTalk Stream: no platform_id to reply to".into());
}
self.send_to_ids(&[uid.as_str()], content).await
}
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
}
}
// ─── Token helpers ───────────────────────────────────────────────────────────
#[derive(Default)]
struct TokenCache {
token: String,
expire_at: u64,
}
async fn get_access_token(
http: &reqwest::Client,
app_key: &str,
app_secret: &str,
cache: &Arc<Mutex<TokenCache>>,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
{
let c = cache.lock().unwrap();
if !c.token.is_empty() && c.expire_at > now + 300 {
return Ok(c.token.clone());
}
}
let resp: serde_json::Value = http
.post(format!("{API_BASE}/v1.0/oauth2/accessToken"))
.json(&serde_json::json!({ "appKey": app_key, "appSecret": app_secret }))
.send()
.await?
.error_for_status()?
.json()
.await?;
let token = resp["accessToken"]
.as_str()
.ok_or("missing accessToken")?
.to_string();
let expire_in = resp["expireIn"].as_u64().unwrap_or(7200);
{
let mut c = cache.lock().unwrap();
c.token = token.clone();
c.expire_at = now + expire_in;
}
Ok(token)
}
// ─── Gateway / WebSocket helpers ─────────────────────────────────────────────
#[derive(Serialize)]
struct OpenConnectionRequest<'a> {
#[serde(rename = "clientId")]
client_id: &'a str,
#[serde(rename = "clientSecret")]
client_secret: &'a str,
subscriptions: Vec<SubItem>,
ua: &'a str,
#[serde(rename = "localIp")]
local_ip: &'a str,
}
#[derive(Serialize)]
struct SubItem {
#[serde(rename = "type")]
sub_type: String,
topic: String,
}
#[derive(Deserialize)]
struct OpenConnectionResponse {
endpoint: String,
ticket: String,
}
async fn get_ws_endpoint(
http: &reqwest::Client,
app_key: &str,
app_secret: &str,
token: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let body = OpenConnectionRequest {
client_id: app_key,
client_secret: app_secret,
subscriptions: vec![SubItem {
sub_type: "CALLBACK".to_string(),
topic: "/v1.0/im/bot/messages/get".to_string(),
}],
ua: "openfang/0.3",
local_ip: "",
};
let resp: OpenConnectionResponse = http
.post(format!("{API_BASE}/v1.0/gateway/connections/open"))
.header("x-acs-dingtalk-access-token", token)
.json(&body)
.send()
.await?
.error_for_status()?
.json()
.await?;
let sep = if resp.endpoint.contains('?') {
"&"
} else {
"?"
};
Ok(format!("{}{}ticket={}", resp.endpoint, sep, resp.ticket))
}
// ─── Frame handling ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct ProtoFrame {
#[serde(rename = "type")]
msg_type: String,
headers: ProtoHeaders,
#[serde(default)]
data: serde_json::Value,
}
#[derive(Deserialize)]
struct ProtoHeaders {
#[serde(rename = "messageId", default)]
message_id: String,
#[serde(default)]
topic: String,
}
#[derive(Serialize)]
struct AckReply {
code: u32,
headers: AckHeaders,
message: String,
data: String,
}
#[derive(Serialize)]
struct AckHeaders {
#[serde(rename = "contentType")]
content_type: String,
#[serde(rename = "messageId")]
message_id: String,
topic: String,
}
fn make_ack(message_id: &str, topic: &str) -> String {
serde_json::to_string(&AckReply {
code: 200,
headers: AckHeaders {
content_type: "application/json".to_string(),
message_id: message_id.to_string(),
topic: topic.to_string(),
},
message: "OK".to_string(),
data: String::new(),
})
.unwrap_or_default()
}
#[derive(Deserialize)]
struct CallbackPayload {
#[serde(rename = "msgtype", default)]
msg_type: String,
#[serde(default)]
text: Option<TextContent>,
#[serde(rename = "senderStaffId", default)]
sender_staff_id: String,
#[serde(rename = "senderId", default)]
sender_id: String,
#[serde(rename = "senderNick", default)]
sender_nick: String,
#[serde(rename = "conversationId", default)]
conversation_id: String,
#[serde(rename = "conversationType", default)]
conversation_type: String,
#[serde(rename = "messageId", default)]
message_id: String,
}
#[derive(Deserialize)]
struct TextContent {
content: String,
}
async fn handle_frame<S>(text: &str, sink: &mut S, tx: &mpsc::Sender<ChannelMessage>)
where
S: SinkExt<Message> + Unpin,
<S as futures::Sink<Message>>::Error: std::fmt::Display,
{
let frame: ProtoFrame = match serde_json::from_str(text) {
Ok(f) => f,
Err(e) => {
warn!("DingTalk Stream: bad frame: {e}");
return;
}
};
let mid = &frame.headers.message_id;
let topic = &frame.headers.topic;
match frame.msg_type.as_str() {
"SYSTEM" if topic == "ping" => {
let _ = sink.send(Message::Text(make_ack(mid, "pong"))).await;
}
"CALLBACK" | "EVENT" => {
let data_str = frame.data.to_string();
// Try direct parse, then try unwrapping double-encoded string
let cb: Option<CallbackPayload> = serde_json::from_str(&data_str).ok().or_else(|| {
serde_json::from_str::<String>(&data_str)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
});
if let Some(cb) = cb {
if cb.msg_type == "text" {
if let Some(ref tc) = cb.text {
let trimmed = tc.content.trim().to_string();
if !trimmed.is_empty() {
let content = if trimmed.starts_with('/') {
let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
let cmd = parts[0].trim_start_matches('/');
let args: Vec<String> = parts
.get(1)
.map(|a| a.split_whitespace().map(String::from).collect())
.unwrap_or_default();
ChannelContent::Command {
name: cmd.to_string(),
args,
}
} else {
ChannelContent::Text(trimmed)
};
let mut meta = HashMap::new();
meta.insert(
"conversation_id".to_string(),
serde_json::Value::String(cb.conversation_id),
);
let uid = if cb.sender_staff_id.is_empty() {
cb.sender_id
} else {
cb.sender_staff_id
};
let msg = ChannelMessage {
channel: ChannelType::Custom("dingtalk_stream".to_string()),
platform_message_id: cb.message_id,
sender: ChannelUser {
platform_id: uid,
display_name: cb.sender_nick,
openfang_user: None,
},
content,
target_agent: None,
timestamp: Utc::now(),
is_group: cb.conversation_type == "2",
thread_id: None,
metadata: meta,
};
if tx.send(msg).await.is_err() {
error!("DingTalk Stream: channel receiver dropped");
}
}
}
}
}
let _ = sink.send(Message::Text(make_ack(mid, topic))).await;
}
_ => {
let _ = sink.send(Message::Text(make_ack(mid, topic))).await;
}
}
}
fn backoff(attempt: u32) -> Duration {
let ms = (1000u64 * 2u64.saturating_pow(attempt.min(6))).min(60_000);
Duration::from_millis(ms)
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adapter_creation() {
let a = DingTalkStreamAdapter::new("k".into(), "s".into(), "r".into());
assert_eq!(a.name(), "dingtalk_stream");
assert_eq!(
a.channel_type(),
ChannelType::Custom("dingtalk_stream".to_string())
);
}
#[test]
fn backoff_doubles() {
assert_eq!(backoff(0), Duration::from_millis(1000));
assert_eq!(backoff(1), Duration::from_millis(2000));
assert_eq!(backoff(2), Duration::from_millis(4000));
}
#[test]
fn backoff_capped() {
assert_eq!(backoff(10), Duration::from_millis(60_000));
assert_eq!(backoff(20), Duration::from_millis(60_000));
}
#[test]
fn make_ack_valid_json() {
let ack = make_ack("msg1", "topic1");
let v: serde_json::Value = serde_json::from_str(&ack).unwrap();
assert_eq!(v["code"], 200);
assert_eq!(v["headers"]["messageId"], "msg1");
}
}
+52 -16
View File
@@ -318,9 +318,14 @@ impl ChannelAdapter for DiscordAdapter {
}
"MESSAGE_CREATE" | "MESSAGE_UPDATE" => {
if let Some(msg) =
parse_discord_message(d, &bot_user_id, &allowed_guilds, &allowed_users, ignore_bots)
.await
if let Some(msg) = parse_discord_message(
d,
&bot_user_id,
&allowed_guilds,
&allowed_users,
ignore_bots,
)
.await
{
debug!(
"Discord {event_name} from {}: {:?}",
@@ -517,8 +522,8 @@ async fn parse_discord_message(
.map(|arr| arr.iter().any(|m| m["id"].as_str() == Some(bid.as_str())))
.unwrap_or(false);
// Also check content for <@bot_id> or <@!bot_id> patterns
let mentioned_in_content =
content_text.contains(&format!("<@{bid}>")) || content_text.contains(&format!("<@!{bid}>"));
let mentioned_in_content = content_text.contains(&format!("<@{bid}>"))
|| content_text.contains(&format!("<@!{bid}>"));
mentioned_in_array || mentioned_in_content
} else {
false
@@ -566,7 +571,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
assert_eq!(msg.sender.display_name, "alice");
assert_eq!(msg.sender.platform_id, "ch1");
@@ -674,7 +681,8 @@ mod tests {
});
// Not in allowed guilds
let msg = parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
let msg =
parse_discord_message(&d, &bot_id, &["111".into(), "222".into()], &[], true).await;
assert!(msg.is_none());
// In allowed guilds
@@ -697,7 +705,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -741,7 +751,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert_eq!(msg.sender.display_name, "alice#1234");
}
@@ -763,7 +775,9 @@ mod tests {
});
// MESSAGE_UPDATE uses the same parse function as MESSAGE_CREATE
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Discord);
assert!(
matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message content")
@@ -786,7 +800,14 @@ mod tests {
});
// Not in allowed users
let msg = parse_discord_message(&d, &bot_id, &[], &["user111".into(), "user222".into()], true).await;
let msg = parse_discord_message(
&d,
&bot_id,
&[],
&["user111".into(), "user222".into()],
true,
)
.await;
assert!(msg.is_none());
// In allowed users
@@ -817,9 +838,14 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert!(msg.is_group);
assert_eq!(msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()), Some(true));
assert_eq!(
msg.metadata.get("was_mentioned").and_then(|v| v.as_bool()),
Some(true)
);
// Message without mention in group
let d2 = serde_json::json!({
@@ -835,7 +861,9 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true).await.unwrap();
let msg2 = parse_discord_message(&d2, &bot_id, &[], &[], true)
.await
.unwrap();
assert!(msg2.is_group);
assert!(!msg2.metadata.contains_key("was_mentioned"));
}
@@ -855,13 +883,21 @@ mod tests {
"timestamp": "2024-01-01T00:00:00+00:00"
});
let msg = parse_discord_message(&d, &bot_id, &[], &[], true).await.unwrap();
let msg = parse_discord_message(&d, &bot_id, &[], &[], true)
.await
.unwrap();
assert!(!msg.is_group);
}
#[test]
fn test_discord_adapter_creation() {
let adapter = DiscordAdapter::new("test-token".to_string(), vec!["123".to_string(), "456".to_string()], vec![], true, 37376);
let adapter = DiscordAdapter::new(
"test-token".to_string(),
vec!["123".to_string(), "456".to_string()],
vec![],
true,
37376,
);
assert_eq!(adapter.name(), "discord");
assert_eq!(adapter.channel_type(), ChannelType::Discord);
}
+4 -6
View File
@@ -139,8 +139,7 @@ impl EmailAdapter {
async fn build_smtp_transport(
&self,
) -> Result<AsyncSmtpTransport<Tokio1Executor>, Box<dyn std::error::Error>> {
let creds =
Credentials::new(self.username.clone(), self.password.as_str().to_string());
let creds = Credentials::new(self.username.clone(), self.password.as_str().to_string());
let transport = if self.smtp_port == 465 {
// Implicit TLS (port 465)
@@ -215,8 +214,8 @@ fn fetch_unseen_emails(
.build()
.map_err(|e| format!("TLS connector error: {e}"))?;
let client = imap::connect((host, port), host, &tls)
.map_err(|e| format!("IMAP connect failed: {e}"))?;
let client =
imap::connect((host, port), host, &tls).map_err(|e| format!("IMAP connect failed: {e}"))?;
// Try LOGIN first; fall back to AUTHENTICATE PLAIN for servers like Lark
// that reject LOGIN and only support AUTH=PLAIN (SASL).
@@ -390,8 +389,7 @@ impl ChannelAdapter for EmailAdapter {
}
// Extract target agent from subject brackets (stored in metadata for router)
let _target_agent =
EmailAdapter::extract_agent_from_subject(&subject);
let _target_agent = EmailAdapter::extract_agent_from_subject(&subject);
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
// Build the message body: prepend subject context
File diff suppressed because it is too large Load Diff
+466 -48
View File
@@ -17,21 +17,177 @@ pub fn format_for_channel(text: &str, format: OutputFormat) -> String {
}
}
/// Format a message for WeCom, using a stronger plain-text conversion to avoid
/// leaking Markdown syntax into enterprise chat replies.
pub fn format_for_wecom(text: &str, format: OutputFormat) -> String {
match format {
OutputFormat::PlainText => markdown_to_wecom_plain(text),
_ => format_for_channel(text, format),
}
}
/// Convert Markdown to Telegram HTML subset.
///
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`.
/// Supported tags: `<b>`, `<i>`, `<code>`, `<pre>`, `<a href="">`, `<blockquote>`.
fn markdown_to_telegram_html(text: &str) -> String {
// Escape HTML special characters first so agent names and other text
// don't get interpreted as HTML tags by Telegram's parser.
let mut result = text
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
let mut blocks = Vec::new();
let lines: Vec<&str> = normalized.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim();
if trimmed.is_empty() {
i += 1;
continue;
}
// Fenced code block
if let Some(fence) = fence_delimiter(trimmed) {
i += 1;
let mut code_lines = Vec::new();
while i < lines.len() {
let candidate = lines[i].trim();
if candidate.starts_with(fence) {
i += 1;
break;
}
code_lines.push(lines[i]);
i += 1;
}
let code = escape_html(&code_lines.join("\n"));
blocks.push(format!("<pre><code>{}</code></pre>", code));
continue;
}
// ATX heading (#, ##, ...)
if let Some(content) = heading_text(trimmed) {
blocks.push(format!("<b>{}</b>", render_inline_markdown(content.trim())));
i += 1;
continue;
}
// Blockquote
if trimmed.starts_with('>') {
let mut quote_lines = Vec::new();
while i < lines.len() {
let current = lines[i].trim();
if current.is_empty() || !current.starts_with('>') {
break;
}
let content = current.strip_prefix('>').unwrap_or(current).trim_start();
quote_lines.push(render_inline_markdown(content));
i += 1;
}
blocks.push(format!(
"<blockquote>{}</blockquote>",
quote_lines.join("\n")
));
continue;
}
// Unordered list
if let Some(item) = unordered_list_item(trimmed) {
let mut items = vec![format!("{}", render_inline_markdown(item.trim()))];
i += 1;
while i < lines.len() {
let current = lines[i].trim();
if let Some(next_item) = unordered_list_item(current) {
items.push(format!("{}", render_inline_markdown(next_item.trim())));
i += 1;
} else if current.is_empty() {
i += 1;
break;
} else {
break;
}
}
blocks.push(items.join("\n"));
continue;
}
// Ordered list
if let Some(item) = ordered_list_item(trimmed) {
let mut items = vec![format!("1. {}", render_inline_markdown(item.trim()))];
let mut counter = 2;
i += 1;
while i < lines.len() {
let current = lines[i].trim();
if let Some(next_item) = ordered_list_item(current) {
items.push(format!(
"{}. {}",
counter,
render_inline_markdown(next_item.trim())
));
counter += 1;
i += 1;
} else if current.is_empty() {
i += 1;
break;
} else {
break;
}
}
blocks.push(items.join("\n"));
continue;
}
// Paragraph
let mut paragraph_lines = vec![trimmed];
i += 1;
while i < lines.len() {
let current = lines[i].trim();
if current.is_empty()
|| fence_delimiter(current).is_some()
|| heading_text(current).is_some()
|| current.starts_with('>')
|| unordered_list_item(current).is_some()
|| ordered_list_item(current).is_some()
{
break;
}
paragraph_lines.push(current);
i += 1;
}
let joined = paragraph_lines.join("\n");
blocks.push(render_inline_markdown(&joined));
}
blocks.join("\n\n")
}
fn render_inline_markdown(text: &str) -> String {
let mut result = escape_html(text);
// Links: [text](url) → <a href="url">text</a>
while let Some(bracket_start) = result.find('[') {
if let Some(bracket_end_rel) = result[bracket_start..].find("](") {
let bracket_end = bracket_start + bracket_end_rel;
if let Some(paren_end_rel) = result[bracket_end + 2..].find(')') {
let paren_end = bracket_end + 2 + paren_end_rel;
let link_text = result[bracket_start + 1..bracket_end].to_string();
let url = result[bracket_end + 2..paren_end].to_string();
result = format!(
"{}<a href=\"{}\">{}</a>{}",
&result[..bracket_start],
url,
link_text,
&result[paren_end + 1..]
);
} else {
break;
}
} else {
break;
}
}
// Bold: **text** → <b>text</b>
while let Some(start) = result.find("**") {
if let Some(end) = result[start + 2..].find("**") {
let end = start + 2 + end;
if let Some(end_rel) = result[start + 2..].find("**") {
let end = start + 2 + end_rel;
let inner = result[start + 2..end].to_string();
result = format!("{}<b>{}</b>{}", &result[..start], inner, &result[end + 2..]);
} else {
@@ -39,8 +195,23 @@ fn markdown_to_telegram_html(text: &str) -> String {
}
}
// Italic: *text* → <i>text</i> (but not inside bold tags)
// Simple heuristic: match single * not preceded/followed by *
// Inline code: `text` → <code>text</code>
while let Some(start) = result.find('`') {
if let Some(end_rel) = result[start + 1..].find('`') {
let end = start + 1 + end_rel;
let inner = result[start + 1..end].to_string();
result = format!(
"{}<code>{}</code>{}",
&result[..start],
inner,
&result[end + 1..]
);
} else {
break;
}
}
// Italic: *text* → <i>text</i> (single star only)
let mut out = String::with_capacity(result.len());
let chars: Vec<char> = result.chars().collect();
let mut i = 0;
@@ -61,48 +232,57 @@ fn markdown_to_telegram_html(text: &str) -> String {
}
i += 1;
}
result = out;
// Inline code: `text` → <code>text</code>
while let Some(start) = result.find('`') {
if let Some(end) = result[start + 1..].find('`') {
let end = start + 1 + end;
let inner = result[start + 1..end].to_string();
result = format!(
"{}<code>{}</code>{}",
&result[..start],
inner,
&result[end + 1..]
);
} else {
break;
out
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn fence_delimiter(line: &str) -> Option<&'static str> {
if line.starts_with("```") {
Some("```")
} else if line.starts_with("~~~") {
Some("~~~")
} else {
None
}
}
fn heading_text(line: &str) -> Option<&str> {
let hashes = line.chars().take_while(|c| *c == '#').count();
if (1..=6).contains(&hashes) && line.chars().nth(hashes) == Some(' ') {
Some(&line[hashes + 1..])
} else {
None
}
}
fn unordered_list_item(line: &str) -> Option<&str> {
for prefix in ["- ", "* ", "+ "] {
if let Some(rest) = line.strip_prefix(prefix) {
return Some(rest);
}
}
None
}
// Links: [text](url) → <a href="url">text</a>
while let Some(bracket_start) = result.find('[') {
if let Some(bracket_end) = result[bracket_start..].find("](") {
let bracket_end = bracket_start + bracket_end;
if let Some(paren_end) = result[bracket_end + 2..].find(')') {
let paren_end = bracket_end + 2 + paren_end;
let link_text = &result[bracket_start + 1..bracket_end];
let url = &result[bracket_end + 2..paren_end];
result = format!(
"{}<a href=\"{}\">{}</a>{}",
&result[..bracket_start],
url,
link_text,
&result[paren_end + 1..]
);
} else {
break;
}
} else {
break;
}
fn ordered_list_item(line: &str) -> Option<&str> {
let digit_count = line.chars().take_while(|c| c.is_ascii_digit()).count();
if digit_count == 0 {
return None;
}
let rest = &line[digit_count..];
if let Some(item) = rest.strip_prefix(". ") {
Some(item)
} else if let Some(item) = rest.strip_prefix(") ") {
Some(item)
} else {
None
}
result
}
/// Convert Markdown to Slack mrkdwn format.
@@ -146,6 +326,192 @@ fn markdown_to_slack_mrkdwn(text: &str) -> String {
result
}
fn strip_atx_heading(line: &str) -> String {
let trimmed = line.trim_start();
let heading_level = trimmed.chars().take_while(|c| *c == '#').count();
if !(1..=6).contains(&heading_level) {
return line.to_string();
}
if trimmed.chars().nth(heading_level) != Some(' ') {
return line.to_string();
}
trimmed[heading_level..]
.trim()
.trim_end_matches('#')
.trim_end()
.to_string()
}
fn strip_blockquote_prefix(line: &str) -> String {
let mut trimmed = line.trim_start();
while let Some(rest) = trimmed.strip_prefix('>') {
trimmed = rest.trim_start();
}
trimmed.to_string()
}
fn strip_task_list_prefix(line: &str) -> String {
let trimmed = line.trim_start();
for prefix in [
"- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] ", "+ [ ] ", "+ [x] ", "+ [X] ",
] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
return rest.to_string();
}
}
line.to_string()
}
fn is_fenced_code_marker(line: &str) -> bool {
let trimmed = line.trim();
let mut chars = trimmed.chars();
let Some(marker) = chars.next() else {
return false;
};
if marker != '`' && marker != '~' {
return false;
}
chars.all(|c| c == marker || c.is_ascii_alphanumeric())
}
fn is_setext_heading_underline(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.len() < 3 {
return false;
}
trimmed.chars().all(|c| c == '=' || c == '-') && trimmed.contains(['=', '-'])
}
fn is_table_divider(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty() && trimmed.chars().all(|c| matches!(c, '|' | ':' | '-' | ' '))
}
fn strip_inline_markdown(mut text: String) -> String {
while let Some(start) = text.find("![") {
if let Some(mid) = text[start..].find("](") {
let mid = start + mid;
if let Some(end) = text[mid + 2..].find(')') {
let end = mid + 2 + end;
let alt = &text[start + 2..mid];
let url = &text[mid + 2..end];
let replacement = if alt.is_empty() {
url.to_string()
} else {
format!("{alt} ({url})")
};
text = format!("{}{}{}", &text[..start], replacement, &text[end + 1..]);
continue;
}
}
break;
}
while let Some(start) = text.find('[') {
if let Some(mid) = text[start..].find("](") {
let mid = start + mid;
if let Some(end) = text[mid + 2..].find(')') {
let end = mid + 2 + end;
let label = &text[start + 1..mid];
let url = &text[mid + 2..end];
text = format!("{}{} ({}){}", &text[..start], label, url, &text[end + 1..]);
continue;
}
}
break;
}
while let Some(start) = text.find('<') {
if let Some(end) = text[start + 1..].find('>') {
let end = start + 1 + end;
let inner = &text[start + 1..end];
if inner.starts_with("http://")
|| inner.starts_with("https://")
|| inner.starts_with("mailto:")
{
text = format!("{}{}{}", &text[..start], inner, &text[end + 1..]);
continue;
}
}
break;
}
text = text.replace("**", "");
text = text.replace("__", "");
text = text.replace("~~", "");
text = text.replace('`', "");
let mut out = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
for (i, &ch) in chars.iter().enumerate() {
if ch == '*'
&& (i == 0 || chars[i - 1] != '*')
&& (i + 1 >= chars.len() || chars[i + 1] != '*')
{
continue;
}
out.push(ch);
}
out
}
/// Strip common Markdown blocks for WeCom plain-text replies.
fn markdown_to_wecom_plain(text: &str) -> String {
let mut result_lines = Vec::new();
let mut in_fenced_code = false;
for raw_line in text.replace("\r\n", "\n").lines() {
let trimmed = raw_line.trim();
if is_fenced_code_marker(trimmed) {
in_fenced_code = !in_fenced_code;
continue;
}
if in_fenced_code {
result_lines.push(raw_line.trim_end().to_string());
continue;
}
if is_setext_heading_underline(trimmed) || is_table_divider(trimmed) {
continue;
}
let mut line = strip_atx_heading(raw_line);
line = strip_blockquote_prefix(&line);
line = strip_task_list_prefix(&line);
let trimmed_line = line.trim();
if trimmed_line.starts_with('|') && trimmed_line.ends_with('|') && trimmed_line.len() > 2 {
line = trimmed_line
.trim_matches('|')
.split('|')
.map(|cell| cell.trim())
.collect::<Vec<_>>()
.join(" ");
}
line = strip_inline_markdown(line);
result_lines.push(line.trim().to_string());
}
let mut collapsed = Vec::new();
for line in result_lines {
if line.is_empty()
&& collapsed
.last()
.is_some_and(|prev: &String| prev.is_empty())
{
continue;
}
collapsed.push(line);
}
collapsed.join("\n").trim().to_string()
}
/// Strip all Markdown formatting, producing plain text.
fn markdown_to_plain(text: &str) -> String {
let mut result = text.to_string();
@@ -231,6 +597,36 @@ mod tests {
assert_eq!(result, "<a href=\"https://example.com\">click here</a>");
}
#[test]
fn test_telegram_html_heading() {
let result = markdown_to_telegram_html("## Result");
assert_eq!(result, "<b>Result</b>");
}
#[test]
fn test_telegram_html_unordered_list() {
let result = markdown_to_telegram_html("- alpha\n- beta");
assert_eq!(result, "• alpha\n• beta");
}
#[test]
fn test_telegram_html_ordered_list() {
let result = markdown_to_telegram_html("1. alpha\n2. beta");
assert_eq!(result, "1. alpha\n2. beta");
}
#[test]
fn test_telegram_html_fenced_code_block() {
let result = markdown_to_telegram_html("```rust\nfn main() {}\n```");
assert_eq!(result, "<pre><code>fn main() {}</code></pre>");
}
#[test]
fn test_telegram_html_blockquote() {
let result = markdown_to_telegram_html("> note\n> second line");
assert_eq!(result, "<blockquote>note\nsecond line</blockquote>");
}
#[test]
fn test_slack_mrkdwn_bold() {
let result = markdown_to_slack_mrkdwn("Hello **world**!");
@@ -254,4 +650,26 @@ mod tests {
let result = markdown_to_plain("[click](https://example.com)");
assert_eq!(result, "click (https://example.com)");
}
#[test]
fn test_wecom_plain_text_strips_common_markdown_blocks() {
let result = markdown_to_wecom_plain(
"# Title\n\
\n\
> quoted text\n\
\n\
- [x] done item\n\
- [ ] todo item\n\
\n\
```rust\n\
let value = 1;\n\
```\n\
\n\
[docs](https://example.com)\n",
);
assert_eq!(
result,
"Title\n\nquoted text\n\ndone item\ntodo item\n\nlet value = 1;\n\ndocs (https://example.com)"
);
}
}
+3
View File
@@ -43,10 +43,13 @@ pub mod twist;
pub mod webex;
// Wave 5 — Niche & differentiating channels
pub mod dingtalk;
pub mod dingtalk_stream;
pub mod discourse;
pub mod gitter;
pub mod gotify;
pub mod linkedin;
pub mod mumble;
pub mod mqtt;
pub mod ntfy;
pub mod webhook;
pub mod wecom;
+106 -9
View File
@@ -15,7 +15,7 @@ use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::{mpsc, watch};
use tracing::{info, warn};
use tracing::{debug, info, warn};
use zeroize::Zeroizing;
/// LINE push message API endpoint.
@@ -62,8 +62,8 @@ impl LineAdapter {
pub fn new(channel_secret: String, access_token: String, webhook_port: u16) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
channel_secret: Zeroizing::new(channel_secret),
access_token: Zeroizing::new(access_token),
channel_secret: Zeroizing::new(channel_secret.trim().to_string()),
access_token: Zeroizing::new(access_token.trim().to_string()),
webhook_port,
client: reqwest::Client::new(),
shutdown_tx: Arc::new(shutdown_tx),
@@ -96,12 +96,35 @@ impl LineAdapter {
// Constant-time comparison to prevent timing attacks
if result.len() != expected.len() {
debug!(
"LINE: signature length mismatch: computed={} received={}",
result.len(),
expected.len()
);
return false;
}
let mut diff = 0u8;
for (a, b) in result.iter().zip(expected.iter()) {
diff |= a ^ b;
}
if diff != 0 {
let computed = base64::engine::general_purpose::STANDARD.encode(&result);
// Log first/last 4 chars of each signature for debugging without leaking full HMAC
let comp_redacted = format!(
"{}...{}",
&computed[..4.min(computed.len())],
&computed[computed.len().saturating_sub(4)..]
);
let recv_redacted = format!(
"{}...{}",
&signature[..4.min(signature.len())],
&signature[signature.len().saturating_sub(4)..]
);
debug!(
"LINE: signature mismatch: computed={comp_redacted} received={recv_redacted} body_len={}",
body.len()
);
}
diff == 0
}
@@ -359,18 +382,18 @@ impl ChannelAdapter for LineAdapter {
let secret = Arc::clone(&channel_secret);
let tx = Arc::clone(&tx);
move |headers: axum::http::HeaderMap,
body: axum::extract::Json<serde_json::Value>| {
body: axum::body::Bytes| {
let secret = Arc::clone(&secret);
let tx = Arc::clone(&tx);
async move {
// Verify X-Line-Signature
// Verify X-Line-Signature using the raw request
// body bytes — NOT re-serialized JSON — because the
// HMAC must be computed over the exact bytes LINE sent.
let signature = headers
.get("x-line-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let body_bytes = serde_json::to_vec(&body.0).unwrap_or_default();
// Create a temporary adapter-like verifier
let adapter = LineAdapter {
channel_secret: secret.as_ref().clone(),
@@ -382,14 +405,23 @@ impl ChannelAdapter for LineAdapter {
};
if !signature.is_empty()
&& !adapter.verify_signature(&body_bytes, signature)
&& !adapter.verify_signature(&body, signature)
{
warn!("LINE: invalid webhook signature");
return axum::http::StatusCode::UNAUTHORIZED;
}
// Parse the raw bytes into JSON after signature verification
let parsed: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => {
warn!("LINE: failed to parse webhook body as JSON: {e}");
return axum::http::StatusCode::BAD_REQUEST;
}
};
// Parse events array
if let Some(events) = body.0["events"].as_array() {
if let Some(events) = parsed["events"].as_array() {
for event in events {
if let Some(msg) = parse_line_event(event) {
let _ = tx.send(msg).await;
@@ -626,6 +658,71 @@ mod tests {
assert!(parse_line_event(&event).is_none());
}
#[test]
fn test_verify_signature_with_raw_body() {
// Verify that HMAC-SHA256 signature validation works with raw body bytes
let secret = "test-channel-secret";
let adapter = LineAdapter::new(secret.to_string(), "token".to_string(), 9000);
// Compute the expected signature manually
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let body = br#"{"events":[{"type":"message"}]}"#;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
let expected_sig =
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
assert!(adapter.verify_signature(body, &expected_sig));
// Re-serialized JSON should NOT match (this was the bug)
let parsed: serde_json::Value = serde_json::from_slice(body).unwrap();
let reserialized = serde_json::to_vec(&parsed).unwrap();
// The re-serialized form may differ in whitespace/key order
// If it happens to be identical for this input, the test still validates
// the core mechanism works with raw bytes
if reserialized != body.to_vec() {
assert!(!adapter.verify_signature(&reserialized, &expected_sig));
}
}
#[test]
fn test_channel_secret_trimmed() {
// Environment variables often have trailing newlines or spaces
let adapter = LineAdapter::new(
" my-secret\n".to_string(),
" my-token\r\n".to_string(),
9000,
);
assert_eq!(adapter.channel_secret.as_str(), "my-secret");
assert_eq!(adapter.access_token.as_str(), "my-token");
}
#[test]
fn test_verify_signature_bad_base64() {
let adapter = LineAdapter::new("secret".to_string(), "token".to_string(), 9000);
assert!(!adapter.verify_signature(b"body", "not-valid-base64!!!"));
}
#[test]
fn test_verify_signature_wrong_secret() {
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let body = b"test body";
let mut mac = HmacSha256::new_from_slice(b"wrong-secret").unwrap();
mac.update(body);
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
let adapter = LineAdapter::new("correct-secret".to_string(), "token".to_string(), 9000);
assert!(!adapter.verify_signature(body, &sig));
}
#[test]
fn test_parse_line_event_room_source() {
let event = serde_json::json!({
+14 -2
View File
@@ -456,10 +456,18 @@ impl ChannelAdapter for MastodonAdapter {
let notifications: Vec<serde_json::Value> =
poll_resp.json().await.unwrap_or_default();
for notif in &notifications {
if let Some(nid) = notif["id"].as_str() {
// Mastodon returns notifications newest-first. Record the first
// (highest) ID before processing so we never re-fetch these on
// the next poll. Updating inside the loop would leave us with
// the oldest ID, causing every previously seen notification to
// be re-delivered and re-processed.
if let Some(newest) = notifications.first() {
if let Some(nid) = newest["id"].as_str() {
last_notification_id = Some(nid.to_string());
}
}
for notif in &notifications {
if let Some(msg) = parse_mastodon_notification(notif, &own_account_id) {
if tx.send(msg).await.is_err() {
return;
@@ -518,6 +526,10 @@ impl ChannelAdapter for MastodonAdapter {
Ok(())
}
fn suppress_error_responses(&self) -> bool {
true
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
+194 -8
View File
@@ -12,7 +12,7 @@ use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{info, warn};
use tracing::{debug, info, warn};
use zeroize::Zeroizing;
const SYNC_TIMEOUT_MS: u64 = 30000;
@@ -35,6 +35,8 @@ pub struct MatrixAdapter {
shutdown_rx: watch::Receiver<bool>,
/// Sync token for resuming /sync.
since_token: Arc<RwLock<Option<String>>>,
/// Whether to auto-accept room invites.
auto_accept_invites: bool,
}
impl MatrixAdapter {
@@ -44,6 +46,7 @@ impl MatrixAdapter {
user_id: String,
access_token: String,
allowed_rooms: Vec<String>,
auto_accept_invites: bool,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
@@ -55,6 +58,7 @@ impl MatrixAdapter {
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
since_token: Arc::new(RwLock::new(None)),
auto_accept_invites,
}
}
@@ -116,12 +120,84 @@ impl MatrixAdapter {
Ok(user_id)
}
#[allow(dead_code)]
#[cfg(test)]
fn is_allowed_room(&self, room_id: &str) -> bool {
self.allowed_rooms.is_empty() || self.allowed_rooms.iter().any(|r| r == room_id)
}
}
/// Accept a room invite by calling POST /_matrix/client/v3/rooms/{room_id}/join.
async fn accept_invite(
client: &reqwest::Client,
homeserver: &str,
access_token: &str,
room_id: &str,
) {
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/join");
match client
.post(&url)
.bearer_auth(access_token)
.json(&serde_json::json!({}))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
info!("Matrix: auto-accepted invite to {room_id}");
}
Ok(resp) => {
let status = resp.status();
warn!("Matrix: failed to accept invite to {room_id}: {status}");
}
Err(e) => {
warn!("Matrix: error accepting invite to {room_id}: {e}");
}
}
}
/// Get the number of joined members in a room.
async fn get_room_member_count(
client: &reqwest::Client,
homeserver: &str,
access_token: &str,
room_id: &str,
) -> Option<usize> {
let url = format!("{homeserver}/_matrix/client/v3/rooms/{room_id}/joined_members");
let resp = client
.get(&url)
.bearer_auth(access_token)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body["joined"].as_object().map(|m| m.len())
}
/// Do an initial /sync with timeout=0 to get the since token without processing events.
/// This prevents replaying old messages when the adapter first connects.
async fn initial_sync(
client: &reqwest::Client,
homeserver: &str,
access_token: &str,
) -> Option<String> {
let url = format!(
"{homeserver}/_matrix/client/v3/sync?timeout=0&filter={{\"room\":{{\"timeline\":{{\"limit\":0}}}}}}"
);
let resp = client
.get(&url)
.bearer_auth(access_token)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body["next_batch"].as_str().map(String::from)
}
#[async_trait]
impl ChannelAdapter for MatrixAdapter {
fn name(&self) -> &str {
@@ -143,14 +219,32 @@ impl ChannelAdapter for MatrixAdapter {
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let homeserver = self.homeserver_url.clone();
let access_token = self.access_token.clone();
let user_id = self.user_id.clone();
// Use the validated user ID from /whoami instead of the config value.
// Matrix server delegation or casing differences can cause self.user_id
// to not match the sender field in timeline events, making the bot
// process its own replies in an infinite loop (see #757).
let user_id = validated_user;
let allowed_rooms = self.allowed_rooms.clone();
let client = self.client.clone();
let since_token = Arc::clone(&self.since_token);
let mut shutdown_rx = self.shutdown_rx.clone();
let auto_accept = self.auto_accept_invites;
// FIX #4: Do an initial sync to get the since token, skipping old messages.
if since_token.read().await.is_none() {
if let Some(token) = initial_sync(&client, &homeserver, access_token.as_str()).await {
info!("Matrix: initial sync complete, skipping old messages");
*since_token.write().await = Some(token);
}
}
tokio::spawn(async move {
let mut backoff = Duration::from_secs(1);
// Track recently seen event IDs to prevent duplicate processing
// on sync token races or reconnects.
let mut seen_events: std::collections::HashSet<String> =
std::collections::HashSet::new();
const MAX_SEEN: usize = 500;
loop {
// Build /sync URL
@@ -168,7 +262,7 @@ impl ChannelAdapter for MatrixAdapter {
info!("Matrix adapter shutting down");
break;
}
result = client.get(&url).bearer_auth(&*access_token).send() => {
result = client.get(&url).bearer_auth(access_token.as_str()).send() => {
match result {
Ok(r) => r,
Err(e) => {
@@ -203,6 +297,24 @@ impl ChannelAdapter for MatrixAdapter {
*since_token.write().await = Some(next.to_string());
}
// FIX #1: Auto-accept room invites.
if auto_accept {
if let Some(invites) = body["rooms"]["invite"].as_object() {
for (room_id, _invite_data) in invites {
if !allowed_rooms.is_empty()
&& !allowed_rooms.iter().any(|r| r == room_id)
{
debug!(
"Matrix: ignoring invite to {room_id} (not in allowed_rooms)"
);
continue;
}
accept_invite(&client, &homeserver, access_token.as_str(), room_id)
.await;
}
}
}
// Process room events
if let Some(rooms) = body["rooms"]["join"].as_object() {
for (room_id, room_data) in rooms {
@@ -223,6 +335,21 @@ impl ChannelAdapter for MatrixAdapter {
continue; // Skip own messages
}
// Dedup: skip events we've already processed.
let event_id_str =
event["event_id"].as_str().unwrap_or("").to_string();
if !event_id_str.is_empty() {
if seen_events.contains(&event_id_str) {
debug!("Matrix: skipping duplicate event {event_id_str}");
continue;
}
seen_events.insert(event_id_str.clone());
// Prevent unbounded growth
if seen_events.len() > MAX_SEEN {
seen_events.clear();
}
}
let content = event["content"]["body"].as_str().unwrap_or("");
if content.is_empty() {
continue;
@@ -243,11 +370,67 @@ impl ChannelAdapter for MatrixAdapter {
ChannelContent::Text(content.to_string())
};
let event_id = event["event_id"].as_str().unwrap_or("").to_string();
// FIX #2: Detect @mentions in message text.
let mut metadata = HashMap::new();
if content.contains(&user_id) {
metadata.insert(
"was_mentioned".to_string(),
serde_json::json!(true),
);
}
// FIX #3: Determine if room is a DM (2 members) or group.
let is_group = get_room_member_count(
&client,
&homeserver,
access_token.as_str(),
room_id,
)
.await
.map(|count| count > 2)
.unwrap_or(true);
// For DMs, auto-set was_mentioned so dm_policy works.
if !is_group {
metadata.insert(
"was_mentioned".to_string(),
serde_json::json!(true),
);
metadata.insert("is_dm".to_string(), serde_json::json!(true));
}
// FIX #2: Detect @mentions in message text.
let mut metadata = HashMap::new();
if content.contains(&user_id) {
metadata.insert(
"was_mentioned".to_string(),
serde_json::json!(true),
);
}
// FIX #3: Determine if room is a DM (2 members) or group.
let is_group = get_room_member_count(
&client,
&homeserver,
access_token.as_str(),
room_id,
)
.await
.map(|count| count > 2)
.unwrap_or(true);
// For DMs, auto-set was_mentioned so dm_policy works.
if !is_group {
metadata.insert(
"was_mentioned".to_string(),
serde_json::json!(true),
);
metadata.insert("is_dm".to_string(), serde_json::json!(true));
}
let channel_msg = ChannelMessage {
channel: ChannelType::Matrix,
platform_message_id: event_id,
platform_message_id: event_id_str,
sender: ChannelUser {
platform_id: room_id.clone(),
display_name: sender.to_string(),
@@ -256,9 +439,9 @@ impl ChannelAdapter for MatrixAdapter {
content: msg_content,
target_agent: None,
timestamp: Utc::now(),
is_group: true,
is_group,
thread_id: None,
metadata: HashMap::new(),
metadata,
};
if tx.send(channel_msg).await.is_err() {
@@ -330,6 +513,7 @@ mod tests {
"@bot:matrix.org".to_string(),
"access_token".to_string(),
vec![],
false,
);
assert_eq!(adapter.name(), "matrix");
}
@@ -341,6 +525,7 @@ mod tests {
"@bot:matrix.org".to_string(),
"token".to_string(),
vec!["!room1:matrix.org".to_string()],
false,
);
assert!(adapter.is_allowed_room("!room1:matrix.org"));
assert!(!adapter.is_allowed_room("!room2:matrix.org"));
@@ -350,6 +535,7 @@ mod tests {
"@bot:matrix.org".to_string(),
"token".to_string(),
vec![],
false,
);
assert!(open.is_allowed_room("!any:matrix.org"));
}
+604
View File
@@ -0,0 +1,604 @@
//! MQTT channel adapter.
//!
//! Provides a generic MQTT pub/sub interface for IoT and messaging integration.
//! Supports standard MQTT 3.1.1/5.0 brokers with optional TLS and authentication.
//!
//! # Configuration
//!
//! ```toml
//! [channels.mqtt]
//! broker_url = "tcp://broker.hivemq.com:1883"
//! subscribe_topic = "openfang/inbox"
//! publish_topic = "openfang/outbox"
//! username_env = "MQTT_USERNAME"
//! password_env = "MQTT_PASSWORD"
//! use_tls = false
//! qos = 1
//! ```
//!
//! # Message Format
//!
//! Incoming messages are expected as UTF-8 text. The adapter supports:
//! - Plain text messages
//! - JSON payloads with `{"text": "message"}` format
//! - Command messages starting with `/`
use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use chrono::Utc;
use futures::Stream;
use rumqttc::{AsyncClient, Event, Incoming, MqttOptions, QoS};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{info, warn};
/// Maximum MQTT message length.
const MAX_MESSAGE_LEN: usize = 4096;
/// Type alias for the publish channel sender.
type PublishSender = Arc<RwLock<Option<mpsc::Sender<(String, String)>>>>;
/// MQTT pub/sub channel adapter.
///
/// Connects to an MQTT broker, subscribes to a topic for incoming messages,
/// and publishes responses to another topic.
pub struct MqttAdapter {
/// MQTT broker URL (e.g., `"tcp://broker.hivemq.com:1883"`).
broker_url: String,
/// Client identifier (auto-generated if empty).
client_id: String,
/// Topic to subscribe to for incoming messages.
subscribe_topic: String,
/// Topic to publish responses to.
publish_topic: String,
/// Optional username for authentication.
username: Option<String>,
/// Optional password for authentication.
password: Option<String>,
/// Use TLS/SSL connection.
use_tls: bool,
/// Keep-alive interval in seconds.
keep_alive: u16,
/// Clean session flag.
clean_session: bool,
/// QoS level for subscriptions.
qos: QoS,
/// Shutdown signal.
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
/// Sender for publishing messages (used to communicate with the event loop task).
publish_tx: PublishSender,
}
impl MqttAdapter {
/// Create a new MQTT adapter.
///
/// # Arguments
/// * `broker_url` - MQTT broker URL (e.g., `"tcp://broker.hivemq.com:1883"`).
/// * `client_id` - Client identifier (auto-generated if empty).
/// * `subscribe_topic` - Topic to subscribe to for incoming messages.
/// * `publish_topic` - Topic to publish responses to (defaults to subscribe_topic if empty).
/// * `username` - Optional username for authentication.
/// * `password` - Optional password for authentication.
/// * `use_tls` - Use TLS/SSL connection.
/// * `keep_alive` - Keep-alive interval in seconds.
/// * `clean_session` - Clean session flag.
/// * `qos` - QoS level (0, 1, or 2).
#[allow(clippy::too_many_arguments)]
pub fn new(
broker_url: String,
client_id: String,
subscribe_topic: String,
publish_topic: String,
username: Option<String>,
password: Option<String>,
use_tls: bool,
keep_alive: u16,
clean_session: bool,
qos: u8,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let publish_topic = if publish_topic.is_empty() {
subscribe_topic.clone()
} else {
publish_topic
};
let qos = match qos {
0 => QoS::AtMostOnce,
2 => QoS::ExactlyOnce,
_ => QoS::AtLeastOnce,
};
Self {
broker_url,
client_id,
subscribe_topic,
publish_topic,
username,
password,
use_tls,
keep_alive,
clean_session,
qos,
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
publish_tx: Arc::new(RwLock::new(None)),
}
}
/// Parse broker URL into host and port.
fn parse_broker_url(&self) -> Result<(String, u16), Box<dyn std::error::Error>> {
let url = self.broker_url.trim();
// Handle different URL schemes
if let Some(rest) = url.strip_prefix("tcp://") {
Self::parse_host_port(rest, 1883)
} else if let Some(rest) = url.strip_prefix("ssl://") {
Self::parse_host_port(rest, 8883)
} else if self.use_tls && !url.contains("://") {
// Plain host with TLS flag
Self::parse_host_port(url, 8883)
} else if url.contains("://") {
Err(format!("Unsupported MQTT URL scheme: {url}").into())
} else {
// Plain host:port or just host (no TLS)
Self::parse_host_port(url, 1883)
}
}
/// Parse host:port string.
fn parse_host_port(s: &str, default_port: u16) -> Result<(String, u16), Box<dyn std::error::Error>> {
let s = s.trim();
if let Some(colon_pos) = s.rfind(':') {
let host = s[..colon_pos].to_string();
let port = s[colon_pos + 1..].parse::<u16>()?;
Ok((host, port))
} else {
Ok((s.to_string(), default_port))
}
}
/// Build MQTT options.
fn build_mqtt_options(&self) -> Result<MqttOptions, Box<dyn std::error::Error>> {
let (host, port) = self.parse_broker_url()?;
let client_id = if self.client_id.is_empty() {
format!("openfang-{}", uuid::Uuid::new_v4())
} else {
self.client_id.clone()
};
let mut options = MqttOptions::new(client_id, host, port);
options.set_keep_alive(Duration::from_secs(self.keep_alive as u64));
options.set_clean_session(self.clean_session);
if let (Some(user), Some(pass)) = (&self.username, &self.password) {
options.set_credentials(user, pass);
}
// Note: TLS support requires additional configuration with rustls
// For now, we use native TLS through the use_tls flag
if self.use_tls {
// rumqttc handles TLS automatically when using ssl:// or with explicit config
// This is a simplified approach; production use may need custom TLS config
}
Ok(options)
}
/// Parse incoming MQTT payload.
fn parse_payload(payload: &[u8]) -> Option<String> {
if payload.is_empty() {
return None;
}
// Try UTF-8 first
if let Ok(text) = std::str::from_utf8(payload) {
// Check for JSON format {"text": "message"}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(text) {
if let Some(text_val) = json.get("text").and_then(|v| v.as_str()) {
return Some(text_val.to_string());
}
}
return Some(text.to_string());
}
None
}
/// Publish a message to the configured topic.
async fn publish_message(&self, text: &str) -> Result<(), Box<dyn std::error::Error>> {
let tx_guard = self.publish_tx.read().await;
if let Some(tx) = tx_guard.as_ref() {
let chunks = split_message(text, MAX_MESSAGE_LEN);
for chunk in chunks {
tx.send((self.publish_topic.clone(), chunk.to_string()))
.await
.map_err(|e| format!("Failed to send publish request: {e}"))?;
}
Ok(())
} else {
Err("MQTT client not connected".into())
}
}
}
#[async_trait]
impl ChannelAdapter for MqttAdapter {
fn name(&self) -> &str {
"mqtt"
}
fn channel_type(&self) -> ChannelType {
ChannelType::Mqtt
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>> {
let options = self.build_mqtt_options()?;
let (client, mut eventloop) = AsyncClient::new(options, 10);
info!(
"MQTT adapter connecting to {} (subscribe: {}, publish: {})",
self.broker_url, self.subscribe_topic, self.publish_topic
);
// Subscribe to topic
client.subscribe(&self.subscribe_topic, self.qos).await?;
// Channel for incoming messages
let (msg_tx, rx) = mpsc::channel::<ChannelMessage>(256);
// Channel for outgoing publish requests
let (publish_tx, mut publish_rx) = mpsc::channel::<(String, String)>(64);
// Store the publish sender
{
let mut tx_guard = self.publish_tx.write().await;
*tx_guard = Some(publish_tx);
}
let subscribe_topic = self.subscribe_topic.clone();
let qos = self.qos;
let mut shutdown_rx = self.shutdown_rx.clone();
// Spawn the event loop task
tokio::spawn(async move {
let mut backoff = Duration::from_secs(1);
let max_backoff = Duration::from_secs(60);
loop {
if *shutdown_rx.borrow() {
info!("MQTT adapter shutting down");
break;
}
tokio::select! {
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
info!("MQTT adapter shutting down");
break;
}
}
publish_req = publish_rx.recv() => {
if let Some((topic, payload)) = publish_req {
if let Err(e) = client.publish(&topic, qos, false, payload).await {
warn!("MQTT publish error: {}", e);
}
}
}
event = eventloop.poll() => {
match event {
Ok(Event::Incoming(Incoming::Publish(publish))) => {
backoff = Duration::from_secs(1); // Reset backoff on success
let topic = publish.topic.clone();
if topic != subscribe_topic {
continue;
}
if let Some(text) = Self::parse_payload(&publish.payload) {
if text.is_empty() {
continue;
}
let content = if text.starts_with('/') {
let parts: Vec<&str> = text.splitn(2, ' ').collect();
let cmd = parts[0].trim_start_matches('/');
let args: Vec<String> = parts
.get(1)
.map(|a| {
a.split_whitespace()
.map(String::from)
.collect()
})
.unwrap_or_default();
ChannelContent::Command {
name: cmd.to_string(),
args,
}
} else {
ChannelContent::Text(text)
};
let msg = ChannelMessage {
channel: ChannelType::Mqtt,
platform_message_id: format!("{:?}", publish.pkid),
sender: ChannelUser {
platform_id: "mqtt-user".to_string(),
display_name: "MQTT User".to_string(),
openfang_user: None,
},
content,
target_agent: None,
timestamp: Utc::now(),
is_group: true,
thread_id: None,
metadata: {
let mut m = HashMap::new();
m.insert(
"topic".to_string(),
serde_json::Value::String(topic.clone()),
);
m.insert(
"qos".to_string(),
serde_json::Value::Number((publish.qos as i64).into()),
);
m
},
};
if msg_tx.send(msg).await.is_err() {
info!("MQTT receiver dropped, stopping");
return;
}
}
}
Ok(Event::Incoming(Incoming::ConnAck(_))) => {
info!("MQTT connected to broker");
backoff = Duration::from_secs(1);
}
Ok(Event::Incoming(Incoming::Disconnect)) => {
warn!("MQTT disconnected from broker");
}
Err(e) => {
warn!("MQTT connection error: {}, backing off for {:?}", e, backoff);
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(max_backoff);
}
_ => {}
}
}
}
}
info!("MQTT event loop stopped");
});
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn send(
&self,
_user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let text = match content {
ChannelContent::Text(t) => t,
ChannelContent::Command { name, args } => {
if args.is_empty() {
format!("/{name}")
} else {
format!("/{} {}", name, args.join(" "))
}
}
_ => "(Unsupported content type)".to_string(),
};
self.publish_message(&text).await
}
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
// MQTT has no typing indicator concept.
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
// Clear the publish channel
let mut tx_guard = self.publish_tx.write().await;
*tx_guard = None;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mqtt_adapter_creation() {
let adapter = MqttAdapter::new(
"tcp://broker.hivemq.com:1883".to_string(),
"test-client".to_string(),
"test/topic".to_string(),
String::new(),
None,
None,
false,
60,
true,
1,
);
assert_eq!(adapter.name(), "mqtt");
assert_eq!(adapter.channel_type(), ChannelType::Mqtt);
assert_eq!(adapter.subscribe_topic, "test/topic");
assert_eq!(adapter.publish_topic, "test/topic"); // Falls back to subscribe_topic
}
#[test]
fn test_mqtt_adapter_with_separate_publish_topic() {
let adapter = MqttAdapter::new(
"tcp://broker.hivemq.com:1883".to_string(),
String::new(),
"inbox".to_string(),
"outbox".to_string(),
None,
None,
false,
60,
true,
1,
);
assert_eq!(adapter.subscribe_topic, "inbox");
assert_eq!(adapter.publish_topic, "outbox");
}
#[test]
fn test_parse_broker_url_tcp() {
let adapter = MqttAdapter::new(
"tcp://broker.example.com:1883".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
false,
60,
true,
1,
);
let (host, port) = adapter.parse_broker_url().unwrap();
assert_eq!(host, "broker.example.com");
assert_eq!(port, 1883);
}
#[test]
fn test_parse_broker_url_tcp_default_port() {
let adapter = MqttAdapter::new(
"tcp://broker.example.com".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
false,
60,
true,
1,
);
let (host, port) = adapter.parse_broker_url().unwrap();
assert_eq!(host, "broker.example.com");
assert_eq!(port, 1883);
}
#[test]
fn test_parse_broker_url_ssl() {
let adapter = MqttAdapter::new(
"ssl://broker.example.com:8883".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
true,
60,
true,
1,
);
let (host, port) = adapter.parse_broker_url().unwrap();
assert_eq!(host, "broker.example.com");
assert_eq!(port, 8883);
}
#[test]
fn test_parse_broker_url_plain_host() {
let adapter = MqttAdapter::new(
"broker.example.com".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
false,
60,
true,
1,
);
let (host, port) = adapter.parse_broker_url().unwrap();
assert_eq!(host, "broker.example.com");
assert_eq!(port, 1883);
}
#[test]
fn test_parse_payload_text() {
let payload = b"Hello, MQTT!";
let result = MqttAdapter::parse_payload(payload);
assert_eq!(result, Some("Hello, MQTT!".to_string()));
}
#[test]
fn test_parse_payload_json() {
let payload = br#"{"text": "Hello from JSON"}"#;
let result = MqttAdapter::parse_payload(payload);
assert_eq!(result, Some("Hello from JSON".to_string()));
}
#[test]
fn test_parse_payload_empty() {
let payload = b"";
let result = MqttAdapter::parse_payload(payload);
assert!(result.is_none());
}
#[test]
fn test_qos_conversion() {
let adapter = MqttAdapter::new(
"tcp://broker.example.com".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
false,
60,
true,
0,
);
assert_eq!(adapter.qos, QoS::AtMostOnce);
let adapter = MqttAdapter::new(
"tcp://broker.example.com".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
false,
60,
true,
1,
);
assert_eq!(adapter.qos, QoS::AtLeastOnce);
let adapter = MqttAdapter::new(
"tcp://broker.example.com".to_string(),
String::new(),
"test".to_string(),
String::new(),
None,
None,
false,
60,
true,
2,
);
assert_eq!(adapter.qos, QoS::ExactlyOnce);
}
}
+5 -2
View File
@@ -165,7 +165,10 @@ impl ChannelAdapter for NostrAdapter {
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
let pubkey = self.derive_pubkey();
info!("Nostr adapter starting (pubkey: {}...)", openfang_types::truncate_str(&pubkey, 16));
info!(
"Nostr adapter starting (pubkey: {}...)",
openfang_types::truncate_str(&pubkey, 16)
);
if self.relays.is_empty() {
return Err("Nostr: no relay URLs configured".into());
@@ -339,7 +342,7 @@ impl ChannelAdapter for NostrAdapter {
platform_id: sender_pubkey.clone(),
display_name: format!(
"{}...",
&sender_pubkey[..8.min(sender_pubkey.len())]
openfang_types::truncate_str(&sender_pubkey, 8)
),
openfang_user: None,
},
+29
View File
@@ -34,6 +34,8 @@ pub struct AgentRouter {
default_agent: Option<AgentId>,
/// Per-channel-type default agent (e.g., Telegram -> agent_a, Discord -> agent_b).
channel_defaults: DashMap<String, AgentId>,
/// Per-channel-type default agent *name* (for re-resolution when UUID becomes stale).
channel_default_names: DashMap<String, String>,
/// 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.
@@ -50,6 +52,7 @@ impl AgentRouter {
direct_routes: DashMap::new(),
default_agent: None,
channel_defaults: DashMap::new(),
channel_default_names: DashMap::new(),
bindings: Mutex::new(Vec::new()),
broadcast: Mutex::new(BroadcastConfig::default()),
agent_name_cache: DashMap::new(),
@@ -66,6 +69,31 @@ impl AgentRouter {
self.channel_defaults.insert(channel_key, agent_id);
}
/// Set a per-channel-type default agent AND remember the agent name for
/// re-resolution when the cached UUID becomes stale (e.g. after agent restart).
pub fn set_channel_default_with_name(
&self,
channel_key: String,
agent_id: AgentId,
agent_name: String,
) {
self.channel_defaults.insert(channel_key.clone(), agent_id);
self.channel_default_names.insert(channel_key, agent_name);
}
/// Retrieve the stored agent name for a channel default (if any).
pub fn channel_default_name(&self, channel_key: &str) -> Option<String> {
self.channel_default_names
.get(channel_key)
.map(|r| r.clone())
}
/// Update the cached agent ID for a channel default (after re-resolution).
pub fn update_channel_default(&self, channel_key: &str, new_agent_id: AgentId) {
self.channel_defaults
.insert(channel_key.to_string(), new_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);
@@ -327,6 +355,7 @@ fn channel_type_to_str(ct: &ChannelType) -> &str {
ChannelType::WebChat => "webchat",
ChannelType::CLI => "cli",
ChannelType::Custom(s) => s.as_str(),
_ => "unknown",
}
}
+189 -19
View File
@@ -7,11 +7,12 @@ use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use dashmap::DashMap;
use futures::{SinkExt, Stream, StreamExt};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{debug, error, info, warn};
use zeroize::Zeroizing;
@@ -32,10 +33,25 @@ pub struct SlackAdapter {
shutdown_rx: watch::Receiver<bool>,
/// Bot's own user ID (populated after auth.test).
bot_user_id: Arc<RwLock<Option<String>>>,
/// Threads where the bot was @-mentioned. Maps thread_ts -> last interaction time.
active_threads: Arc<DashMap<String, Instant>>,
/// How long to track a thread after last interaction.
thread_ttl: Duration,
/// Whether auto-thread-reply is enabled.
auto_thread_reply: bool,
/// Whether to unfurl (expand previews for) links in posted messages.
unfurl_links: bool,
}
impl SlackAdapter {
pub fn new(app_token: String, bot_token: String, allowed_channels: Vec<String>) -> Self {
pub fn new(
app_token: String,
bot_token: String,
allowed_channels: Vec<String>,
auto_thread_reply: bool,
thread_ttl_hours: u64,
unfurl_links: bool,
) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
app_token: Zeroizing::new(app_token),
@@ -45,6 +61,10 @@ impl SlackAdapter {
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
bot_user_id: Arc::new(RwLock::new(None)),
active_threads: Arc::new(DashMap::new()),
thread_ttl: Duration::from_secs(thread_ttl_hours * 3600),
auto_thread_reply,
unfurl_links,
}
}
@@ -76,14 +96,20 @@ impl SlackAdapter {
&self,
channel_id: &str,
text: &str,
thread_ts: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let chunks = split_message(text, SLACK_MSG_LIMIT);
for chunk in chunks {
let body = serde_json::json!({
let mut body = serde_json::json!({
"channel": channel_id,
"text": chunk,
"unfurl_links": self.unfurl_links,
"unfurl_media": self.unfurl_links,
});
if let Some(ts) = thread_ts {
body["thread_ts"] = serde_json::json!(ts);
}
let resp: serde_json::Value = self
.client
@@ -133,6 +159,30 @@ impl ChannelAdapter for SlackAdapter {
let allowed_channels = self.allowed_channels.clone();
let client = self.client.clone();
let mut shutdown = self.shutdown_rx.clone();
let active_threads = self.active_threads.clone();
let auto_thread_reply = self.auto_thread_reply;
// Spawn periodic cleanup of expired thread entries.
{
let active_threads = self.active_threads.clone();
let thread_ttl = self.thread_ttl;
let mut cleanup_shutdown = self.shutdown_rx.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(300));
loop {
tokio::select! {
_ = interval.tick() => {
active_threads.retain(|_, last| last.elapsed() < thread_ttl);
}
_ = cleanup_shutdown.changed() => {
if *cleanup_shutdown.borrow() {
return;
}
}
}
}
});
}
tokio::spawn(async move {
let mut backoff = INITIAL_BACKOFF;
@@ -240,8 +290,14 @@ impl ChannelAdapter for SlackAdapter {
// Extract the event
let event = &payload["payload"]["event"];
if let Some(msg) =
parse_slack_event(event, &bot_user_id, &allowed_channels).await
if let Some(msg) = parse_slack_event(
event,
&bot_user_id,
&allowed_channels,
&active_threads,
auto_thread_reply,
)
.await
{
debug!(
"Slack message from {}: {:?}",
@@ -289,10 +345,30 @@ impl ChannelAdapter for SlackAdapter {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_message(channel_id, &text).await?;
self.api_send_message(channel_id, &text, None).await?;
}
_ => {
self.api_send_message(channel_id, "(Unsupported content type)")
self.api_send_message(channel_id, "(Unsupported content type)", None)
.await?;
}
}
Ok(())
}
async fn send_in_thread(
&self,
user: &ChannelUser,
content: ChannelContent,
thread_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let channel_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
self.api_send_message(channel_id, &text, Some(thread_id))
.await?;
}
_ => {
self.api_send_message(channel_id, "(Unsupported content type)", Some(thread_id))
.await?;
}
}
@@ -335,9 +411,11 @@ async fn parse_slack_event(
event: &serde_json::Value,
bot_user_id: &Arc<RwLock<Option<String>>>,
allowed_channels: &[String],
active_threads: &Arc<DashMap<String, Instant>>,
auto_thread_reply: bool,
) -> Option<ChannelMessage> {
let event_type = event["type"].as_str()?;
if event_type != "message" {
if event_type != "message" && event_type != "app_mention" {
return None;
}
@@ -413,6 +491,50 @@ async fn parse_slack_event(
ChannelContent::Text(text.to_string())
};
// Extract thread_id: threaded replies have `thread_ts`, top-level messages
// use their own `ts` so the reply will start a thread under the original.
let thread_id = msg_data["thread_ts"]
.as_str()
.or_else(|| event["thread_ts"].as_str())
.map(|s| s.to_string())
.or_else(|| Some(ts.to_string()));
// Check if the bot was @-mentioned (for group_policy = "mention_only")
let mut metadata = HashMap::new();
if event_type == "app_mention" {
metadata.insert("was_mentioned".to_string(), serde_json::Value::Bool(true));
}
// Determine the real thread_ts from the event (None for top-level messages).
let real_thread_ts = msg_data["thread_ts"]
.as_str()
.or_else(|| event["thread_ts"].as_str());
let mut explicitly_mentioned = false;
if let Some(ref bid) = *bot_user_id.read().await {
let mention_tag = format!("<@{bid}>");
if text.contains(&mention_tag) {
explicitly_mentioned = true;
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
// Track thread for auto-reply on subsequent messages.
if let Some(tts) = real_thread_ts {
active_threads.insert(tts.to_string(), Instant::now());
}
}
}
// Auto-reply to follow-up messages in tracked threads.
if !explicitly_mentioned && auto_thread_reply {
if let Some(tts) = real_thread_ts {
if let Some(mut entry) = active_threads.get_mut(tts) {
// Refresh TTL and mark as mentioned so dispatch proceeds.
*entry = Instant::now();
metadata.insert("was_mentioned".to_string(), serde_json::json!(true));
}
}
}
Some(ChannelMessage {
channel: ChannelType::Slack,
platform_message_id: ts.to_string(),
@@ -425,8 +547,8 @@ async fn parse_slack_event(
target_agent: None,
timestamp,
is_group: true,
thread_id: None,
metadata: HashMap::new(),
thread_id,
metadata,
})
}
@@ -445,7 +567,9 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Slack);
assert_eq!(msg.sender.platform_id, "C789");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello agent!"));
@@ -463,7 +587,7 @@ mod tests {
"bot_id": "B999"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await;
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
}
@@ -478,7 +602,7 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await;
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
}
@@ -494,12 +618,25 @@ mod tests {
});
// Not in allowed channels
let msg =
parse_slack_event(&event, &bot_id, &["C111".to_string(), "C222".to_string()]).await;
let msg = parse_slack_event(
&event,
&bot_id,
&["C111".to_string(), "C222".to_string()],
&Arc::new(DashMap::new()),
true,
)
.await;
assert!(msg.is_none());
// In allowed channels
let msg = parse_slack_event(&event, &bot_id, &["C789".to_string()]).await;
let msg = parse_slack_event(
&event,
&bot_id,
&["C789".to_string()],
&Arc::new(DashMap::new()),
true,
)
.await;
assert!(msg.is_some());
}
@@ -516,7 +653,7 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await;
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true).await;
assert!(msg.is_none());
}
@@ -531,7 +668,9 @@ mod tests {
"ts": "1700000000.000100"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
.await
.unwrap();
match &msg.content {
ChannelContent::Command { name, args } => {
assert_eq!(name, "agent");
@@ -556,7 +695,9 @@ mod tests {
"ts": "1700000001.000200"
});
let msg = parse_slack_event(&event, &bot_id, &[]).await.unwrap();
let msg = parse_slack_event(&event, &bot_id, &[], &Arc::new(DashMap::new()), true)
.await
.unwrap();
assert_eq!(msg.channel, ChannelType::Slack);
assert_eq!(msg.sender.platform_id, "C789");
assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Edited message text"));
@@ -568,8 +709,37 @@ mod tests {
"xapp-test".to_string(),
"xoxb-test".to_string(),
vec!["C123".to_string()],
true,
24,
true,
);
assert_eq!(adapter.name(), "slack");
assert_eq!(adapter.channel_type(), ChannelType::Slack);
}
#[test]
fn test_slack_adapter_unfurl_links_enabled() {
let adapter = SlackAdapter::new(
"xapp-test".to_string(),
"xoxb-test".to_string(),
vec![],
true,
24,
true,
);
assert!(adapter.unfurl_links);
}
#[test]
fn test_slack_adapter_unfurl_links_disabled() {
let adapter = SlackAdapter::new(
"xapp-test".to_string(),
"xoxb-test".to_string(),
vec![],
true,
24,
false,
);
assert!(!adapter.unfurl_links);
}
}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -23,6 +23,8 @@ pub enum ChannelType {
Mattermost,
WebChat,
CLI,
/// MQTT pub/sub messaging.
Mqtt,
Custom(String),
}
@@ -49,6 +51,13 @@ pub enum ChannelContent {
url: String,
filename: String,
},
/// Local file data (bytes read from disk). Used by the proactive `channel_send`
/// tool when `file_path` is provided instead of `file_url`.
FileData {
data: Vec<u8>,
filename: String,
mime_type: String,
},
Voice {
url: String,
duration_seconds: u32,
@@ -261,6 +270,15 @@ pub trait ChannelAdapter: Send + Sync {
) -> Result<(), Box<dyn std::error::Error>> {
self.send(user, content).await
}
/// Whether this adapter should suppress sending internal agent errors back to the user.
///
/// Returns `true` for public broadcast channels (e.g. Mastodon) where posting
/// an error message would create a public status update. Errors are always
/// logged regardless of this setting.
fn suppress_error_responses(&self) -> bool {
false
}
}
/// Split a message into chunks of at most `max_len` characters,
+691
View File
@@ -0,0 +1,691 @@
//! WeCom (WeChat Work) channel adapter.
//!
//! Uses the WeCom Work API for sending messages and a webhook HTTP server for
//! receiving inbound events. Authentication is performed via an access token
//! obtained from `https://qyapi.weixin.qq.com/cgi-bin/gettoken`.
//! The token is cached and refreshed automatically.
use crate::types::{
split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser,
};
use async_trait::async_trait;
use axum::response::IntoResponse;
use chrono::Utc;
use futures::Stream;
use sha1::{Digest, Sha1};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch, RwLock};
use tracing::{info, warn};
use zeroize::Zeroizing;
/// WeCom token endpoint.
const WECOM_TOKEN_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/gettoken";
/// WeCom send message endpoint.
const WECOM_SEND_URL: &str = "https://qyapi.weixin.qq.com/cgi-bin/message/send";
/// Maximum WeCom message text length (characters).
const MAX_MESSAGE_LEN: usize = 2048;
/// Token refresh buffer — refresh 5 minutes before actual expiry.
const TOKEN_REFRESH_BUFFER_SECS: u64 = 300;
fn decrypt_aes_cbc(key: &[u8], encrypted_base64: &str) -> Result<Vec<u8>, String> {
use base64::Engine;
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
// Decode base64
let mut encrypted = base64::engine::general_purpose::STANDARD
.decode(encrypted_base64)
.map_err(|e| format!("base64 decode error: {}", e))?;
// IV is first 16 bytes of key
type Aes256CbcDecrypt = cbc::Decryptor<aes::Aes256>;
let iv = &key[..16];
let cipher = Aes256CbcDecrypt::new(key.into(), iv.into());
let decrypted = cipher
.decrypt_padded_mut::<aes::cipher::block_padding::NoPadding>(&mut encrypted)
.map_err(|e| format!("decrypt error: {}", e))?;
let decrypted = decrypted.to_vec();
let pad = decrypted
.last()
.copied()
.ok_or_else(|| "decrypted payload is empty".to_string())? as usize;
if pad == 0 || pad > 32 || decrypted.len() < pad {
return Err(format!("invalid WeCom PKCS7 padding length: {pad}"));
}
if !decrypted[decrypted.len() - pad..]
.iter()
.all(|byte| *byte as usize == pad)
{
return Err("invalid WeCom PKCS7 padding bytes".to_string());
}
Ok(decrypted[..decrypted.len() - pad].to_vec())
}
fn is_valid_wecom_signature(
token: &str,
timestamp: &str,
nonce: &str,
encrypted_payload: &str,
msg_signature: &str,
) -> bool {
let mut parts = [token, timestamp, nonce, encrypted_payload];
parts.sort_unstable();
let mut hasher = Sha1::new();
hasher.update(parts.concat().as_bytes());
hex::encode(hasher.finalize()) == msg_signature
}
fn decode_wecom_payload(encoding_aes_key: &str, encrypted_payload: &str) -> Result<String, String> {
use base64::{
alphabet,
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
Engine,
};
let aes_key_engine = GeneralPurpose::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new()
.with_decode_padding_mode(DecodePaddingMode::RequireNone)
.with_decode_allow_trailing_bits(true),
);
let aes_key = aes_key_engine
.decode(encoding_aes_key)
.map_err(|e| format!("aes key decode error: {e}"))?;
let decrypted = decrypt_aes_cbc(&aes_key, encrypted_payload)?;
if decrypted.len() < 20 {
return Err("decrypted payload too short".to_string());
}
let msg_len =
u32::from_be_bytes([decrypted[16], decrypted[17], decrypted[18], decrypted[19]]) as usize;
if decrypted.len() < 20 + msg_len {
return Err("decrypted payload shorter than declared echostr".to_string());
}
String::from_utf8(decrypted[20..20 + msg_len].to_vec())
.map_err(|e| format!("echostr is not valid utf-8: {e}"))
}
fn parse_wecom_xml_fields(xml: &str) -> Result<HashMap<String, String>, String> {
let doc = roxmltree::Document::parse(xml).map_err(|e| format!("invalid xml: {e}"))?;
let root = doc.root_element();
if root.tag_name().name() != "xml" {
return Err("root element is not <xml>".to_string());
}
let mut fields = HashMap::new();
for child in root.children().filter(|node| node.is_element()) {
let value = child
.children()
.filter_map(|node| node.text())
.collect::<String>()
.trim()
.to_string();
fields.insert(child.tag_name().name().to_string(), value);
}
Ok(fields)
}
fn decode_wecom_post_body(
body: &str,
params: &HashMap<String, String>,
token: Option<&str>,
encoding_aes_key: Option<&str>,
) -> Result<HashMap<String, String>, String> {
let parsed = parse_wecom_xml_fields(body)?;
let Some(encrypted_payload) = parsed.get("Encrypt") else {
return Ok(parsed);
};
let token = token.ok_or_else(|| "missing WeCom callback token".to_string())?;
let timestamp = params
.get("timestamp")
.ok_or_else(|| "missing timestamp".to_string())?;
let nonce = params
.get("nonce")
.ok_or_else(|| "missing nonce".to_string())?;
let msg_signature = params
.get("msg_signature")
.ok_or_else(|| "missing msg_signature".to_string())?;
if !is_valid_wecom_signature(token, timestamp, nonce, encrypted_payload, msg_signature) {
return Err("invalid WeCom callback signature".to_string());
}
let aes_key = encoding_aes_key
.filter(|key| !key.is_empty())
.ok_or_else(|| "missing WeCom encoding_aes_key".to_string())?;
let decrypted_xml = decode_wecom_payload(aes_key, encrypted_payload)?;
parse_wecom_xml_fields(&decrypted_xml)
}
fn wecom_success_response() -> axum::response::Response {
(
axum::http::StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
"success",
)
.into_response()
}
/// WeCom adapter.
pub struct WeComAdapter {
/// WeCom corp ID.
corp_id: String,
/// WeCom application agent ID.
agent_id: String,
/// WeCom application secret, zeroized on drop.
secret: Zeroizing<String>,
/// Encoding AES key for callback verification (optional).
encoding_aes_key: Option<String>,
/// Token for callback verification (optional).
token: Option<String>,
/// Port on which the inbound webhook HTTP server listens.
webhook_port: u16,
/// HTTP client for API calls.
client: reqwest::Client,
/// Shutdown signal.
shutdown_tx: Arc<watch::Sender<bool>>,
shutdown_rx: watch::Receiver<bool>,
/// Cached access token and its expiry instant.
cached_token: Arc<RwLock<Option<(String, Instant)>>>,
}
impl WeComAdapter {
/// Create a new WeCom adapter.
pub fn new(corp_id: String, agent_id: String, secret: String, webhook_port: u16) -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
Self {
corp_id,
agent_id,
secret: Zeroizing::new(secret),
encoding_aes_key: None,
token: None,
webhook_port,
client: reqwest::Client::new(),
shutdown_tx: Arc::new(shutdown_tx),
shutdown_rx,
cached_token: Arc::new(RwLock::new(None)),
}
}
/// Create a new WeCom adapter with callback verification.
pub fn with_verification(
corp_id: String,
agent_id: String,
secret: String,
webhook_port: u16,
encoding_aes_key: Option<String>,
token: Option<String>,
) -> Self {
let mut adapter = Self::new(corp_id, agent_id, secret, webhook_port);
adapter.encoding_aes_key = encoding_aes_key;
adapter.token = token;
adapter
}
/// Obtain a valid access token, refreshing if expired or missing.
async fn get_token(&self) -> Result<String, Box<dyn std::error::Error>> {
let mut cached = self.cached_token.write().await;
// Check if we have a valid cached token
if let Some((token, expiry)) = cached.as_ref() {
let now = Instant::now();
let buffer = Duration::from_secs(TOKEN_REFRESH_BUFFER_SECS);
if now + buffer < *expiry {
return Ok(token.clone());
}
}
// Fetch new token
let url = format!(
"{}?corpid={}&corpsecret={}",
WECOM_TOKEN_URL,
self.corp_id,
self.secret.as_str()
);
let response = self.client.get(&url).send().await?;
let json: serde_json::Value = response.json().await?;
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
if errcode != 0 {
return Err(format!(
"WeCom API error: {} - {}",
errcode,
json.get("errmsg").and_then(|v| v.as_str()).unwrap_or("")
)
.into());
}
}
let token = json["access_token"]
.as_str()
.ok_or("Missing access_token in response")?
.to_string();
let expires_in = json["expires_in"].as_i64().unwrap_or(7200) as u64;
let expiry = Instant::now() + Duration::from_secs(expires_in);
*cached = Some((token.clone(), expiry));
info!("WeCom access token refreshed, expires in {}s", expires_in);
Ok(token)
}
/// Send a text message to a user.
async fn send_text(
&self,
user_id: &str,
content: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let token = self.get_token().await?;
let url = format!("{}?access_token={}", WECOM_SEND_URL, token);
let payload = serde_json::json!({
"touser": user_id,
"msgtype": "text",
"agentid": self.agent_id,
"text": {
"content": content
}
});
let response = self.client.post(&url).json(&payload).send().await?;
let json: serde_json::Value = response.json().await?;
if let Some(errcode) = json.get("errcode").and_then(|v| v.as_i64()) {
if errcode != 0 {
return Err(format!(
"WeCom send error: {} - {}",
errcode,
json.get("errmsg").and_then(|v| v.as_str()).unwrap_or("")
)
.into());
}
}
Ok(())
}
/// Validate credentials by getting the token.
async fn validate(&self) -> Result<String, Box<dyn std::error::Error>> {
let _token = self.get_token().await?;
// Token obtained successfully means credentials are valid
Ok(format!("corp_id={}", self.corp_id))
}
}
#[async_trait]
impl ChannelAdapter for WeComAdapter {
fn name(&self) -> &str {
"wecom"
}
fn channel_type(&self) -> ChannelType {
ChannelType::Custom("wecom".to_string())
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
{
// Validate credentials
let _ = self.validate().await?;
info!("WeCom adapter initialized");
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
let port = self.webhook_port;
let token = self.token.clone();
let encoding_aes_key = self.encoding_aes_key.clone();
let mut shutdown_rx = self.shutdown_rx.clone();
tokio::spawn(async move {
let token = Arc::new(token);
let encoding_aes_key = Arc::new(encoding_aes_key);
let tx = Arc::new(tx);
let app = axum::Router::new().route(
"/wecom/webhook",
axum::routing::get({
let encoding_aes_key = Arc::clone(&encoding_aes_key);
let token = Arc::clone(&token);
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>| {
let encoding_aes_key = Arc::clone(&encoding_aes_key);
let token = Arc::clone(&token);
async move {
// Handle callback verification (URL validation GET request)
// WeChat Work sends GET with msg_signature, timestamp, nonce, echostr
if let (Some(echostr_encoded), Some(msg_sig), Some(timestamp), Some(nonce)) = (
params.get("echostr"),
params.get("msg_signature"),
params.get("timestamp"),
params.get("nonce"),
) {
let Some(token_str) = token.as_deref() else {
return (
axum::http::StatusCode::BAD_REQUEST,
"missing WeCom callback token",
)
.into_response();
};
if !is_valid_wecom_signature(
token_str,
timestamp,
nonce,
echostr_encoded,
msg_sig,
) {
return (
axum::http::StatusCode::FORBIDDEN,
"invalid WeCom callback signature",
)
.into_response();
}
let body = match encoding_aes_key.as_deref() {
Some(aes_key) if !aes_key.is_empty() => {
match decode_wecom_payload(aes_key, echostr_encoded) {
Ok(echostr_plain) => echostr_plain,
Err(err) => {
warn!(error = %err, "Failed to decrypt WeCom echostr");
return (
axum::http::StatusCode::BAD_REQUEST,
"invalid WeCom echostr",
)
.into_response();
}
}
}
_ => echostr_encoded.clone(),
};
return (
axum::http::StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
body,
)
.into_response();
}
(
axum::http::StatusCode::BAD_REQUEST,
"missing WeCom verification parameters",
)
.into_response()
}
}
}).post({
let token = Arc::clone(&token);
let encoding_aes_key = Arc::clone(&encoding_aes_key);
let tx = Arc::clone(&tx);
move |axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>, body: String| {
let token = Arc::clone(&token);
let encoding_aes_key = Arc::clone(&encoding_aes_key);
let tx = Arc::clone(&tx);
async move {
let fields = match decode_wecom_post_body(
&body,
&params,
token.as_deref(),
encoding_aes_key.as_deref(),
) {
Ok(fields) => fields,
Err(err) => {
warn!(error = %err, "Failed to parse WeCom callback body");
return (
axum::http::StatusCode::BAD_REQUEST,
[(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
"invalid WeCom callback body",
)
.into_response();
}
};
let msg_type = fields.get("MsgType").map(String::as_str).unwrap_or("");
let user_id = fields
.get("FromUserName")
.cloned()
.unwrap_or_default();
let event = fields.get("Event").map(String::as_str).unwrap_or("");
info!(
msg_type = msg_type,
event = event,
from_user = %user_id,
"Received WeCom callback"
);
if msg_type == "event" {
if (event == "subscribe" || event == "enter_agent")
&& !user_id.is_empty()
{
let msg = ChannelMessage {
channel: ChannelType::Custom("wecom".to_string()),
platform_message_id: String::new(),
sender: ChannelUser {
platform_id: user_id.clone(),
display_name: user_id.clone(),
openfang_user: None,
},
content: ChannelContent::Text(String::new()),
target_agent: None,
timestamp: Utc::now(),
is_group: false,
thread_id: None,
metadata: HashMap::new(),
};
let _ = tx.send(msg).await;
}
return wecom_success_response();
}
if msg_type == "text" {
let content = fields.get("Content").cloned().unwrap_or_default();
let msg_id = fields.get("MsgId").cloned().unwrap_or_default();
if !user_id.is_empty() && !content.is_empty() {
let msg = ChannelMessage {
channel: ChannelType::Custom("wecom".to_string()),
platform_message_id: msg_id,
sender: ChannelUser {
platform_id: user_id.clone(),
display_name: user_id.clone(),
openfang_user: None,
},
content: ChannelContent::Text(content),
target_agent: None,
timestamp: Utc::now(),
is_group: false,
thread_id: None,
metadata: HashMap::new(),
};
let _ = tx.send(msg).await;
}
}
wecom_success_response()
}
}
}),
);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
info!("WeCom webhook server listening on http://0.0.0.0:{}", port);
let server = axum::serve(listener, app);
tokio::select! {
result = server => {
if let Err(e) = result {
warn!("WeCom webhook server error: {}", e);
}
}
_ = shutdown_rx.changed() => {
info!("WeCom adapter shutting down");
}
}
});
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
}
async fn send(
&self,
user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
let user_id = &user.platform_id;
match content {
ChannelContent::Text(text) => {
// Split long messages
for chunk in split_message(&text, MAX_MESSAGE_LEN) {
self.send_text(user_id, chunk).await?;
}
}
ChannelContent::Command { name: _, args: _ } => {
// WeCom doesn't support commands natively
warn!("WeCom: commands not supported");
}
_ => {
warn!("WeCom: unsupported content type");
}
}
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.shutdown_tx.send(true);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_adapter_name() {
let adapter = WeComAdapter::new(
"corp_id".to_string(),
"agent_id".to_string(),
"secret".to_string(),
8080,
);
assert_eq!(adapter.name(), "wecom");
}
#[test]
fn test_adapter_channel_type() {
let adapter = WeComAdapter::new(
"corp_id".to_string(),
"agent_id".to_string(),
"secret".to_string(),
8080,
);
assert_eq!(
adapter.channel_type(),
ChannelType::Custom("wecom".to_string())
);
}
#[test]
fn test_adapter_with_verification() {
let adapter = WeComAdapter::with_verification(
"corp_id".to_string(),
"agent_id".to_string(),
"secret".to_string(),
8080,
Some("encoding_aes_key".to_string()),
Some("token".to_string()),
);
assert_eq!(adapter.name(), "wecom");
}
#[test]
fn test_max_message_length() {
// MAX_MESSAGE_LEN should be 2048 for WeCom
assert_eq!(MAX_MESSAGE_LEN, 2048);
}
#[test]
fn test_token_refresh_buffer() {
// Token refresh buffer should be 5 minutes
assert_eq!(TOKEN_REFRESH_BUFFER_SECS, 300);
}
#[test]
fn test_wecom_signature_validation() {
assert!(is_valid_wecom_signature(
"token",
"1710000000",
"nonce",
"echostr",
"bf56bf867459f80e3ceb854596f39f02a5ac5e13",
));
assert!(!is_valid_wecom_signature(
"token",
"1710000000",
"nonce",
"echostr",
"bad-signature",
));
}
#[test]
fn test_decode_wecom_payload() {
let plain = decode_wecom_payload(
"ShlNaJ0PrdXQAuCDVqMki7c2JLNnY6mebvQodTv9qoV",
"/gKbXNFpvlyYNTCneTag1rGm1P4Q5fExE3OPzdYlEyUVDgi55PHVIbo+mHMXWatdW8H8RTQJCly0HBNrWry2Uw==",
)
.expect("echostr should decrypt");
assert_eq!(plain, "openfang-wecom-check");
}
#[test]
fn test_parse_wecom_xml_fields() {
let fields = parse_wecom_xml_fields(
r#"<xml>
<ToUserName><![CDATA[wwcorp]]></ToUserName>
<FromUserName><![CDATA[user123]]></FromUserName>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[hello]]></Content>
<MsgId>123456</MsgId>
</xml>"#,
)
.expect("xml should parse");
assert_eq!(
fields.get("FromUserName").map(String::as_str),
Some("user123")
);
assert_eq!(fields.get("MsgType").map(String::as_str), Some("text"));
assert_eq!(fields.get("Content").map(String::as_str), Some("hello"));
assert_eq!(fields.get("MsgId").map(String::as_str), Some("123456"));
}
}
+24 -8
View File
@@ -222,11 +222,9 @@ impl ChannelAdapter for WhatsAppAdapter {
if let Some(ref gw) = self.gateway_url {
let text = match &content {
ChannelContent::Text(t) => t.clone(),
ChannelContent::Image { caption, .. } => {
caption
.clone()
.unwrap_or_else(|| "(Image — not supported in Web mode)".to_string())
}
ChannelContent::Image { caption, .. } => caption
.clone()
.unwrap_or_else(|| "(Image — not supported in Web mode)".to_string()),
ChannelContent::File { filename, .. } => {
format!("(File: {filename} — not supported in Web mode)")
}
@@ -260,12 +258,18 @@ impl ChannelAdapter for WhatsAppAdapter {
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
self.client
let resp = self
.client
.post(&api_url)
.bearer_auth(&*self.access_token)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("WhatsApp API error {status}: {body}").into());
}
}
ChannelContent::File { url, filename } => {
let body = serde_json::json!({
@@ -281,12 +285,18 @@ impl ChannelAdapter for WhatsAppAdapter {
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
self.client
let resp = self
.client
.post(&api_url)
.bearer_auth(&*self.access_token)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("WhatsApp API error {status}: {body}").into());
}
}
ChannelContent::Location { lat, lon } => {
let body = serde_json::json!({
@@ -302,12 +312,18 @@ impl ChannelAdapter for WhatsAppAdapter {
"https://graph.facebook.com/v21.0/{}/messages",
self.phone_number_id
);
self.client
let resp = self
.client
.post(&api_url)
.bearer_auth(&*self.access_token)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("WhatsApp API error {status}: {body}").into());
}
}
_ => {
self.api_send_message(&user.platform_id, "(Unsupported content type)")
@@ -229,14 +229,23 @@ async fn test_bridge_dispatch_text_message() {
let sent = adapter_ref.get_sent();
assert_eq!(sent.len(), 1, "Expected 1 response, got {}", sent.len());
assert_eq!(sent[0].0, "user1");
assert_eq!(sent[0].1, "Echo: Hello agent!");
// The bridge prepends sender identity: [From: Name] or [From: Name <email>]
assert!(
sent[0].1.contains("Hello agent!"),
"Response should contain original text, got: {}",
sent[0].1
);
// Verify: handle received the message
// Verify: handle received the message (with sender prefix)
{
let received = handle.received.lock().unwrap();
assert_eq!(received.len(), 1);
assert_eq!(received[0].0, agent_id);
assert_eq!(received[0].1, "Hello agent!");
assert!(
received[0].1.contains("Hello agent!"),
"Handle should receive text containing original message, got: {}",
received[0].1
);
}
manager.stop().await;
@@ -486,7 +495,10 @@ async fn test_bridge_manager_lifecycle() {
assert_eq!(sent.len(), 5, "Expected 5 responses, got {}", sent.len());
for (i, (_, text)) in sent.iter().enumerate() {
assert_eq!(*text, format!("Echo: message {i}"));
assert!(
text.contains(&format!("message {i}")),
"Expected 'message {i}' in: {text}"
);
}
// Stop — should complete without hanging
@@ -535,11 +547,19 @@ async fn test_bridge_multiple_adapters() {
let tg_sent = tg_ref.get_sent();
assert_eq!(tg_sent.len(), 1);
assert_eq!(tg_sent[0].1, "Echo: from telegram");
assert!(
tg_sent[0].1.contains("from telegram"),
"Expected 'from telegram' in: {}",
tg_sent[0].1
);
let dc_sent = dc_ref.get_sent();
assert_eq!(dc_sent.len(), 1);
assert_eq!(dc_sent[0].1, "Echo: from discord");
assert!(
dc_sent[0].1.contains("from discord"),
"Expected 'from discord' in: {}",
dc_sent[0].1
);
manager.stop().await;
}
+1
View File
@@ -31,3 +31,4 @@ openfang-runtime = { path = "../openfang-runtime" }
uuid = { workspace = true }
ratatui = { workspace = true }
colored = { workspace = true }
tempfile = { workspace = true }
+104 -26
View File
@@ -7,34 +7,112 @@
/// Returns all bundled agent templates as `(name, toml_content)` pairs.
pub fn bundled_agents() -> Vec<(&'static str, &'static str)> {
vec![
("analyst", include_str!("../../../agents/analyst/agent.toml")),
("architect", include_str!("../../../agents/architect/agent.toml")),
("assistant", include_str!("../../../agents/assistant/agent.toml")),
(
"analyst",
include_str!("../../../agents/analyst/agent.toml"),
),
(
"architect",
include_str!("../../../agents/architect/agent.toml"),
),
(
"assistant",
include_str!("../../../agents/assistant/agent.toml"),
),
("coder", include_str!("../../../agents/coder/agent.toml")),
("code-reviewer", include_str!("../../../agents/code-reviewer/agent.toml")),
("customer-support", include_str!("../../../agents/customer-support/agent.toml")),
("data-scientist", include_str!("../../../agents/data-scientist/agent.toml")),
("debugger", include_str!("../../../agents/debugger/agent.toml")),
("devops-lead", include_str!("../../../agents/devops-lead/agent.toml")),
("doc-writer", include_str!("../../../agents/doc-writer/agent.toml")),
("email-assistant", include_str!("../../../agents/email-assistant/agent.toml")),
("health-tracker", include_str!("../../../agents/health-tracker/agent.toml")),
("hello-world", include_str!("../../../agents/hello-world/agent.toml")),
("home-automation", include_str!("../../../agents/home-automation/agent.toml")),
("legal-assistant", include_str!("../../../agents/legal-assistant/agent.toml")),
("meeting-assistant", include_str!("../../../agents/meeting-assistant/agent.toml")),
(
"code-reviewer",
include_str!("../../../agents/code-reviewer/agent.toml"),
),
(
"customer-support",
include_str!("../../../agents/customer-support/agent.toml"),
),
(
"data-scientist",
include_str!("../../../agents/data-scientist/agent.toml"),
),
(
"debugger",
include_str!("../../../agents/debugger/agent.toml"),
),
(
"devops-lead",
include_str!("../../../agents/devops-lead/agent.toml"),
),
(
"doc-writer",
include_str!("../../../agents/doc-writer/agent.toml"),
),
(
"email-assistant",
include_str!("../../../agents/email-assistant/agent.toml"),
),
(
"health-tracker",
include_str!("../../../agents/health-tracker/agent.toml"),
),
(
"hello-world",
include_str!("../../../agents/hello-world/agent.toml"),
),
(
"home-automation",
include_str!("../../../agents/home-automation/agent.toml"),
),
(
"legal-assistant",
include_str!("../../../agents/legal-assistant/agent.toml"),
),
(
"meeting-assistant",
include_str!("../../../agents/meeting-assistant/agent.toml"),
),
("ops", include_str!("../../../agents/ops/agent.toml")),
("orchestrator", include_str!("../../../agents/orchestrator/agent.toml")),
("personal-finance", include_str!("../../../agents/personal-finance/agent.toml")),
("planner", include_str!("../../../agents/planner/agent.toml")),
("recruiter", include_str!("../../../agents/recruiter/agent.toml")),
("researcher", include_str!("../../../agents/researcher/agent.toml")),
("sales-assistant", include_str!("../../../agents/sales-assistant/agent.toml")),
("security-auditor", include_str!("../../../agents/security-auditor/agent.toml")),
("social-media", include_str!("../../../agents/social-media/agent.toml")),
("test-engineer", include_str!("../../../agents/test-engineer/agent.toml")),
("translator", include_str!("../../../agents/translator/agent.toml")),
("travel-planner", include_str!("../../../agents/travel-planner/agent.toml")),
(
"orchestrator",
include_str!("../../../agents/orchestrator/agent.toml"),
),
(
"personal-finance",
include_str!("../../../agents/personal-finance/agent.toml"),
),
(
"planner",
include_str!("../../../agents/planner/agent.toml"),
),
(
"recruiter",
include_str!("../../../agents/recruiter/agent.toml"),
),
(
"researcher",
include_str!("../../../agents/researcher/agent.toml"),
),
(
"sales-assistant",
include_str!("../../../agents/sales-assistant/agent.toml"),
),
(
"security-auditor",
include_str!("../../../agents/security-auditor/agent.toml"),
),
(
"social-media",
include_str!("../../../agents/social-media/agent.toml"),
),
(
"test-engineer",
include_str!("../../../agents/test-engineer/agent.toml"),
),
(
"translator",
include_str!("../../../agents/translator/agent.toml"),
),
(
"travel-planner",
include_str!("../../../agents/travel-planner/agent.toml"),
),
("tutor", include_str!("../../../agents/tutor/agent.toml")),
("writer", include_str!("../../../agents/writer/agent.toml")),
]
+407 -68
View File
@@ -113,7 +113,11 @@ enum Commands {
quick: bool,
},
/// Start the OpenFang kernel daemon (API server + kernel).
Start,
Start {
/// Auto-approve all tool calls (no confirmation prompts).
#[arg(long)]
yolo: bool,
},
/// Stop the running daemon.
Stop,
/// Manage agents (new, list, chat, kill, spawn) [*].
@@ -520,6 +524,23 @@ enum WorkflowCommands {
/// Path to a JSON file describing the workflow.
file: PathBuf,
},
/// Get a workflow by ID.
Get {
/// Workflow ID (UUID).
workflow_id: String,
},
/// Update a workflow from a JSON file.
Update {
/// Workflow ID (UUID).
workflow_id: String,
/// Path to a JSON file with the updated workflow definition.
file: PathBuf,
},
/// Delete a workflow by ID.
Delete {
/// Workflow ID (UUID).
workflow_id: String,
},
/// Run a workflow by ID.
Run {
/// Workflow ID (UUID).
@@ -777,12 +798,38 @@ enum SystemCommands {
},
}
fn config_log_level() -> String {
let config_path = if let Ok(home) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(home).join("config.toml")
} else {
dirs::home_dir()
.unwrap_or_else(std::env::temp_dir)
.join(".openfang")
.join("config.toml")
};
if let Ok(content) = std::fs::read_to_string(config_path) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("log_level") {
if let Some(val) = trimmed.split('=').nth(1) {
let level = val.trim().trim_matches('"').trim_matches('\'');
if !level.is_empty() {
return level.to_string();
}
}
}
}
}
"info".to_string()
}
fn init_tracing_stderr() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
)
.with_writer(std::io::stderr)
.init();
}
@@ -807,7 +854,7 @@ fn init_tracing_file() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(config_log_level())),
)
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false)
@@ -823,6 +870,21 @@ fn init_tracing_file() {
}
}
/// Write `msg` to stdout, silently exiting with code 0 on BrokenPipe.
/// Use this instead of `println!` for machine-readable (JSON) output that is
/// commonly piped into other tools.
fn write_stdout_safe(msg: &str) {
let out = std::io::stdout();
let mut lock = out.lock();
if let Err(e) = writeln!(lock, "{}", msg) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
std::process::exit(0);
}
eprintln!("error: failed writing to stdout: {e}");
std::process::exit(1);
}
}
fn main() {
// Load ~/.openfang/.env into process environment (system env takes priority).
dotenv::load_dotenv();
@@ -876,7 +938,7 @@ fn main() {
}
Some(Commands::Tui) => tui::run(cli.config),
Some(Commands::Init { quick }) => cmd_init(quick),
Some(Commands::Start) => cmd_start(cli.config),
Some(Commands::Start { yolo }) => cmd_start(cli.config, yolo),
Some(Commands::Stop) => cmd_stop(),
Some(Commands::Agent(sub)) => match sub {
AgentCommands::New { template } => cmd_agent_new(cli.config, template),
@@ -893,6 +955,11 @@ fn main() {
Some(Commands::Workflow(sub)) => match sub {
WorkflowCommands::List => cmd_workflow_list(),
WorkflowCommands::Create { file } => cmd_workflow_create(file),
WorkflowCommands::Get { workflow_id } => cmd_workflow_get(&workflow_id),
WorkflowCommands::Update { workflow_id, file } => {
cmd_workflow_update(&workflow_id, file)
}
WorkflowCommands::Delete { workflow_id } => cmd_workflow_delete(&workflow_id),
WorkflowCommands::Run { workflow_id, input } => cmd_workflow_run(&workflow_id, &input),
},
Some(Commands::Trigger(sub)) => match sub {
@@ -966,7 +1033,7 @@ fn main() {
ModelsCommands::Set { model } => cmd_models_set(model),
},
Some(Commands::Gateway(sub)) => match sub {
GatewayCommands::Start => cmd_start(cli.config),
GatewayCommands::Start => cmd_start(cli.config, false),
GatewayCommands::Stop => cmd_stop(),
GatewayCommands::Status { json } => cmd_status(cli.config, json),
},
@@ -1021,7 +1088,10 @@ fn main() {
SystemCommands::Version { json } => cmd_system_version(json),
},
Some(Commands::Reset { confirm }) => cmd_reset(confirm),
Some(Commands::Uninstall { confirm, keep_config }) => cmd_uninstall(confirm, keep_config),
Some(Commands::Uninstall {
confirm,
keep_config,
}) => cmd_uninstall(confirm, keep_config),
}
}
@@ -1078,8 +1148,8 @@ pub(crate) fn find_daemon() -> Option<String> {
/// includes a `Authorization: Bearer <key>` header on every request.
/// When api_key is empty or missing, no auth header is sent.
pub(crate) fn daemon_client() -> reqwest::blocking::Client {
let mut builder = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(120));
let mut builder =
reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(120));
if let Some(key) = read_api_key() {
let mut headers = reqwest::header::HeaderMap::new();
@@ -1411,7 +1481,7 @@ decay_rate = 0.05
}
}
fn cmd_start(config: Option<PathBuf>) {
fn cmd_start(config: Option<PathBuf>, yolo: bool) {
if let Some(base) = find_daemon() {
ui::error_with_fix(
&format!("Daemon already running at {base}"),
@@ -1427,7 +1497,12 @@ fn cmd_start(config: Option<PathBuf>) {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let kernel = match OpenFangKernel::boot(config.as_deref()) {
let mut kernel_config = openfang_kernel::config::load_config(config.as_deref());
if yolo {
kernel_config.approval.auto_approve = true;
kernel_config.approval.apply_shorthands();
}
let kernel = match OpenFangKernel::boot_with_config(kernel_config) {
Ok(k) => k,
Err(e) => {
boot_kernel_error(&e);
@@ -1480,15 +1555,26 @@ fn cmd_start(config: Option<PathBuf>) {
/// Returns `None` when the key is missing, empty, or whitespace-only —
/// meaning the daemon is running in public (unauthenticated) mode.
fn read_api_key() -> Option<String> {
// 1. Config file takes precedence
let config_path = cli_openfang_home().join("config.toml");
let text = std::fs::read_to_string(config_path).ok()?;
let table: toml::Value = text.parse().ok()?;
let key = table.get("api_key")?.as_str()?.trim();
if key.is_empty() {
None
} else {
Some(key.to_string())
if let Ok(text) = std::fs::read_to_string(config_path) {
if let Ok(table) = text.parse::<toml::Value>() {
if let Some(key) = table.get("api_key").and_then(|v| v.as_str()) {
let key = key.trim();
if !key.is_empty() {
return Some(key.to_string());
}
}
}
}
// 2. Fall back to OPENFANG_API_KEY env var
if let Ok(key) = std::env::var("OPENFANG_API_KEY") {
let key = key.trim().to_string();
if !key.is_empty() {
return Some(key);
}
}
None
}
fn cmd_stop() {
@@ -2045,18 +2131,14 @@ fn cmd_doctor(json: bool, repair: bool) {
ui::check_ok(".env file (permissions fixed to 0600)");
}
repaired = true;
} else {
if !json {
ui::check_warn(&format!(
".env file has loose permissions ({:o}), should be 0600",
mode
));
}
}
} else {
if !json {
ui::check_ok(".env file");
} else if !json {
ui::check_warn(&format!(
".env file has loose permissions ({:o}), should be 0600",
mode
));
}
} else if !json {
ui::check_ok(".env file");
}
}
#[cfg(not(unix))]
@@ -2183,7 +2265,9 @@ decay_rate = 0.05
if !json {
ui::check_ok(&format!("Port {api_listen} is available"));
}
checks.push(serde_json::json!({"check": "port", "status": "ok", "address": api_listen}));
checks.push(
serde_json::json!({"check": "port", "status": "ok", "address": api_listen}),
);
}
Err(_) => {
if !json {
@@ -2342,11 +2426,14 @@ decay_rate = 0.05
if !json {
ui::provider_status(name, env_var, true);
}
} else if !json {
ui::check_warn(&format!("{name} ({env_var}) - key rejected (401/403)"));
} else {
if !json {
ui::check_fail(&format!("{name} ({env_var}) - key rejected (401/403)"));
}
all_ok = false;
}
any_key_set = true;
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "warn" }, "live_test": !valid}));
checks.push(serde_json::json!({"check": "provider", "name": name, "env_var": env_var, "status": if valid { "ok" } else { "fail" }, "live_test": !valid}));
} else {
if !json {
ui::provider_status(name, env_var, false);
@@ -2508,7 +2595,8 @@ decay_rate = 0.05
checks.push(serde_json::json!({"check": "mcp_server_config", "status": "warn", "name": server.name}));
}
}
openfang_types::config::McpTransportEntry::Sse { url } => {
openfang_types::config::McpTransportEntry::Sse { url }
| openfang_types::config::McpTransportEntry::Http { url } => {
if url.is_empty() {
if !json {
ui::check_warn(&format!(
@@ -2554,9 +2642,7 @@ decay_rate = 0.05
// Check workspace skills if home dir available
if skills_dir.exists() {
match skill_reg.load_workspace_skills(&skills_dir) {
Ok(_) => {
let total = skill_reg.count();
let ws_count = total.saturating_sub(bundled_count);
Ok(ws_count) => {
if ws_count > 0 {
if !json {
ui::check_ok(&format!("Workspace skills loaded: {ws_count}"));
@@ -2598,8 +2684,15 @@ decay_rate = 0.05
}
}
}
if injection_warnings > 0 {
checks.push(serde_json::json!({"check": "skill_injection_scan", "status": "warn", "warnings": injection_warnings}));
let blocked = skill_reg.blocked_count();
if injection_warnings > 0 || blocked > 0 {
let total_warnings = injection_warnings + blocked;
if blocked > 0 && !json {
ui::check_warn(&format!(
"{blocked} workspace skill(s) were blocked for critical prompt injection"
));
}
checks.push(serde_json::json!({"check": "skill_injection_scan", "status": "warn", "warnings": total_warnings, "blocked": blocked}));
} else {
if !json {
ui::check_ok("All skills pass prompt injection scan");
@@ -2837,19 +2930,20 @@ decay_rate = 0.05
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
write_stdout_safe(
&serde_json::to_string_pretty(&serde_json::json!({
"all_ok": all_ok,
"checks": checks,
}))
.unwrap_or_default()
.unwrap_or_default(),
);
} else {
println!();
if all_ok {
ui::success("All checks passed! OpenFang is ready.");
ui::hint("Start the daemon: openfang start");
if find_daemon().is_none() {
ui::hint("Start the daemon: openfang start");
}
} else if repaired {
ui::success("Repairs applied. Re-run `openfang doctor` to verify.");
} else {
@@ -3111,6 +3205,104 @@ fn cmd_workflow_run(workflow_id: &str, input: &str) {
}
}
fn cmd_workflow_get(workflow_id: &str) {
let base = require_daemon("workflow get");
let client = daemon_client();
let body = daemon_json(
client
.get(format!("{base}/api/workflows/{workflow_id}"))
.send(),
);
if body.get("error").is_some() {
eprintln!(
"Workflow not found: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
println!("Workflow: {}", body["name"].as_str().unwrap_or("?"));
println!(" ID: {}", body["id"].as_str().unwrap_or("?"));
println!(
" Description: {}",
body["description"].as_str().unwrap_or("")
);
println!(
" Created: {}",
body["created_at"].as_str().unwrap_or("?")
);
if let Some(steps) = body["steps"].as_array() {
println!(" Steps ({}):", steps.len());
for (i, s) in steps.iter().enumerate() {
let name = s["name"].as_str().unwrap_or("step");
let agent = s["agent"]
.get("name")
.or_else(|| s["agent"].get("id"))
.and_then(|v| v.as_str())
.unwrap_or("?");
println!(" #{}: {} -> {}", i + 1, name, agent);
}
}
}
fn cmd_workflow_update(workflow_id: &str, file: PathBuf) {
let base = require_daemon("workflow update");
if !file.exists() {
eprintln!("Workflow file not found: {}", file.display());
std::process::exit(1);
}
let contents = std::fs::read_to_string(&file).unwrap_or_else(|e| {
eprintln!("Error reading workflow file: {e}");
std::process::exit(1);
});
let json_body: serde_json::Value = serde_json::from_str(&contents).unwrap_or_else(|e| {
eprintln!("Invalid JSON: {e}");
std::process::exit(1);
});
let client = daemon_client();
let body = daemon_json(
client
.put(format!("{base}/api/workflows/{workflow_id}"))
.json(&json_body)
.send(),
);
if body["status"].as_str() == Some("updated") {
println!("Workflow updated successfully!");
println!(" ID: {}", body["workflow_id"].as_str().unwrap_or("?"));
} else {
eprintln!(
"Failed to update workflow: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
}
fn cmd_workflow_delete(workflow_id: &str) {
let base = require_daemon("workflow delete");
let client = daemon_client();
let body = daemon_json(
client
.delete(format!("{base}/api/workflows/{workflow_id}"))
.send(),
);
if body["status"].as_str() == Some("removed") {
println!("Workflow deleted successfully!");
println!(" ID: {}", body["workflow_id"].as_str().unwrap_or("?"));
} else {
eprintln!(
"Failed to delete workflow: {}",
body["error"].as_str().unwrap_or("Unknown error")
);
std::process::exit(1);
}
}
// ---------------------------------------------------------------------------
// Trigger commands
// ---------------------------------------------------------------------------
@@ -3318,6 +3510,7 @@ fn cmd_skill_install(source: &str) {
std::process::exit(1);
}
println!("Installed OpenClaw skill: {}", manifest.skill.name);
notify_daemon_skill_reload();
}
Err(e) => {
eprintln!("Failed to convert OpenClaw skill: {e}");
@@ -3347,6 +3540,86 @@ fn cmd_skill_install(source: &str) {
"Installed skill: {} v{}",
manifest.skill.name, manifest.skill.version
);
notify_daemon_skill_reload();
} else if source.starts_with("https://")
|| source.starts_with("http://")
|| source.starts_with("git@")
{
// Git URL install — clone to temp dir then install from there
ui::step(&format!("Cloning skill from {source}..."));
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| {
eprintln!("Failed to create temp directory: {e}");
std::process::exit(1);
});
let clone_path = tmp_dir.path().join("skill");
let status = std::process::Command::new("git")
.args([
"clone",
"--depth",
"1",
source,
clone_path.to_str().unwrap(),
])
.status();
match status {
Ok(s) if s.success() => {}
Ok(_) => {
eprintln!("Failed to clone repository: {source}");
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to run git: {e}");
ui::hint("Make sure git is installed and available on your PATH.");
std::process::exit(1);
}
}
// Reuse the local directory install logic on the cloned repo
let manifest_path = clone_path.join("skill.toml");
if !manifest_path.exists() {
if openfang_skills::openclaw_compat::detect_openclaw_skill(&clone_path) {
println!("Detected OpenClaw skill format. Converting...");
match openfang_skills::openclaw_compat::convert_openclaw_skill(&clone_path) {
Ok(manifest) => {
let dest = skills_dir.join(&manifest.skill.name);
copy_dir_recursive(&clone_path, &dest);
if let Err(e) = openfang_skills::openclaw_compat::write_openfang_manifest(
&dest, &manifest,
) {
eprintln!("Failed to write manifest: {e}");
std::process::exit(1);
}
println!("Installed OpenClaw skill: {}", manifest.skill.name);
notify_daemon_skill_reload();
}
Err(e) => {
eprintln!("Failed to convert OpenClaw skill: {e}");
std::process::exit(1);
}
}
return;
}
eprintln!("No skill.toml found in cloned repository: {source}");
std::process::exit(1);
}
let toml_str = std::fs::read_to_string(&manifest_path).unwrap_or_else(|e| {
eprintln!("Error reading skill.toml: {e}");
std::process::exit(1);
});
let manifest: openfang_skills::SkillManifest =
toml::from_str(&toml_str).unwrap_or_else(|e| {
eprintln!("Error parsing skill.toml: {e}");
std::process::exit(1);
});
let dest = skills_dir.join(&manifest.skill.name);
copy_dir_recursive(&clone_path, &dest);
println!(
"Installed skill: {} v{}",
manifest.skill.name, manifest.skill.version
);
notify_daemon_skill_reload();
} else {
// Remote install from FangHub
println!("Installing {source} from FangHub...");
@@ -3355,7 +3628,10 @@ fn cmd_skill_install(source: &str) {
openfang_skills::marketplace::MarketplaceConfig::default(),
);
match rt.block_on(client.install(source, &skills_dir)) {
Ok(version) => println!("Installed {source} {version}"),
Ok(version) => {
println!("Installed {source} {version}");
notify_daemon_skill_reload();
}
Err(e) => {
eprintln!("Failed to install skill: {e}");
std::process::exit(1);
@@ -3364,6 +3640,25 @@ fn cmd_skill_install(source: &str) {
}
}
/// Notify the running daemon to hot-reload its skill registry after a CLI install.
///
/// If the daemon is not running, this is a no-op with a hint to the user.
fn notify_daemon_skill_reload() {
if let Some(base) = find_daemon() {
let client = daemon_client();
match client.post(format!("{base}/api/skills/reload")).send() {
Ok(resp) if resp.status().is_success() => {
ui::step("Daemon notified — skill registry reloaded.");
}
_ => {
ui::check_warn("Could not notify daemon. Restart with: openfang restart");
}
}
} else {
ui::hint("Start the daemon to make this skill available to agents: openfang start");
}
}
fn cmd_skill_list() {
let home = openfang_home();
let skills_dir = home.join("skills");
@@ -3985,7 +4280,10 @@ fn cmd_hand_install(path: &str) {
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("?"));
println!(
"Use `openfang hand activate {}` to start it.",
body["id"].as_str().unwrap_or("?")
);
}
fn cmd_hand_list() {
@@ -4010,10 +4308,7 @@ fn cmd_hand_list() {
println!("No hands available.");
return;
}
println!(
"{:<14} {:<20} {:<10} DESCRIPTION",
"ID", "NAME", "CATEGORY"
);
println!("{:<14} {:<20} {:<10} DESCRIPTION", "ID", "NAME", "CATEGORY");
println!("{}", "-".repeat(72));
for h in arr {
println!(
@@ -4021,7 +4316,12 @@ fn cmd_hand_list() {
h["id"].as_str().unwrap_or("?"),
h["name"].as_str().unwrap_or("?"),
h["category"].as_str().unwrap_or("?"),
h["description"].as_str().unwrap_or("").chars().take(40).collect::<String>(),
h["description"]
.as_str()
.unwrap_or("")
.chars()
.take(40)
.collect::<String>(),
);
}
println!("\nUse `openfang hand activate <id>` to activate a hand.");
@@ -4043,10 +4343,7 @@ fn cmd_hand_active() {
println!("No active hands.");
return;
}
println!(
"{:<38} {:<14} {:<10} AGENT",
"INSTANCE", "HAND", "STATUS"
);
println!("{:<38} {:<14} {:<10} AGENT", "INSTANCE", "HAND", "STATUS");
println!("{}", "-".repeat(72));
for i in &arr {
println!(
@@ -4134,10 +4431,7 @@ fn cmd_hand_info(id: &str) {
let client = daemon_client();
let body = daemon_json(client.get(format!("{base}/api/hands/{id}")).send());
if body.get("error").is_some() {
eprintln!(
"Hand not found: {}",
body["error"].as_str().unwrap_or(id)
);
eprintln!("Hand not found: {}", body["error"].as_str().unwrap_or(id));
std::process::exit(1);
}
println!(
@@ -4566,6 +4860,8 @@ fn cmd_config_set(key: &str, value: &str) {
std::process::exit(1);
});
let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak"));
std::fs::write(&config_path, &serialized).unwrap_or_else(|e| {
ui::error(&format!("Failed to write config: {e}"));
std::process::exit(1);
@@ -4632,6 +4928,8 @@ fn cmd_config_unset(key: &str) {
std::process::exit(1);
});
let _ = std::fs::copy(&config_path, config_path.with_extension("toml.bak"));
std::fs::write(&config_path, &serialized).unwrap_or_else(|e| {
ui::error(&format!("Failed to write config: {e}"));
std::process::exit(1);
@@ -4650,6 +4948,10 @@ fn cmd_config_set_key(provider: &str) {
return;
}
// Try vault first (best-effort)
save_credential_prefer_vault(&env_var, &key);
// Always save to dotenv as fallback
match dotenv::save_env_key(&env_var, &key) {
Ok(()) => {
ui::success(&format!("Saved {env_var} to ~/.openfang/.env"));
@@ -4672,6 +4974,18 @@ fn cmd_config_set_key(provider: &str) {
fn cmd_config_delete_key(provider: &str) {
let env_var = provider_to_env_var(provider);
// Remove from vault (best-effort)
{
let home = openfang_home();
let vault_path = home.join("vault.enc");
if vault_path.exists() {
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
if vault.unlock().is_ok() {
let _ = vault.remove(&env_var);
}
}
}
match dotenv::remove_env_key(&env_var) {
Ok(()) => ui::success(&format!("Removed {env_var} from ~/.openfang/.env")),
Err(e) => {
@@ -4701,6 +5015,26 @@ fn cmd_config_test_key(provider: &str) {
}
}
/// Try to store a credential in the vault first; silently falls through if vault
/// is not initialized or cannot be unlocked. The caller should always also
/// write to dotenv as a fallback.
fn save_credential_prefer_vault(env_var: &str, value: &str) {
use zeroize::Zeroizing;
let home = openfang_home();
let vault_path = home.join("vault.enc");
if !vault_path.exists() {
return;
}
let mut vault = openfang_extensions::vault::CredentialVault::new(vault_path);
if vault.unlock().is_err() {
return;
}
if let Ok(()) = vault.set(env_var.to_string(), Zeroizing::new(value.to_string())) {
println!(" {}", "Also stored in encrypted vault".dimmed());
}
}
// ---------------------------------------------------------------------------
// Quick chat (OpenClaw alias)
// ---------------------------------------------------------------------------
@@ -5456,7 +5790,15 @@ fn cmd_cron_create(agent: &str, spec: &str, prompt: &str, explicit_name: Option<
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.take(64)
.collect();
format!("{}-{}", agent, if short_prompt.is_empty() { "job" } else { &short_prompt })
format!(
"{}-{}",
agent,
if short_prompt.is_empty() {
"job"
} else {
&short_prompt
}
)
};
let body = daemon_json(
@@ -6210,10 +6552,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
} else {
match std::fs::remove_dir_all(&openfang_dir) {
Ok(()) => ui::success(&format!("Removed {}", openfang_dir.display())),
Err(e) => ui::error(&format!(
"Failed to remove {}: {e}",
openfang_dir.display()
)),
Err(e) => ui::error(&format!("Failed to remove {}: {e}", openfang_dir.display())),
}
}
}
@@ -6222,10 +6561,7 @@ fn cmd_uninstall(confirm: bool, keep_config: bool) {
if cargo_bin.exists() && exe_path.as_ref().is_none_or(|e| *e != cargo_bin) {
match std::fs::remove_file(&cargo_bin) {
Ok(()) => ui::success(&format!("Removed {}", cargo_bin.display())),
Err(e) => ui::error(&format!(
"Failed to remove {}: {e}",
cargo_bin.display()
)),
Err(e) => ui::error(&format!("Failed to remove {}: {e}", cargo_bin.display())),
}
}
@@ -6465,7 +6801,10 @@ fn remove_self_binary(exe_path: &std::path::Path) {
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
.spawn();
ui::success(&format!("Removed {} (deferred cleanup)", exe_path.display()));
ui::success(&format!(
"Removed {} (deferred cleanup)",
exe_path.display()
));
}
}
+5 -1
View File
@@ -329,7 +329,11 @@ fn handle_message(backend: &McpBackend, msg: &Value) -> Option<Value> {
_ => {
// Unknown method — always respond with error
Some(jsonrpc_error(rid, -32601, &format!("Method not found: {method}")))
Some(jsonrpc_error(
rid,
-32601,
&format!("Method not found: {method}"),
))
}
}
}
+9 -18
View File
@@ -153,6 +153,7 @@ impl StandaloneChat {
name,
result_preview,
is_error,
..
} => {
self.chat.tool_result(&name, &result_preview, is_error);
}
@@ -394,10 +395,7 @@ impl StandaloneChat {
.as_str()
.unwrap_or("")
.to_string(),
provider: m["provider"]
.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()
@@ -459,16 +457,13 @@ impl StandaloneChat {
.send()
{
if let Ok(body) = resp.json::<serde_json::Value>() {
let provider =
body["model_provider"].as_str().unwrap_or("?");
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!("Switched to {model_id}"));
}
_ => {
self.chat.push_message(
@@ -506,16 +501,12 @@ impl StandaloneChat {
.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}"),
);
self.chat
.push_message(Role::System, format!("Switched to {model_id}"));
}
Err(e) => {
self.chat.push_message(
Role::System,
format!("Switch failed: {e}"),
);
self.chat
.push_message(Role::System, format!("Switch failed: {e}"));
}
}
}
+14 -4
View File
@@ -303,7 +303,7 @@ pub fn spawn_inprocess_stream(
// send_message_streaming() finds the reactor.
let _guard = rt.enter();
match kernel.send_message_streaming(agent_id, &message, None) {
match kernel.send_message_streaming(agent_id, &message, None, None, None, None) {
Ok((mut rx, handle)) => {
rt.block_on(async {
while let Some(ev) = rx.recv().await {
@@ -1417,14 +1417,24 @@ pub fn spawn_fetch_skills(backend: BackendRef, tx: mpsc::Sender<AppEvent>) {
let client = daemon_client();
if let Ok(resp) = client.get(format!("{base_url}/api/skills")).send() {
if let Ok(body) = resp.json::<serde_json::Value>() {
let skills: Vec<SkillInfo> = body
.as_array()
// API returns {"skills": [...], "total": N} — extract the inner array.
// Fall back to bare array for backward compat.
let items = body
.get("skills")
.and_then(|v| v.as_array())
.or_else(|| body.as_array());
let skills: Vec<SkillInfo> = items
.map(|arr| {
arr.iter()
.map(|s| SkillInfo {
name: s["name"].as_str().unwrap_or("").to_string(),
runtime: s["runtime"].as_str().unwrap_or("").to_string(),
source: s["source"].as_str().unwrap_or("").to_string(),
// "source" is an object {"type": "..."} — extract the type string
source: s["source"]["type"]
.as_str()
.or_else(|| s["source"].as_str())
.unwrap_or("")
.to_string(),
description: s["description"]
.as_str()
.unwrap_or("")
+7 -16
View File
@@ -516,8 +516,7 @@ impl App {
}
AppEvent::CommsEventsLoaded(events) => {
self.comms.events = events;
if !self.comms.events.is_empty()
&& self.comms.event_list_state.selected().is_none()
if !self.comms.events.is_empty() && self.comms.event_list_state.selected().is_none()
{
self.comms.event_list_state.select(Some(0));
}
@@ -1184,6 +1183,7 @@ impl App {
name,
result_preview,
is_error,
..
} => {
self.chat.tool_result(&name, &result_preview, is_error);
}
@@ -1869,14 +1869,8 @@ impl App {
.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(),
provider: m["provider"].as_str().unwrap_or("").to_string(),
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
})
.collect()
})
@@ -1935,8 +1929,7 @@ impl App {
.send()
{
if let Ok(body) = resp.json::<serde_json::Value>() {
let provider =
body["model_provider"].as_str().unwrap_or("?");
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}");
}
@@ -1988,10 +1981,8 @@ impl App {
);
}
Err(e) => {
self.chat.push_message(
chat::Role::System,
format!("Switch failed: {e}"),
);
self.chat
.push_message(chat::Role::System, format!("Switch failed: {e}"));
}
}
}
@@ -1524,6 +1524,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
+4 -1
View File
@@ -341,6 +341,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -200,7 +200,18 @@ const CHANNEL_DEFS: &[ChannelDef] = &[
display_name: "DingTalk",
category: "Enterprise",
env_vars: &["DINGTALK_ACCESS_TOKEN", "DINGTALK_SECRET"],
description: "DingTalk Robot API adapter",
description: "DingTalk Robot API adapter (webhook mode)",
},
ChannelDef {
name: "dingtalk_stream",
display_name: "DingTalk Stream",
category: "Enterprise",
env_vars: &[
"DINGTALK_APP_KEY",
"DINGTALK_APP_SECRET",
"DINGTALK_ROBOT_CODE",
],
description: "DingTalk Stream Mode (WebSocket long-connection)",
},
ChannelDef {
name: "pumble",
+11 -4
View File
@@ -483,8 +483,7 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
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 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);
@@ -548,7 +547,12 @@ fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
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) {
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 { " " };
@@ -882,6 +886,9 @@ fn truncate_line(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max_len.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max_len.saturating_sub(1))
)
}
}
+15 -16
View File
@@ -158,11 +158,7 @@ impl CommsState {
KeyCode::Up | KeyCode::Char('k') => {
if self.focus == CommsFocus::EventList && !self.events.is_empty() {
let i = self.event_list_state.selected().unwrap_or(0);
let next = if i == 0 {
self.events.len() - 1
} else {
i - 1
};
let next = if i == 0 { self.events.len() - 1 } else { i - 1 };
self.event_list_state.select(Some(next));
}
}
@@ -339,12 +335,12 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut CommsState) {
f.render_widget(block, area);
let chunks = Layout::vertical([
Constraint::Length(2), // header
Constraint::Length(1), // separator
Constraint::Length(2), // header
Constraint::Length(1), // separator
Constraint::Percentage(35), // topology
Constraint::Length(1), // separator
Constraint::Min(4), // event list
Constraint::Length(1), // hints
Constraint::Length(1), // separator
Constraint::Min(4), // event list
Constraint::Length(1), // hints
])
.split(inner);
@@ -441,10 +437,7 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
if state.nodes.is_empty() {
f.render_widget(
Paragraph::new(Span::styled(
" No agents running.",
theme::dim_style(),
)),
Paragraph::new(Span::styled(" No agents running.", theme::dim_style())),
area,
);
return;
@@ -491,7 +484,10 @@ fn draw_topology(f: &mut Frame, area: Rect, state: &CommsState) {
Span::styled(" ", Style::default()),
Span::styled(branch, theme::dim_style()),
Span::styled(format!("[{}]", child.state), state_color(&child.state)),
Span::styled(format!(" {} ", child.name), Style::default().fg(theme::TEXT)),
Span::styled(
format!(" {} ", child.name),
Style::default().fg(theme::TEXT),
),
Span::styled(format!("({})", child.model), theme::dim_style()),
]));
}
@@ -679,7 +675,10 @@ fn draw_task_modal(f: &mut Frame, area: Rect, state: &CommsState) {
rows[3],
);
f.render_widget(
Paragraph::new(Span::styled("Assign to (agent ID, optional):", field_style(2))),
Paragraph::new(Span::styled(
"Assign to (agent ID, optional):",
field_style(2),
)),
rows[4],
);
f.render_widget(
@@ -273,6 +273,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -188,6 +188,14 @@ const PROVIDERS: &[ProviderInfo] = &[
needs_key: true,
hint: "",
},
ProviderInfo {
name: "nvidia",
display: "NVIDIA NIM",
env_var: "NVIDIA_API_KEY",
default_model: "nvidia/llama-3.1-nemotron-70b-instruct",
needs_key: true,
hint: "",
},
ProviderInfo {
name: "claude-code",
display: "Claude Code",
@@ -950,7 +958,9 @@ fn handle_migration_key(
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")
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".openfang")
};
let tx = migrate_tx.clone();
std::thread::spawn(move || {
+4 -1
View File
@@ -405,6 +405,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -317,10 +317,7 @@ fn draw_agent_select(f: &mut Frame, area: Rect, state: &mut MemoryState) {
.iter()
.map(|a| {
let id_short = if a.id.len() > 12 {
format!(
"{}\u{2026}",
openfang_types::truncate_str(&a.id, 12)
)
format!("{}\u{2026}", openfang_types::truncate_str(&a.id, 12))
} else {
a.id.clone()
};
@@ -408,10 +405,7 @@ fn draw_kv_browser(f: &mut Frame, area: Rect, state: &mut MemoryState) {
.iter()
.map(|kv| {
let val_display = if kv.value.len() > 40 {
format!(
"{}\u{2026}",
openfang_types::truncate_str(&kv.value, 39)
)
format!("{}\u{2026}", openfang_types::truncate_str(&kv.value, 39))
} else {
kv.value.clone()
};
@@ -555,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
+1 -1
View File
@@ -1,8 +1,8 @@
pub mod agents;
pub mod audit;
pub mod channels;
pub mod comms;
pub mod chat;
pub mod comms;
pub mod dashboard;
pub mod extensions;
pub mod hands;
+5 -5
View File
@@ -149,10 +149,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut PeersState) {
.iter()
.map(|p| {
let id_short = if p.node_id.len() > 12 {
format!(
"{}\u{2026}",
openfang_types::truncate_str(&p.node_id, 12)
)
format!("{}\u{2026}", openfang_types::truncate_str(&p.node_id, 12))
} else {
p.node_id.clone()
};
@@ -211,6 +208,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -251,10 +251,7 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut SessionsState) {
.map(|&idx| {
let s = &state.sessions[idx];
let id_short = if s.id.len() > 12 {
format!(
"{}\u{2026}",
openfang_types::truncate_str(&s.id, 12)
)
format!("{}\u{2026}", openfang_types::truncate_str(&s.id, 12))
} else {
s.id.clone()
};
@@ -311,6 +308,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -604,7 +604,10 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -612,7 +612,10 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -399,6 +399,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -549,6 +549,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
+4 -1
View File
@@ -439,6 +439,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
@@ -127,6 +127,12 @@ const PROVIDERS: &[ProviderInfo] = &[
default_model: "codegeex-4",
needs_key: true,
},
ProviderInfo {
name: "nvidia",
env_var: "NVIDIA_API_KEY",
default_model: "nvidia/llama-3.1-nemotron-70b-instruct",
needs_key: true,
},
ProviderInfo {
name: "claude-code",
env_var: "",
@@ -697,6 +697,9 @@ fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}\u{2026}", openfang_types::truncate_str(s, max.saturating_sub(1)))
format!(
"{}\u{2026}",
openfang_types::truncate_str(s, max.saturating_sub(1))
)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+72 -7
View File
@@ -6,6 +6,7 @@
use openfang_api::server::build_router;
use openfang_kernel::OpenFangKernel;
use std::net::{SocketAddr, TcpListener};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::watch;
use tracing::{error, info};
@@ -20,24 +21,40 @@ pub struct ServerHandle {
shutdown_tx: watch::Sender<bool>,
/// Join handle for the background server thread.
server_thread: Option<std::thread::JoinHandle<()>>,
/// Track whether shutdown has already been initiated to prevent double shutdown.
shutdown_initiated: Arc<AtomicBool>,
}
impl ServerHandle {
/// Signal the server to shut down and wait for the background thread.
pub fn shutdown(mut self) {
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.server_thread.take() {
let _ = handle.join();
// Only proceed if shutdown hasn't been initiated yet
if self
.shutdown_initiated
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
{
let _ = self.shutdown_tx.send(true);
if let Some(handle) = self.server_thread.take() {
let _ = handle.join();
}
self.kernel.shutdown();
info!("OpenFang embedded server stopped");
}
self.kernel.shutdown();
info!("OpenFang embedded server stopped");
}
}
impl Drop for ServerHandle {
fn drop(&mut self) {
let _ = self.shutdown_tx.send(true);
// Best-effort: don't block in drop, the thread will exit on its own.
// Only send shutdown signal if it hasn't been initiated yet
if self
.shutdown_initiated
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
{
let _ = self.shutdown_tx.send(true);
// Best-effort: don't block in drop, the thread will exit on its own.
}
}
}
@@ -47,6 +64,11 @@ impl Drop for ServerHandle {
/// any Tauri window is created. The actual axum server runs on a dedicated
/// thread with its own tokio runtime.
pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
// Load .env and secrets.env into process environment (same as CLI).
// Without this, API keys stored in ~/.openfang/.env are invisible to
// the kernel's provider detection and credential resolver.
load_dotenv_files();
// Boot kernel (sync — no tokio needed)
let kernel = OpenFangKernel::boot(None)?;
let kernel = Arc::new(kernel);
@@ -61,6 +83,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let kernel_clone = kernel.clone();
let shutdown_initiated = Arc::new(AtomicBool::new(false));
let server_thread = std::thread::Builder::new()
.name("openfang-server".into())
@@ -83,6 +106,7 @@ pub fn start_server() -> Result<ServerHandle, Box<dyn std::error::Error>> {
kernel,
shutdown_tx,
server_thread: Some(server_thread),
shutdown_initiated,
})
}
@@ -126,3 +150,44 @@ async fn run_embedded_server(
}
}
}
/// Load ~/.openfang/.env and ~/.openfang/secrets.env into the process environment.
/// System env vars take priority — existing vars are NOT overridden.
fn load_dotenv_files() {
let home = if let Ok(h) = std::env::var("OPENFANG_HOME") {
std::path::PathBuf::from(h)
} else {
let user_home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();
if user_home.is_empty() {
return;
}
std::path::PathBuf::from(user_home).join(".openfang")
};
for filename in &[".env", "secrets.env"] {
let path = home.join(filename);
if let Ok(content) = std::fs::read_to_string(&path) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((key, value)) = trimmed.split_once('=') {
let key = key.trim();
let mut value = value.trim().to_string();
if ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
&& value.len() >= 2
{
value = value[1..value.len() - 1].to_string();
}
if !key.is_empty() && std::env::var(key).is_err() {
std::env::set_var(key, &value);
}
}
}
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenFang",
"version": "0.1.0",
"version": "0.5.5",
"identifier": "ai.openfang.desktop",
"build": {},
"app": {
@@ -11,7 +11,7 @@ command = "npx"
args = ["-y", "@notionhq/notion-mcp-server"]
[[required_env]]
name = "NOTION_API_KEY"
name = "NOTION_TOKEN"
label = "Notion Integration Token"
help = "An internal integration token created in your Notion workspace settings"
is_secret = true
@@ -24,5 +24,5 @@ unhealthy_threshold = 3
setup_instructions = """
1. Go to https://www.notion.so/my-integrations and click 'New integration'.
2. Give it a name, select your workspace, and grant the required capabilities (Read/Update/Insert content).
3. Copy the Internal Integration Token and paste it into the NOTION_API_KEY field above. Then share relevant pages with the integration in Notion.
3. Copy the Internal Integration Token and paste it into the NOTION_TOKEN field above. Then share relevant pages with the integration in Notion.
"""

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