Compare commits

..
511 Commits
Author SHA1 Message Date
jaberjaber23 acf2587e46 bump v0.6.9 2026-05-12 21:42:08 +03:00
jaberjaber23 4583157b49 audit fixes 2026-05-12 21:13:41 +03:00
jaberjaber23 7185ea8808 cargo fmt 2026-05-12 20:57:57 +03:00
jaberjaber23 4aa1508f54 clippy lint 2026-05-12 20:43:56 +03:00
jaberjaber23 5447bf7f1d toctou staging 2026-05-12 17:11:36 +03:00
jaberjaber23 b77ebfb897 thread routing 2026-05-12 17:09:09 +03:00
jaberjaber23 2323cd5e67 audit hands 2026-05-12 17:06:43 +03:00
jaberjaber23 df29e5e8b9 audit endpoint 2026-05-12 17:05:42 +03:00
jaberjaber23 8b411c21c4 codex hardening 2026-05-12 16:38:50 +03:00
jaberjaber23 b00af5eddd add requesty 2026-05-12 16:35:22 +03:00
jaberjaber23 36177425c4 skill tools 2026-05-12 16:34:49 +03:00
jaberjaber23 6bed6c04ff workspace split 2026-05-12 16:33:49 +03:00
jaberjaber23 4c496be02f integration fixes 2026-05-12 16:13:08 +03:00
jaberjaber23 7f7b071528 provider refs 2026-05-12 15:53:15 +03:00
jaberjaber23 2d1fb8171c matrix refresh 2026-05-12 15:50:44 +03:00
Jaber Jaber e683acc565 Merge pull request #1045 from dongtran16092006/fix-mcp-system-prompt
fix: system prompt and identity handling, and config form hydration
2026-05-12 15:49:20 +03:00
jaberjaber23 5396889ff1 bedrock redacted 2026-05-12 15:48:54 +03:00
jaberjaber23 c9e8d31571 ws auth align 2026-05-12 15:47:38 +03:00
Jaber Jaber d8ad91572e Merge pull request #1054 from Hypn0sis/feat/discord-smart-thread
feat(discord): smart auto-thread mode (true/false/smart)
2026-05-12 15:47:05 +03:00
jaberjaber23 c27bfebd17 bump v0.6.7 2026-05-12 15:44:29 +03:00
jaberjaber23 f05ba5e42f tts image urls 2026-05-12 15:38:31 +03:00
jaberjaber23 d9e72abb4b uninstall agent 2026-05-12 15:37:44 +03:00
jaberjaber23 505a8e8080 hand stop 2026-05-12 15:37:02 +03:00
jaberjaber23 5cc865e6e6 shell env 2026-05-12 15:34:39 +03:00
jaberjaber23 6a1ce40d86 require signed 2026-05-12 15:34:01 +03:00
jaberjaber23 fbb7936234 docker docs 2026-05-12 15:30:40 +03:00
jaberjaber23 569e76c79a ws reconnect 2026-05-12 15:29:56 +03:00
jaberjaber23 88ad029999 bump v0.6.6 2026-05-12 15:27:09 +03:00
jaberjaber23 838836b29c integration fixes 2026-05-12 15:25:34 +03:00
jaberjaber23 8564872181 create directory 2026-05-12 15:12:16 +03:00
jaberjaber23 efbefa1682 chat agents 2026-05-12 15:10:59 +03:00
Jaber Jaber bdcd440cd6 Merge pull request #1143 from benhoverter/discord-file-sharing
fix(channels/discord): surface image attachments to text-only providers
2026-05-12 15:08:30 +03:00
Jaber Jaber 25516c7f87 Merge pull request #1168 from nimitbhardwaj/fix/latex-rendering
fix: render LaTeX math in chat messages
2026-05-12 15:08:15 +03:00
Jaber Jaber ae2706bdab Merge pull request #1176 from nimitbhardwaj/fix/new-line-chat
fix(chat): support Shift+Enter for multi-line input and proper newline display
2026-05-12 15:07:58 +03:00
Jaber Jaber 32299bb506 Merge pull request #1147 from benhoverter/harden-channel-id-binding
feat(channels): harden channel_id binding — adapter allowlist, strict validation, single source of truth for routing
2026-05-12 15:07:50 +03:00
jaberjaber23 6f8463fc91 bump v0.6.5 2026-05-12 15:05:53 +03:00
jaberjaber23 6b03cb2e9d test fix 2026-05-12 15:04:47 +03:00
Jaber Jaber 8cb7541678 Merge pull request #1146 from benhoverter/lenient-binding-parse
fix(kernel): lenient binding parsing — one typo no longer drops the entire bindings table
2026-05-12 14:57:09 +03:00
jaberjaber23 6b5b7674d3 server ids 2026-05-12 14:56:32 +03:00
jaberjaber23 247dca508b inferencing flag 2026-05-12 14:55:47 +03:00
Jaber Jaber 90d16e52be Merge pull request #1175 from aqilaziz/docs-fix-getting-started-links
Fix getting started documentation links
2026-05-12 14:55:27 +03:00
jaberjaber23 68bde60fac activate agents 2026-05-12 14:54:22 +03:00
Jaber Jaber a422058049 Merge pull request #1135 from RightNow-AI/dependabot/cargo/open-5.3.4
build(deps): bump open from 5.3.3 to 5.3.4
2026-05-12 14:53:44 +03:00
jaberjaber23 6ba0bfb7ef providers screen 2026-05-12 14:53:17 +03:00
jaberjaber23 7699b86037 clone agent 2026-05-12 14:53:10 +03:00
jaberjaber23 e31216d5ec docs accuracy 2026-05-12 14:51:09 +03:00
jaberjaber23 538e943d3d clippy fix 2026-05-12 14:46:00 +03:00
jaberjaber23 94fca22124 redacted thinking 2026-05-12 14:36:05 +03:00
jaberjaber23 37e2043ed7 another timeout 2026-05-12 14:35:12 +03:00
jaberjaber23 31eb833cdf agent history 2026-05-12 14:34:22 +03:00
jaberjaber23 15da248faf another timeout 2026-05-12 14:33:05 +03:00
jaberjaber23 c27a6f3609 local fallback 2026-05-12 14:26:58 +03:00
jaberjaber23 f792f1a14b ws auth 2026-05-12 14:26:27 +03:00
Nimit Bhardwaj 5e228336e4 fix(chat): support Shift+Enter for multi-line input and proper newline display 2026-05-08 23:12:29 +05:30
aqilaziz 8b10930e40 Fix getting started documentation links 2026-05-08 06:21:25 +07:00
Nimit Bhardwaj 5c1b1508a2 Fix Latex Rendering in Openfang Web 2026-05-07 00:24:36 +05:30
Ben Hoverter 701fcd8e2e channels/bridge: disable transparent decompression on image download
Discord's CDN edges occasionally advertise `content-encoding: gzip` (or
deflate/brotli) on PNG/JPEG passthroughs while the body is raw,
uncompressed image bytes. With the default `reqwest::Client::new()` and
the workspace's gzip/deflate/brotli features all enabled, reqwest's
transparent-decompression layer chokes on the PNG/JPEG header and
returns "error decoding response body" only on `bytes().await` (not on
`send()`), causing `download_image_to_blocks` to silently fall back to a
text-only block — the user's image never reaches the model.

Build the client explicitly with no_gzip/no_deflate/no_brotli so the
request advertises identity encoding and the body is read raw. Also set
a User-Agent (some CDN edges 403 clients without one) and a 30s timeout
aligned with the upstream 5 MB cap.

Repro: send an image attachment via Discord; the daemon logs
`Failed to read image bytes: error decoding response body` and the turn
appends as text-only with `appended_has_image=false`. After this fix the
PNG bytes are read and emitted as an Image content block as intended.
2026-05-04 12:04:31 -07:00
Ben Hoverter 118eacea64 channels: handle Discord image attachments coherently across providers
Discord MESSAGE_CREATE payloads with attachments were previously parsed
in a way that either dropped the attachment (when text was present, only
the text was kept) or dropped the whole message (when text was empty,
the early `content.is_empty()` return killed bare-image posts). The
result on text-only providers like claude-code: silent drops, then
hallucinated acknowledgements of content the model never saw.

This rewires the inbound path end-to-end:

* types: add ChannelContent::Multipart(Vec<ChannelContent>) so a single
  inbound message can carry a caption + one or more attachments as
  sibling blocks. Doc forbids nesting; consumers debug_assert.

* discord: classify attachments by MIME (with extension fallback for
  bot-relayed payloads that omit content_type) and a 5 MB vision-size
  cap matching Anthropic's image block limit. Vision-eligible images
  become ChannelContent::Image; everything else becomes File. Emit
  Multipart whenever text and attachments coexist, or when there are
  multiple attachments.

* bridge: flat-map Multipart in both dispatch paths — into Vec<ContentBlock>
  for multimodal-capable providers, and into a newline-joined text
  descriptor for text-flatten providers.

* telegram: add the Multipart arm to send_to_user for exhaustive-match
  parity; flattens defensively.

* claude_code driver: render Image blocks as
  "[attachment: <mime> image, ~N KB — not viewable on this provider]"
  instead of dropping them. The model still cannot see the image, but
  it can acknowledge it coherently rather than confabulating.

Adds 9 discord parser tests covering all (text, attachment-count) shapes
plus MIME edge cases, and 2 claude_code driver tests covering captioned
and bare-image rendering.
2026-05-02 15:16:28 -07:00
Ben Hoverter aaad1fdf32 discord: log raw MESSAGE_CREATE/UPDATE payloads at debug
Adds a single tracing::debug! at the top of parse_discord_message that
dumps the full payload JSON. Silent at default `info` level; enable with
`RUST_LOG=openfang_channels::discord=debug` to capture real attachment
JSON when developing the file-passing parse code.

Logs before any filters (bot, allowed_users, allowed_guilds, empty
content) so attachment-only messages are visible too.
2026-05-02 15:16:27 -07:00
Ben Hoverter dd8c53026e channels/bridge: support file:// URLs in download_image_to_blocks
Pick 3a-bis of the Discord file-passing plan: teach the multimodal
image fetcher to handle file:// URLs by reading from local disk
instead of going through reqwest. PR-A (Discord inbound) will
materialize attachments to a shared inbox dir and emit
ChannelContent::Image { url: "file://..." }, so this branch is what
unblocks vision on inbox-materialized images after the Discord CDN
URL has expired.

Implementation:
- Branch on url.strip_prefix("file://"); local read uses tokio::fs::read.
- HTTP path unchanged. Both paths converge on (Vec<u8>, Option<String>)
  before the existing 5MB cap, magic-byte sniffing, and base64 path.
- No content-type header on file:// — magic-byte detection and URL
  extension fallback do all the media-type work, which is fine since
  detect_image_magic and media_type_from_url already exist.
- No new deps. Vec<u8> instead of bytes::Bytes to avoid pulling in
  the bytes crate as a direct dep.
- No URL percent-decoding: the inbox writer (PR-A) controls filenames
  and avoids characters that would need encoding.

Refs: projects/openfang-fork/discord-file-passing-plan.md (step 2)
2026-05-02 15:16:27 -07:00
Ben Hoverter 218f2dba1f channels: add mime and size to ChannelContent::File
Pick 3a of the Discord file-passing plan: extend the URL-flavored File
variant with optional mime and size metadata so adapters can pass
attachment context through to bridges. FileData (bytes-flavored) is
unchanged; size is implicit in data.len() and mime_type already exists.

Match-arm sites in bridge.rs, telegram.rs, whatsapp.rs use `..` to stay
forward-compatible. Construction sites in telegram.rs and kernel.rs
pass `mime: None, size: None` for now; Discord inbound (PR-A) will
populate them.

Refs: projects/openfang-fork/discord-file-passing-plan.md
2026-05-02 15:16:27 -07:00
dependabot[bot] 24aca4e31d build(deps): bump open from 5.3.3 to 5.3.4
Bumps [open](https://github.com/Byron/open-rs) from 5.3.3 to 5.3.4.
- [Release notes](https://github.com/Byron/open-rs/releases)
- [Changelog](https://github.com/Byron/open-rs/blob/main/changelog.md)
- [Commits](https://github.com/Byron/open-rs/compare/v5.3.3...v5.3.4)

---
updated-dependencies:
- dependency-name: open
  dependency-version: 5.3.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 10:50:27 +00:00
jaberjaber23 3cce1eb3fb bump v0.6.4 2026-05-01 13:48:18 +03:00
jaberjaber23 c89958b66d firefox sidebar 2026-05-01 13:28:26 +03:00
jaberjaber23 b0a92456bf openrouter free 2026-05-01 13:19:50 +03:00
jaberjaber23 67bbcc623d cachyos build 2026-05-01 13:18:19 +03:00
jaberjaber23 a91bfc0e9c dashboard bind 2026-05-01 13:17:32 +03:00
jaberjaber23 948117d5de bump v0.6.3 2026-05-01 13:12:25 +03:00
jaberjaber23 2dedab2a8b think persist 2026-05-01 13:01:55 +03:00
jaberjaber23 8642c4d442 timeout reload 2026-05-01 12:57:05 +03:00
jaberjaber23 46a6eb33d9 slack dedup 2026-05-01 12:50:38 +03:00
jaberjaber23 99b4ce2931 telegram cache 2026-05-01 12:50:38 +03:00
Jaber Jaber 87932f5da0 Merge pull request #1061 from RightNow-AI/dependabot/github_actions/softprops/action-gh-release-3
build(deps): bump softprops/action-gh-release from 2 to 3
2026-05-01 12:49:07 +03:00
Ben HoverterandClaude Opus 4.7 9130811433 docs(types): drop internal spec reference from KernelConfig comment
Replaces "Spec §5.5 scoped strict-field validation to bindings" with
self-contained wording. The §5.5 reference points to an internal-fork
spec document that means nothing to upstream readers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 17:49:28 -07:00
Ben HoverterandClaude Opus 4.7 325734c6aa fix(kernel): lenient binding parsing — partial-success table parse
A typo in any binding's match_rule no longer drops the entire bindings
table. Each entry is parsed independently; malformed entries log an
ERROR with index, agent name, and the underlying serde error, then are
skipped. A single WARN summarizes total dropped vs. surviving bindings.
Per-entry deny_unknown_fields is preserved so silent typos still fail
loudly — just no longer catastrophically.

Before this change, a single misspelled field anywhere in [[bindings]]
caused the whole table to fail parsing, silently unbinding every
agent — the worst possible failure mode for a routing config.

- New `lenient_extract_bindings` runs after include-merge / [api]
  migration, before `try_into::<KernelConfig>()`.
- 7 new config tests cover the reproducer, happy path, all-malformed,
  no-bindings, missing-agent, survivor-order preservation, and
  top-level field typos:
    * test_lenient_bindings_drops_typo_keeps_rest
    * test_lenient_bindings_all_valid_unchanged
    * test_lenient_bindings_all_malformed_yields_empty_but_keeps_rest_of_config
    * test_lenient_bindings_no_bindings_section_is_noop
    * test_lenient_bindings_missing_agent_field_dropped
    * test_lenient_bindings_preserves_survivor_order — locks in that
      first-match-wins routing semantics cannot silently regress when
      a middle entry is dropped
    * test_lenient_bindings_top_level_field_typo_dropped — locks in
      that deny_unknown_fields catches operator typos at the binding
      top level (e.g. \`agnt = ...\`), not just inside match_rule
- TODO marker added on the remaining \`warn!\` fallback in \`load_config\`
  for the non-binding silent-default path (follow-up work).

Tested live: typo'd \`hannel\` field on binding #2 logged as expected;
remaining 5 bindings loaded and routed correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 17:10:18 -07:00
Ben Hoverter faf2cf9211 feat(channels): harden channel_id binding (extends d336314)
Layers richer config validation, an explicit adapter allowlist, and a
stricter bridge routing path on top of upstream `d336314` ("binding
rule"), which shipped the same `channel_id` field as our PR #1127 in a
parallel implementation. Replaces upstream's `sender_user_id`/
`platform_id` heuristic with a single source of truth shared between
config validation and routing.

## What changes vs upstream `d336314`

**Data model** (`openfang-types/src/config.rs`)
- `#[serde(deny_unknown_fields)]` on `AgentBinding` so a typo at the
  binding level (e.g. `match_rules` plural) fails loudly instead of
  silently leaving the rule defaulted to "match everything". Upstream
  has it on `BindingMatchRule` only.
- New `pub const CHANNELS_WITH_PLATFORM_ID_AS_CHANNEL` (19 adapters:
  discord, slack, telegram, matrix, mattermost, teams, webex,
  rocketchat, nextcloud, pumble, revolt, guilded, feishu, lark,
  keybase, google_chat, line, twist, flock, twitch). Single source of
  truth shared with the bridge — no drift between routing and
  validation paths possible. Hybrid adapters (IRC, Zulip) are
  excluded; see source comment.
- Startup validation: warn when a binding sets `channel_id` for a
  non-supporting adapter, or when `channel_id` is set without
  `channel`. Documents the metadata escape hatch in the warning.
- Top-level `KernelConfig` keeps no `deny_unknown_fields` — comment
  explains the §5.5 scoping decision so a future reader doesn't
  "tighten" it without realizing it would break forward-compat keys.

**Bridge** (`openfang-channels/src/bridge.rs`)
- Replaces upstream's `sender_channel_id()` heuristic ("if metadata has
  `sender_user_id` and it differs from `platform_id`, assume
  `platform_id` IS the channel") with `binding_context_for(message)`,
  which delegates to `ChannelMessage::channel_id()`. The heuristic
  worked for Discord/Slack but would fail silently on Matrix, Teams,
  Mattermost, Telegram, etc. — adapters whose `platform_id` IS the
  channel ID but whose metadata does not happen to set
  `sender_user_id` differently.
- Routes both dispatch paths (text + blocks) through
  `resolve_with_context` so `guild_id` and `channel_id` bindings can
  match. (Upstream's `resolve_with_channel_id` only handled
  channel_id.)

**Channels types** (`openfang-channels/src/types.rs`)
- New `ChannelMessage::channel_id()` accessor: reads `platform_id` for
  allowlisted adapters, falls back to `metadata["channel_id"]` for
  opt-in adapters, else `None`. Case-folds `Custom(...)` variants so
  a stray `Custom("Twitch")` cannot silently slip past the allowlist
  (the validation path lowercases user input — accessor must match).

**Tests** (+8 in `bridge.rs`, +5 in `config.rs`, +1 in `types.rs`)
- Bridge: Discord/Telegram/Matrix/custom-supported/user-id-only-adapter
  /metadata-fallback/guild-id-from-metadata/Email-returns-None
  coverage of `binding_context_for` and `channel_id()`.
- Config: typo rejection on both `BindingMatchRule` and `AgentBinding`;
  channel_id-without-channel warning; unsupported-adapter warning;
  no-warning for discord/slack/telegram.
- Types: `channel_id()` case-insensitivity for Custom variants
  including the Lark/Feishu Intl spelling.

**Docs** (`docs/channel-adapters.md`)
- Routing section rewritten: bindings are step 1 in the resolution
  order. New "Bindings" subsection documents the rule shape, the
  `peer_id` vs `channel_id` distinction (the easy confusion), full
  specificity table, the adapter allowlist with the metadata escape
  hatch, and the strict-field parsing rule.

## Why this layering instead of replacing d336314

Upstream's commit and our PR #1127 are functionally equivalent on
Discord and Slack. Shipping a richer extension on top is less churn
than ripping out the upstream commit and substituting ours, and keeps
the API surface upstream just added (`resolve_with_channel_id`)
intact for any third-party consumers.

`cargo check --workspace` and `cargo test -p openfang-types -p
openfang-channels --lib` (850 tests) pass.
2026-04-30 17:10:16 -07:00
jaberjaber23 15ed29c667 bump v0.6.2 2026-04-29 20:39:59 +03:00
jaberjaber23 1d1bf0fb09 exec full 2026-04-29 16:19:23 +03:00
jaberjaber23 d3363142b2 binding rule 2026-04-29 16:19:23 +03:00
Hypn0sis 76929a41aa fix(clippy): resolve upstream warnings breaking CI
Mechanical clippy fixes for collapsible_match, unnecessary_sort_by, and redundant into_iter. Resolves the 5 errors blocking openfang-runtime in CI.
2026-04-29 16:17:48 +03:00
Jaber Jaber fe34a37e6f Merge pull request #1112 from RightNow-AI/dependabot/cargo/lettre-0.11.21
build(deps): bump lettre from 0.11.20 to 0.11.21
2026-04-29 16:10:44 +03:00
Jaber Jaber 4b63eb18cc Merge pull request #1111 from RightNow-AI/dependabot/cargo/libc-0.2.185
build(deps): bump libc from 0.2.183 to 0.2.185
2026-04-29 16:10:31 +03:00
Jaber Jaber fe21d4b4df Merge pull request #1110 from RightNow-AI/dependabot/cargo/rustls-0.23.39
build(deps): bump rustls from 0.23.37 to 0.23.39
2026-04-29 16:10:19 +03:00
Jaber Jaber f52bc53e47 Merge pull request #1109 from RightNow-AI/dependabot/cargo/uuid-1.23.1
build(deps): bump uuid from 1.23.0 to 1.23.1
2026-04-29 16:10:05 +03:00
Jaber Jaber 10f7ee1885 Merge pull request #1060 from ferr079/fix/unify-ssrf-protection
fix(security): unify SSRF protection for WASM host calls
2026-04-29 16:02:51 +03:00
Jaber Jaber 7bc6591338 Merge pull request #1058 from lc-soft/fix/trader-dashboard-style
fix(hands): correct trader dashboard style
2026-04-29 15:45:05 +03:00
jack-wzandjack-wz01 53f2066945 chore: add health stack and stabilize provider env tests (#1126)
Co-authored-by: jack-wz01 <15474862+jack-wz01@user.noreply.gitee.com>
2026-04-29 15:45:01 +03:00
Jaber Jaber c69dd84184 Merge pull request #1095 from Streamweaver/fix/mcp-stdio-env-passthrough-linux
fix(runtime): pass HOME/TMP/TEMP to stdio MCP servers on all platforms
2026-04-29 15:44:05 +03:00
Jaber Jaber c1356fc95d Merge pull request #1100 from Streamweaver/fix/telegram-silent-failures
channels/telegram: propagate send failures and cache terminal reaction errors
2026-04-29 15:43:42 +03:00
jaberjaber23 aabf83b351 bump v0.6.1 2026-04-29 15:30:46 +03:00
jaberjaber23 da6b567ac3 manifest merge 2026-04-29 15:19:41 +03:00
jaberjaber23 ccdd7943a2 fix clippy 2026-04-29 15:14:42 +03:00
jaberjaber23 7fe87babe6 preserve workspace 2026-04-29 15:08:44 +03:00
jaberjaber23 9c0e1637a5 fmt drift 2026-04-29 15:07:22 +03:00
jaberjaber23 fd450dbfc2 fmt cleanup 2026-04-29 15:06:17 +03:00
Jaber Jaber 81176dc626 Merge pull request #1130 from benhoverter/fix/message-timeout-config
fix(runtime): add subprocess timeout config for claude-code driver
2026-04-29 15:03:10 +03:00
Octopusandocto-patch ef9096f7c5 fix(kernel): sync all agent.toml fields to DB on restart (fixes #1087) (#1118)
The TOML-vs-DB change detection at boot only checked a subset of fields,
causing edits to workspace, schedule, resources, autonomous, and exec_policy
to be silently ignored after a restart.

Add the missing fields to the changed-detection predicate so the kernel
properly reflects all agent.toml edits in the SQL database. The workspace
comparison is intentionally guarded — if the TOML omits workspace (None),
the kernel-assigned default path already stored in the DB is kept rather
than being overwritten with None.

Derive PartialEq on ScheduleMode, AutonomousConfig, ResourceQuota, and
ExecPolicy to enable the comparisons without manual field-by-field expansion.

Co-authored-by: octo-patch <octo-patch@github.com>
2026-04-29 14:50:38 +03:00
Jaber Jaber fbb5bb1ae9 Merge pull request #1099 from nimitbhardwaj/fix#1088/websocket-scheduled
fix(ws): broadcast cron job results to WebSocket clients in real-time
2026-04-29 14:47:39 +03:00
Jaber Jaber c435a6adcd Merge pull request #1114 from pandego/fix/1102-idle-heartbeat
fix(kernel): avoid crashing idle reactive agents
2026-04-29 14:46:15 +03:00
Ben Hoverter f67c4e8754 fix(channels): key router default-agent map on user_id, not channel_id (#1123)
* fix(channels): key router on user, not channel (Discord/Slack)

Discord and Slack adapters set sender.platform_id to the channel/conversation
ID (needed for the send path), so router.resolve(channel, sender.platform_id, ..)
was matching peer_id bindings against the channel ID and never finding the
user-keyed binding. The sender_user_id() helper already existed but only the
rate-limit/authz paths used it; the routing reads did not.

Read-path fix:
- discord.rs / slack.rs: stash author/user ID in metadata["sender_user_id"]
- bridge.rs: route the text and audio paths through sender_user_id(message)
- bridge.rs: thread user_id through handle_command() so the 6 CLI slash-command
  resolves (/new, /compact, /model, /stop, /usage, /think) also key on user.
  Tests updated for the new signature.

Write-path follow-up (set_user_default + broadcast routing) deferred to a
separate commit so this change can be validated in isolation.

* fix(channels): close write-path keying gap; broadcast user-scoped

Completes the router keying fix started in 6a90aa0. The read path
resolves on user_id, but four write sites and the broadcast lookup
were still keyed on sender.platform_id (channel ID on Discord/Slack),
producing the split-keying state that surfaced in GAP-008.

- 4 x set_user_default writes (text/audio fallback, /agent existing,
  /agent spawned) now key on sender_user_id(message)
- 2 x broadcast lookups (has_broadcast / resolve_broadcast) switched
  to sender_user_id(message), matching the upstream test's intent
  (router.rs:521-547 keys on "vip_user", not a channel)
- boot-time log warning on Discord/Slack adapter start: any
  pre-existing /agent default may need to be re-run once
- new test test_handle_command_agent_select_keys_on_user_id_not_
  platform_id locks in the round-trip
2026-04-29 14:40:36 +03:00
Jaber Jaber 3b237ac526 Merge pull request #1090 from chrisyoung2005/fix/streaming-heartbeat-touch
Stamp last_active in streaming agent loop to prevent heartbeat false-positives
2026-04-29 14:39:28 +03:00
Jaber Jaber 96c572df32 Merge pull request #1082 from octo-patch/fix/issue-1081-lark-websocket-region
fix(feishu): respect region setting for WebSocket endpoint URL
2026-04-29 14:38:12 +03:00
Jaber Jaber 17e0d519ca Merge pull request #1080 from pandego/fix/1079-minimax-init
fix: expose MiniMax in openfang init
2026-04-29 14:30:05 +03:00
Charles Hakes 37c233d489 fix(flake): NixOS build — nativeBuildInputs, wrapGAppsHook3, libayatana-appindicator runtime closure (#1063)
Closes #1092

Four fixes that together make `nix build .#openfang-cli` and `nix build .#openfang-desktop` work on NixOS:

1. perl / clang / pkg-config moved to nativeBuildInputs (fixes openssl-src build failure — this is the bug filed in #1092)
2. openfang-desktop nativeBuildInputs gets pkg-config + wrapGAppsHook3 (GTK runtime wrappers + webkit2gtk-4.1 .pc discovery)
3. libayatana-appindicator added to desktop buildInputs (tray.rs dlopen at runtime)
4. preFixup hook prefixes LD_LIBRARY_PATH so the dlopen-only library actually ends up in the runtime closure

Authored by @Aypex.

Supersedes #1086 (which only fixed item 1).
2026-04-29 14:29:24 +03:00
guatoc-ecohubandMiguel Guerrero 92f7e996de feat(media): add audio_base_url override for local OpenAI-compat Whisper (#1124)
Adds an optional `audio_base_url` field to `MediaConfig` that overrides
the hardcoded provider URLs in `media_understanding::transcribe_audio`,
allowing the same OpenAI-compatible multipart wire format to be sent to
a local Whisper service (speaches, faster-whisper-server, LM Studio,
etc.) instead of api.openai.com / api.groq.com.

Closes #1051.

## Why

Self-hosted, sovereignty-conscious, or rate-limited deployments often
need to route audio transcription to a local Whisper backend while
keeping `media_transcribe` / `speech_to_text` working as native tools
(no helper scripts, no shell_exec workarounds). Today the URLs in
`media_understanding.rs:118-128` are literal `&'static str` so neither
`OPENAI_BASE_URL` nor `provider_urls` (which the LLM drivers do
respect) is read for audio. The same problem existed for embeddings
and was already addressable via `provider_urls`, so this change keeps
the pattern symmetric for media at the simplest possible surface area.

## Wire format

The endpoint shape and Authorization header remain identical:

  POST <audio_base_url>/v1/audio/transcriptions
  Authorization: Bearer $<provider>_API_KEY
  Content-Type: multipart/form-data
  fields: file (binary), model, response_format=text

This means **any OpenAI-compatible Whisper server is drop-in**
(Speaches, faster-whisper-server, LM Studio's Whisper server, etc.).
Local servers typically accept any non-empty bearer string, so users
can keep `OPENAI_API_KEY=anything` for the auth header.

## Configuration

```toml
[media]
audio_provider = "openai"
audio_base_url = "http://127.0.0.1:8000"
# → POST http://127.0.0.1:8000/v1/audio/transcriptions
```

Or for Groq-compatible local servers:

```toml
[media]
audio_provider = "groq"
audio_base_url = "http://127.0.0.1:9000"
# → POST http://127.0.0.1:9000/v1/audio/transcriptions
```

Trailing slash on the user-supplied base is stripped to avoid double
slashes in the final URL.

## Backward compatibility

- `MediaConfig` already uses `#[serde(default)]`, so existing
  configs without `audio_base_url` deserialize as `None` and behave
  exactly as before (cloud provider URLs).
- `Default` impl extended; `audio_base_url: None`.
- `parakeet-mlx` provider path unaffected (it's a separate code branch).
- No new dependencies, no breaking changes to public API.

## Tests

- `test_media_config_default` extended to assert `audio_base_url.is_none()`.
- `test_media_config_audio_base_url_serde_roundtrip` — set + JSON roundtrip.
- `test_media_config_backward_compat_no_audio_base_url` — legacy JSON
  parses with the new field as None.
- `test_audio_base_url_override_logic` — pure-function test that
  exercises the URL building branch (default URLs preserved when
  unset, override applied for both providers, trailing-slash strip).

The runtime branch in `transcribe_audio` was kept as a straight
`if Some/else default` rather than a helper function to minimize the
diff and keep the patch obviously safe to review.

## Operational note

This change does not affect anyone running the cloud provider URLs
out of the box. The override is opt-in via a single optional config
field. Useful for users like myself running a local Speaches container
behind a reverse proxy and a chat-only LLM key (z.ai Coding Plan)
that can't satisfy openai.com's audio endpoint.

Linked: #1051 (Configurable STT/TTS/image URLs and local backends).

Co-authored-by: Miguel Guerrero <kortux@gmail.com>
2026-04-29 14:28:04 +03:00
Ben Hoverter b1c4061247 fix(runtime): wire subprocess_timeout_secs through config.toml
Follow-up to 79aa34c. The previous commit added the public surface
(DriverConfig field + OPENFANG_SUBPROCESS_TIMEOUT_SECS env var) but
left every DriverConfig construction site hardcoded to None — so the
struct field was wired but had no on-disk source feeding it. The env
var was the only operator-facing knob.

This commit plumbs the missing layer: the timeout is now deserializable
from config.toml on both the primary and global-fallback providers.

Public surface
- DefaultModelConfig.subprocess_timeout_secs: Option<u64>
- FallbackProviderConfig.subprocess_timeout_secs: Option<u64>
- Both fields are #[serde(default)] — existing config.toml files
  without the field deserialize cleanly to None (no breaking change).

Placement rationale
- Per-provider on each config struct, not a top-level field or a new
  [driver] section. This matches the existing per-provider config shape
  and lets operators set different timeouts for primary vs. fallback
  (e.g. tighter timeout on a fast fallback to fail over sooner). If a
  second driver-level setting ever lands, refactoring two struct fields
  into a [driver] section is cheap; we don't pre-pay for it now.

Wiring (kernel.rs)
- L663  primary driver  ........  pulls config.default_model.subprocess_timeout_secs
- L687  auto-detect path  ......  inherits default_model intent (the swap
                                  is replacing the *provider*, not the
                                  timeout policy)
- L736  global fallback loop  ..  pulls fb.subprocess_timeout_secs
- L5031 agent primary  .........  inherits effective_default's value when
                                  agent_provider == default_provider;
                                  None for cross-provider overrides
- L5108 agent manifest fallback   inherits dm's value when the manifest
                                  fallback resolves to "default" (matching
                                  the existing fb.provider sentinel logic);
                                  None for explicit cross-provider entries
- L5139 global fallback (per-agent loop) — pulls fb.subprocess_timeout_secs

Sites kept as None (intentional)
- agent_loop.rs:1146, 1330: ModelNotFound recovery iterates over the
  agent manifest's fallback_models (FallbackModel, not the config-toml
  type) — no per-provider config in scope.
- routes.rs:7701: provider connectivity test endpoint; no config source.
- routes.rs:7529: dashboard hot-update path constructs a fresh DM with
  defaults (None) — operator sets timeout via config.toml, not via the
  set-key flow.

Tests
- test_subprocess_timeout_secs_in_toml: round-trips a TOML doc with
  default_model.subprocess_timeout_secs = 600 and one fallback at 180,
  one fallback omitted; asserts each value (or None) reaches the parsed
  config struct.
- test_subprocess_timeout_secs_omitted_defaults_to_none: asserts a
  legacy-shaped config.toml (no timeout fields) parses cleanly with
  both fields = None — backward-compat guard.
- 4 existing claude_code driver timeout tests still pass.

Mechanical pass-throughs
- 8 test fixtures across openfang-kernel/tests and openfang-api/tests
  gain subprocess_timeout_secs: None on their DefaultModelConfig
  literals.
- 1 production literal in routes.rs gains the same field.
- The existing FallbackProviderConfig serde-roundtrip test gains
  subprocess_timeout_secs: None plus an assertion.

Precedence comment in drivers/mod.rs::create_driver updated to reflect
that the config-field path is now real, with explicit pointers to the
kernel.rs wiring sites for future contributors.

Validated: cargo check --workspace --tests is clean; openfang-types
(362), openfang-runtime (933), and openfang-kernel (260) lib tests
all pass.
2026-04-27 23:40:12 -07:00
Ben Hoverter 79aa34c77a fix(runtime): add subprocess timeout config for claude-code driver
The claude-code driver hardcodes its per-message turn timeout inside
ClaudeCodeDriver and exposed no operator-facing knob, so long-running
CC subprocess turns (large prompt-caches, deep tool chains) hit the
internal default with no escape hatch. Adds a public config surface,
honored today only by the claude-code driver, designed so future
subprocess drivers can opt in without re-shaping the API.

Public surface
- DriverConfig.subprocess_timeout_secs: Option<u64> (llm_driver.rs)
- OPENFANG_SUBPROCESS_TIMEOUT_SECS env var (drivers/mod.rs)
- Precedence in create_driver(): env var > config field > driver default

Naming rationale
- Field/env are scope-flavored, not semantic, on purpose: the name
  telegraphs that HTTP providers (default/Anthropic, openai, bedrock,
  qwen-code) accept-but-silently-ignore the field today. A semantic
  name (message_timeout_secs) would have invited the same silent-no-op
  footgun on those providers.
- Driver-internal field in claude_code.rs intentionally kept as
  message_timeout_secs — it's not on the public boundary and the
  semantic name accurately describes what it stores.

Tests (drivers/mod.rs)
- default_when_unset: no env, no config -> driver default
- config_set: config field flows through
- env_overrides_config: env var wins over config (construction-only
  assertion; trait-object opacity prevents reading the value back)
- malformed_env_falls_through: unparseable env silently falls through
  to config, matching the .parse::<u64>().ok() chain in production
- All four tests scrub OPENFANG_SUBPROCESS_TIMEOUT_SECS pre/post to
  avoid cross-test pollution

Mechanical pass-throughs
- 12 x DriverConfig { .. } test fixtures in drivers/mod.rs gain
  subprocess_timeout_secs: None
- routes.rs (1), kernel.rs (6), agent_loop.rs (2): same pass-through
  fills in DriverConfig literals; no logic touched

Forward-compat note
- A NOTE block in drivers/mod.rs flags the scope-vs-implementation
  gap so the next contributor adding a subprocess driver knows
  exactly where to wire the config in.

Validated end-to-end against a live daemon: dry-run + full deploy
(deploy-local.sh, all 7 phases) + post-swap agent_send round-trip
through the claude-code dispatch path.
2026-04-27 23:17:41 -07:00
Ben Hoverter 4ae2961b1c fix(channels): close write-path keying gap; broadcast user-scoped
Completes the router keying fix started in 6a90aa0. The read path
resolves on user_id, but four write sites and the broadcast lookup
were still keyed on sender.platform_id (channel ID on Discord/Slack),
producing the split-keying state that surfaced in GAP-008.

- 4 x set_user_default writes (text/audio fallback, /agent existing,
  /agent spawned) now key on sender_user_id(message)
- 2 x broadcast lookups (has_broadcast / resolve_broadcast) switched
  to sender_user_id(message), matching the upstream test's intent
  (router.rs:521-547 keys on "vip_user", not a channel)
- boot-time log warning on Discord/Slack adapter start: any
  pre-existing /agent default may need to be re-run once
- new test test_handle_command_agent_select_keys_on_user_id_not_
  platform_id locks in the round-trip
2026-04-26 16:14:17 -07:00
Ben Hoverter 6a90aa08df fix(channels): key router on user, not channel (Discord/Slack)
Discord and Slack adapters set sender.platform_id to the channel/conversation
ID (needed for the send path), so router.resolve(channel, sender.platform_id, ..)
was matching peer_id bindings against the channel ID and never finding the
user-keyed binding. The sender_user_id() helper already existed but only the
rate-limit/authz paths used it; the routing reads did not.

Read-path fix:
- discord.rs / slack.rs: stash author/user ID in metadata["sender_user_id"]
- bridge.rs: route the text and audio paths through sender_user_id(message)
- bridge.rs: thread user_id through handle_command() so the 6 CLI slash-command
  resolves (/new, /compact, /model, /stop, /usage, /think) also key on user.
  Tests updated for the new signature.

Write-path follow-up (set_user_default + broadcast routing) deferred to a
separate commit so this change can be validated in isolation.
2026-04-26 12:41:06 -07:00
pandego 356500bb1e fix(kernel): ignore idle reactive heartbeat silence 2026-04-23 18:42:47 +02:00
dependabot[bot] 40bd7e2c11 build(deps): bump lettre from 0.11.20 to 0.11.21
Bumps [lettre](https://github.com/lettre/lettre) from 0.11.20 to 0.11.21.
- [Release notes](https://github.com/lettre/lettre/releases)
- [Changelog](https://github.com/lettre/lettre/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lettre/lettre/compare/v0.11.20...v0.11.21)

---
updated-dependencies:
- dependency-name: lettre
  dependency-version: 0.11.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 08:24:47 +00:00
dependabot[bot] a7197d7b97 build(deps): bump libc from 0.2.183 to 0.2.185
Bumps [libc](https://github.com/rust-lang/libc) from 0.2.183 to 0.2.185.
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.185/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.183...0.2.185)

---
updated-dependencies:
- dependency-name: libc
  dependency-version: 0.2.185
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 08:24:21 +00:00
dependabot[bot] bc26d5e8c3 build(deps): bump rustls from 0.23.37 to 0.23.39
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.37 to 0.23.39.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.37...v/0.23.39)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.39
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 08:23:42 +00:00
dependabot[bot] 84d90ad342 build(deps): bump uuid from 1.23.0 to 1.23.1
Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.23.0 to 1.23.1.
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.0...v1.23.1)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.23.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 08:23:16 +00:00
Scott Turnbull 9fee63d58c channels/telegram: cache terminal setMessageReaction errors per (chat, emoji)
`fire_reaction` calls `setMessageReaction` fire-and-forget on every
agent lifecycle event. When Telegram returns a terminal error like
`REACTION_INVALID` (emoji not in the bot's free-reaction allowlist),
`REACTION_NOT_AVAILABLE` (chat admin restricted this emoji), or
`REACTION_TOO_MANY` (per-message cap), retrying on every subsequent
turn is pointless log spam and wasted API quota.

This adds a per-bot-instance `HashSet<(i64, String)>` keyed by
`(chat_id, emoji)` that records terminal rejections and short-circuits
future calls for the same pair. Keyed by chat, not just emoji, because
`Chat.available_reactions` varies across chats and is admin-mutable
(https://core.telegram.org/bots/api#setmessagereaction) — an emoji
rejected in chat A may still be valid in chat B. Cache is
per-process; on restart it rebuilds naturally, which handles any
runtime allowlist change without needing persistence.

The terminal-error match uses a small private helper
`is_terminal_reaction_error` that substring-matches the three
permanent errors. Transient errors (429, 5xx, `MESSAGE_NOT_MODIFIED`,
unrelated 400s) are deliberately NOT cached.

Concurrency: the cache uses `std::sync::Mutex` — critical section is
two `HashSet` ops (contains + insert), never held across `.await`.
Endorsed by the Tokio shared-state tutorial
(https://tokio.rs/tokio/tutorial/shared-state) for exactly this shape.
Two concurrent `fire_reaction` calls for the same (chat, emoji) can
both pass the cache check before either rejection lands, producing up
to N duplicate API calls on the first rejection; the duplicate
`insert` is idempotent so this is benign and self-limits on the
second turn. Documented in-code.

Tests: 6 new tests covering terminal-error matching, cache insertion,
per-chat key isolation, and non-caching of transient and successful
responses. Total 47 telegram tests pass (41 existing + 6 new). No new
clippy warnings.
2026-04-21 18:03:23 -04:00
Scott Turnbull 40903cceee channels/telegram: propagate send failures from api_send_* helpers
The six outbound helpers in the Telegram adapter (sendMessage, sendPhoto,
sendDocument, sendDocument_upload, sendVoice, sendLocation) previously
logged a `warn!` on HTTP non-success and still returned `Ok(())`. Callers
interpreted that as successful delivery and told the agent "Message sent"
even when Telegram had rejected the request (e.g. 400 Bad Request from
malformed HTML entities with parse_mode=HTML). The agent recorded phantom
success in its session history, corrupting subsequent behavior.

The fix returns `Err(format!(...).into())` on HTTP non-success in all six
helpers, matching the error-handling convention documented in
CONTRIBUTING.md.

`api_send_message` is slightly different because it splits long messages
into chunks via `split_message(4096)`. Naively returning `Err` on any
chunk failure would create a partial-delivery-then-error regression —
worse than the original silent success. The function now tracks
`delivered_any` across chunks:

- First-chunk failure (nothing delivered yet) → return `Err` to surface
  the failure. This is where the motivating HTML-parse-error bug lives,
  so the fix is fully effective.
- Subsequent-chunk failure (user already received preceding chunks) →
  log `warn!` and continue with best-effort delivery, matching the
  convention used by every other adapter in the crate that calls
  `split_message` (Discord, Gitter, Mattermost, Nextcloud, Twitch,
  Pumble, etc.).

Tests: 4 new tests using a small in-crate stub server (axum on an
ephemeral port, reached via the existing `api_url` constructor seam —
zero new dependencies). 41 telegram tests pass (37 existing + 4 new).
2026-04-21 18:01:24 -04:00
Nimit Bhardwaj 5a86141677 fix(ws): broadcast cron job results to WebSocket clients in real-time
Fixes #1088 - scheduled task results now appear in web UI without page
refresh.
2026-04-21 22:34:36 +05:30
Scott Turnbull e97eb6fff3 fix(runtime): pass HOME/TMP/TEMP to stdio MCP servers on all platforms
Node/npx-backed stdio MCP servers (Gmail, AgentMail, Exa, etc.) need a
usable HOME directory for npm cache and temp-file scratch space. Without
it, npm errors with EACCES on /nonexistent or silently falls over when
trying to write cache entries.

Previously these three variables were only passed on Windows. Linux and
macOS hosts launching stdio MCP servers through npx would get an empty
env for HOME/TMP/TEMP, breaking most community MCP servers.

Move the HOME/TMP/TEMP passthrough above the cfg!(windows) block so it
applies to every platform. Remove the now-redundant entries from the
Windows-only list.
2026-04-20 12:24:33 -04:00
dongtran16092006 93b57bdd52 fix: system prompt and identity handling, and config form hydration #1045 2026-04-20 18:10:16 +07:00
Matteo De Agazio 0227ff1790 fix(discord): cap dedup set on thread delete, add #[cfg(test)] to test mod
- Replace retain(|_| true) no-op with a size-capped clear: when
  threaded_message_ids exceeds MAX_DEDUP_MSG_IDS (2000) it is cleared.
  MESSAGE_UPDATE embed events arrive within seconds so old entries are
  always safe to discard; prevents unbounded growth on busy servers.
- Add #[cfg(test)] to mod tests so empty_threads() helper is only
  compiled in test mode — removes the need for #[allow(dead_code)].
2026-04-20 09:31:09 +02:00
Matteo De Agazio 525d7d844a feat(discord): smart auto-thread mode (true/false/smart) 2026-04-20 09:31:09 +02:00
chris-youngandClaude Sonnet 4.6 f2587995a2 Stamp last_active in streaming agent loop to prevent heartbeat false-positives
Fixes #1089

run_agent_loop_streaming skipped the touch_agent() call that the
non-streaming run_agent_loop performs before every LLM request. On slow
local inference (e.g. Ollama qwen3.5:35b, multi-minute generations),
last_active went stale and the heartbeat monitor flagged the agent as
unresponsive, triggering crash recovery mid-stream. With multiple agents
sharing one Ollama instance, queued agents appeared frozen while the
active one generated.

Mirror the non-streaming behavior: stamp last_active immediately before
stream_with_retry so the heartbeat window covers the full LLM call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:59:47 -07:00
jaberjaber23 e6bab993ae bump v0.6.0 2026-04-19 22:57:55 +03:00
jaberjaber23 a39a675ba9 skill config ui 2026-04-19 22:27:23 +03:00
jaberjaber23 0ce390e09f cron delivery ui 2026-04-19 21:53:20 +03:00
jaberjaber23 88eeaa6a4d commands ui 2026-04-19 21:42:33 +03:00
jaberjaber23 3db5d3a825 cron delivery 2026-04-19 17:10:23 +03:00
jaberjaber23 5a1f372612 command registry 2026-04-19 17:08:11 +03:00
jaberjaber23 a9f15b2d23 skill config 2026-04-19 16:54:11 +03:00
octo-patch 45e7ea7948 fix(feishu): respect region setting for WebSocket endpoint URL
When using Lark international (open.larksuite.com) with WebSocket mode,
the adapter was hardcoding the Chinese Feishu endpoint URL and also
ignoring the configured region entirely when constructing the adapter.

Two bugs fixed:
1. FEISHU_WS_ENDPOINT_URL was hardcoded to open.feishu.cn — international
   Lark apps could not authenticate because their credentials are only
   valid on open.larksuite.com. Changed to FEISHU_WS_ENDPOINT_PATH and
   compute the full URL using self.region.domain() at call time.
2. new_websocket() in FeishuAdapter always set region = FeishuRegion::Cn.
   Added new_websocket_with_region() that accepts an explicit region, and
   updated the call site in channel_bridge.rs to pass the parsed region.

Fixes #1081
2026-04-19 11:23:46 +08:00
Stephane de8a692036 fix(security): unify SSRF protection for WASM host calls
The WASM sandbox host_net_fetch() had its own SSRF implementation
(is_ssrf_target) that was incomplete compared to the canonical
check_ssrf() in web_fetch.rs:

- Missing 6 blocked hostnames (ip6-localhost, Alibaba/Azure IMDS,
  0.0.0.0, ::1, [::1])
- Missing cloud metadata IP detection (is_metadata_ip)
- Missing IPv6 bracket notation support
- Ignoring ssrf_allowed_hosts from config.toml entirely
- Duplicate is_private_ip() and extract_host_from_url() functions

This meant a WASM agent could bypass SSRF protections that the
builtin web_fetch tool correctly enforced.

Changes:
- Remove duplicated is_ssrf_target(), is_private_ip(), and
  extract_host_from_url() from host_functions.rs
- Delegate to web_fetch::check_ssrf() which has the complete
  implementation with allowlist, CIDR matching, and metadata
  IP detection
- Add ssrf_allowed_hosts to SandboxConfig and GuestState so the
  config propagates to WASM host calls
- Make extract_host() pub(crate) for reuse
- Update tests to exercise the unified code path, including new
  coverage for IPv6 and cloud metadata endpoints

All 908 runtime tests pass. Zero clippy warnings.
2026-04-18 14:22:12 +02:00
pandego 8d3d77dd99 fix: expose MiniMax in init provider lists 2026-04-18 13:26:28 +02:00
jaberjaber23 d3d9fa842d release: v0.5.10
Bump workspace version to 0.5.10 and refresh docs.

Bundles the 7 fixes merged on main since v0.5.9:
- #1034 auth fail-closed (#1071)
- #980  channel agent name prefix (#1072)
- #1043 multimodal text+images (#1073)
- #809  openfang hand config subcommand (#1074)
- #843  context.md re-read per turn (#1075)
- #905  config get default_model.base_url (#1076)
- #1069 scheduler unification and migration (#1077)

README: fix stale 0.3.30 badge and March 2026 header to 0.5.10 and April 2026,
drop em dashes throughout.

CHANGELOG: new 0.5.10 section with the above, plus notes on #818 and #819
which were closed as invalid.
2026-04-17 22:54:56 +03:00
Jaber Jaber ff44cfbe87 fix(scheduler): route schedule_* tools and /api/schedules through kernel cron scheduler (#1069) (#1077)
The schedule_create tool, its sibling schedule_list and schedule_delete,
and the matching /api/schedules HTTP routes were all writing to a
shared-memory key that no executor ever read. Jobs registered that way
silently never fired.

Route all three tools and all /api/schedules endpoints through the real
cron scheduler in openfang-kernel. Add a one-shot idempotent migration
at kernel startup that imports legacy __openfang_schedules entries into
the cron scheduler and clears the old key.

Tests:
- Unit tests for sanitize_schedule_name and sanitize_cron_job_name
- Tool wrapper tests using a fake KernelHandle that verify
  schedule_create/list/delete route into cron_create/list/cancel
- Migration tests cover the happy path, idempotency via the marker key,
  and skipping entries whose target agent is not in the registry

Quality gates: cargo check + test + clippy -D warnings + fmt clean on
openfang-kernel, openfang-runtime, openfang-api.

Made-with: Cursor
2026-04-17 22:47:11 +03:00
Jaber Jaber ce89d05987 fix(runtime): combine text and image blocks in multimodal user messages (#1043) (#1073)
When an upload supplied image content blocks alongside the user's text, the agent loop pushed only the image blocks and dropped the text. The LLM saw images with no accompanying prompt.

Build the user turn through a single helper that combines text and image blocks into one multimodal message when both are present, and keeps the existing single-mode representation when only one is supplied. Both the streaming and non-streaming paths now share the same builder.

Closes #1043

Made-with: Cursor
2026-04-17 22:47:08 +03:00
Jaber Jaber 00c0ff60de feat(channels): optional agent name prefix on outbound messages (#980) (#1072)
Adds an opt-in per-channel knob prefix_agent_name on ChannelOverrides
with styles Off (default), Bracket ([agent] text) and BoldBracket
(**[agent]** text).

The bridge wraps the final outbound text once in dispatch_message,
dispatch_with_blocks and the auto-reply path. Off is byte-identical
to pre-feature behavior so existing configs are unaffected.

Platform-native identity overrides (Slack per-message username,
Discord embed author field) are intentionally out of scope here and
tracked as a follow-up.

Made-with: Cursor
2026-04-17 22:47:04 +03:00
Jaber Jaber 07af248a07 fix(cli): config get returns base_url from default_model (#905) (#1076)
Extract the dotted-key lookup in `openfang config get` into a pure
`lookup_config_value` helper with clear outcomes (scalar value, non-scalar
section, or key not found) so the behaviour is covered by unit tests.

Previously the command only had integration coverage, so regressions where
`config get default_model.base_url` silently returned an empty string could
slip through. Tests now pin down every `[default_model]` scalar, including
`base_url`, plus unset, missing, numeric, boolean, and section cases.

Also distinguishes a section-valued key (e.g. `config get default_model`)
from a scalar instead of printing a debug-style `{}`.

Made-with: Cursor
2026-04-17 22:46:08 +03:00
Jaber Jaber 6ab07d155e fix(runtime): re-read agent context.md per turn so external updates take effect (#843) (#1075)
When an agent had a context.md file updated externally (e.g. a cron job
refreshing live market data), the updated content never reached the LLM
during an active session. The file was effectively cached for the
lifetime of the conversation.

The runtime now reads context.md from the agent workspace once per turn,
right before the system prompt is built, and injects it as a dedicated
'Live Context' section. Agents that still want the old cache-at-start
behaviour can opt back in with 'cache_context = true' on the manifest.

- new openfang-runtime::agent_context module with a small per-path cache
- if a re-read fails after a previous success, fall back to the cached
  content with a warning instead of dropping context mid-conversation
- new PromptContext.context_md field wired up in both kernel streaming
  and non-streaming paths
- one small disk read per agent turn (not per streaming token); file
  size capped at 32 KB like the other identity files

Made-with: Cursor
2026-04-17 22:46:05 +03:00
Jaber Jaber e2b0a54720 feat(cli): add hand config subcommand (#809) (#1074)
Docs reference `openfang hand config browser --set headless=true` but
the CLI never shipped that command. Add it as a thin wrapper over the
existing GET/PUT `/api/hands/{id}/settings` routes so the documented
example works.

- `openfang hand config <id>` prints the current settings merged with
  schema defaults.
- `--get KEY` prints one value, `--set KEY=VAL` (repeatable) updates
  values, `--unset KEY` removes them. Empty keys are rejected up front.
- When no instance is active, the daemon's existing 404 is surfaced
  with a hint to run `openfang hand activate <id>` first.

Unit tests cover the KEY=VAL parser (including empty keys, urls with
equals signs, and blank values). Integration test runs the registry
update_config path end-to-end through a persist/load round-trip so
the CLI semantics match what the daemon stores.

Made-with: Cursor
2026-04-17 22:46:01 +03:00
Jaber Jaber 6f519b9122 fix(api): reject unauth requests from non-loopback by default (#1034) (#1071)
Empty api_key used to skip auth for everyone. Now it only skips auth for
loopback origins. Non-loopback requests get 401 unless the operator opts
in with OPENFANG_ALLOW_NO_AUTH=1.

- middleware: check ConnectInfo, fail closed on LAN/public origins
- ws: same fail-closed logic for WebSocket upgrades
- server: loud startup warning when bound to non-loopback with no key
- AuthState: new allow_no_auth flag
- tests: 8 new unit tests covering loopback, LAN, public, missing info

Made-with: Cursor
2026-04-17 22:45:58 +03:00
Jaber Jaber 983519c8e8 Merge pull request #1041 from Hypn0sis/chore/security-deps
fix(deps): upgrade wasmtime 41->43 and rumqttc 0.24->0.25 to resolve active CVEs

Addresses RUSTSEC advisories surfaced by cargo-audit on main. wasmtime
jump required matching adjustments in sandbox.rs error types; rumqttc
switched to `default-features = false` + `use-native-tls` to drop
the vulnerable `rustls-webpki 0.102` path. CI green across Check/
Test/Clippy/Format/Security-Audit on all three platforms.
2026-04-17 21:21:17 +03:00
dependabot[bot] c9701627a9 build(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-16 08:21:33 +00:00
Liu 643a22b295 fix(hands): correct trader dashboard style 2026-04-15 16:59:52 +08:00
Matteo De Agazio 890ab8c177 style: apply cargo fmt 2026-04-14 15:11:29 +02:00
Matteo De Agazio 1b9a68dc0b fix: resolve remaining clippy warnings blocking CI (ptr_arg, dead_code, map_or, collapsible_if) 2026-04-14 15:06:54 +02:00
Matteo De Agazio 2b6286e469 fix: suppress pre-existing dead code and clippy warnings in openfang-runtime 2026-04-12 00:07:13 +02:00
Matteo De Agazio 589f32c8e5 style: apply cargo fmt 2026-04-11 23:55:38 +02:00
Matteo De Agazio 528a7b9ff7 fix(deps): upgrade wasmtime 41->43 and rumqttc 0.24->0.25 to resolve CVEs (RUSTSEC-2026-0049, 0085-0096) 2026-04-11 23:50:55 +02:00
jaberjaber23 6851bbab09 bump v0.5.9 2026-04-10 21:19:33 +03:00
jaberjaber23 9323edccf4 fix config persistence, PowerShell bypass, WS 404 race, feishu panic, Revolt self-hosted 2026-04-10 20:51:27 +03:00
Jaber Jaber 0bccba10cf Merge pull request #1009 from normal-coder/feat-ux-optimize
Refactor UI navigation and improve visual consistency with SVGs
2026-04-10 20:01:16 +03:00
Jaber Jaber 2daaf92ad7 Merge pull request #923 from smitb/feat/add-aws-bedrock-llm-provider-support-with-token-auth
feat: Add AWS Bedrock LLM provider support with token auth
2026-04-10 20:00:44 +03:00
Jaber Jaber 80359b4487 Merge pull request #945 from felix307253927/pr-main-utf8
fix: Fix panic due to UTF-8 character boundary errors
2026-04-10 20:00:33 +03:00
Jaber Jaber b866bb6b21 Merge pull request #965 from chrisyoung2005/fix/ghcr-package-visibility
Set GHCR package visibility to public on every release
2026-04-10 20:00:14 +03:00
Jaber Jaber 3fa8c6c527 Merge pull request #928 from Alex-wuhu/novita-integration
feat: add Novita AI as LLM provider
2026-04-10 19:59:41 +03:00
Jaber Jaber e322afbebd Merge pull request #956 from RightNow-AI/dependabot/cargo/cron-0.16.0
chore(deps): bump cron from 0.15.0 to 0.16.0
2026-04-10 19:59:26 +03:00
jaberjaber23 a4c6c038a0 bump v0.5.8 2026-04-10 19:57:59 +03:00
Jaber Jaber 864e957261 Merge pull request #1015 from AlexZander85/feature/russian-i18n-translation
feat(i18n): add Russian localization and i18n framework
2026-04-10 19:37:20 +03:00
Jaber Jaber f9921780e2 Merge pull request #1017 from dmbutko/fix/copilot-oauth-device-flow
fix: rewrite Copilot driver with OAuth device flow authentication
2026-04-10 19:37:10 +03:00
Jaber Jaber 1c049d06c1 Merge pull request #922 from Myshkouski/build/armv7
Add armv7-unknown-linux-gnueabihf target
2026-04-10 19:36:36 +03:00
dependabot[bot] 6f86f7a857 build(deps): bump cron from 0.15.0 to 0.16.0
Bumps [cron](https://github.com/zslayton/cron) from 0.15.0 to 0.16.0.
- [Release notes](https://github.com/zslayton/cron/releases)
- [Commits](https://github.com/zslayton/cron/commits)

---
updated-dependencies:
- dependency-name: cron
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 16:17:45 +00:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c47e15c1ce chore(deps): bump prost 0.13 -> 0.14
Bumps [prost](https://github.com/tokio-rs/prost) from 0.13.5 to 0.14.3.
- [Release notes](https://github.com/tokio-rs/prost/releases)
- [Changelog](https://github.com/tokio-rs/prost/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/prost/compare/v0.13.5...v0.14.3)

---
updated-dependencies:
- dependency-name: prost
  dependency-version: 0.14.3
  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-04-10 19:16:03 +03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 185ebc429f chore(deps): bump lettre 0.11.19 -> 0.11.20
Bumps [lettre](https://github.com/lettre/lettre) from 0.11.19 to 0.11.20.
- [Release notes](https://github.com/lettre/lettre/releases)
- [Changelog](https://github.com/lettre/lettre/blob/master/CHANGELOG.md)
- [Commits](https://github.com/lettre/lettre/compare/v0.11.19...v0.11.20)

---
updated-dependencies:
- dependency-name: lettre
  dependency-version: 0.11.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 19:15:56 +03:00
Jaber Jaber 87626ae1c9 Merge pull request #966 from t4min0/fix/nix-perl-buildinput
fix(nix): add perl to buildInputs for openssl-sys vendored build
2026-04-10 19:14:20 +03:00
Jaber Jaber 4a8af88fe8 Merge pull request #1002 from lc-soft/fix/first-chat-bug
fix(chat): prevent duplicate message keys on first chat welcome message
2026-04-10 19:14:11 +03:00
Jaber Jaber 2ef5704132 Merge pull request #1006 from lc-soft/fix/comms-api-base-url
fix(comms): OpenFangAPI.baseUrl is undefined
2026-04-10 19:14:02 +03:00
Jaber Jaber 747314b19b Merge pull request #1022 from lc-soft/fix/bar-width-style
fix(analytics): correct bar style attribute binding
2026-04-10 19:13:53 +03:00
Jaber Jaber 3787c13fb1 Merge pull request #1024 from Hypn0sis/fix/crypto-discord-free-response
fix: crypto provider, silent failure debug, Discord free_response_channels
2026-04-10 19:13:47 +03:00
Jaber Jaber 8136281e68 Merge pull request #1027 from lc-soft/fix/comms-page-modals-style
fix(comms): align task and send modals to center
2026-04-10 19:13:45 +03:00
Jaber Jaber 779389e68d Merge pull request #968 from chrisyoung2005/fix/new-agent-default-model
fix: new agents default to configured model instead of hardcoded groq
2026-04-10 19:13:37 +03:00
Jaber Jaber 4e3569778e Merge pull request #1029 from zhujg007/main
Fix: Safe char boundary handling for UTF-8 string slicing
2026-04-10 19:13:33 +03:00
Jaber Jaber 6b19bac049 Merge pull request #989 from letzdoo-js/fix/accumulated-text-response
fix: capture text from intermediate tool_use iterations
2026-04-10 19:13:25 +03:00
Jaber Jaber 2671791742 Merge pull request #1004 from lc-soft/fix/analytics-svg-rendering-errors
fix: avoid Alpine SVG template rendering errors in cost charts
2026-04-10 19:13:16 +03:00
Jaber Jaber 1ca86c4284 Merge pull request #1010 from nldhuyen0047/fix/gemini-array-items-missing
fix: inject default items schema for array parameters in Gemini normalization
2026-04-10 19:13:01 +03:00
Jaber Jaber a603dc9d96 Merge pull request #1011 from nldhuyen0047/fix/gemini-function-call-turn-ordering
fix: ensure message history starts with a user turn for Gemini compatibility
2026-04-10 19:12:47 +03:00
Jaber Jaber 8a2197126c Merge pull request #1019 from letzdoo-js/upstream-pr/cron-loss
fix(kernel): preserve cron jobs across hand reactivation
2026-04-10 19:12:35 +03:00
Jaber Jaber c743a72481 Merge pull request #1020 from rozsival/upstream
feat(channels): use plain text as default output formatter for Signal
2026-04-10 19:12:22 +03:00
jaberjaber23 605ce747ec fix: 6 bugs (#962, #939, #983, #987, #970, #882)
- #962: WebSocket auth now URL-decodes token before comparison. API keys with +/=/
  characters (base64-derived) now work correctly for WS streaming.
- #939: Clippy bool_comparison lint fixed in web_fetch.rs test.
- #983: Dockerfile adds perl and make for openssl-sys compilation on slim-bookworm.
- #987: Nextcloud chat poll endpoint corrected from api/v4/room/{token}/chat to
  api/v1/chat/{token}/ matching the send endpoint.
- #970: Moonshot Kimi K2/K2.5 models now redirect to api.moonshot.cn/v1 instead of
  api.moonshot.ai/v1. The .ai domain only serves legacy moonshot-v1-* models.
- #882: Closed as resolved by v0.5.7 custom hand persistence fix (#984).
- #926: Verified already fixed (rmcp builder API from previous session).

All tests passing. 8 files changed, 75 insertions.
2026-04-10 16:35:31 +03:00
zhujg007 34a27de85e Fix: Safe char boundary handling for UTF-8 string slicing 2026-04-10 20:51:36 +08:00
Liu 3e7d57b095 fix(comms): align task and send modals to center 2026-04-10 15:42:10 +08:00
Matteo De Agazio e988572c82 fix: clippy bool_comparison in web_fetch test 2026-04-09 17:31:28 +02:00
Matteo De Agazio 2d94963627 chore: gitignore personal deploy script 2026-04-09 17:16:39 +02:00
Matteo De Agazio d94508e72e feat: add free_response_channels support for Discord
Allow certain Discord channel IDs to respond without requiring @mention,
similar to Hermes gateway's free_response_channels.

- Add free_response_channels field to DiscordConfig
- Add free_response_channels method to ChannelBridgeHandle trait
- Implement free_response_channels in KernelBridgeAdapter
- Modify dispatch_message to bypass mention_only policy for free channels
- Add test for free_response_channels deserialization
2026-04-09 17:10:21 +02:00
Brandon Freeman af51f6c971 chore: don't fail silently, leave a debug message. 2026-04-09 17:10:21 +02:00
Brandon Freeman e1790b5380 fix: add explicit crypto provider 2026-04-09 17:10:21 +02:00
Dmitry ButkoandCopilot be1c4dba47 fix: revert Claude-specific strip, keep empty-only + empty tool name fix
The model-aware assistant strip caused infinite agent loops for Claude.
Reverted to empty-only strip which is safe for all models. The
Telegram prefill issue needs to be fixed in the agent loop, not the
driver.

Remaining openai.rs changes:
- strip_trailing_empty_assistant: strips truly empty trailing messages
- Skip tool calls with empty ID or name from streaming responses

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 21:23:55 +10:00
Liu 51a5f7983c fix(analytics): correct style attribute binding to ensure it takes effect 2026-04-09 19:17:30 +08:00
Vít Rozsíval 760f35f9de test(channels): add assert for Signal default output formatter 2026-04-09 13:16:06 +02:00
Dmitry ButkoandCopilot 64b1ec5a7e fix: model-aware assistant message stripping for Claude via Copilot
The Copilot proxy for Claude enforces Anthropic's rule that conversations
must end with a user message. For Claude models, strip any trailing
assistant message without tool_calls (including non-empty ones). For
other models, only strip truly empty assistant messages.

This fixes the 'assistant message prefill not supported' error seen
in Telegram and other channel adapters when using Claude via Copilot,
without causing infinite agent loops for other models.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 21:07:14 +10:00
Dmitry ButkoandCopilot dc6119d2cc fix: revert aggressive assistant strip, keep empty-only version
The aggressive strip (all trailing assistant messages) caused infinite
agent loops by removing non-empty responses the agent loop needs.
Reverted to only strip truly empty assistant messages (no content,
no tool_calls). The Telegram prefill issue needs a different fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 21:00:16 +10:00
Dmitry ButkoandCopilot a8b4d3b48d fix: strip all trailing assistant messages without tool_calls
Strengthens the strip to remove any trailing assistant message (not
just empty ones) when it has no tool_calls. The Copilot proxy for
Claude rejects conversations ending with any assistant message as
unsupported prefill. This fixes the Telegram bot channel where the
agent loop appends an assistant message with content.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 20:37:56 +10:00
Vít Rozsíval 36ba675f02 chore(clippy): fix warning 2026-04-09 12:34:40 +02:00
Vít Rozsíval 546816f692 chore(cargo): fix formatting 2026-04-09 12:27:18 +02:00
Vít Rozsíval 80af18a174 feat(channels): use plain text as default output formatter for Signal 2026-04-09 12:26:54 +02:00
Dmitry ButkoandCopilot abeaaf5446 fix: skip tool calls with empty ID or name from streaming response
The Copilot API proxy can sometimes deliver streaming tool call chunks
without a function name, resulting in empty-name tool calls stored in
conversation history. When replayed to the API, these cause
'tool call must have a tool call ID and function name' errors.

Skip malformed tool calls (empty ID or name) during streaming response
finalization and log a warning.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 19:13:43 +10:00
Dmitry ButkoandCopilot 3854e3eb89 chore: remove debug logging, keep defensive strip_trailing_empty_assistant
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 19:01:23 +10:00
Dmitry ButkoandCopilot 73d50c0284 debug: add OAI message logging for tool call investigation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 18:55:43 +10:00
Dmitry ButkoandCopilot 6403871aa1 fix: strip trailing empty assistant messages for Claude/Gemini via Copilot proxy
The Copilot API proxy rejects conversations ending with an empty
assistant message as unsupported 'assistant message prefill' when
proxying Claude and Gemini models. GPT models are unaffected.

Strips trailing empty assistant messages (no content, no tool calls)
before sending the request. Applied in both complete() and stream()
paths.

Also reverts unused fixup_request method from copilot.rs since the
fix belongs in the OpenAI driver layer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 17:38:28 +10:00
defaultandClaude Opus 4.6 df22a3db64 fix(kernel): preserve cron jobs across hand reactivation
Bug: in activate_hand(), kill_agent() is called on the existing agent
BEFORE the new agent is spawned. kill_agent() invokes
cron_scheduler.remove_agent_jobs() which deletes all cron jobs from memory
AND persists [] to cron_jobs.json. The reassign_agent_jobs() call further
down was meant to migrate jobs from old to new (per #461), but it always
runs as a no-op because the jobs are already gone — the order of
operations defeats the fix.

Symptom: every daemon restart silently destroys cron jobs for hand-style
agents. cron_jobs.json is rewritten as []. /api/cron/jobs returns empty.
No error message.

Fix: snapshot the cron jobs into a local Vec BEFORE kill_agent (same
pattern as saved_triggers above), then re-add them under the new agent_id
AFTER spawn_agent_with_parent. Runtime state (next_run, last_run) is
reset so jobs get a fresh start. The existing reassign_agent_jobs()
block is kept as a defensive safety net but is now redundant in the
common path.

Verified with cargo check -p openfang-kernel --lib (clean compile, no
warnings).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:18:44 +00:00
Dmitry ButkoandCopilot c1a14e884e fix: rewrite Copilot driver with OAuth device flow authentication
The Copilot LLM driver was broken - it expected users to provide a
GITHUB_TOKEN env var, but no standard token type (PAT, gh CLI token)
works with the Copilot token exchange endpoint.

Changes:
- Full rewrite of copilot.rs with OAuth device flow using Copilot's
  client ID (Iv1.b507a08c87ecfe98)
- Three-layer token chain: ghu_ (8h) -> Copilot API token (30min),
  with automatic caching and refresh
- Dynamic model fetching from Copilot API on daemon startup and on
  model_not_supported error
- Init wizard: TUI auth screen with device code display, live model
  picker after authentication
- set-key command: interactive device flow for github-copilot provider
- Doctor: detects Copilot auth via persisted token file
- Removed static Copilot model entries (now fetched dynamically)
- Simplified driver instantiation (no env vars needed)

Tested end-to-end with Copilot Enterprise: auth, token exchange,
43 models fetched, completions working with claude-opus-4.6-1m.

Closes #1014

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 13:01:07 +10:00
诺墨 77bef773e5 Merge branch 'main' into feat-ux-optimize 2026-04-09 04:43:52 +08:00
jaberjaber23 a26f762635 v0.5.7: multi-instance hands + 8 critical fixes
## Headline feature
- Multi-instance Hands via optional instance_name (customer ask + #878).
  Web UI, CLI (--name / -n), API, kernel, registry all threaded. Two
  clip-youtube + clip-tiktok instances now coexist. Backward compatible
  when instance_name is omitted.

## Critical bug fixes
- #919 [SECURITY] rm bypass closed. process_start tool now validates against
  exec_policy allowlist and rejects shell metacharacters in both command
  and args. Added 5 regression tests.
- #1013 session_repair phase ordering — dedup now runs BEFORE synthetic
  result insertion, fixing Moonshot's non-unique tool_call_id format
  (function_name:index). Added regression test.
- #1003 global [[fallback_providers]] now actually used at runtime.
  resolve_driver wraps primary in FallbackDriver with global fallback
  chain. Network errors escalate to fallback instead of infinite retry.
- #937 Discord gateway heartbeat. Spawns interval task, tracks sequence,
  handles ACKs, detects zombie connections, force-closes on missing ACK.
  Credits @hello-world-bfree (PR #938) for the diagnosis.
- #935 System prompt leak in Web UI. get_agent_session now filters
  Role::System by default (?include_system=true for debug). Defense in
  depth client-side filter too.
- #984 Custom hands persistence. install_from_path copies to
  ~/.openfang/hands/. Kernel loads them on startup.
- #884 Workspace version bump 0.5.5 -> 0.5.7. Binaries now correctly
  report --version as 0.5.7 instead of stale 0.5.5.

## Cleanup
- rmcp 1.3 builder API adopted (credits @jefflower PR #986) for
  StreamableHttpClientTransportConfig. Drops unused Arc import.

## Stats
- 22 files changed, all workspace tests passing (1800+)
- Live-tested with daemon: v0.5.7 reported, multi-instance hands
  verified end-to-end, Groq round-trip PONG confirmed
2026-04-08 22:59:56 +03:00
AlexZander85 62b6aa4eb8 feat(i18n): add Russian localization and i18n framework
- Add complete i18n framework with en.json and ru.json locale files
- Add i18n.js with language loading, detection, and fallback logic
- Localize chat.js: slash commands, Toast messages, welcome message
- Localize agents.js: personality presets, profile descriptions
- Localize wizard.js: step labels, channel options, suggestions, profiles
- Localize skills.js: 18 skill categories, uninstall confirm
- Localize sessions.js: delete session/key confirm dialogs

Provides full Russian translation for OpenFang UI as first non-English locale.
2026-04-08 18:30:59 +05:00
诺墨 c000392093 refactor(ui): replace HTML entity icons with inline SVGs for consistent rendering
- Replace `&times;` close icons with SVG X icons across all modals and panels
- Replace `&#10003;` checkmarks with SVG check icons in setup checklist, step indicators, and success states
- Replace `&bull;` and `&middot;` separators with small SVG dot icons for better visual consistency
- Improve icon sizing, alignment, and stroke properties for crisp rendering

Signed-off-by: 诺墨 <normal@normalcoder.com>
2026-04-07 19:13:38 +08:00
诺墨 ae32af1b06 fix(manifest): align the logo.png with the icon size to resolve browser loading issues.
Signed-off-by: 诺墨 <normal@normalcoder.com>
2026-04-07 18:54:02 +08:00
诺墨 d3972b23c0 refactor(ui): introduce CSS theme tokens for consistent accent text contrast
- Add `--text-on-accent` CSS variable (white) for text on accent backgrounds
- Replace hardcoded `var(--bg-primary)` with `--text-on-accent` across components
- Update dark theme surface, border, and text color tokens for better contrast
- Adjust dark mode surface colors (`#1F1D1C` → `#242221`) for improved depth
- Refine border and text-muted colors for better visual hierarchy

Signed-off-by: 诺墨 <normal@normalcoder.com>
2026-04-07 18:38:47 +08:00
诺墨 ded95f3180 refactor(ui): restructure navigation sidebar with Overview as primary entry
- Move Overview to first navigation item for better UX
- Consolidate `Chat` into `Agents` section
- Increase chevron and section title font sizes for improved readability
- Adjust section title padding for better visual spacing

Signed-off-by: 诺墨 <normal@normalcoder.com>
2026-04-07 18:19:48 +08:00
Liu 09ec6f5549 fix(comms): OpenFangAPI.baseUrl is undefined 2026-04-07 17:07:17 +08:00
Liu 47c743f17c fix: avoid Alpine SVG template rendering errors in cost charts 2026-04-07 16:59:22 +08:00
Liu 489fc1312c fix(chat): prevent duplicate message keys on first chat welcome message 2026-04-07 15:20:55 +08:00
default 0408d65d8f fix: capture text from intermediate tool_use iterations
When an LLM produces text alongside tool_use blocks (e.g., a chat
message followed by memory_store calls), the text was lost if the
final EndTurn iteration returned empty text. The empty-response guard
would activate and return "[Task completed — the agent executed tools
but did not produce a text summary.]" even though the agent DID
produce text in an earlier iteration.

This is a common pattern when agents are instructed to respond to
users AND persist state via memory_store in the same turn.

Fix: accumulate text content from all ToolUse iterations. When the
final EndTurn has empty text, use accumulated text as fallback before
triggering the empty-response guard.

Applied to both sync and streaming agent loop paths.
2026-04-05 11:17:10 +00:00
chris-young 8d14d0c225 fix: new agents default to configured model instead of hardcoded groq
Built-in templates and the spawn wizard both hardcoded
provider = "groq" / model = "llama-3.3-70b-versatile" in the
manifest TOML sent to the API. The kernel's default_model overlay
only activates when provider/model are empty or "default", so
hardcoded values bypassed the user's configured default entirely.

Fixes #967
2026-04-02 18:04:54 -07:00
jaberjaber23 07963779be fix: install script skips empty releases, finds latest with binary assets 2026-04-03 03:52:46 +03:00
t4min0 28d01acf91 fix(nix): add perl to buildInputs for openssl-sys vendored build
Since v0.5.4, native-tls uses features = ["vendored"], which compiles OpenSSL from source via openssl-sys. This requires perl for the OpenSSL Configure script, but perl was missing from the flake's buildInputs.

Mirrors the Dockerfile fix in #952. Fixes #894.
2026-04-03 00:10:54 +02:00
chris-young be149597e6 Set GHCR package visibility to public on every release
GHCR defaults new packages to private, so docker pull
ghcr.io/rightnow-ai/openfang:... returned 401 for unauthenticated
users despite the repo being public.

Two changes to the docker job in release.yml:

1. Add OCI labels to the build — links the package to the repo so
   GHCR associates it correctly, and is standard practice for
   container images.

2. After each push, call the GitHub Packages API (PATCH
   /orgs/RightNow-AI/packages/container/openfang) to set visibility
   to public. The workflow already holds packages: write, which is
   the required scope. This runs on every release tag so visibility
   cannot regress if the package is ever reset.

Fixes #961
2026-04-02 11:31:41 -07:00
nldhuyen0047 3b21494867 fix: inject default items schema for array parameters in Gemini normalization 2026-04-02 17:08:46 +07:00
Felix 4565988e2e fix: Fix panic due to UTF-8 character boundary errors 2026-04-01 18:57:03 +08:00
Alex-wuhuandClaude Opus 4.6 4714efb9e4 test: add unit tests for Novita provider defaults, alias, and driver creation
- test_provider_defaults_novita: verify base_url, api_key_env, key_required
- test_provider_defaults_novita_ai_alias: verify "novita-ai" alias resolves
- test_novita_provider_with_env_key: driver creation succeeds with env key
- test_novita_provider_no_key_errors: driver creation fails without key

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 14:26:49 +08:00
Alex-wuhu 365bec868c Add Novita AI as LLM provider
- Add NOVITA_BASE_URL constant to model_catalog.rs
- Register novita provider in provider_defaults() with OpenAI-compatible endpoint
- Add novita to known_providers() list
- Add novita to detect_available_provider() auto-detection probe
- Document NOVITA_API_KEY in .env.example

Novita AI uses an OpenAI-compatible API at https://api.novita.ai/openai/v1
Default model: moonshotai/kimi-k2.5
2026-03-31 12:47:41 +08:00
Jaber Jaber a78299ed3d Merge pull request #753 from RamXX/fix/dashboard-password-argon2
Replace SHA256 password hashing with Argon2id for dashboard auth
2026-03-31 02:22:02 +03:00
Jaber Jaber eebb83c79a Merge pull request #877 from pbranchu/fix/silent-reinforcement
Fix silent reinforcement: recognize [SILENT] token
2026-03-31 02:21:59 +03:00
Jaber Jaber 0b59205b0c Merge pull request #881 from pbranchu/fix/token-estimation-tooluse
Fix token estimation: include ToolUse arguments in text_length
2026-03-31 02:21:55 +03:00
Jaber Jaber 3f8ceabc51 Merge pull request #897 from tytsxai/pr/upstream-nested-xml
feat(runtime): recover nested XML tool call parameters
2026-03-31 02:21:51 +03:00
Jaber Jaber 4921ee5ece Merge pull request #898 from tytsxai/pr/upstream-generic-fixes
channels: add startup timeout for Telegram control-plane calls
2026-03-31 02:21:47 +03:00
Jaber Jaber 0c4769a07f Merge pull request #900 from neo-wanderer/fix/agent-skills-reload
Fix/agent skills reload
2026-03-31 02:21:44 +03:00
Jaber Jaber 167b37f10e Merge pull request #917 from lc-soft/fix/alpine-exp-error
fix: Alpine Expression Error in settings page caused by x-show
2026-03-31 02:21:40 +03:00
Jaber Jaber 545e710abb Merge pull request #920 from norci/add-searxng-search-provider
feat: add SearXNG search provider with custom URL and JSON output
2026-03-31 02:21:36 +03:00
Alexei e421594185 Merge branch 'RightNow-AI:main' into build/armv7 2026-03-30 23:26:29 +03:00
Bas Smit 2fe926c3b9 feat: Add AWS Bedrock LLM provider support with token auth 2026-03-30 22:06:49 +02:00
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
Alexei Myshkouski 8b925d8a04 ci: add armv7 target to CLI build matrix 2026-03-30 19:05:21 +03:00
Alexei Myshkouski 449a29418d build(cross): add armv7 target pre-build configuration 2026-03-30 19:02:20 +03:00
beann 46eac44635 feat: add searxng search specialist skill 2026-03-30 19:35:53 +08:00
beann 9372cc6ff2 docs: add SearXNG search provider configuration 2026-03-30 18:57:06 +08:00
beann 9cf37eab22 feat: SearXNG pagination support 2026-03-30 18:40:26 +08:00
beann 79ca1cda32 feat: SearXNG dynamic category support with validation 2026-03-30 18:32:57 +08:00
beann 50c51dd6b7 refactor: remove redundant max_results from SearxngSearchConfig 2026-03-30 18:26:11 +08:00
beann d75a56a0f6 fix: filter SearXNG noise fields, only expose title/url/content/published_date to LLM 2026-03-30 18:15:35 +08:00
beann ce3344a994 fix: SearXNG does not support limit param, truncate results client-side 2026-03-30 18:12:16 +08:00
beann 656e2734ce feat: add SearXNG search provider with custom URL and JSON output 2026-03-30 18:04:39 +08:00
nldhuyen0047 3c221dc3ca fix: ensure message history starts with a user turn for Gemini compatibility 2026-03-30 17:00:09 +07:00
Liu cfda9b9bfc fix: Alpine Expression Error in settings page caused by x-show 2026-03-30 13:52:10 +08:00
vigneshnrfs a428b1cd66 test: add test for agent skills/mcp_servers TOML parsing
The skills and mcp_servers fields must be at the top level of the
agent.toml, not after [capabilities], due to TOML implicit table
ordering rules.
2026-03-29 09:12:25 +05:30
vigneshnrfs 51d358f9d9 fix: detect skills and mcp_servers changes in agent config reload
The agent config reload logic was missing skills and mcp_servers from
the change detection, so edits to these fields in agent.toml weren't
being picked up when loading agents from SQLite.

Added both fields to the comparison to ensure proper hot-reload.
2026-03-29 09:03:51 +05:30
ww 1c61b869c0 feat(runtime): recover nested XML tool call parameters
(cherry picked from commit 336240b28996bcd4c6a823d5d0a45efe4a6aaba3)
2026-03-29 04:56:03 +08:00
ww fc902a9ceb channels: add startup timeout for telegram control-plane calls
(cherry picked from commit 7432086493)
2026-03-29 04:55:15 +08:00
Philippe BranchuandClaude Opus 4.6 a3cefa424c Fix clippy: remove needless borrow in line.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:22:10 +00:00
Philippe BranchuandClaude Opus 4.6 06d0479419 Fix clippy: remove needless borrow in line.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:22:06 +00:00
Philippe BranchuandClaude Opus 4.6 613a7d4a3b Fix corrupt Cargo.lock (resolve merge conflict markers)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:19:43 +00:00
Philippe BranchuandClaude Opus 4.6 6ed6d3ac3b Fix cargo fmt formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:15:46 +00:00
Philippe BranchuandClaude Opus 4.6 9f72d921c3 Fix cargo fmt formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:14:01 +00:00
Philippe BranchuandClaude Opus 4.6 4b5aba28cf Revert unrelated mcp.rs rewrite and Cargo.lock changes
The previous commit accidentally included a complete MCP module rewrite
that removed Http transport and headers support, breaking compilation
against upstream kernel.rs tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 00:42:52 +00:00
Philippe BranchuandClaude Opus 4.6 bbed72b491 Fix cargo fmt: join Image/Unknown match arms on single line
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:18:49 +00:00
Philippe BranchuandClaude Opus 4.6 55395c80db Fix token estimation: include ToolUse arguments in text_length
`MessageContent::text_length()` returned 0 for `ToolUse` blocks,
ignoring the tool name and JSON input arguments. This caused the
compactor's `estimate_token_count()` (which uses `text_length()`)
to massively undercount tokens when conversations contained tool
calls with large arguments (e.g. web_search results, page content).

The result: compaction never triggered despite the session exceeding
the context window, leading to "Token limit exceeded" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:15:57 +00:00
Philippe BranchuandClaude Opus 4.6 946363e919 Fix silent reinforcement: recognize [SILENT] token in agent replies
Extract is_silent_token() helper for case-insensitive [SILENT] detection.
Revert unrelated Cargo.lock and formatting changes. Add unit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:15:05 +00: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
RamXX cf38b49e4d Add openfang auth hash-password CLI command and startup warning
Addresses review feedback:
- Add `openfang auth hash-password` subcommand so users can generate
  Argon2id hashes after upgrading (the command referenced in docs).
- Emit a tracing::warn at daemon startup when auth is enabled but the
  password_hash is not in Argon2id format, so users know why login fails.
2026-03-27 13:03:52 -07:00
RamXX 8ba84ada9d Replace SHA256 password hashing with Argon2id for dashboard auth
Dashboard passwords were hashed with plain SHA256 (no salt), vulnerable
to rainbow tables and GPU brute force. Switch to Argon2id with random
per-hash salts. Breaking change: existing SHA256 hashes in config.toml
must be regenerated with `openfang auth hash-password`.
2026-03-27 13:03:52 -07: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
jaberjaber23 b4e6a693f5 version bump 2026-03-10 01:27:39 +03:00
jaberjaber23 48d5418c91 claude code fix
Fix Claude Code provider setup flow: wizard now shows Detect button instead of API key input for keyless providers, TUI wizards include claude-code in provider list, credentials detection checks both .credentials.json paths, subprocess env_clear prevents API key leaks. Fixes #376 #303.
2026-03-10 01:19:02 +03:00
jaberjaber23 cc93ef4571 community fixes
Fix tool name mapping so LLM-hallucinated aliases (fs-write, fsRead, writeFile, etc.) normalize to canonical names before capability check and dispatch (#349). Fix provider keys not loading after dashboard save by creating fresh drivers that read current env vars instead of stale boot-time cache (#465, #458, #355). Fix Moonshot/kimi model IDs and provider inference (#428). Add Telegram message reactions for agent lifecycle feedback (#435). Add configurable api_url for Telegram proxy support (#477). Add Discord ignore_bots config option (#403). Fix openfang init EPERM crash with 7-browser fallback on Linux (#389). Add text-based tool call parsing for models without native function calling — [TOOL_CALL], <tool_call>, bare JSON patterns (#354, #332). Fix pre-existing Windows test failures with cross-platform paths. 1948 tests pass, 0 clippy warnings.
2026-03-10 00:40:45 +03:00
jaberjaber23 3e069798f9 community fixes
Fix 8 issues: empty LLM response after ~4 rounds by re-validating message pairs after history trim (#460), MCP tools permission denied by bypassing ToolInvoke capability filter for extension tools (#352), Telegram photos silently dropped now downloaded and passed as multimodal ContentBlock::Image (#362), workflow visual builder double-click editing and live property updates (#357), Claude Code provider card reflects actual install/auth status (#376), Python 3 detection runs actual command instead of path lookup (#405), hand agent_id persisted for cron job reassignment on restart (#402), CLI sends auth headers on all commands not just stop (#478). 1921 tests pass, 0 clippy warnings.
2026-03-09 23:07:08 +03:00
jaberjaber23 ad10aa5e80 community fixes
Fix 12 GitHub issues: SSE streaming token counts (#stream_options), UTF-8 boundary panics (#472), cron timezone scheduling (#473), TOML multiline system_prompt (#463), dashboard 401 auth interceptor (#468), custom provider env var convention (#471), cron stale agent_id reassignment (#461), concurrent provider probing with cache (#474), model switch provider sync (#466/#387), OpenRouter real models (#385), embedding URL normalization (#395), ZHIPU content format (#384), Fish shell PATH detection (#372). 1915 tests pass, 0 clippy warnings.
2026-03-09 21:19:50 +03:00
jaberjaber23 385aee8e56 fix streaming
- Add stream_options (include_usage) for accurate token counts in streaming mode
- Add fallback for providers that don't support stream_options
- Add SSE stream diagnostic logging
2026-03-09 04:57:04 +03:00
jaberjaber23 a00327abe9 fix auth 2026-03-09 03:19:14 +03:00
jaberjaber23 487555a5e5 bump version 2026-03-09 02:18:51 +03:00
jaberjaber23 9d51426cb4 fix bugs 2026-03-09 02:16:36 +03:00
jaberjaber23 6fab720843 bump version 2026-03-08 22:53:48 +03:00
jaberjaber23 4667f497ef fix csp 2026-03-08 22:51:21 +03:00
jaberjaber23 eba9198827 community fixes 2026-03-08 22:29:54 +03:00
jaberjaber23 f2413949bc shell hardening 2026-03-08 20:20:34 +03:00
jaberjaber23 9e230f423e security hardening 2026-03-08 16:59:48 +03:00
jaberjaber23 8138b7e0e8 version bump 2026-03-08 04:05:39 +03:00
jaberjaber23 cfae867908 batch fixes 2026-03-08 04:04:37 +03:00
jaberjaber23 6857e3cf06 issue fixes 2026-03-08 01:25:20 +03:00
jaberjaber23 772cbdbe38 community fixes 2026-03-07 23:31:38 +03:00
jaberjaber23 b2e2b1a038 version bump 2026-03-07 05:29:52 +03:00
jaberjaber23 d237ecf161 community batch 2026-03-07 04:22:16 +03:00
jaberjaber23 4a3d570155 community hardening 2026-03-07 00:22:25 +03:00
jaberjaber23 ebcdc17c13 default resilience 2026-03-05 22:57:08 +03:00
jaberjaber23 45e06b9bad channel resilience 2026-03-05 21:35:37 +03:00
jaberjaber23 c6b46ccbe1 catalog composite 2026-03-05 20:31:49 +03:00
jaberjaber23 9fc0fe71bf driver resilience 2026-03-05 20:21:03 +03:00
jaberjaber23 06df0795c8 think stripping 2026-03-05 15:41:08 +03:00
jaberjaber23 eafeb6a012 bugfix batch 2026-03-05 15:27:10 +03:00
jaberjaber23 05431509be bugfix batch 2026-03-05 14:53:22 +03:00
jaberjaber23 9d3136e512 bugfix batch 2026-03-05 14:39:43 +03:00
jaberjaber23 60566f22fb bugfix batch 2026-03-05 03:17:37 +03:00
jaberjaber23 50440e4047 bugfix batch 2026-03-05 02:13:48 +03:00
jaberjaber23 f45268aedc stress hardening 2026-03-05 01:21:32 +03:00
jaberjaber23 cc54e14114 bugfix batch 2026-03-05 00:25:25 +03:00
jaberjaber23 1037ef768d bugfix batch 2026-03-04 15:22:10 +03:00
jaberjaber23 c3dcf02e3c bugfix batch 2026-03-04 05:43:38 +03:00
jaberjaber23 b157e3c7e6 issue fixes 2026-03-04 04:11:07 +03:00
jaberjaber23 74ac992420 issue fixes 2026-03-04 03:34:12 +03:00
jaberjaber23 53e1b31777 version bump 2026-03-04 02:08:52 +03:00
jaberjaber23 603a94e560 issue fixes 2026-03-04 02:08:32 +03:00
jaberjaber23 fac4ad33e5 discord bugfixes 2026-03-04 01:17:37 +03:00
jaberjaber23 fe96cd1004 bugfixes batch 2026-03-03 21:26:06 +03:00
jaberjaber23 7c85308cf6 bugfixes batch 2026-03-03 20:28:46 +03:00
jaberjaber23 a4a83b1699 bugfixes batch 2026-03-03 16:54:30 +03:00
325 changed files with 71078 additions and 7101 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)
]
+3
View File
@@ -32,6 +32,9 @@
# Fireworks AI
# FIREWORKS_API_KEY=...
# Novita AI (multi-model gateway)
# NOVITA_API_KEY=...
# ─── Local LLM Providers (no API key needed) ─────────────────────────
# Ollama (default: http://localhost:11434)
+62
View File
@@ -0,0 +1,62 @@
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: Description
description: What happened?
placeholder: Describe the bug clearly and concisely.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: How can we reproduce this?
placeholder: |
1. Run `openfang start`
2. Open dashboard at http://localhost:4200
3. Click ...
validations:
required: true
- type: input
id: version
attributes:
label: OpenFang Version
description: Output of `openfang -V`
placeholder: "0.3.23"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- Linux (x86_64)
- Linux (aarch64/ARM64)
- macOS (Apple Silicon)
- macOS (Intel)
- Windows
- Android (Termux)
- Other
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / Screenshots
description: Paste relevant logs or attach screenshots.
@@ -0,0 +1,24 @@
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: Description
description: What feature would you like?
placeholder: Describe the feature and why it would be useful.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you tried any workarounds?
- type: textarea
id: context
attributes:
label: Additional Context
description: Any other context, screenshots, or references.
+17
View File
@@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
labels:
- "ci"
+19
View File
@@ -0,0 +1,19 @@
## Summary
<!-- What does this PR do? Link related issues with "Fixes #123". -->
## Changes
<!-- Brief list of what changed. -->
## Testing
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` passes
- [ ] `cargo test --workspace` passes
- [ ] Live integration tested (if applicable)
## Security
- [ ] No new unsafe code
- [ ] No secrets or API keys in diff
- [ ] User input validated at boundaries
+9 -7
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,17 +87,19 @@ jobs:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --check
# Gate every workspace crate on rustfmt to keep `cargo fmt --all --check` clean.
# See issue #1121.
- run: cargo fmt --all -- --check
audit:
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 +111,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
+33 -13
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'
@@ -134,7 +134,7 @@ jobs:
projectPath: crates/openfang-desktop
args: ${{ matrix.platform.args }}
# ── CLI Binary (5 platforms) ──────────────────────────────────────────────
# ── CLI Binary (7 platforms) ──────────────────────────────────────────────
cli:
name: CLI / ${{ matrix.target }}
runs-on: ${{ matrix.os }}
@@ -148,6 +148,9 @@ jobs:
- target: aarch64-unknown-linux-gnu
os: ubuntu-22.04
archive: tar.gz
- target: armv7-unknown-linux-gnueabihf
os: ubuntu-22.04
archive: tar.gz
- target: x86_64-apple-darwin
os: macos-latest
archive: tar.gz
@@ -162,25 +165,30 @@ jobs:
archive: zip
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install build deps (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev
- name: Install cross (Linux aarch64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
- name: Install cross (Linux aarch64/armv7)
if: matrix.target == 'aarch64-unknown-linux-gnu' || matrix.target == 'armv7-unknown-linux-gnueabihf'
run: cargo install cross --locked
- uses: Swatinem/rust-cache@v2
with:
key: cli-${{ matrix.target }}
- name: Build CLI (cross)
if: matrix.target == 'aarch64-unknown-linux-gnu'
if: matrix.target == 'aarch64-unknown-linux-gnu' || matrix.target == 'armv7-unknown-linux-gnueabihf'
run: cross build --release --target ${{ matrix.target }} --bin openfang
- name: Build CLI
if: matrix.target != 'aarch64-unknown-linux-gnu'
if: matrix.target != 'aarch64-unknown-linux-gnu' && matrix.target != 'armv7-unknown-linux-gnueabihf'
run: cargo build --release --target ${{ matrix.target }} --bin openfang
- name: Ad-hoc codesign CLI binary (macOS)
if: runner.os == 'macOS'
run: |
xattr -cr target/${{ matrix.target }}/release/openfang || true
codesign --force --sign - target/${{ matrix.target }}/release/openfang
- name: Package (Unix)
if: matrix.archive == 'tar.gz'
run: |
@@ -196,7 +204,7 @@ jobs:
$hash = (Get-FileHash "openfang-${{ matrix.target }}.zip" -Algorithm SHA256).Hash.ToLower()
"$hash openfang-${{ matrix.target }}.zip" | Out-File -Encoding ASCII "openfang-${{ matrix.target }}.zip.sha256"
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: openfang-${{ matrix.target }}.*
env:
@@ -207,22 +215,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
@@ -230,5 +238,17 @@ jobs:
tags: |
ghcr.io/rightnow-ai/openfang:latest
ghcr.io/rightnow-ai/openfang:${{ steps.version.outputs.version }}
labels: |
org.opencontainers.image.source=https://github.com/RightNow-AI/openfang
org.opencontainers.image.licenses=MIT
org.opencontainers.image.description=OpenFang Agent OS — single-binary Rust agent framework
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Set GHCR package visibility to public
run: |
curl -fsSL -X PATCH \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/orgs/RightNow-AI/packages/container/openfang \
-d '{"visibility":"public"}'
+5
View File
@@ -34,6 +34,7 @@ BUILD_LOG.md
# OS
.DS_Store
._*
Thumbs.db
# IDE & tools
@@ -43,3 +44,7 @@ Thumbs.db
*.swp
*.swo
*~
.serena/
# Personal deploy scripts
scripts/deploy-remote.sh
+6
View File
@@ -0,0 +1,6 @@
## Health Stack
- typecheck: cargo build --workspace --lib
- lint: cargo clippy --workspace --all-targets -- -D warnings
- test: cargo test --workspace
- shell: shellcheck scripts/install.sh
+31
View File
@@ -5,6 +5,37 @@ All notable changes to OpenFang will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.5.10] - 2026-04-17
### Fixed
- Non-loopback requests with no `api_key` configured now return 401 by default. Opt out with `OPENFANG_ALLOW_NO_AUTH=1`. Fixes the B1/B2 authentication bypass from #1034.
- Agent `context.md` is re-read on every turn so external updates take effect mid-session. Opt out per agent with `cache_context = true` on the manifest. Fixes #843.
- `openfang config get default_model.base_url` now prints the configured URL instead of an empty string. Missing keys return a clear "not found" error. Fixes #905.
- `schedule_create`, `schedule_list`, and `schedule_delete` tools plus the `/api/schedules` routes now use the kernel cron scheduler, so scheduled jobs actually fire. One-shot idempotent migration imports legacy shared-memory entries at startup. Fixes #1069.
- Multimodal user messages now combine text and image blocks into a single message so the LLM sees both. Fixes #1043.
### Added
- `openfang hand config <id>` subcommand: get, set, unset, and list settings on an active hand instance. Fixes #809.
- Optional per-channel `prefix_agent_name` setting (`off` / `bracket` / `bold_bracket`). Wraps outbound agent responses so users in multi-agent channels can see which agent replied. Default is off, byte-identical to prior behavior. Fixes #980.
### Closed as invalid
- #818 and #819. Both reference a knowledge-domain API that does not exist on `main`. Filed against an unmerged feature branch (`plan/013-audit-remediation`). Close with a note to build the proposed validation and stale-timestamp surfacing into that feature when it lands.
## [0.5.9] - 2026-04-10
### Changed
- **BREAKING:** Dashboard password hashing switched from SHA256 to Argon2id. Existing `password_hash` values in `config.toml` must be regenerated with `openfang auth hash-password`. Only affects users with `[auth] enabled = true`.
### Fixed
- Dashboard passwords were hashed with plain SHA256 (no salt), making them vulnerable to rainbow table and GPU-accelerated brute force attacks. Now uses Argon2id with random salts.
## [0.1.0] - 2026-02-24
### Added
+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
+1080 -644
View File
File diff suppressed because it is too large Load Diff
+38 -10
View File
@@ -18,7 +18,7 @@ members = [
]
[workspace.package]
version = "0.3.4"
version = "0.6.9"
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
@@ -49,9 +49,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# Time
chrono = { version = "0.4", features = ["serde"] }
chrono-tz = "0.10"
# IDs
uuid = { version = "1", features = ["v4", "serde"] }
uuid = { version = "1", features = ["v4", "v5", "serde"] }
# Database
rusqlite = { version = "0.31", features = ["bundled", "serde_json"] }
@@ -61,7 +62,8 @@ 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"] }
rustls = { version = "0.23", default-features = false, features = ["ring"] }
# Async trait
async-trait = "0.1"
@@ -74,16 +76,17 @@ bytes = "1"
# Futures
futures = "0.3"
prost = "0.14"
# WebSocket client (for Discord/Slack gateway)
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
url = "2"
# WASM sandbox
wasmtime = "41"
wasmtime = "43"
# 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"] }
@@ -101,6 +104,9 @@ walkdir = "2"
# Security
sha2 = "0.10"
sha1 = "0.10"
aes = "0.8"
cbc = "0.1"
hmac = "0.12"
hex = "0.4"
subtle = "2"
@@ -109,7 +115,7 @@ rand = "0.8"
zeroize = { version = "1", features = ["derive"] }
# Rate limiting
governor = "0.8"
governor = "0.10"
# Interactive CLI
ratatui = "0.29"
@@ -119,17 +125,32 @@ colored = "3"
aes-gcm = "0.10"
argon2 = "0.5"
# HTML entity decoding
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 = { version = "0.25", default-features = false, features = ["use-native-tls"] }
# OpenSSL (vendored = statically compiled, no runtime libssl dependency on Linux)
openssl = { version = "0.10", features = ["vendored"] }
# Testing
tokio-test = "0.4"
@@ -140,3 +161,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
+6
View File
@@ -3,3 +3,9 @@ pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
]
[target.armv7-unknown-linux-gnueabihf]
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
]
+17 -3
View File
@@ -1,16 +1,30 @@
# syntax=docker/dockerfile:1
FROM rust:1-slim-bookworm AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y pkg-config libssl-dev perl make && rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
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
+128 -31
View File
@@ -19,25 +19,25 @@
<p align="center">
<img src="https://img.shields.io/badge/language-Rust-orange?style=flat-square" alt="Rust" />
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
<img src="https://img.shields.io/badge/version-0.1.0-green?style=flat-square" alt="v0.1.0" />
<img src="https://img.shields.io/badge/tests-1,767%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/version-0.6.9-green?style=flat-square" alt="v0.6.9" />
<img src="https://img.shields.io/badge/tests-2,696%2B%20passing-brightgreen?style=flat-square" alt="Tests" />
<img src="https://img.shields.io/badge/clippy-0%20warnings-brightgreen?style=flat-square" alt="Clippy" />
<a href="https://www.buymeacoffee.com/openfang" target="_blank"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-FFDD00?style=flat-square&logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
</p>
---
> **v0.1.0 — First Release (February 2026)**
> **v0.5.10 (April 2026)**
>
> OpenFang is feature-complete but this is the first public release. You may encounter instability, rough edges, or breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
> OpenFang is feature complete but still pre-1.0. Expect rough edges and breaking changes between minor versions. We ship fast and fix fast. Pin to a specific commit for production use until v1.0. [Report issues here.](https://github.com/RightNow-AI/openfang/issues)
---
## What is OpenFang?
OpenFang is an **open-source Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust.
OpenFang is an **open-source Agent Operating System**. Not a chatbot framework. Not a Python wrapper around an LLM. Not a "multi-agent orchestrator." A full operating system for autonomous agents, built from scratch in Rust.
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you** on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you**: on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard.
The entire system compiles to a **single ~32MB binary**. One install, one command, your agents are live.
@@ -65,13 +65,13 @@ openfang start
<p align="center"><em>"Traditional agents wait for you to type. Hands work <strong>for</strong> you."</em></p>
**Hands** are OpenFang's core innovation — pre-built autonomous capability packages that run independently, on schedules, without you having to prompt them. This is not a chatbot. This is an agent that wakes up at 6 AM, researches your competitors, builds a knowledge graph, scores the findings, and delivers a report to your Telegram before you've had coffee.
**Hands** are OpenFang's core innovation. Pre-built autonomous capability packages that run independently, on schedules, without you having to prompt them. This is not a chatbot. This is an agent that wakes up at 6 AM, researches your competitors, builds a knowledge graph, scores the findings, and delivers a report to your Telegram before you've had coffee.
Each Hand bundles:
- **HAND.toml** — Manifest declaring tools, settings, requirements, and dashboard metrics
- **System Prompt** — Multi-phase operational playbook (not a one-liner — these are 500+ word expert procedures)
- **SKILL.md** — Domain expertise reference injected into context at runtime
- **Guardrails** — Approval gates for sensitive actions (e.g. Browser Hand requires approval before any purchase)
- **HAND.toml**: manifest declaring tools, settings, requirements, and dashboard metrics.
- **System Prompt**: multi-phase operational playbook. Not a one-liner. These are 500+ word expert procedures.
- **SKILL.md**: domain expertise reference injected into context at runtime.
- **Guardrails**: approval gates for sensitive actions (e.g. Browser Hand requires approval before any purchase).
All compiled into the binary. No downloading, no pip install, no Docker pull.
@@ -81,14 +81,14 @@ All compiled into the binary. No downloading, no pip install, no Docker pull.
|------|----------------------|
| **Clip** | Takes a YouTube URL, downloads it, identifies the best moments, cuts them into vertical shorts with captions and thumbnails, optionally adds AI voice-over, and publishes to Telegram and WhatsApp. 8-phase pipeline. FFmpeg + yt-dlp + 5 STT backends. |
| **Lead** | Runs daily. Discovers prospects matching your ICP, enriches them with web research, scores 0-100, deduplicates against your existing database, and delivers qualified leads in CSV/JSON/Markdown. Builds ICP profiles over time. |
| **Collector** | OSINT-grade intelligence. You give it a target (company, person, topic). It monitors continuously change detection, sentiment tracking, knowledge graph construction, and critical alerts when something important shifts. |
| **Collector** | OSINT grade intelligence. You give it a target (company, person, topic). It monitors continuously: change detection, sentiment tracking, knowledge graph construction, and critical alerts when something important shifts. |
| **Predictor** | Superforecasting engine. Collects signals from multiple sources, builds calibrated reasoning chains, makes predictions with confidence intervals, and tracks its own accuracy using Brier scores. Has a contrarian mode that deliberately argues against consensus. |
| **Researcher** | Deep autonomous researcher. Cross-references multiple sources, evaluates credibility using CRAAP criteria (Currency, Relevance, Authority, Accuracy, Purpose), generates cited reports with APA formatting, supports multiple languages. |
| **Twitter** | Autonomous Twitter/X account manager. Creates content in 7 rotating formats, schedules posts for optimal engagement, responds to mentions, tracks performance metrics. Has an approval queue nothing posts without your OK. |
| **Browser** | Web automation agent. Navigates sites, fills forms, clicks buttons, handles multi-step workflows. Uses Playwright bridge with session persistence. **Mandatory purchase approval gate** it will never spend your money without explicit confirmation. |
| **Twitter** | Autonomous Twitter/X account manager. Creates content in 7 rotating formats, schedules posts for optimal engagement, responds to mentions, tracks performance metrics. Has an approval queue, so nothing posts without your OK. |
| **Browser** | Web automation agent. Navigates sites, fills forms, clicks buttons, handles multi-step workflows. Uses Playwright bridge with session persistence. **Mandatory purchase approval gate**: it will never spend your money without explicit confirmation. |
```bash
# Activate the Researcher Hand — it starts working immediately
# Activate the Researcher Hand. It starts working immediately.
openfang hand activate researcher
# Check its progress anytime
@@ -116,7 +116,7 @@ openfang hand list
### Benchmarks: Measured, Not Marketed
All data from official documentation and public repositories February 2026.
All data from official documentation and public repositories, February 2026.
#### Cold Start Time (lower is better)
@@ -203,7 +203,7 @@ AutoGen ███████████░░░░░░░░░░░░
---
## 16 Security Systems Defense in Depth
## 16 Security Systems: Defense in Depth
OpenFang doesn't bolt security on after the fact. Every layer is independently testable and operates without a single point of failure.
@@ -211,19 +211,19 @@ OpenFang doesn't bolt security on after the fact. Every layer is independently t
|---|--------|-------------|
| 1 | **WASM Dual-Metered Sandbox** | Tool code runs in WebAssembly with fuel metering + epoch interruption. A watchdog thread kills runaway code. |
| 2 | **Merkle Hash-Chain Audit Trail** | Every action is cryptographically linked to the previous one. Tamper with one entry and the entire chain breaks. |
| 3 | **Information Flow Taint Tracking** | Labels propagate through execution — secrets are tracked from source to sink. |
| 3 | **Information Flow Taint Tracking** | Labels propagate through execution. Secrets are tracked from source to sink. |
| 4 | **Ed25519 Signed Agent Manifests** | Every agent identity and capability set is cryptographically signed. |
| 5 | **SSRF Protection** | Blocks private IPs, cloud metadata endpoints, and DNS rebinding attacks. |
| 6 | **Secret Zeroization** | `Zeroizing<String>` auto-wipes API keys from memory the instant they're no longer needed. |
| 7 | **OFP Mutual Authentication** | HMAC-SHA256 nonce-based, constant-time verification for P2P networking. |
| 8 | **Capability Gates** | Role-based access control — agents declare required tools, the kernel enforces it. |
| 8 | **Capability Gates** | Role based access control. Agents declare required tools, the kernel enforces it. |
| 9 | **Security Headers** | CSP, X-Frame-Options, HSTS, X-Content-Type-Options on every response. |
| 10 | **Health Endpoint Redaction** | Public health check returns minimal info. Full diagnostics require authentication. |
| 11 | **Subprocess Sandbox** | `env_clear()` + selective variable passthrough. Process tree isolation with cross-platform kill. |
| 12 | **Prompt Injection Scanner** | Detects override attempts, data exfiltration patterns, and shell reference injection in skills. |
| 13 | **Loop Guard** | SHA256-based tool call loop detection with circuit breaker. Handles ping-pong patterns. |
| 14 | **Session Repair** | 7-phase message history validation and automatic recovery from corruption. |
| 15 | **Path Traversal Prevention** | Canonicalization with symlink escape prevention. `../` doesn't work here. |
| 15 | **Path Traversal Prevention** | Canonicalization with symlink escape prevention. ``../`` doesn't work here. |
| 16 | **GCRA Rate Limiter** | Cost-aware token bucket rate limiting with per-IP tracking and stale cleanup. |
---
@@ -266,7 +266,98 @@ Each adapter supports per-channel model overrides, DM/group policies, rate limit
---
## 27 LLM Providers — 123+ Models
## 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 native drivers (Anthropic, Gemini, OpenAI-compatible) route to 27 providers:
@@ -281,7 +372,7 @@ Intelligent routing with task complexity scoring, automatic fallback, cost track
Already running OpenClaw? One command:
```bash
# Migrate everything agents, memory, skills, configs
# Migrate everything: agents, memory, skills, configs.
openfang migrate --from openclaw
# Migrate from a specific path
@@ -319,7 +410,7 @@ curl -X POST localhost:4200/v1/chat/completions \
# 1. Install (macOS/Linux)
curl -fsSL https://openfang.sh/install | sh
# 2. Initialize — walks you through provider setup
# 2. Initialize. Walks you through provider setup.
openfang init
# 3. Start the daemon
@@ -327,7 +418,7 @@ openfang start
# 4. Dashboard is live at http://localhost:4200
# 5. Activate a Hand — it starts working for you
# 5. Activate a Hand. It starts working for you.
openfang hand activate researcher
# 6. Chat with an agent
@@ -371,20 +462,26 @@ cargo fmt --all -- --check
## Stability Notice
OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said:
OpenFang v0.5.10 is pre-1.0. The architecture is solid, the test suite is comprehensive, and the security model is deep. That said:
- **Breaking changes** may occur between minor versions until v1.0
- **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested)
- **Edge cases** exist — if you find one, [open an issue](https://github.com/RightNow-AI/openfang/issues)
- **Pin to a specific commit** for production deployments until v1.0
- **Breaking changes** may occur between minor versions until v1.0.
- **Some Hands** are more mature than others. Browser and Researcher are the most battle tested.
- **Edge cases** exist. If you find one, [open an issue](https://github.com/RightNow-AI/openfang/issues).
- **Pin to a specific commit** for production deployments until v1.0.
We ship fast and fix fast. The goal is a rock-solid v1.0 by mid-2026.
We ship fast and fix fast. The goal is a rock solid v1.0 by mid 2026.
---
## Security
To report a security vulnerability, email **jaber@rightnowai.co**. We take all reports seriously and will respond within 48 hours.
---
## License
MIT — use it however you want.
MIT. Use it however you want.
---
+2 -2
View File
@@ -4,7 +4,7 @@
| Version | Supported |
|---------|--------------------|
| 0.1.x | :white_check_mark: |
| 0.3.x | :white_check_mark: |
## Reporting a Vulnerability
@@ -14,7 +14,7 @@ If you discover a security vulnerability in OpenFang, please report it responsib
### How to Report
1. Email: **security@openfang.ai**
1. Email: **jaber@rightnowai.co**
2. Include:
- Description of the vulnerability
- Steps to reproduce
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.4
@@ -34,8 +34,8 @@ OUTPUT FORMAT:
- Caveats and limitations"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["architecture", "design", "planning"]
[model]
provider = "deepseek"
model = "deepseek-chat"
provider = "default"
model = "default"
api_key_env = "DEEPSEEK_API_KEY"
max_tokens = 8192
temperature = 0.3
@@ -31,8 +31,8 @@ Output format: Use clear headings, diagrams (ASCII), and structured reasoning.
When asked to review, be honest about weaknesses."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+6 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["general", "assistant", "default", "multipurpose", "conversation", "productivity"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.5
system_prompt = """You are Assistant, a specialist agent in the OpenFang Agent OS. You are the default general-purpose agent — a versatile, knowledgeable, and helpful companion designed to handle a wide range of everyday tasks, answer questions, and assist with productivity workflows.
@@ -61,7 +61,7 @@ TOOLS AVAILABLE:
You are reliable, adaptable, and genuinely helpful. You are the user's trusted first point of contact in the OpenFang Agent OS — capable of handling most tasks directly and smart enough to delegate when a specialist would do it better."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
@@ -76,3 +76,6 @@ memory_read = ["*"]
memory_write = ["self.*", "shared.*"]
agent_message = ["*"]
shell = ["python *", "cargo *", "git *", "npm *"]
[autonomous]
max_iterations = 100
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["review", "code-quality", "best-practices"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.3
@@ -34,8 +34,8 @@ Rules:
- Focus on things that matter for production"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["coding", "implementation", "rust", "python"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 8192
temperature = 0.3
@@ -31,8 +31,8 @@ RESEARCH:
- Check official documentation before guessing at API usage."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["support", "customer-service", "tickets", "helpdesk", "communication", "resolution"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.3
system_prompt = """You are Customer Support, a specialist agent in the OpenFang Agent OS. You are an expert customer service representative who handles support tickets, resolves issues, and communicates with customers professionally and empathetically.
@@ -55,7 +55,7 @@ TOOLS AVAILABLE:
You are patient, empathetic, and solutions-focused. You turn frustrated customers into satisfied advocates."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.3
@@ -36,8 +36,8 @@ Output format:
- Caveats and limitations"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.2
@@ -37,8 +37,8 @@ OUTPUT FORMAT:
- Prevention: Test or pattern to prevent recurrence"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.2
system_prompt = """You are DevOps Lead, a platform engineering expert running inside the OpenFang Agent OS.
@@ -35,7 +35,7 @@ When designing pipelines:
5. Automated rollback on failure"""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.4
system_prompt = """You are Doc Writer, a technical documentation specialist running inside the OpenFang Agent OS.
@@ -33,7 +33,7 @@ Style guide:
- Consistent formatting and structure"""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["email", "communication", "triage", "drafting", "scheduling", "productivity"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.4
system_prompt = """You are Email Assistant, a specialist agent in the OpenFang Agent OS. Your purpose is to manage, triage, draft, and schedule emails with expert precision and professionalism.
@@ -47,7 +47,7 @@ TOOLS AVAILABLE:
You are thorough, discreet, and efficient. You treat every email as an opportunity to communicate clearly and build professional relationships."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["health", "wellness", "fitness", "medication", "habits", "tracking"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.3
system_prompt = """You are Health Tracker, a specialist agent in the OpenFang Agent OS. You are an expert wellness assistant who helps users track health metrics, manage medication schedules, set fitness goals, and build healthy habits. You are NOT a medical professional and you always make this clear.
+2 -2
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.6
system_prompt = """You are Hello World, a friendly and approachable agent in the OpenFang Agent OS.
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["smart-home", "iot", "automation", "devices", "monitoring", "home"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.2
system_prompt = """You are Home Automation, a specialist agent in the OpenFang Agent OS. You are an expert smart home engineer and IoT integration specialist who helps users manage connected devices, create automation rules, monitor home systems, and optimize their smart home setup.
@@ -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
}
]
}
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["legal", "contracts", "compliance", "research", "review", "documents"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 8192
temperature = 0.2
@@ -58,8 +58,8 @@ DISCLAIMER: You are an AI assistant providing legal information for educational
You are meticulous, cautious, and precise. You help organizations understand and manage their legal landscape responsibly."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["meetings", "notes", "action-items", "agenda", "follow-up", "productivity"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.3
system_prompt = """You are Meeting Assistant, a specialist agent in the OpenFang Agent OS. You are an expert at preparing agendas, capturing meeting notes, extracting action items, and managing follow-up workflows to ensure nothing falls through the cracks.
@@ -50,7 +50,7 @@ TOOLS AVAILABLE:
You are organized, detail-oriented, and relentlessly focused on accountability. You turn chaotic meetings into clear outcomes."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+2 -2
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.1-8b-instant"
provider = "default"
model = "default"
max_tokens = 2048
temperature = 0.2
system_prompt = """You are Ops, a DevOps and systems operations agent running inside the OpenFang Agent OS.
+4 -4
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "deepseek"
model = "deepseek-chat"
provider = "default"
model = "default"
api_key_env = "DEEPSEEK_API_KEY"
max_tokens = 8192
temperature = 0.3
@@ -45,8 +45,8 @@ Always explain your delegation strategy before executing it.
Be thorough but efficient — don't delegate trivially simple tasks."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[schedule]
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["finance", "budget", "expenses", "savings", "planning", "money"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.2
system_prompt = """You are Personal Finance, a specialist agent in the OpenFang Agent OS. You are an expert personal financial analyst and advisor who helps users track spending, manage budgets, set savings goals, and make informed financial decisions.
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.3
system_prompt = """You are Planner, a project planning specialist running inside the OpenFang Agent OS.
@@ -37,7 +37,7 @@ Output format:
### Open Questions"""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["recruiting", "hiring", "resume", "outreach", "talent", "hr"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.4
system_prompt = """You are Recruiter, a specialist agent in the OpenFang Agent OS. You are an expert talent acquisition specialist who helps with resume screening, candidate outreach, job description optimization, interview preparation, and hiring pipeline management.
@@ -55,7 +55,7 @@ TOOLS AVAILABLE:
You are thorough, fair, and people-oriented. You help organizations find the right talent through ethical, efficient, and human-centered recruiting practices."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["research", "analysis", "web"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.5
@@ -36,8 +36,8 @@ OUTPUT:
Always cite your sources. Never present uncertain information as fact."""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["sales", "crm", "outreach", "pipeline", "prospecting", "deals"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.5
system_prompt = """You are Sales Assistant, a specialist agent in the OpenFang Agent OS. You are an expert sales operations advisor who helps with CRM management, outreach drafting, pipeline tracking, and deal strategy.
@@ -54,7 +54,7 @@ TOOLS AVAILABLE:
You are strategic, persuasive, and detail-oriented. You help sales teams work smarter and close more deals."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["security", "audit", "vulnerability"]
[model]
provider = "deepseek"
model = "deepseek-chat"
provider = "default"
model = "default"
api_key_env = "DEEPSEEK_API_KEY"
max_tokens = 4096
temperature = 0.2
@@ -37,8 +37,8 @@ Severity levels: CRITICAL / HIGH / MEDIUM / LOW / INFO
Report format: Finding → Impact → Evidence → Remediation"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[schedule]
+3 -3
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["social-media", "content", "marketing", "engagement", "scheduling", "analytics"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.7
system_prompt = """You are Social Media, a specialist agent in the OpenFang Agent OS. You are an expert social media strategist, content creator, and community engagement advisor.
@@ -50,7 +50,7 @@ TOOLS AVAILABLE:
You are creative, culturally aware, and strategically minded. You balance creativity with data-driven decision-making."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+4 -4
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["testing", "qa", "validation"]
[model]
provider = "gemini"
model = "gemini-2.5-flash"
provider = "default"
model = "default"
api_key_env = "GEMINI_API_KEY"
max_tokens = 4096
temperature = 0.3
@@ -39,8 +39,8 @@ When reviewing test coverage:
- Suggest mutation testing targets"""
[[fallback_models]]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
api_key_env = "GROQ_API_KEY"
[resources]
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["translation", "languages", "localization", "multilingual", "communication", "i18n"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.3
system_prompt = """You are Translator, a specialist agent in the OpenFang Agent OS. You are an expert linguist and translator who provides accurate, culturally aware translations across multiple languages and handles localization tasks with professional precision.
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["travel", "planning", "itinerary", "booking", "logistics", "vacation"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.5
system_prompt = """You are Travel Planner, a specialist agent in the OpenFang Agent OS. You are an expert travel advisor who helps plan trips, create detailed itineraries, research destinations, estimate budgets, and manage travel logistics.
+2 -2
View File
@@ -6,8 +6,8 @@ module = "builtin:chat"
tags = ["education", "teaching", "tutoring", "learning", "explanation", "knowledge"]
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 8192
temperature = 0.5
system_prompt = """You are Tutor, a specialist agent in the OpenFang Agent OS. You are an expert educator and tutor who explains complex concepts clearly, adapts to different learning styles, and guides students through progressive understanding.
+3 -3
View File
@@ -5,8 +5,8 @@ author = "openfang"
module = "builtin:chat"
[model]
provider = "groq"
model = "llama-3.3-70b-versatile"
provider = "default"
model = "default"
max_tokens = 4096
temperature = 0.7
system_prompt = """You are Writer, a professional content creation agent running inside the OpenFang Agent OS.
@@ -30,7 +30,7 @@ OUTPUT:
- Adapt formatting to the target platform when specified."""
[[fallback_models]]
provider = "gemini"
provider = "default"
model = "gemini-2.0-flash"
api_key_env = "GEMINI_API_KEY"
+7 -1
View File
@@ -33,9 +33,15 @@ 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 }
argon2 = { workspace = true }
rand = { workspace = true }
[dev-dependencies]
tokio-test = { workspace = true }
reqwest = { workspace = true }
tempfile = { workspace = true }
uuid = { workspace = true }
+322 -31
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,25 @@ 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::mqtt::MqttAdapter;
use openfang_channels::mumble::MumbleAdapter;
use openfang_channels::ntfy::NtfyAdapter;
use openfang_channels::webhook::WebhookAdapter;
use openfang_channels::wecom::WeComAdapter;
use openfang_kernel::OpenFangKernel;
use openfang_runtime::kernel_handle::KernelHandle;
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 +77,37 @@ 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)
}
async fn send_message_with_blocks(
&self,
agent_id: AgentId,
blocks: Vec<openfang_types::message::ContentBlock>,
) -> Result<String, String> {
// Extract text for the message parameter (used for memory recall / logging)
let text: String = blocks
.iter()
.filter_map(|b| match b {
openfang_types::message::ContentBlock::Text { text, .. } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
let text = if text.is_empty() {
"[Image]".to_string()
} else {
text
};
let result = self
.kernel
.send_message_with_blocks(agent_id, &text, blocks)
.await
.map_err(|e| format!("{e}"))?;
Ok(result.response)
}
@@ -351,7 +389,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| t.agent_id.to_string());
let status = if t.enabled { "on" } else { "off" };
let id_short = &t.id.0.to_string()[..8];
let id_str = t.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
msg.push_str(&format!(
" [{}] {} -> {} ({:?}) fires:{} [{}]\n",
id_short,
@@ -390,7 +429,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.kernel
.triggers
.register(agent.id, pattern, prompt.to_string(), 0);
let id_short = &trigger_id.0.to_string()[..8];
let id_str = trigger_id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Trigger created [{id_short}] for agent '{agent_name}'.")
}
@@ -405,7 +445,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
1 => {
let t = matched[0];
if self.kernel.triggers.remove(t.id) {
format!("Trigger [{}] removed.", &t.id.0.to_string()[..8])
let id_str = t.id.0.to_string();
format!("Trigger [{}] removed.", safe_truncate_str(&id_str, 8))
} else {
"Failed to remove trigger.".to_string()
}
@@ -428,7 +469,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
.map(|e| e.name.clone())
.unwrap_or_else(|| job.agent_id.to_string());
let status = if job.enabled { "on" } else { "off" };
let id_short = &job.id.0.to_string()[..8];
let id_str = job.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let sched = match &job.schedule {
openfang_types::scheduler::CronSchedule::Cron { expr, .. } => expr.clone(),
openfang_types::scheduler::CronSchedule::Every { every_secs } => {
@@ -450,6 +492,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
msg
}
#[allow(dead_code)]
async fn manage_schedule_text(&self, action: &str, args: &[String]) -> String {
match action {
"add" => {
@@ -481,6 +524,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
timeout_secs: None,
},
delivery: openfang_types::scheduler::CronDelivery::None,
delivery_targets: Vec::new(),
created_at: chrono::Utc::now(),
last_run: None,
next_run: None,
@@ -488,7 +532,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
match self.kernel.cron_scheduler.add_job(job, false) {
Ok(id) => {
let id_short = &id.0.to_string()[..8];
let id_str = id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] created: '{cron_expr}' -> {agent_name}: \"{message}\"")
}
Err(e) => format!("Failed to create job: {e}"),
@@ -510,7 +555,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}"),
}
@@ -539,10 +589,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
openfang_types::scheduler::CronAction::SystemEvent { text } => {
text.clone()
}
openfang_types::scheduler::CronAction::WorkflowRun {
workflow_id,
input,
..
} => {
format!(
"Run workflow {workflow_id}{}",
input
.as_deref()
.map(|i| format!(" with input: {i}"))
.unwrap_or_default()
)
}
};
match self.kernel.send_message(j.agent_id, &message).await {
Ok(result) => {
let id_short = &j.id.0.to_string()[..8];
let id_str = j.id.0.to_string();
let id_short = safe_truncate_str(&id_str, 8);
format!("Job [{id_short}] ran:\n{}", result.response)
}
Err(e) => format!("Failed to run job: {e}"),
@@ -562,7 +626,8 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
}
let mut msg = format!("Pending approvals ({}):\n", pending.len());
for req in &pending {
let id_short = &req.id.to_string()[..8];
let id_str = req.id.to_string();
let id_short = safe_truncate_str(&id_str, 8);
let age_secs = (chrono::Utc::now() - req.requested_at).num_seconds();
let age = if age_secs >= 60 {
format!("{}m", age_secs / 60)
@@ -603,10 +668,11 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
) {
Ok(_) => {
let verb = if approve { "Approved" } else { "Rejected" };
let id_str = req.id.to_string();
format!(
"{} [{}] {}{}",
verb,
&req.id.to_string()[..8],
safe_truncate_str(&id_str, 8),
req.tool_name,
req.agent_id
)
@@ -646,9 +712,18 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
));
}
self.kernel
.set_agent_model(agent_id, model)
.set_agent_model(agent_id, model, None)
.map_err(|e| format!("{e}"))?;
Ok(format!("Model switched to: {model}"))
// Read back resolved model+provider from registry
let entry = self
.kernel
.registry
.get(agent_id)
.ok_or_else(|| "Agent not found after model switch".to_string())?;
Ok(format!(
"Model switched to: {} (provider: {})",
entry.manifest.model.model, entry.manifest.model.provider
))
}
async fn stop_run(&self, agent_id: AgentId) -> Result<String, String> {
@@ -727,16 +802,35 @@ 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,
}
}
async fn free_response_channels(&self, channel_type: &str) -> Vec<String> {
let channels = &self.kernel.config.channels;
match channel_type {
"discord" => channels
.discord
.as_ref()
.map(|c| c.free_response_channels.clone())
.unwrap_or_default(),
// Add other channel types here as needed (e.g., "telegram" => ...)
_ => Vec::new(),
}
}
async fn authorize_channel_user(
&self,
channel_type: &str,
@@ -774,6 +868,7 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
recipient: &str,
success: bool,
error: Option<&str>,
thread_id: Option<&str>,
) {
let receipt = if success {
openfang_kernel::DeliveryTracker::sent_receipt(channel, recipient)
@@ -786,9 +881,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
@@ -796,6 +895,23 @@ impl ChannelBridgeHandle for KernelBridgeAdapter {
}
}
async fn send_channel_message(
&self,
channel_type: &str,
recipient: &str,
message: &str,
) -> Result<(), String> {
<OpenFangKernel as KernelHandle>::send_channel_message(
&self.kernel,
channel_type,
recipient,
message,
None,
)
.await
.map(|_| ())
}
async fn check_auto_reply(&self, agent_id: AgentId, message: &str) -> Option<String> {
// Check if auto-reply should fire for this message
let channel_type = "bridge"; // Generic; the bridge layer handles specifics
@@ -859,7 +975,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() {
@@ -934,16 +1050,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
}
}
@@ -1003,6 +1142,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()
@@ -1026,10 +1166,12 @@ pub async fn start_channel_bridge_with_config(
if let Some(ref tg_config) = config.telegram {
if let Some(token) = read_token(&tg_config.bot_token_env, "Telegram") {
let poll_interval = Duration::from_secs(tg_config.poll_interval_secs);
let adapter = Arc::new(TelegramAdapter::new(
let adapter = Arc::new(TelegramAdapter::with_thread_routes(
token,
tg_config.allowed_users.clone(),
poll_interval,
tg_config.api_url.clone(),
tg_config.thread_routes.clone(),
));
adapters.push((adapter, tg_config.default_agent.clone()));
}
@@ -1041,7 +1183,10 @@ pub async fn start_channel_bridge_with_config(
let adapter = Arc::new(DiscordAdapter::new(
token,
dc_config.allowed_guilds.clone(),
dc_config.allowed_users.clone(),
dc_config.ignore_bots,
dc_config.intents,
dc_config.auto_thread.clone(),
));
adapters.push((adapter, dc_config.default_agent.clone()));
}
@@ -1055,6 +1200,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()));
}
@@ -1064,7 +1212,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();
@@ -1101,11 +1251,19 @@ pub async fn start_channel_bridge_with_config(
// Matrix
if let Some(ref mx_config) = config.matrix {
if let Some(token) = read_token(&mx_config.access_token_env, "Matrix") {
let adapter = Arc::new(MatrixAdapter::new(
// MSC2918 refresh-token support: optional env var, when present the
// adapter auto-recovers from M_UNKNOWN_TOKEN 401s.
let refresh = mx_config
.refresh_token_env
.as_deref()
.and_then(|env| read_token(env, "Matrix refresh"));
let adapter = Arc::new(MatrixAdapter::with_refresh_token(
mx_config.homeserver_url.clone(),
mx_config.user_id.clone(),
token,
refresh,
mx_config.allowed_rooms.clone(),
mx_config.auto_accept_invites,
));
adapters.push((adapter, mx_config.default_agent.clone()));
}
@@ -1312,11 +1470,28 @@ 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_with_region(
fs_config.app_id.clone(),
secret,
region,
)),
};
adapters.push((adapter, fs_config.default_agent.clone()));
}
}
@@ -1324,8 +1499,30 @@ pub async fn start_channel_bridge_with_config(
// Revolt
if let Some(ref rv_config) = config.revolt {
if let Some(token) = read_token(&rv_config.bot_token_env, "Revolt") {
let adapter = Arc::new(RevoltAdapter::new(token));
adapters.push((adapter, rv_config.default_agent.clone()));
let mut adapter = RevoltAdapter::with_urls(
token,
rv_config.api_url.clone(),
rv_config.ws_url.clone(),
);
if !rv_config.allowed_channels.is_empty() {
adapter.set_allowed_channels(rv_config.allowed_channels.clone());
}
adapters.push((Arc::new(adapter), rv_config.default_agent.clone()));
}
}
// 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()));
}
}
@@ -1435,7 +1632,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();
@@ -1444,6 +1641,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") {
@@ -1517,6 +1729,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());
}
@@ -1548,7 +1779,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);
@@ -1620,6 +1851,35 @@ pub async fn reload_channels_from_disk(
*guard = None;
}
// Re-read secrets.env so new API tokens are available in std::env
let secrets_path = state.kernel.config.home_dir.join("secrets.env");
if secrets_path.exists() {
if let Ok(content) = std::fs::read_to_string(&secrets_path) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let key = trimmed[..eq_pos].trim();
let mut value = trimmed[eq_pos + 1..].trim().to_string();
if !key.is_empty() {
// Strip matching quotes
if ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
&& value.len() >= 2
{
value = value[1..value.len() - 1].to_string();
}
// Always overwrite — the file is the source of truth after dashboard edits
std::env::set_var(key, &value);
}
}
}
info!("Reloaded secrets.env for channel hot-reload");
}
}
// Re-read config from disk
let config_path = state.kernel.config.home_dir.join("config.toml");
let fresh_config = openfang_kernel::config::load_config(Some(&config_path));
@@ -1692,4 +1952,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);
}
}
+30
View File
@@ -3,12 +3,42 @@
//! Exposes agent management, status, and chat via JSON REST endpoints.
//! The kernel runs in-process; the CLI connects over HTTP.
/// Decode percent-encoded strings (e.g. `%2B` → `+`).
/// Used to normalise `?token=` values that browsers encode with `encodeURIComponent`.
pub(crate) fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
out.push(hi << 4 | lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| input.to_string())
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
pub mod channel_bridge;
pub mod middleware;
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;
+247 -68
View File
@@ -43,86 +43,132 @@ 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,
/// Set from `OPENFANG_ALLOW_NO_AUTH=1` to permit running without an api_key
/// on a non-loopback bind. Off by default so empty keys fail closed.
pub allow_no_auth: bool,
}
/// Bearer token authentication middleware.
///
/// When `api_key` is non-empty, all requests must include
/// `Authorization: Bearer <api_key>`. If the key is empty, auth is bypassed.
/// When `api_key` is non-empty (after trimming), requests to non-public
/// endpoints must include `Authorization: Bearer <api_key>`.
///
/// When `api_key` is empty (no key configured) the server defaults to
/// fail-closed for any request that does NOT originate from loopback.
/// Loopback traffic (127.0.0.1 / ::1) is always allowed through with no
/// key so single-user local setups keep zero-config UX. To explicitly
/// run a no-auth server on a LAN/WAN address, set
/// `OPENFANG_ALLOW_NO_AUTH=1`; this opts out of fail-closed and is
/// reported loudly at startup.
///
/// When dashboard auth is enabled, session cookies are also accepted.
pub async fn auth(
axum::extract::State(api_key): axum::extract::State<String>,
axum::extract::State(auth_state): axum::extract::State<AuthState>,
request: Request<Body>,
next: Next,
) -> Response<Body> {
// If no API key configured, restrict to loopback addresses only.
if api_key.is_empty() {
let is_loopback = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(false);
// SECURITY: Capture method early for method-aware public endpoint checks.
let method = request.method().clone();
if !is_loopback {
tracing::warn!(
"Rejected non-localhost request: no API key configured. \
Set api_key in config.toml for remote access."
);
return Response::builder()
.status(StatusCode::FORBIDDEN)
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"error": "No API key configured. Remote access denied. Configure api_key in ~/.openfang/config.toml"
})
.to_string(),
))
.unwrap_or_default();
}
let is_loopback = request
.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().is_loopback())
.unwrap_or(false); // SECURITY: default-deny; unknown origin is NOT loopback
// Shutdown is loopback-only (CLI on same machine). Skip token auth only
// when the request is from loopback.
let path = request.uri().path();
if path == "/api/shutdown" && is_loopback {
return next.run(request).await;
}
// Public endpoints that don't require auth (dashboard needs these)
let path = request.uri().path();
if path == "/"
// Public endpoints that don't require auth (dashboard needs these).
// SECURITY: /api/agents is GET-only (listing). POST (spawn) requires auth.
// SECURITY: Public endpoints are GET-only unless explicitly noted.
// POST/PUT/DELETE to any endpoint ALWAYS requires auth to prevent
// unauthenticated writes (cron job creation, skill install, etc.).
let is_get = method == axum::http::Method::GET;
let is_public = path == "/"
|| path == "/logo.png"
|| path == "/favicon.ico"
|| path == "/.well-known/agent.json"
|| path.starts_with("/a2a/")
|| (path == "/.well-known/agent.json" && is_get)
|| (path.starts_with("/a2a/") && is_get)
|| path == "/api/health"
|| path == "/api/health/detail"
|| path == "/api/status"
|| path == "/api/version"
|| path == "/api/agents"
|| path == "/api/profiles"
|| path == "/api/config"
|| path.starts_with("/api/uploads/")
|| (path == "/api/agents" && is_get)
|| (path == "/api/profiles" && is_get)
|| (path == "/api/config" && is_get)
|| (path == "/api/config/schema" && is_get)
|| (path.starts_with("/api/uploads/") && is_get)
// Dashboard read endpoints — allow unauthenticated so the SPA can
// render before the user enters their API key.
|| path == "/api/models"
|| path == "/api/models/aliases"
|| path == "/api/providers"
|| path == "/api/budget"
|| path == "/api/budget/agents"
|| path.starts_with("/api/budget/agents/")
|| path == "/api/network/status"
|| path == "/api/a2a/agents"
|| path == "/api/approvals"
|| path.starts_with("/api/approvals/")
|| path == "/api/channels"
|| path == "/api/hands"
|| path == "/api/hands/active"
|| path.starts_with("/api/hands/")
|| path == "/api/skills"
|| path == "/api/sessions"
|| path == "/api/integrations"
|| path == "/api/integrations/available"
|| path == "/api/integrations/health"
|| path == "/api/workflows"
|| path == "/api/logs/stream"
|| path.starts_with("/api/cron/")
|| (path == "/api/models" && is_get)
|| (path == "/api/models/aliases" && is_get)
|| (path == "/api/providers" && is_get)
|| (path == "/api/budget" && is_get)
|| (path == "/api/budget/agents" && is_get)
|| (path.starts_with("/api/budget/agents/") && is_get)
|| (path == "/api/network/status" && is_get)
|| (path == "/api/a2a/agents" && is_get)
|| (path == "/api/approvals" && is_get)
|| (path.starts_with("/api/approvals/") && is_get)
|| (path == "/api/channels" && is_get)
|| (path == "/api/hands" && is_get)
|| (path == "/api/hands/active" && is_get)
|| (path.starts_with("/api/hands/") && is_get)
|| (path == "/api/skills" && is_get)
|| (path.starts_with("/api/skills/") && path.ends_with("/config") && is_get)
|| (path == "/api/sessions" && is_get)
|| (path == "/api/integrations" && is_get)
|| (path == "/api/integrations/available" && is_get)
|| (path == "/api/integrations/health" && is_get)
|| (path == "/api/workflows" && is_get)
|| path == "/api/logs/stream" // SSE stream, read-only
|| (path.starts_with("/api/cron/") && is_get)
|| path.starts_with("/api/providers/github-copilot/oauth/")
{
|| path == "/api/auth/login"
|| path == "/api/auth/logout"
|| (path == "/api/auth/check" && is_get);
if is_public {
return next.run(request).await;
}
// If no API key configured and no dashboard login is active, fail closed
// for anything that did not come from loopback. Opting out of this
// behavior requires setting `OPENFANG_ALLOW_NO_AUTH=1`, which is logged
// loudly at startup.
//
// See issue #1034 (B1/B2): empty api_key previously bypassed auth for
// all origins, exposing agent config, channel tokens, and LLM keys on
// any LAN-reachable bind.
let api_key_trimmed = auth_state.api_key.trim().to_string();
if api_key_trimmed.is_empty() && !auth_state.auth_enabled {
if is_loopback || auth_state.allow_no_auth {
return next.run(request).await;
}
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("www-authenticate", "Bearer")
.body(Body::from(
serde_json::json!({
"error": "API key required for non-loopback requests. Set OPENFANG_API_KEY or bind to 127.0.0.1."
})
.to_string(),
))
.unwrap_or_default();
}
let api_key = api_key_trimmed.as_str();
// Check Authorization: Bearer <token> header, then fallback to X-API-Key
let bearer_token = request
.headers()
@@ -148,13 +194,14 @@ pub async fn auth(
// Also check ?token= query parameter (for EventSource/SSE clients that
// cannot set custom headers, same approach as WebSocket auth).
let query_token = request
let query_token_decoded = request
.uri()
.query()
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")));
.and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token=")))
.map(crate::percent_decode);
// SECURITY: Use constant-time comparison to prevent timing attacks.
let query_auth = query_token.map(|token| {
let query_auth = query_token_decoded.as_deref().map(|token| {
use subtle::ConstantTimeEq;
if token.len() != api_key.len() {
return false;
@@ -167,6 +214,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) = crate::session_auth::extract_session_cookie(request.headers()) {
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 {
@@ -191,13 +249,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(),
@@ -206,15 +267,133 @@ pub async fn security_headers(request: Request<Body>, next: Next) -> Response<Bo
"cache-control",
"no-store, no-cache, must-revalidate".parse().unwrap(),
);
headers.insert(
"strict-transport-security",
"max-age=63072000; includeSubDomains".parse().unwrap(),
);
response
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Method, Request};
use axum::routing::get;
use axum::Router;
use std::net::SocketAddr;
use tower::ServiceExt;
#[test]
fn test_request_id_header_constant() {
assert_eq!(REQUEST_ID_HEADER, "x-request-id");
}
fn auth_state_empty() -> AuthState {
AuthState {
api_key: String::new(),
auth_enabled: false,
session_secret: String::new(),
allow_no_auth: false,
}
}
fn auth_state_with_key(key: &str) -> AuthState {
AuthState {
api_key: key.to_string(),
auth_enabled: false,
session_secret: key.to_string(),
allow_no_auth: false,
}
}
async fn ok_handler() -> &'static str {
"ok"
}
fn router(state: AuthState) -> Router {
Router::new()
.route("/api/agents/1", get(ok_handler))
.route_layer(axum::middleware::from_fn_with_state(state, auth))
}
fn req_from(ip: &str) -> Request<Body> {
let addr: SocketAddr = format!("{ip}:40000").parse().unwrap();
let mut req = Request::builder()
.method(Method::GET)
.uri("/api/agents/1")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(addr));
req
}
#[tokio::test]
async fn empty_key_allows_loopback() {
let app = router(auth_state_empty());
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn empty_key_blocks_lan_origin() {
// Issue #1034 B1: previously 192.168/10/... could hit every non-public
// endpoint when api_key was unset. Must now be 401.
let app = router(auth_state_empty());
let resp = app.oneshot(req_from("192.168.1.50")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_key_blocks_public_origin() {
let app = router(auth_state_empty());
let resp = app.oneshot(req_from("203.0.113.5")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_key_blocks_unknown_connect_info() {
// Paranoia: if ConnectInfo is missing for any reason, we must fail
// closed, not open.
let app = router(auth_state_empty());
let req = Request::builder()
.method(Method::GET)
.uri("/api/agents/1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn empty_key_with_allow_no_auth_opens_everything() {
let mut s = auth_state_empty();
s.allow_no_auth = true;
let app = router(s);
let resp = app.oneshot(req_from("10.0.0.9")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn configured_key_rejects_missing_token_from_loopback() {
let app = router(auth_state_with_key("secret"));
let resp = app.oneshot(req_from("127.0.0.1")).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn configured_key_accepts_bearer() {
let app = router(auth_state_with_key("secret"));
let addr: SocketAddr = "127.0.0.1:40000".parse().unwrap();
let mut req = Request::builder()
.method(Method::GET)
.uri("/api/agents/1")
.header("authorization", "Bearer secret")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(ConnectInfo(addr));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
}
+15 -10
View File
@@ -179,9 +179,8 @@ fn resolve_agent(state: &AppState, model: &str) -> Option<(AgentId, String)> {
return Some((entry.id, entry.name.clone()));
}
// 4. Fallback → first registered agent
let agents = state.kernel.registry.list();
agents.first().map(|e| (e.id, e.name.clone()))
// No match — return None so the caller returns a proper 404
None
}
// ── Message conversion ──────────────────────────────────────────────────────
@@ -203,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:") {
@@ -235,7 +235,12 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec<Message> {
OaiContent::Null => return None,
};
Some(Message { role, content })
Some(Message {
msg_id: uuid::Uuid::new_v4().to_string(),
provider_msg_id: None,
role,
content,
})
})
.collect()
}
@@ -323,7 +328,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) => {
@@ -336,7 +341,7 @@ pub async fn chat_completions(
index: 0,
message: ChoiceMessage {
role: "assistant",
content: Some(result.response),
content: Some(crate::ws::strip_think_tags(&result.response)),
tool_calls: None,
},
finish_reason: "stop",
@@ -379,7 +384,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);
+10
View File
@@ -29,6 +29,16 @@ 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(),
("GET", p) if p.starts_with("/api/skills/") && p.ends_with("/config") => {
NonZeroU32::new(3).unwrap()
}
("PUT", p) if p.starts_with("/api/skills/") && p.ends_with("/config") => {
NonZeroU32::new(10).unwrap()
}
("DELETE", p) if p.starts_with("/api/skills/") && p.contains("/config/") => {
NonZeroU32::new(10).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
+170 -20
View File
@@ -45,16 +45,22 @@ 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())),
});
// Start WS cron broadcaster — subscribes to kernel event bus and pushes
// cron job results to all connected WebSocket clients in real-time.
ws::start_ws_cron_broadcaster(kernel.clone());
// CORS: allow localhost origins by default. If API key is set, the API
// is protected anyway. For development, permissive CORS is convenient.
let cors = if state.kernel.config.api_key.is_empty() {
let cors = if state.kernel.config.api_key.trim().is_empty() {
// No auth → restrict CORS to localhost origins (include both 127.0.0.1 and localhost)
let port = listen_addr.port();
let mut origins: Vec<axum::http::HeaderValue> = vec![
@@ -102,13 +108,63 @@ pub async fn build_router(
.allow_headers(tower_http::cors::Any)
};
let api_key = state.kernel.config.api_key.clone();
// Warn if dashboard auth is enabled but the password hash is not Argon2id.
let ph = &state.kernel.config.auth.password_hash;
if state.kernel.config.auth.enabled && !ph.is_empty() && !ph.starts_with("$argon2") {
tracing::warn!(
"Dashboard auth password_hash is not in Argon2id format. \
Login will fail. Regenerate with: openfang auth hash-password"
);
}
// Trim whitespace so `api_key = ""` or `api_key = " "` both disable auth.
let api_key = state.kernel.config.api_key.trim().to_string();
let allow_no_auth = std::env::var("OPENFANG_ALLOW_NO_AUTH")
.map(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "on"))
.unwrap_or(false);
// Fail-closed warning: if no api_key and no dashboard auth, and the
// server is bound to a non-loopback address without an explicit opt-in,
// shout about it. The middleware will reject non-loopback traffic.
let bind_is_loopback = listen_addr.ip().is_loopback();
if api_key.is_empty() && !state.kernel.config.auth.enabled && !bind_is_loopback {
if allow_no_auth {
tracing::warn!(
"OPENFANG_ALLOW_NO_AUTH=1 is set. Running WITHOUT authentication on {}. \
Anyone reachable at this address can read/write agents, channels, and keys.",
listen_addr
);
} else {
tracing::warn!(
"No api_key configured and server is bound to {} (non-loopback). \
Non-loopback requests will be rejected with 401. \
Set OPENFANG_API_KEY (or api_key in config.toml), or bind to 127.0.0.1, \
or set OPENFANG_ALLOW_NO_AUTH=1 to explicitly run open.",
listen_addr
);
}
}
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()
},
allow_no_auth,
};
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),
@@ -126,13 +182,33 @@ pub async fn build_router(
)
.route(
"/api/agents/{id}",
axum::routing::get(routes::get_agent).delete(routes::kill_agent),
axum::routing::get(routes::get_agent)
.delete(routes::kill_agent)
.patch(routes::patch_agent),
)
.route(
"/api/agents/{id}/uninstall",
axum::routing::delete(routes::uninstall_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(
// Issue #890 — alias so dashboards and external orchestrators can
// wake an inactive agent via a verb that matches the agent_activate tool.
"/api/agents/{id}/activate",
axum::routing::post(routes::restart_agent),
)
.route(
"/api/agents/{id}/message",
axum::routing::post(routes::send_message),
@@ -281,11 +357,21 @@ pub async fn build_router(
"/api/schedules/{id}/run",
axum::routing::post(routes::run_schedule),
)
.route(
"/api/schedules/{id}/delivery-log",
axum::routing::get(routes::schedule_delivery_log),
)
// Workflow endpoints
.route(
"/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),
@@ -304,6 +390,23 @@ pub async fn build_router(
"/api/skills/uninstall",
axum::routing::post(routes::uninstall_skill),
)
.route(
"/api/skills/reload",
axum::routing::post(routes::reload_skills),
)
// Audit trail (issue #1174 — instance-side wrapper integration)
.route(
"/api/audit/append",
axum::routing::post(routes::audit_append),
)
.route(
"/api/skills/{id}/config",
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
)
.route(
"/api/skills/{id}/config/{var_name}",
axum::routing::delete(routes::delete_skill_config_var),
)
.route(
"/api/marketplace/search",
axum::routing::get(routes::marketplace_search),
@@ -331,6 +434,14 @@ pub async fn build_router(
)
// Hands endpoints
.route("/api/hands", axum::routing::get(routes::list_hands))
.route(
"/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),
@@ -350,8 +461,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",
@@ -408,14 +518,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
@@ -460,8 +567,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))
@@ -552,6 +658,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))
@@ -665,8 +775,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(
@@ -736,7 +850,8 @@ pub async fn run_daemon(
if info_path.exists() {
if let Ok(existing) = std::fs::read_to_string(info_path) {
if let Ok(info) = serde_json::from_str::<DaemonInfo>(&existing) {
if is_process_alive(info.pid) {
// PID alive AND the health endpoint responds → truly running
if is_process_alive(info.pid) && is_daemon_responding(&info.listen_addr) {
return Err(format!(
"Another daemon (PID {}) is already running at {}",
info.pid, info.listen_addr
@@ -745,7 +860,8 @@ pub async fn run_daemon(
}
}
}
// Stale PID file, remove it
// Stale PID file (process dead or different process reused PID), remove it
info!("Removing stale daemon info file");
let _ = std::fs::remove_file(info_path);
}
@@ -767,7 +883,21 @@ pub async fn run_daemon(
info!("WebChat UI available at http://{addr}/",);
info!("WebSocket endpoint: ws://{addr}/api/agents/{{id}}/ws",);
let listener = tokio::net::TcpListener::bind(addr).await?;
// Use SO_REUSEADDR to allow binding immediately after reboot (avoids TIME_WAIT).
let socket = socket2::Socket::new(
if addr.is_ipv4() {
socket2::Domain::IPV4
} else {
socket2::Domain::IPV6
},
socket2::Type::STREAM,
None,
)?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(1024)?;
let listener = tokio::net::TcpListener::from_std(std::net::TcpListener::from(socket))?;
// Run server with graceful shutdown.
// SECURITY: `into_make_service_with_connect_info` injects the peer
@@ -887,3 +1017,23 @@ fn is_process_alive(pid: u32) -> bool {
false
}
}
/// Check if an OpenFang daemon is actually responding at the given address.
/// This avoids false positives where a different process reused the same PID
/// after a system reboot.
fn is_daemon_responding(addr: &str) -> bool {
// Quick TCP connect check — don't make a full HTTP request to avoid delays
let addr_only = addr
.strip_prefix("http://")
.or_else(|| addr.strip_prefix("https://"))
.unwrap_or(addr);
if let Ok(sock_addr) = addr_only.parse::<std::net::SocketAddr>() {
std::net::TcpStream::connect_timeout(&sock_addr, std::time::Duration::from_millis(500))
.is_ok()
} else {
// Fallback: try connecting to hostname
std::net::TcpStream::connect(addr_only)
.map(|_| true)
.unwrap_or(false)
}
}
+195
View File
@@ -0,0 +1,195 @@
//! 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}"))
}
/// Extract the `openfang_session` cookie value from a `Cookie` header string.
///
/// Returns `None` if the header is absent or the cookie is not present.
/// Used by both the HTTP auth middleware and the WebSocket upgrade handler so
/// that browser sessions established via `sessionLogin()` are honored on both
/// surfaces (issue #1085).
pub fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
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())
})
})
}
/// 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 Argon2id for config storage.
///
/// Returns a PHC-format string (e.g. `$argon2id$v=19$m=19456,t=2,p=1$...`).
pub fn hash_password(password: &str) -> String {
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
let salt = SaltString::generate(&mut rand::thread_rng());
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.expect("Argon2 hashing should not fail with valid inputs")
.to_string()
}
/// Verify a password against a stored Argon2id hash (PHC string format).
pub fn verify_password(password: &str, stored_hash: &str) -> bool {
use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier};
let Ok(parsed) = PasswordHash::new(stored_hash) else {
return false;
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_and_verify_password() {
let hash = hash_password("secret123");
assert!(
hash.starts_with("$argon2id$"),
"should produce Argon2id PHC string"
);
assert!(verify_password("secret123", &hash));
assert!(!verify_password("wrong", &hash));
}
#[test]
fn test_hash_produces_unique_salts() {
let h1 = hash_password("same");
let h2 = hash_password("same");
assert_ne!(h1, h2, "each hash should use a unique salt");
assert!(verify_password("same", &h1));
assert!(verify_password("same", &h2));
}
#[test]
fn test_rejects_non_argon2_hash() {
// A plain SHA256 hex string should no longer be accepted.
use sha2::Digest;
let sha256_hash = hex::encode(sha2::Sha256::digest(b"password"));
assert!(!verify_password("password", &sha256_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_rejects_garbage_input() {
assert!(!verify_password("x", "short"));
assert!(!verify_password("x", ""));
}
#[test]
fn test_verify_malformed_argon2_hash() {
// Starts with $argon2 but is not a valid PHC string.
assert!(!verify_password("x", "$argon2id$garbage"));
}
#[test]
fn test_extract_session_cookie_present() {
let mut h = axum::http::HeaderMap::new();
h.insert(
"cookie",
"foo=bar; openfang_session=abc.def.ghi; baz=qux"
.parse()
.unwrap(),
);
assert_eq!(extract_session_cookie(&h).as_deref(), Some("abc.def.ghi"));
}
#[test]
fn test_extract_session_cookie_absent() {
let mut h = axum::http::HeaderMap::new();
h.insert("cookie", "foo=bar; baz=qux".parse().unwrap());
assert_eq!(extract_session_cookie(&h), None);
}
#[test]
fn test_extract_session_cookie_no_header() {
let h = axum::http::HeaderMap::new();
assert_eq!(extract_session_cookie(&h), None);
}
#[test]
fn test_extract_session_cookie_only_value() {
let mut h = axum::http::HeaderMap::new();
h.insert("cookie", "openfang_session=lonely".parse().unwrap());
assert_eq!(extract_session_cookie(&h).as_deref(), Some("lonely"));
}
}
+16 -2
View File
@@ -140,9 +140,23 @@ impl StreamChunker {
}
/// Find the last occurrence of a pattern within a byte range.
///
/// Both `range.start` and `range.end` are clamped to the nearest valid UTF-8
/// char boundary so that slicing never panics on multi-byte content.
fn find_last_in_range(text: &str, pattern: &str, range: &std::ops::Range<usize>) -> Option<usize> {
let search_text = &text[range.start..range.end.min(text.len())];
search_text.rfind(pattern).map(|pos| range.start + pos)
let len = text.len();
// Clamp end to text length and walk back to a char boundary
let mut end = range.end.min(len);
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
// Walk start forward to the nearest char boundary (never past end)
let mut start = range.start.min(end);
while start < end && !text.is_char_boundary(start) {
start += 1;
}
let search_text = &text[start..end];
search_text.rfind(pattern).map(|pos| start + pos)
}
#[cfg(test)]
+123 -2
View File
@@ -2,11 +2,16 @@
use serde::{Deserialize, Serialize};
/// Request to spawn an agent from a TOML manifest string.
/// Request to spawn an agent from a TOML manifest string or a template name.
#[derive(Debug, Deserialize)]
pub struct SpawnRequest {
/// Agent manifest as TOML string.
/// Agent manifest as TOML string (optional if `template` is provided).
#[serde(default)]
pub manifest_toml: String,
/// Template name from `~/.openfang/agents/{template}/agent.toml`.
/// When provided and `manifest_toml` is empty, the template is loaded automatically.
#[serde(default)]
pub template: Option<String>,
/// Optional Ed25519 signed manifest envelope (JSON).
/// When present, the signature is verified before spawning.
#[serde(default)]
@@ -37,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.
@@ -54,6 +65,15 @@ pub struct MessageResponse {
#[derive(Debug, Deserialize)]
pub struct SkillInstallRequest {
pub name: String,
/// When true, reject the install unless the bundle ships a valid
/// Ed25519 SignedManifest envelope bound to the on-disk manifest.
/// Maps to `InstallOptions::require_signed` (issue #1170).
#[serde(default)]
pub require_signed: bool,
/// Optional hex-encoded allow-list of acceptable signer public keys.
/// Empty = TOFU (any valid signature accepted).
#[serde(default)]
pub allowed_signer_keys: Vec<String>,
}
/// Request to uninstall a skill.
@@ -96,3 +116,104 @@ pub struct ClawHubInstallRequest {
/// ClawHub skill slug (e.g., "github-helper").
pub slug: String,
}
/// Query parameters for `GET /api/commands`.
#[derive(Debug, Deserialize)]
pub struct CommandsQuery {
/// Surface filter: `web` (default), `cli`, `channel`, or `all`.
#[serde(default)]
pub surface: Option<String>,
}
/// Request body for `POST /api/audit/append` (issue #1174).
///
/// Lets external (instance-side) wrappers append entries to the Merkle hash
/// chain audit log. The handler maps `event_type` to an `AuditAction` and
/// records the entry through `kernel.audit_log`.
#[derive(Debug, Deserialize)]
pub struct AuditAppendRequest {
/// Operator-supplied event category. Case-insensitive, matched against the
/// `AuditAction` enum variants (e.g. `tool_invoke`, `ConfigChange`,
/// `agent_message`). Unknown values fall back to `ToolInvoke`.
pub event_type: String,
/// Agent or wrapper identifier responsible for the event. When empty,
/// recorded as `"external-wrapper"`.
#[serde(default)]
pub agent_id: String,
/// Free-form detail string (e.g. tool name, URL, file path).
#[serde(default)]
pub detail: String,
/// Optional arbitrary payload. When present it is serialised to JSON and
/// appended onto the entry's detail so the wrapper retains structured
/// context without changing the on-chain schema.
#[serde(default)]
pub payload: Option<serde_json::Value>,
/// Optional outcome string (`"ok"`, `"denied"`, or an error). Defaults to
/// `"ok"` when omitted.
#[serde(default)]
pub outcome: Option<String>,
/// Optional operator-supplied signing context (e.g. wrapper identity, key
/// fingerprint). Mixed into the detail when present so the chain captures
/// who attested to the event.
#[serde(default)]
pub signing_context: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skill_install_request_defaults_back_compat() {
// Existing callers send `{"name": "..."}` only. New optional fields
// must default cleanly (issue #1170).
let req: SkillInstallRequest = serde_json::from_str(r#"{"name":"github-helper"}"#).unwrap();
assert_eq!(req.name, "github-helper");
assert!(!req.require_signed);
assert!(req.allowed_signer_keys.is_empty());
}
#[test]
fn skill_install_request_parses_require_signed() {
let req: SkillInstallRequest = serde_json::from_str(
r#"{"name":"x","require_signed":true,"allowed_signer_keys":["abc123"]}"#,
)
.unwrap();
assert!(req.require_signed);
assert_eq!(req.allowed_signer_keys, vec!["abc123".to_string()]);
}
#[test]
fn audit_append_request_required_only() {
// Only `event_type` is required; everything else must default.
let req: AuditAppendRequest =
serde_json::from_str(r#"{"event_type":"ToolInvoke"}"#).unwrap();
assert_eq!(req.event_type, "ToolInvoke");
assert!(req.agent_id.is_empty());
assert!(req.detail.is_empty());
assert!(req.payload.is_none());
assert!(req.outcome.is_none());
assert!(req.signing_context.is_none());
}
#[test]
fn audit_append_request_full_payload() {
let body = r#"{
"event_type": "config_change",
"agent_id": "wrapper-1",
"detail": "rotated key",
"payload": {"key_id": "k-42", "ts": 1700000000},
"outcome": "ok",
"signing_context": "ed25519:deadbeef"
}"#;
let req: AuditAppendRequest = serde_json::from_str(body).unwrap();
assert_eq!(req.event_type, "config_change");
assert_eq!(req.agent_id, "wrapper-1");
assert_eq!(req.detail, "rotated key");
assert_eq!(req.outcome.as_deref(), Some("ok"));
assert_eq!(req.signing_context.as_deref(), Some("ed25519:deadbeef"));
let payload = req.payload.expect("payload present");
assert_eq!(payload["key_id"], "k-42");
assert_eq!(payload["ts"], 1_700_000_000);
}
}
+73 -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' https://cdn.jsdelivr.net; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; \
img-src 'self' data: blob:; \
connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://localhost:* wss://127.0.0.1:* https://cdn.jsdelivr.net; \
font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; \
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,
)
}
@@ -69,6 +120,7 @@ pub async fn webchat_page() -> impl IntoResponse {
/// All vendor libraries (Alpine.js, marked.js, highlight.js) are bundled
/// locally — no CDN dependency. Alpine.js is included LAST because it
/// immediately processes x-data directives and fires alpine:init on load.
/// KaTeX is loaded dynamically from jsdelivr CDN when needed for LaTeX rendering.
const WEBCHAT_HTML: &str = concat!(
include_str!("../static/index_head.html"),
"<style>\n",
@@ -81,21 +133,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 +186,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>"
File diff suppressed because it is too large Load Diff
+270 -13
View File
@@ -23,7 +23,7 @@
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
.btn-primary {
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
box-shadow: var(--shadow-xs), var(--shadow-inset);
}
.btn-primary:hover { background: var(--accent-dim); box-shadow: var(--shadow-sm), var(--shadow-accent); transform: translateY(-1px); }
@@ -69,6 +69,32 @@
gap: 16px;
}
/* Card-based flex containers for agent chips and similar inline layouts */
.card-flex {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
/* Nested list indentation inside cards, detail panels, and modals */
.card ul, .card ol,
.detail-grid ul, .detail-grid ol,
.modal ul, .modal ol,
.info-card ul, .info-card ol {
padding-left: 18px;
margin: 4px 0;
}
.card ul ul, .card ol ol,
.modal ul ul, .modal ol ol {
padding-left: 16px;
margin: 2px 0;
}
.card li, .modal li, .info-card li {
margin-bottom: 2px;
font-size: 12px;
line-height: 1.5;
}
/* Glow effect on card hover */
.card-glow {
overflow: hidden;
@@ -90,13 +116,17 @@
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
padding: 3px 8px;
border-radius: 20px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
white-space: nowrap;
line-height: 1.2;
vertical-align: middle;
}
.badge + .badge { margin-left: 4px; }
.badge-running { background: rgba(74,222,128,0.12); color: var(--success); }
.badge-suspended { background: rgba(245,158,11,0.12); color: var(--warning); }
@@ -110,7 +140,7 @@
.badge-error { background: rgba(239,68,68,0.12); color: var(--error); }
.badge-muted { background: rgba(148,163,184,0.12); color: var(--text-dim); }
.badge-info { background: rgba(59,130,246,0.12); color: var(--info); }
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; }
.badge-dim { background: rgba(148,163,184,0.08); color: var(--text-dim); font-size: 0.65rem; padding: 2px 6px; }
.text-danger { color: var(--error); }
/* Tables */
@@ -282,6 +312,12 @@ tr:hover td { background: var(--surface2); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
/* Issue #1026: live indicator for agents currently calling the LLM */
@keyframes agent-inferencing-pulse {
0%, 100% { transform: scale(1); opacity: 1; box-shadow: 0 0 0 0 var(--accent); }
50% { transform: scale(1.25); opacity: 0.85; box-shadow: 0 0 0 4px rgba(255, 92, 0, 0); }
}
.message.user {
flex-direction: row-reverse;
}
@@ -508,7 +544,7 @@ tr:hover td { background: var(--surface2); }
height: 14px;
border-radius: 50%;
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
font-size: 9px;
font-weight: 700;
display: flex;
@@ -617,6 +653,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;
@@ -829,7 +870,7 @@ mark.search-highlight {
border-radius: 50%;
border: none;
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
cursor: pointer;
display: flex;
align-items: center;
@@ -949,6 +990,14 @@ mark.search-highlight {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.model-switcher-search select {
max-width: 100px;
flex-shrink: 0;
}
.model-switcher-search select:focus {
outline: none;
border-color: var(--accent);
}
.model-switcher-search input {
flex: 1;
background: none;
@@ -1236,8 +1285,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; }
@@ -1245,6 +1297,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; }
@@ -1947,7 +2000,7 @@ mark.search-highlight {
}
.filter-pill:hover { border-color: var(--accent); color: var(--text); }
.filter-pill.active { background: var(--accent); color: var(--bg-primary); border-color: var(--accent); }
.filter-pill.active { background: var(--accent); color: var(--text-on-accent); border-color: var(--accent); }
/* ── Difficulty badges ── */
.difficulty-badge {
@@ -2211,7 +2264,7 @@ mark.search-highlight {
.wizard-progress-step.wiz-active .wizard-progress-circle {
border-color: var(--accent);
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
box-shadow: 0 0 0 4px var(--accent-glow);
}
@@ -2438,7 +2491,7 @@ mark.search-highlight {
/* ── Try-It Mini Chat ── */
.tryit-messages { max-height: 200px; overflow-y: auto; margin: 12px 0; }
.tryit-msg { padding: 6px 10px; border-radius: 6px; margin: 4px 0; font-size: 12px; line-height: 1.5; word-break: break-word; }
.tryit-msg-user { background: var(--accent); color: var(--bg-primary); margin-left: 40px; }
.tryit-msg-user { background: var(--accent); color: var(--text-on-accent); margin-left: 40px; }
.tryit-msg-agent { background: var(--surface2); margin-right: 40px; }
/* ── Suggested Message Chips ── */
@@ -2456,7 +2509,7 @@ mark.search-highlight {
.channel-steps { display: flex; align-items: center; gap: 0; margin-bottom: 20px; }
.channel-step-item { display: flex; align-items: center; gap: 6px; flex: 1; }
.channel-step-num { width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; border: 2px solid var(--border); color: var(--text-dim); flex-shrink: 0; transition: all 0.2s; }
.channel-step-num.active { border-color: var(--accent); background: var(--accent); color: var(--bg-primary); }
.channel-step-num.active { border-color: var(--accent); background: var(--accent); color: var(--text-on-accent); }
.channel-step-num.done { border-color: var(--success); background: var(--success); color: #000; }
.channel-step-label { font-size: 11px; color: var(--text-dim); }
.channel-step-label.active { color: var(--accent); font-weight: 600; }
@@ -2473,7 +2526,7 @@ mark.search-highlight {
.wizard-category-pills { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
.wizard-category-pill { padding: 4px 12px; border-radius: 20px; font-size: 11px; font-weight: 600; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--text-dim); transition: all 0.15s; font-family: var(--font-mono); }
.wizard-category-pill:hover { border-color: var(--accent); color: var(--text); }
.wizard-category-pill.active { background: var(--accent); color: var(--bg-primary); border-color: var(--accent); }
.wizard-category-pill.active { background: var(--accent); color: var(--text-on-accent); border-color: var(--accent); }
/* ── Capability Preview Panel ── */
.capability-preview { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px; margin-top: 12px; }
@@ -2579,7 +2632,7 @@ mark.search-highlight {
.personality-pill.active {
border-color: var(--accent);
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
box-shadow: 0 0 12px var(--accent-subtle);
}
@@ -2646,7 +2699,7 @@ mark.search-highlight {
justify-content: space-between;
}
.nav-section-chevron {
font-size: 8px;
font-size: 16px;
transition: transform var(--transition-fast);
color: var(--text-muted);
}
@@ -2752,7 +2805,7 @@ mark.search-highlight {
.hand-step-item.active .hand-step-num {
border-color: var(--accent);
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
}
.hand-step-item.done .hand-step-num {
border-color: var(--success);
@@ -3200,3 +3253,207 @@ 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(--surface);
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(--surface);
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;
position: relative;
}
.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); }
+31 -4
View File
@@ -1,5 +1,10 @@
/* OpenFang Layout — Grid + Sidebar + Responsive */
/* Firefox compat: hide x-cloak elements until Alpine.js initializes.
Without this, the sidebar flashes hidden in Firefox while Alpine
processes the nested x-data scopes for nav sections. */
[x-cloak] { display: none !important; }
.app-layout {
display: flex;
height: 100vh;
@@ -55,6 +60,11 @@
transform: scale(1.05);
}
[data-theme="light"] .sidebar-logo img,
[data-theme="light"] .message-avatar img {
filter: invert(1);
}
.sidebar-header h1 {
font-size: 14px;
font-weight: 700;
@@ -115,11 +125,11 @@
}
.nav-section-title {
font-size: 9px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-muted);
padding: 12px 12px 4px;
padding: 12px 12px 6px 3px;
font-weight: 600;
}
@@ -149,7 +159,7 @@
.nav-item.active {
background: var(--accent);
color: var(--bg-primary);
color: var(--text-on-accent);
font-weight: 600;
box-shadow: var(--shadow-sm), 0 2px 8px rgba(255, 92, 0, 0.2);
}
@@ -238,6 +248,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)); }
@@ -272,6 +290,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) {
@@ -280,7 +300,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%; }
+11 -9
View File
@@ -50,6 +50,7 @@
/* Chat-specific */
--agent-bg: #F5F4F2;
--user-bg: #FFF3E6;
--text-on-accent: #FFFFFF;
/* Layout */
--sidebar-width: 240px;
@@ -91,16 +92,17 @@
--bg: #080706;
--bg-primary: #0F0E0E;
--bg-elevated: #161413;
--surface: #1F1D1C;
--surface2: #2A2725;
--surface: #242221;
--surface2: #2F2D2C;
--surface3: #1A1817;
--border: #2D2A28;
--border-light: #3D3A38;
--border-subtle: #232120;
--text: #F0EFEE;
--text-secondary: #C4C0BC;
--text-dim: #8A8380;
--text-muted: #5C5754;
--border: #363230;
--border-light: #4A4644;
--border-subtle: #2D2A28;
--text: #FFFFFF;
--text-secondary: #D1D1D1;
--text-dim: #9FA0A0;
--text-muted: #6B6663;
--text-on-accent: #FFFFFF;
--accent: #FF5C00;
--accent-light: #FF7A2E;
--accent-dim: #E05200;
+608
View File
@@ -0,0 +1,608 @@
{
"app.name": "OpenFang",
"app.version": "v",
"nav.chat": "Chat",
"nav.monitor": "Monitor",
"nav.overview": "Overview",
"nav.analytics": "Analytics",
"nav.logs": "Logs",
"nav.agents": "Agents",
"nav.sessions": "Sessions",
"nav.approvals": "Approvals",
"nav.comms": "Comms",
"nav.automation": "Automation",
"nav.workflows": "Workflows",
"nav.scheduler": "Scheduler",
"nav.extensions": "Extensions",
"nav.channels": "Channels",
"nav.skills": "Skills",
"nav.hands": "Hands",
"nav.system": "System",
"nav.runtime": "Runtime",
"nav.settings": "Settings",
"auth.sign_in": "Sign In",
"auth.enter_credentials": "Enter your dashboard credentials.",
"auth.username": "Username",
"auth.password": "Password",
"auth.api_key_required": "API Key Required",
"auth.api_key_desc": "This instance requires an API key. Enter the key from your config.toml.",
"auth.api_key_hint": "Add api_key = \"your-key\" at the top of ~/.openfang/config.toml (not under any [section]).",
"auth.enter_api_key": "Enter API key...",
"auth.unlock_dashboard": "Unlock Dashboard",
"auth.login_failed": "Login failed",
"status.agents_running": "agent(s) running",
"status.connecting": "Connecting...",
"status.reconnecting": "Reconnecting...",
"status.disconnected": "disconnected",
"status.ws": "WS",
"status.http": "HTTP",
"status.ready": "Ready",
"status.loading": "Loading...",
"status.loading_workflows": "Loading workflows...",
"status.loading_channels": "Loading channels...",
"status.loading_skills": "Loading skills...",
"status.loading_jobs": "Loading scheduled jobs...",
"status.loading_triggers": "Loading triggers...",
"status.loading_history": "Loading run history...",
"status.loading_hands": "Loading hands...",
"status.loading_active_hands": "Loading active hands...",
"status.loading_mcp": "Loading MCP servers...",
"status.loading_files": "Loading files...",
"status.loading_skills_details": "Loading skills details...",
"status.no_channels_match": "No channels match your search",
"actions.logout": "Logout",
"actions.new_agent": "New Agent",
"actions.browse_skills": "Browse Skills",
"actions.add_channel": "Add Channel",
"actions.create_workflow": "Create Workflow",
"actions.settings": "Settings",
"actions.create_agent": "Create Agent",
"actions.configure_provider": "Configure Provider",
"actions.cancel": "Cancel",
"actions.confirm": "Confirm",
"actions.save": "Save",
"actions.delete": "Delete",
"actions.edit": "Edit",
"actions.clone": "Clone",
"actions.stop": "Stop",
"actions.run": "Run",
"actions.enable": "Enable",
"actions.disable": "Disable",
"actions.view_all": "View All",
"actions.retry": "Retry",
"actions.refresh": "Refresh",
"actions.approve": "Approve",
"actions.reject": "Reject",
"actions.update": "Update",
"actions.test_connection": "Test Connection",
"actions.remove": "Remove",
"actions.save_test": "Save & Test",
"actions.export_toml": "Export TOML",
"actions.save_workflow": "Save Workflow",
"actions.auto_layout": "Auto Layout",
"actions.clear": "Clear",
"actions.zoom_out": "Zoom out",
"actions.zoom_in": "Zoom in",
"actions.fit": "Fit",
"actions.duplicate": "Duplicate",
"actions.copy_clipboard": "Copy to Clipboard",
"actions.copied": "Copied!",
"actions.copy": "Copy",
"actions.hide_code": "Hide Code",
"actions.view_code": "View Code",
"actions.install": "Install",
"actions.installing": "Installing...",
"actions.installed": "Installed",
"actions.load_more": "Load More",
"actions.back_to_browse": "Back to browse",
"actions.activate": "Activate",
"actions.create_schedule": "Create Schedule",
"actions.submit": "Submit",
"actions.close": "Close",
"actions.next": "Next",
"actions.back": "Back",
"actions.spawn_agent": "Spawn Agent",
"actions.spawning": "Spawning...",
"actions.create_job": "Create Job",
"actions.spawn_wizard": "Wizard",
"actions.raw_toml": "Raw TOML",
"actions.setup_wizard": "Setup Wizard",
"actions.configure_manually": "Configure Manually",
"actions.dismiss": "Dismiss",
"actions.create_workflow_btn": "Create Workflow",
"actions.execute": "Execute",
"actions.executing": "Executing...",
"actions.generated_toml": "Generated TOML",
"actions.running": "Running...",
"footer.shortcuts": "Ctrl+K agents | Ctrl+N new",
"theme.light": "Light",
"theme.system": "System",
"theme.dark": "Dark",
"errors.connection_error": "Connection Error",
"errors.daemon_unreachable": "Cannot reach daemon — is openfang running?",
"errors.not_authorized": "Not authorized — check your API key",
"errors.permission_denied": "Permission denied",
"errors.resource_not_found": "Resource not found",
"errors.rate_limited": "Rate limited — slow down and try again",
"errors.request_too_large": "Request too large",
"errors.server_error": "Server error — check daemon logs",
"errors.daemon_unavailable": "Daemon unavailable — is it running?",
"errors.unexpected": "Unexpected error",
"errors.reconnected": "Reconnected",
"errors.connection_lost": "Connection lost, reconnecting...",
"errors.switched_http": "Connection lost — switched to HTTP mode",
"errors.connection_lost": "Connection lost, reconnecting...",
"errors.switched_http": "Connection lost — switched to HTTP mode",
"errors.reconnected": "Reconnected",
"toasts.approval_waiting": "An agent is waiting for approval. Open Approvals to review.",
"toasts.agent_created": "Agent Created",
"toasts.agent_stopped": "Agent Stopped",
"toasts.tool_used": "Tool Used",
"toasts.tool_completed": "Tool Completed",
"toasts.message_in": "Message In",
"toasts.response_sent": "Response Sent",
"toasts.session_reset": "Session Reset",
"toasts.compacted": "Compacted",
"toasts.model_changed": "Model Changed",
"toasts.login_attempt": "Login Attempt",
"toasts.login_ok": "Login OK",
"toasts.login_failed": "Login Failed",
"toasts.denied": "Denied",
"toasts.rate_limited": "Rate Limited",
"toasts.workflow_run": "Workflow Run",
"toasts.trigger_fired": "Trigger Fired",
"toasts.skill_installed": "Skill Installed",
"toasts.mcp_connected": "MCP Connected",
"toasts.session_deleted": "Session deleted",
"overview.welcome": "Welcome to OpenFang",
"overview.getting_started": "Getting Started",
"overview.setup_wizard": "Setup Wizard",
"overview.steps_completed": "of 5 steps completed",
"overview.agents_running": "Agents Running",
"overview.tokens_used": "Tokens Used",
"overview.total_cost": "Total Cost",
"overview.uptime": "Uptime",
"overview.channels": "Channels",
"overview.skills": "Skills",
"overview.mcp_servers": "MCP Servers",
"overview.tool_calls": "Tool Calls",
"overview.providers": "Providers",
"overview.recent_activity": "Recent Activity",
"overview.no_recent_activity": "No Recent Activity",
"overview.chat_with_agent": "Chat with an Agent",
"overview.system_health": "System Health",
"overview.healthy": "Healthy",
"overview.unreachable": "Unreachable",
"overview.security_systems": "Security Systems",
"overview.llm_providers": "LLM Providers",
"overview.defense_active": "9 defense-in-depth systems active",
"overview.quick_actions": "Quick Actions",
"setup.configure_provider": "Configure an LLM provider",
"setup.create_first_agent": "Create your first agent",
"setup.send_first_message": "Send your first message",
"setup.connect_channel": "Connect a messaging channel",
"setup.browse_install_skill": "Browse or install a skill",
"tooltips.cooling_down": "cooling down (rate limited)",
"tooltips.circuit_open": "circuit breaker open",
"tooltips.ready": "ready",
"tooltips.not_configured": "not configured",
"chat.placeholder": "Message OpenFang... (/ for commands)",
"chat.ready": "Ready",
"chat.generating": "Generating...",
"chat.queued": "queued",
"chat.sessions": "Sessions",
"chat.new_session": "+ New",
"chat.no_sessions": "No sessions",
"chat.search_messages": "Search messages...",
"chat.select_agent": "Select an agent to start chatting",
"chat.recording": "Recording... release to send",
"chat.drop_files": "Drop files here",
"chat.attach_file": "Attach file",
"chat.stop_generating": "Stop generating",
"chat.switch_model": "Switch model",
"chat.search_models": "Search models...",
"chat.no_models_found": "No models found",
"chat.available_models": "Available models — pick one or keep typing",
"chat.switching": "Switching...",
"chat.model_switched": "Switched to",
"chat.model_switch_failed": "Model switch failed",
"chat.using_http_mode": "Using HTTP mode (no streaming)",
"chat.session_name_prompt": "Session name (optional):",
"chat.session_created": "Session created",
"chat.session_create_failed": "Failed to create session",
"chat.stop_agent_title": "Stop Agent",
"chat.stop_agent_confirm": "Stop agent",
"chat.agent_stopped": "Agent stopped",
"chat.stop_agent_failed": "Failed to stop agent",
"chat.welcome_message": "**Welcome to OpenFang Chat!**\n\n- Type `/` to see available commands\n- `/help` shows all commands\n- `/think on` enables extended reasoning\n- `/context` shows context window usage\n- `/verbose off` hides tool details\n- `Ctrl+Shift+F` toggles focus mode\n- Drag & drop files to attach them\n- `Ctrl+/` opens the command palette",
"chat.slash.help": "Show available commands",
"chat.slash.agents": "Switch to Agents page",
"chat.slash.new": "New session (clear history)",
"chat.slash.compact": "Compact session context",
"chat.slash.model": "Show or switch model (/model [name])",
"chat.slash.stop": "Cancel current agent run",
"chat.slash.usage": "Show token usage",
"chat.slash.think": "Toggle reasoning (/think [on|off|stream])",
"chat.slash.context": "Show context window usage",
"chat.slash.verbose": "Toggle tool details (/verbose [off|on|full])",
"chat.slash.queue": "Check if agent is processing",
"chat.slash.status": "Show system status",
"chat.slash.clear": "Clear chat",
"chat.slash.exit": "Disconnect from agent",
"chat.slash.budget": "Show budget limits and costs",
"chat.slash.peers": "Show OFP network status",
"chat.slash.a2a": "List A2A agents",
"commands.help": "Show available commands",
"commands.agents": "Switch to Agents page",
"commands.new": "Reset session",
"commands.switch": "Switch agent",
"commands.clear": "Clear conversation",
"commands.model": "Switch model",
"commands.think": "Toggle reasoning mode",
"commands.focus": "Toggle focus mode",
"commands.theme": "Cycle theme",
"tips.commands": "Type / for commands",
"tips.think": "/think on for reasoning",
"tips.focus": "Ctrl+Shift+F for focus mode",
"agents.info": "Info",
"agents.files": "Files",
"agents.config": "Config",
"agents.chat": "Chat",
"agents.clone": "Clone",
"agents.clear_history": "Clear History",
"agents.change": "Change",
"agents.none_fallback": "None — add a fallback chain",
"agents.add": "+ Add",
"agents.loading_files": "Loading files...",
"agents.no_workspace_files": "No workspace files found",
"agents.save_config": "Save Config",
"agents.tool_filters": "Tool Filters",
"agents.allowlist": "Allowlist",
"agents.blocklist": "Blocklist",
"agents.agent_name": "Agent Name",
"agents.emoji": "Emoji",
"agents.color": "Color",
"agents.archetype": "Archetype",
"agents.provider": "Provider",
"agents.model": "Model",
"agents.system_prompt": "System Prompt",
"agents.soul_persona": "Soul / Persona",
"agents.tool_profile": "Tool Profile",
"agents.minimal_profile": "Minimal — Read-only file access",
"agents.coding_profile": "Coding — Files + shell + web fetch",
"agents.fullstack_profile": "Full-Stack — Files + shell + web fetch + search",
"agents.research_profile": "Research — Web + search + analysis",
"agents.admin_profile": "Admin — Full system access (dangerous)",
"agents.agent_created": "Agent Created",
"agents.agent_stopped": "Agent Stopped",
"agents.agent_deleted": "Agent Deleted",
"presets.professional": "Professional",
"presets.professional_desc": "Precise, business-oriented assistant focused on efficiency and clarity. Prioritizes actionable insights and structured communication.",
"presets.professional_soul": "Communicate in a clear, professional tone. Be direct and structured. Use formal language and data-driven reasoning. Prioritize accuracy over personality.",
"presets.friendly": "Friendly",
"presets.friendly_desc": "Warm and approachable assistant that builds rapport and uses conversational language. Great for brainstorming and exploration.",
"presets.friendly_soul": "Be warm, approachable, and conversational. Use casual language and show genuine interest in the user. Add personality to your responses while staying helpful.",
"presets.technical": "Technical",
"presets.technical_desc": "Expert developer companion optimized for code, architecture, and technical problem-solving. Precise terminology, deep dives, benchmarks.",
"presets.technical_soul": "Focus on technical accuracy and depth. Use precise terminology. Show your work and reasoning. Prefer code examples and structured explanations.",
"presets.creative": "Creative",
"presets.creative_desc": "Imaginative collaborator for content creation, design thinking, and unconventional solutions. Embraces ambiguity and explores possibilities.",
"presets.creative_soul": "Be imaginative and expressive. Use vivid language, analogies, and unexpected connections. Encourage creative thinking and explore multiple perspectives.",
"presets.concise": "Concise",
"presets.concise_desc": "Minimal and direct assistant that respects your time. Cuts through noise to deliver focused, actionable responses.",
"presets.concise_soul": "Be extremely brief and to the point. No filler, no pleasantries. Answer in the fewest words possible while remaining accurate and complete.",
"presets.mentor": "Mentor",
"presets.mentor_desc": "Patient educator that explains concepts thoroughly, provides context, and guides learning. Socratic method when appropriate.",
"presets.mentor_soul": "Be patient and encouraging like a great teacher. Break down complex topics step by step. Ask guiding questions. Celebrate progress and build confidence.",
"agents.profile.minimal": "Minimal",
"agents.profile.minimal_desc": "Read-only file access",
"agents.profile.coding": "Coding",
"agents.profile.coding_desc": "Files + shell + web fetch",
"agents.profile.research": "Research",
"agents.profile.research_desc": "Web search + file read/write",
"agents.profile.messaging": "Messaging",
"agents.profile.messaging_desc": "Agents + memory access",
"agents.profile.automation": "Automation",
"agents.profile.automation_desc": "All tools except custom",
"agents.profile.balanced": "Balanced",
"agents.profile.balanced_desc": "General-purpose tool set",
"agents.profile.precise": "Precise",
"agents.profile.precise_desc": "Focused tool set for accuracy",
"agents.profile.creative": "Creative",
"agents.profile.creative_desc": "Full tools with creative emphasis",
"agents.profile.full": "Full",
"agents.profile.full_desc": "All 35+ tools",
"wizard.general_assistant": "General Assistant",
"wizard.general_assistant_desc": "You are a versatile AI assistant that helps users with a wide range of tasks. You are knowledgeable, helpful, and able to adapt to the user's needs.",
"wizard.code_helper": "Code Helper",
"wizard.code_helper_desc": "You are an expert programming assistant specialized in software development. You help write, debug, and refactor code across multiple languages.",
"wizard.researcher": "Research Assistant",
"wizard.researcher_desc": "You are a research assistant that helps users find, analyze, and synthesize information from various sources.",
"wizard.writer": "Writer",
"wizard.writer_desc": "You are a skilled writer that helps with content creation, editing, and creative writing projects.",
"wizard.data_analyst": "Data Analyst",
"wizard.data_analyst_desc": "You are a data analyst that helps explore, analyze, and visualize data to extract insights.",
"wizard.devops": "DevOps Engineer",
"wizard.devops_desc": "You are a DevOps engineer that helps with infrastructure, deployment, CI/CD, and system administration.",
"wizard.support": "Customer Support",
"wizard.support_desc": "You are a customer support representative that helps resolve inquiries with patience and professionalism.",
"wizard.tutor": "Tutor",
"wizard.tutor_desc": "You are an educational tutor that explains concepts clearly and adapts teaching to the student's level.",
"wizard.api_designer": "API Designer",
"wizard.api_designer_desc": "You are an API designer that helps create well-structured, intuitive APIs following best practices.",
"wizard.meeting_notes": "Meeting Notes",
"wizard.meeting_notes_desc": "You are a meeting notes specialist that summarizes discussions, extracts action items, and tracks decisions.",
"wizard.step_welcome": "Welcome",
"wizard.step_provider": "Provider",
"wizard.step_agent": "Agent",
"wizard.step_try_it": "Try It",
"wizard.step_channel": "Channel",
"wizard.step_done": "Done",
"wizard.cat_general": "General",
"wizard.cat_development": "Development",
"wizard.cat_research": "Research",
"wizard.cat_writing": "Writing",
"wizard.cat_business": "Business",
"wizard.channel_telegram": "Telegram",
"wizard.channel_telegram_desc": "Connect your agent to a Telegram bot for messaging.",
"wizard.channel_telegram_token": "Bot Token",
"wizard.channel_telegram_help": "Create a bot via @BotFather on Telegram to get your token.",
"wizard.channel_discord": "Discord",
"wizard.channel_discord_desc": "Connect your agent to a Discord server via bot token.",
"wizard.channel_discord_token": "Bot Token",
"wizard.channel_discord_help": "Create a Discord application at discord.com/developers and add a bot.",
"wizard.channel_slack": "Slack",
"wizard.channel_slack_desc": "Connect your agent to a Slack workspace.",
"wizard.channel_slack_token": "Bot Token",
"wizard.channel_slack_help": "Create a Slack app at api.slack.com/apps and install it to your workspace.",
"wizard.profile_minimal": "Minimal",
"wizard.profile_minimal_desc": "Read-only file access",
"wizard.profile_coding": "Coding",
"wizard.profile_coding_desc": "Files + shell + web fetch",
"wizard.profile_research": "Research",
"wizard.profile_research_desc": "Web search + file read/write",
"wizard.profile_balanced": "Balanced",
"wizard.profile_balanced_desc": "General-purpose tool set",
"wizard.profile_precise": "Precise",
"wizard.profile_precise_desc": "Focused tool set for accuracy",
"wizard.profile_creative": "Creative",
"wizard.profile_creative_desc": "Full tools with creative emphasis",
"wizard.profile_full": "Full",
"wizard.profile_full_desc": "All 35+ tools",
"wizard.enter_api_key": "Please enter an API key",
"wizard.api_key_saved": "API key saved for",
"wizard.failed_save_key": "Failed to save key:",
"wizard.connected": "connected",
"wizard.connection_failed": "Connection failed",
"wizard.test_failed": "Test failed:",
"wizard.enter_agent_name": "Please enter a name for your agent",
"wizard.agent_created": "Agent created",
"wizard.failed_create_agent": "Failed to create agent:",
"wizard.enter_token": "Please enter the",
"wizard.channel_configured": "configured and activated.",
"wizard.failed_configure": "Failed:",
"wizard.suggestions.general.1": "What can you help me with?",
"wizard.suggestions.general.2": "Tell me a fun fact",
"wizard.suggestions.general.3": "Summarize the latest AI news",
"wizard.suggestions.development.1": "Write a Python hello world",
"wizard.suggestions.development.2": "Explain async/await",
"wizard.suggestions.development.3": "Review this code snippet",
"wizard.suggestions.research.1": "Explain quantum computing simply",
"wizard.suggestions.research.2": "Compare React vs Vue",
"wizard.suggestions.research.3": "What are the latest trends in AI?",
"wizard.suggestions.writing.1": "Help me write a professional email",
"wizard.suggestions.writing.2": "Improve this paragraph",
"wizard.suggestions.writing.3": "Write a blog intro about AI",
"wizard.suggestions.business.1": "Draft a meeting agenda",
"wizard.suggestions.business.2": "How do I handle a complaint?",
"wizard.suggestions.business.3": "Create a project status update",
"approvals.title": "Execution Approvals",
"approvals.pending": "pending",
"approvals.all": "All",
"approvals.pending_tab": "Pending",
"approvals.approved": "Approved",
"approvals.rejected": "Rejected",
"approvals.expired": "Expired",
"approvals.no_approvals": "No approvals",
"approvals.approve": "Approve",
"approvals.reject": "Reject",
"workflows.title": "Workflows",
"workflows.visual_builder": "Visual Builder",
"workflows.what_are": "What are Workflows?",
"workflows.no_workflows": "No workflows yet",
"workflows.sequential": "Sequential",
"workflows.fan_out": "Fan Out",
"workflows.conditional": "Conditional",
"workflows.loop": "Loop",
"workflows.add_step": "+ Add Step",
"workflows.execute": "Execute",
"workflows.result": "Result",
"workflows.node_palette": "Node Palette",
"workflows.drag_nodes": "Drag nodes onto the canvas",
"workflows.steps_connections": "steps, connections",
"workflows.agent": "Agent",
"workflows.prompt_template": "Prompt Template",
"workflows.expression": "Expression",
"workflows.top_port_true": "Top port = true, bottom port = false",
"workflows.max_iterations": "Max Iterations",
"workflows.until_stop": "Until (stop condition)",
"workflows.fan_out_count": "Fan-out Count",
"workflows.wait_all": "Wait for all",
"workflows.first_finish": "First to finish",
"workflows.majority_vote": "Majority vote",
"workflows.connection_selected": "Connection selected",
"workflows.delete_connection": "Delete Connection",
"scheduler.title": "Scheduler",
"scheduler.scheduled_jobs": "Scheduled Jobs",
"scheduler.event_triggers": "Event Triggers",
"scheduler.run_history": "Run History",
"scheduler.new_job": "+ New Job",
"scheduler.job_name": "Job Name",
"scheduler.cron_expression": "Cron Expression",
"scheduler.quick_presets": "Quick Presets",
"scheduler.target_agent": "Target Agent",
"scheduler.any_agent": "Any available agent",
"scheduler.message_send": "Message to Send",
"scheduler.enabled": "Enabled (will start running immediately)",
"scheduler.disabled": "Disabled (create paused)",
"scheduler.active": "Active",
"scheduler.paused": "Paused",
"scheduler.cron_job": "Cron Job",
"scheduler.trigger": "Trigger",
"scheduler.no_jobs": "No scheduled jobs",
"scheduler.no_triggers": "No event triggers",
"scheduler.no_history": "No run history yet",
"channels.title": "Channels",
"channels.configured": "configured",
"channels.search": "Search channels...",
"channels.setup": "Set up",
"channels.edit": "Edit",
"channels.configure": "Configure",
"channels.verify": "Verify",
"channels.ready": "Ready",
"channels.is_ready": "is ready!",
"channels.get_credentials": "How to get credentials",
"channels.show_advanced": "Show advanced",
"channels.hide_advanced": "Hide advanced",
"channels.connecting": "Connecting to WhatsApp Web gateway...",
"channels.linked_success": "WhatsApp linked successfully!",
"channels.business_api": "Business API",
"skills.title": "Skills & Ecosystem",
"skills.installed": "Installed",
"skills.clawhub": "ClawHub",
"skills.mcp_servers": "MCP Servers",
"skills.quick_start": "Quick Start",
"skills.no_installed": "No skills installed",
"skills.browse_clawhub": "Browse ClawHub",
"skills.search_clawhub": "Search ClawHub skills...",
"skills.trending": "Trending",
"skills.most_downloaded": "Most Downloaded",
"skills.most_starred": "Most Starred",
"skills.recently_updated": "Recently Updated",
"skills.categories": "CATEGORIES",
"skills.already_installed": "Already Installed",
"skills.no_skills_found": "No skills found",
"skills.security_warnings": "Security Warnings",
"skills.security_scan": "Skills are security-scanned before installation",
"skills.create": "Create Skill",
"skills.created": "Created",
"skills.cat_coding": "Coding & IDEs",
"skills.cat_git": "Git & GitHub",
"skills.cat_frontend": "Web & Frontend",
"skills.cat_devops": "DevOps & Cloud",
"skills.cat_database": "Database",
"skills.cat_security": "Security",
"skills.cat_ai": "AI & ML",
"skills.cat_data": "Data & Analytics",
"skills.cat_mobile": "Mobile",
"skills.cat_desktop": "Desktop Apps",
"skills.cat_api": "API & Integrations",
"skills.cat_testing": "Testing",
"skills.cat_docs": "Documentation",
"skills.cat_productivity": "Productivity",
"skills.cat_other": "Other",
"skills.cat_browser": "Browser & Automation",
"skills.cat_search": "Search & Research",
"skills.cat_communication": "Communication",
"skills.cat_media": "Media & Streaming",
"skills.cat_notes": "Notes & PKM",
"skills.cat_cli": "CLI Utilities",
"skills.cat_marketing": "Marketing & Sales",
"skills.cat_finance": "Finance",
"skills.cat_smarthome": "Smart Home & IoT",
"skills.uninstall_skill": "Uninstall Skill",
"skills.uninstall_confirm": "Uninstall skill",
"skills.source_clawhub": "ClawHub",
"skills.source_openclaw": "OpenClaw",
"skills.source_builtin": "Built-in",
"skills.source_local": "Local",
"hands.title": "Hands — Curated Autonomous Capability Packages",
"hands.available": "Available",
"hands.active": "Active",
"hands.ready": "Ready",
"hands.setup_needed": "Setup needed",
"hands.requirements": "REQUIREMENTS",
"hands.details": "Details",
"hands.no_hands": "No hands available",
"sessions.title": "Sessions",
"sessions.memory": "Memory",
"sessions.delete_session": "Delete Session",
"sessions.delete_confirm": "This will permanently remove the session and its messages.",
"sessions.delete_key": "Delete Key",
"sessions.delete_key_confirm": "Delete key",
"logs.title": "Logs",
"logs.live": "Live",
"logs.audit_trail": "Audit Trail",
"settings.title": "Settings",
"settings.providers": "Providers",
"settings.models": "Models",
"settings.config": "Config",
"settings.tools": "Tools",
"settings.migration": "Migration",
"settings.security": "Security",
"settings.network": "Network",
"settings.migration": "Migration",
"settings.language": "Language",
"settings.sec_path_traversal": "Path Traversal Prevention",
"settings.sec_path_traversal_desc": "Blocks attempts to access files outside the workspace directory using .. or absolute paths.",
"settings.sec_ssrf": "SSRF Protection",
"settings.sec_ssrf_desc": "Prevents agents from making requests to internal IP ranges (localhost, cloud metadata, private networks).",
"settings.sec_capability": "Capability-Based Access Control",
"settings.sec_capability_desc": "Agents can only access explicitly granted capabilities. No implicit access to tools or data.",
"settings.sec_taint": "Taint Tracking",
"settings.sec_taint_desc": "Tracks untrusted data (user input, file content) through agent reasoning to prevent prompt injection.",
"settings.sec_sandbox": "WASM Sandbox",
"settings.sec_sandbox_desc": "Executes untrusted code in isolated WebAssembly sandboxes with memory and syscall restrictions.",
"settings.sec_audit": "Merkle Audit",
"settings.sec_audit_desc": "Maintains a verifiable audit log of all agent actions using Merkle tree cryptography.",
"settings.sec_workspace": "Workspace Isolation",
"settings.sec_workspace_desc": "Each agent has an isolated workspace directory. No cross-agent file access unless explicitly granted.",
"settings.sec_rate_limit": "Rate Limiting",
"settings.sec_rate_limit_desc": "Enforces per-agent and global rate limits to prevent resource exhaustion and cost overruns.",
"settings.sec_approval": "Execution Approvals",
"settings.sec_approval_desc": "Requires human approval for high-risk actions (shell commands, file writes, external requests).",
"settings.sec_enabled": "Enabled",
"settings.sec_disabled": "Disabled",
"settings.sec_inherited": "Inherited",
"settings.sec_global": "Global"
}
+230
View File
@@ -0,0 +1,230 @@
/**
* OpenFang i18n (Internationalization) Module
*
* Provides runtime language switching for the OpenFang dashboard UI.
* Supports English (default) and Russian.
*
* Usage:
* - HTML: <span data-i18n="nav.overview">Overview</span>
* - JS: window.t('nav.overview')
* - Auto-applies translations on load based on stored/preferred language
*/
(function() {
'use strict';
// Language store
let currentLang = 'en';
let translations = {};
let isInitialized = false;
/**
* Load translations from a JSON file
* @param {string} lang - Language code (en, ru)
* @returns {Promise<Object>} Translation object
*/
async function loadTranslations(lang) {
try {
// Use cached translations if available
if (window.__i18nCache && window.__i18nCache[lang]) {
return window.__i18nCache[lang];
}
const response = await fetch(`/i18n/${lang}.json`);
if (!response.ok) {
console.warn(`[i18n] Failed to load ${lang}.json, falling back to en`);
if (lang !== 'en') {
return loadTranslations('en');
}
return {};
}
const data = await response.json();
// Cache for future use
if (!window.__i18nCache) window.__i18nCache = {};
window.__i18nCache[lang] = data;
return data;
} catch (error) {
console.error(`[i18n] Error loading translations for ${lang}:`, error);
if (lang !== 'en') {
return loadTranslations('en');
}
return {};
}
}
/**
* Get a translated string by key
* @param {string} key - Translation key (e.g., 'nav.overview')
* @param {Object} params - Optional interpolation parameters
* @returns {string} Translated string or key if not found
*/
function t(key, params) {
if (!isInitialized) {
console.warn('[i18n] Not initialized, returning key');
return key;
}
let text = translations[key] || key;
// Handle interpolation (e.g., 'Hello, {{name}}')
if (params && typeof params === 'object') {
Object.keys(params).forEach(param => {
text = text.replace(new RegExp(`{{${param}}}`, 'g'), params[param]);
});
}
return text;
}
/**
* Apply translations to all elements with data-i18n attribute
* Also updates the <html> lang attribute
*/
function applyTranslations() {
// Update document language
document.documentElement.lang = currentLang;
// Find and translate all elements with data-i18n attribute
const elements = document.querySelectorAll('[data-i18n]');
elements.forEach(el => {
const key = el.getAttribute('data-i18n');
const translation = t(key);
// Check if element is a form input/textarea
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
// For form elements, only update if it's a placeholder or aria-label
if (el.hasAttribute('placeholder')) {
el.placeholder = translation;
}
if (el.hasAttribute('aria-label')) {
el.setAttribute('aria-label', translation);
}
if (el.hasAttribute('title')) {
el.setAttribute('title', translation);
}
} else {
// For regular elements, update text content
el.textContent = translation;
}
});
// Update elements with data-i18n-* attributes for attributes
const attrElements = document.querySelectorAll('[data-i18n-placeholder], [data-i18n-title], [data-i18n-aria-label]');
attrElements.forEach(el => {
if (el.hasAttribute('data-i18n-placeholder')) {
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
}
if (el.hasAttribute('data-i18n-title')) {
el.title = t(el.getAttribute('data-i18n-title'));
}
if (el.hasAttribute('data-i18n-aria-label')) {
el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria-label')));
}
});
// Update meta tags
const metaDesc = document.querySelector('meta[name="description"]');
if (metaDesc) {
const desc = t('app.description', { name: 'OpenFang' });
if (desc !== 'app.description') {
metaDesc.content = desc;
}
}
console.log(`[i18n] Applied translations for language: ${currentLang}`);
}
/**
* Set the current language and apply translations
* @param {string} lang - Language code (en, ru)
* @param {boolean} persist - Whether to save to localStorage
*/
async function setLanguage(lang, persist = true) {
if (!['en', 'ru'].includes(lang)) {
console.warn(`[i18n] Unknown language: ${lang}, defaulting to en`);
lang = 'en';
}
currentLang = lang;
translations = await loadTranslations(lang);
isInitialized = true;
// Save preference
if (persist) {
localStorage.setItem('openfang_language', lang);
}
// Apply to DOM
applyTranslations();
// Dispatch event for Alpine.js components to react
window.dispatchEvent(new CustomEvent('i18n:language-changed', {
detail: { language: lang }
}));
}
/**
* Get the current language
* @returns {string} Current language code
*/
function getLanguage() {
return currentLang;
}
/**
* Initialize i18n system
* Loads language preference and applies translations
*/
async function init() {
// Determine language priority:
// 1. localStorage (user preference)
// 2. Browser language
// 3. Default to English
let lang = localStorage.getItem('openfang_language');
if (!lang) {
// Try to detect browser language
const browserLang = navigator.language || navigator.userLanguage || '';
if (browserLang.startsWith('ru')) {
lang = 'ru';
} else {
lang = 'en';
}
}
await setLanguage(lang, false);
}
/**
* Get available languages
* @returns {Array<{code: string, name: string}>}
*/
function getAvailableLanguages() {
return [
{ code: 'en', name: 'English' },
{ code: 'ru', name: 'Русский' }
];
}
// Expose to global scope
window.i18n = {
t,
setLanguage,
getLanguage,
getAvailableLanguages,
init,
isInitialized: () => isInitialized
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+607
View File
@@ -0,0 +1,607 @@
{
"app.name": "OpenFang",
"app.version": "v",
"nav.chat": "Чат",
"nav.monitor": "Мониторинг",
"nav.overview": "Обзор",
"nav.analytics": "Аналитика",
"nav.logs": "Логи",
"nav.agents": "Агенты",
"nav.sessions": "Сессии",
"nav.approvals": "Одобрения",
"nav.comms": "Коммуникации",
"nav.automation": "Автоматизация",
"nav.workflows": "Рабочие процессы",
"nav.scheduler": "Планировщик",
"nav.extensions": "Расширения",
"nav.channels": "Каналы",
"nav.skills": "Навыки",
"nav.hands": "Руки",
"nav.system": "Система",
"nav.runtime": "Среда выполнения",
"nav.settings": "Настройки",
"auth.sign_in": "Войти",
"auth.enter_credentials": "Введите учётные данные панели управления.",
"auth.username": "Имя пользователя",
"auth.password": "Пароль",
"auth.api_key_required": "Требуется API-ключ",
"auth.api_key_desc": "Этот экземпляр требует API-ключ. Введите ключ из вашего config.toml.",
"auth.api_key_hint": "Добавьте api_key = \"your-key\" в начало ~/.openfang/config.toml (не внутри секции).",
"auth.enter_api_key": "Введите API-ключ...",
"auth.unlock_dashboard": "Разблокировать панель",
"auth.login_failed": "Ошибка входа",
"status.agents_running": "агент(ов) запущено",
"status.connecting": "Подключение...",
"status.reconnecting": "Переподключение...",
"status.disconnected": "отключено",
"status.ws": "ВС",
"status.http": "HTTP",
"status.ready": "Готово",
"status.loading": "Загрузка...",
"status.loading_workflows": "Загрузка рабочих процессов...",
"status.loading_channels": "Загрузка каналов...",
"status.loading_skills": "Загрузка навыков...",
"status.loading_jobs": "Загрузка заданий...",
"status.loading_triggers": "Загрузка триггеров...",
"status.loading_history": "Загрузка истории...",
"status.loading_hands": "Загрузка модулей...",
"status.loading_active_hands": "Загрузка активных модулей...",
"status.loading_mcp": "Загрузка MCP-серверов...",
"status.loading_files": "Загрузка файлов...",
"status.loading_skills_details": "Загрузка деталей навыков...",
"status.no_channels_match": "Нет каналов по запросу",
"actions.logout": "Выйти",
"actions.new_agent": "Новый агент",
"actions.browse_skills": "Навыки",
"actions.add_channel": "Добавить канал",
"actions.create_workflow": "Создать процесс",
"actions.settings": "Настройки",
"actions.create_agent": "Создать агента",
"actions.configure_provider": "Настроить провайдера",
"actions.cancel": "Отмена",
"actions.confirm": "Подтвердить",
"actions.save": "Сохранить",
"actions.delete": "Удалить",
"actions.edit": "Редактировать",
"actions.clone": "Клонировать",
"actions.stop": "Остановить",
"actions.run": "Запустить",
"actions.enable": "Включить",
"actions.disable": "Отключить",
"actions.view_all": "Показать все",
"actions.retry": "Повторить",
"actions.refresh": "Обновить",
"actions.approve": "Одобрить",
"actions.reject": "Отклонить",
"actions.update": "Обновить",
"actions.test_connection": "Проверить подключение",
"actions.remove": "Удалить",
"actions.save_test": "Сохранить и проверить",
"actions.export_toml": "Экспорт TOML",
"actions.save_workflow": "Сохранить процесс",
"actions.auto_layout": "Автораскладка",
"actions.clear": "Очистить",
"actions.zoom_out": "Уменьшить",
"actions.zoom_in": "Увеличить",
"actions.fit": "По размеру",
"actions.duplicate": "Дублировать",
"actions.copy_clipboard": "Копировать в буфер",
"actions.copied": "Скопировано!",
"actions.copy": "Копировать",
"actions.hide_code": "Скрыть код",
"actions.view_code": "Показать код",
"actions.install": "Установить",
"actions.installing": "Установка...",
"actions.installed": "Установлено",
"actions.load_more": "Загрузить ещё",
"actions.back_to_browse": "Назад",
"actions.activate": "Активировать",
"actions.create_schedule": "Создать расписание",
"actions.submit": "Отправить",
"actions.close": "Закрыть",
"actions.next": "Далее",
"actions.back": "Назад",
"actions.spawn_agent": "Создать агента",
"actions.spawning": "Создание...",
"actions.spawn_wizard": "Мастер",
"actions.raw_toml": "TOML",
"actions.setup_wizard": "Мастер настройки",
"actions.configure_manually": "Настроить вручную",
"actions.dismiss": "Закрыть",
"actions.create_workflow_btn": "Создать процесс",
"actions.execute": "Выполнить",
"actions.executing": "Выполнение...",
"actions.generated_toml": "Сгенерированный TOML",
"actions.running": "Выполняется...",
"footer.shortcuts": "Ctrl+K агенты | Ctrl+N новый",
"theme.light": "Светлая",
"theme.system": "Системная",
"theme.dark": "Тёмная",
"errors.connection_error": "Ошибка подключения",
"errors.daemon_unreachable": "Не удаётся связаться с демоном — запущен ли openfang?",
"errors.not_authorized": "Не авторизован — проверьте API-ключ",
"errors.permission_denied": "Доступ запрещён",
"errors.resource_not_found": "Ресурс не найден",
"errors.rate_limited": "Превышен лимит — подождите и попробуйте снова",
"errors.request_too_large": "Запрос слишком большой",
"errors.server_error": "Ошибка сервера — проверьте логи демона",
"errors.daemon_unavailable": "Демон недоступен — запущен ли он?",
"errors.unexpected": "Неожиданная ошибка",
"errors.reconnected": "Переподключено",
"errors.connection_lost": "Соединение потеряно, переподключение...",
"errors.switched_http": "Соединение потеряно — переход на режим HTTP",
"errors.connection_lost": "Соединение потеряно, переподключение...",
"errors.switched_http": "Соединение потеряно — переход на режим HTTP",
"errors.reconnected": "Переподключено",
"toasts.approval_waiting": "Агент ожидает одобрения. Откройте раздел Одобрения.",
"toasts.agent_created": "Агент создан",
"toasts.agent_stopped": "Агент остановлен",
"toasts.tool_used": "Инструмент использован",
"toasts.tool_completed": "Инструмент завершён",
"toasts.message_in": "Входящее сообщение",
"toasts.response_sent": "Ответ отправлен",
"toasts.session_reset": "Сессия сброшена",
"toasts.compacted": "Сжато",
"toasts.model_changed": "Модель изменена",
"toasts.login_attempt": "Попытка входа",
"toasts.login_ok": "Вход успешен",
"toasts.login_failed": "Ошибка входа",
"toasts.denied": "Отклонено",
"toasts.rate_limited": "Лимит запросов",
"toasts.workflow_run": "Запуск процесса",
"toasts.trigger_fired": "Триггер сработал",
"toasts.skill_installed": "Навык установлен",
"toasts.mcp_connected": "MCP подключён",
"toasts.session_deleted": "Сессия удалена",
"overview.welcome": "Добро пожаловать в OpenFang",
"overview.getting_started": "Начало работы",
"overview.setup_wizard": "Мастер настройки",
"overview.steps_completed": "из 5 шагов выполнено",
"overview.agents_running": "Агентов запущено",
"overview.tokens_used": "Использовано токенов",
"overview.total_cost": "Общая стоимость",
"overview.uptime": "Время работы",
"overview.channels": "Каналы",
"overview.skills": "Навыки",
"overview.mcp_servers": "MCP-серверы",
"overview.tool_calls": "Вызовов инструментов",
"overview.providers": "Провайдеры",
"overview.recent_activity": "Недавняя активность",
"overview.no_recent_activity": "Нет недавней активности",
"overview.chat_with_agent": "Написать агенту",
"overview.system_health": "Состояние системы",
"overview.healthy": "Исправно",
"overview.unreachable": "Недоступно",
"overview.security_systems": "Системы безопасности",
"overview.llm_providers": "LLM-провайдеры",
"overview.defense_active": "9 уровней защиты активно",
"overview.quick_actions": "Быстрые действия",
"setup.configure_provider": "Настройте LLM-провайдера",
"setup.create_first_agent": "Создайте первого агента",
"setup.send_first_message": "Отправьте первое сообщение",
"setup.connect_channel": "Подключите канал связи",
"setup.browse_install_skill": "Найдите или установите навык",
"tooltips.cooling_down": "остывает (лимит запросов)",
"tooltips.circuit_open": "автомат сработал",
"tooltips.ready": "готово",
"tooltips.not_configured": "не настроено",
"chat.placeholder": "Напишите OpenFang... (/ для команд)",
"chat.ready": "Готово",
"chat.generating": "Генерация...",
"chat.queued": "в очереди",
"chat.sessions": "Сессии",
"chat.new_session": "+ Новая",
"chat.no_sessions": "Нет сессий",
"chat.search_messages": "Поиск сообщений...",
"chat.select_agent": "Выберите агента для начала общения",
"chat.recording": "Запись... отпустите для отправки",
"chat.drop_files": "Перетащите файлы сюда",
"chat.attach_file": "Прикрепить файл",
"chat.stop_generating": "Остановить генерацию",
"chat.switch_model": "Сменить модель",
"chat.search_models": "Поиск моделей...",
"chat.no_models_found": "Модели не найдены",
"chat.available_models": "Доступные модели — выберите или продолжите ввод",
"chat.switching": "Переключение...",
"chat.model_switched": "Модель изменена на",
"chat.model_switch_failed": "Не удалось сменить модель",
"chat.using_http_mode": "Используется HTTP режим (без потоковой передачи)",
"chat.session_name_prompt": "Название сессии (необязательно):",
"chat.session_created": "Сессия создана",
"chat.session_create_failed": "Не удалось создать сессию",
"chat.stop_agent_title": "Остановить агента",
"chat.stop_agent_confirm": "Остановить агента",
"chat.agent_stopped": "Агент остановлен",
"chat.stop_agent_failed": "Не удалось остановить агента",
"chat.welcome_message": "**Добро пожаловать в OpenFang Чат!**\n\n- Введите `/` для просмотра команд\n- `/help` покажет все команды\n- `/think on` включает расширенные размышления\n- `/context` покажет использование контекста\n- `/verbose off` скроет детали инструментов\n- `Ctrl+Shift+F` переключает режим фокуса\n- Перетащите файлы для прикрепления\n- `Ctrl+/` открывает палитру команд",
"chat.slash.help": "Показать доступные команды",
"chat.slash.agents": "Перейти на страницу агентов",
"chat.slash.new": "Новая сессия (очистить историю)",
"chat.slash.compact": "Сжать контекст сессии",
"chat.slash.model": "Показать или сменить модель (/model [имя])",
"chat.slash.stop": "Отменить текущий запуск агента",
"chat.slash.usage": "Показать использование токенов",
"chat.slash.think": "Переключить размышления (/think [on|off|stream])",
"chat.slash.context": "Показать использование контекста",
"chat.slash.verbose": "Переключить детали инструментов (/verbose [off|on|full])",
"chat.slash.queue": "Проверить очередь обработки",
"chat.slash.status": "Показать статус системы",
"chat.slash.clear": "Очистить чат",
"chat.slash.exit": "Отключиться от агента",
"chat.slash.budget": "Показать лимиты и расходы",
"chat.slash.peers": "Показать статус сети OFP",
"chat.slash.a2a": "Список A2A агентов",
"commands.help": "Показать доступные команды",
"commands.agents": "Перейти на страницу агентов",
"commands.new": "Новая сессия",
"commands.switch": "Сменить агента",
"commands.clear": "Очистить диалог",
"commands.model": "Сменить модель",
"commands.think": "Переключить режим размышлений",
"commands.focus": "Переключить режим фокуса",
"commands.theme": "Сменить тему",
"tips.commands": "Введите / для команд",
"tips.think": "/think on для размышлений",
"tips.focus": "Ctrl+Shift+F для режима фокуса",
"agents.info": "Информация",
"agents.files": "Файлы",
"agents.config": "Настройки",
"agents.chat": "Чат",
"agents.clone": "Клонировать",
"agents.clear_history": "Очистить историю",
"agents.change": "Изменить",
"agents.none_fallback": "Нет — добавить цепочку резервов",
"agents.add": "+ Добавить",
"agents.loading_files": "Загрузка файлов...",
"agents.no_workspace_files": "Файлы рабочей области не найдены",
"agents.save_config": "Сохранить настройки",
"agents.tool_filters": "Фильтры инструментов",
"agents.allowlist": "Белый список",
"agents.blocklist": "Чёрный список",
"agents.agent_name": "Имя агента",
"agents.emoji": "Эмодзи",
"agents.color": "Цвет",
"agents.archetype": "Архетип",
"agents.provider": "Провайдер",
"agents.model": "Модель",
"agents.system_prompt": "Системный промпт",
"agents.soul_persona": "Душa / Персона",
"agents.tool_profile": "Профиль инструментов",
"agents.minimal_profile": "Минимальный — только чтение файлов",
"agents.coding_profile": "Кодинг — файлы + оболочка + веб-запросы",
"agents.fullstack_profile": "Full-Stack — файлы + оболочка + веб + поиск",
"agents.research_profile": "Исследование — веб + поиск + анализ",
"agents.admin_profile": "Админ — полный доступ к системе (опасно)",
"agents.agent_created": "Агент создан",
"agents.agent_stopped": "Агент остановлен",
"agents.agent_deleted": "Агент удалён",
"presets.professional": "Деловой",
"presets.professional_desc": "Точный, бизнес-ориентированный ассистент, сосредоточенный на эффективности и ясности. Приоритет — практические выводы и структурированная коммуникация.",
"presets.professional_soul": "Общайтесь чётко и профессионально. Будьте прямым и структурированным. Используйте формальный язык и выводы на основе данных. ставьте точность выше личности.",
"presets.friendly": "Дружелюбный",
"presets.friendly_desc": "Тёплый и открытый ассистент, который выстраивает rapport и использует разговорный язык. Отлично подходит для мозгового штурма и исследования.",
"presets.friendly_soul": "Будьте тёплым, доступным и разговорчивым. Используйте неформальный язык и проявляйте искренний интерес к пользователю. Добавляйте личность к вашим ответам, оставаясь полезным.",
"presets.technical": "Технический",
"presets.technical_desc": "Эксперт-помощник по разработке, оптимизированный для кода, архитектуры и технических задач. Точная терминология, глубокие погружения, бенчмарки.",
"presets.technical_soul": "Сосредоточьтесь на технической точности и глубине. Используйте точную терминологию. Покажите вашу работу и рассуждения. Предпочитайте примеры кода и структурированные объяснения.",
"presets.creative": "Креативный",
"presets.creative_desc": "Творческий партнёр для создания контента, дизайн-мышления и нестандартных решений. Приветствует неоднозначность и исследует возможности.",
"presets.creative_soul": "Будьте изобретательным и выразительным. Используйте яркий язык, аналогии и неожиданные связи. Поощряйте творческое мышление и исследуйте различные перспективы.",
"presets.concise": "Краткий",
"presets.concise_desc": "Минималистичный и прямой ассистент, который ценит ваше время. Убирает лишнее и даёт сфокусированные, практичные ответы.",
"presets.concise_soul": "Будьте предельно кратким и точным. Без воды и формальностей. Отвечайте наименьшим количеством слов, оставаясь точным и полным.",
"presets.mentor": "Наставник",
"presets.mentor_desc": "Терпеливый педагог, который подробно объясняет концепции, даёт контекст и направляет обучение. Метод Сократа при необходимости.",
"presets.mentor_soul": "Будьте терпеливым и ободряющим как хороший учитель. Разбивайте сложные темы по шагам. Задавайте направляющие вопросы. Празднуйте прогресс и укрепляйте уверенность.",
"agents.profile.minimal": "Минимальный",
"agents.profile.minimal_desc": "Только чтение файлов",
"agents.profile.coding": "Кодинг",
"agents.profile.coding_desc": "Файлы + оболочка + веб-запросы",
"agents.profile.research": "Исследование",
"agents.profile.research_desc": "Веб-поиск + чтение/запись файлов",
"agents.profile.messaging": "Коммуникации",
"agents.profile.messaging_desc": "Агенты + доступ к памяти",
"agents.profile.automation": "Автоматизация",
"agents.profile.automation_desc": "Все инструменты кроме пользовательских",
"agents.profile.balanced": "Сбалансированный",
"agents.profile.balanced_desc": "Набор инструментов общего назначения",
"agents.profile.precise": "Точный",
"agents.profile.precise_desc": "Фокусированный набор инструментов для точности",
"agents.profile.creative": "Креативный",
"agents.profile.creative_desc": "Полный набор инструментов с творческим уклоном",
"agents.profile.full": "Полный",
"agents.profile.full_desc": "Все 35+ инструментов",
"wizard.general_assistant": "Универсальный ассистент",
"wizard.general_assistant_desc": "Вы универсальный AI-ассистент, который помогает пользователям с широким кругом задач. Вы знающий, полезный и способны адаптироваться к потребностям пользователя.",
"wizard.code_helper": "Помощник по коду",
"wizard.code_helper_desc": "Вы опытный программный ассистент, специализирующийся на разработке ПО. Вы помогаете писать, отлаживать и рефакторить код на разных языках.",
"wizard.researcher": "Исследовательский ассистент",
"wizard.researcher_desc": "Вы исследовательский ассистент, который помогает находить, анализировать и синтезировать информацию из различных источников.",
"wizard.writer": "Писатель",
"wizard.writer_desc": "Вы квалифицированный писатель, который помогает с созданием контента, редактированием и творческими проектами.",
"wizard.data_analyst": "Аналитик данных",
"wizard.data_analyst_desc": "Вы аналитик данных, который помогает исследовать, анализировать и визуализировать данные для извлечения инсайтов.",
"wizard.devops": "DevOps-инженер",
"wizard.devops_desc": "Вы DevOps-инженер, который помогает с инфраструктурой, деплоем, CI/CD и системным администрированием.",
"wizard.support": "Поддержка клиентов",
"wizard.support_desc": "Вы представитель поддержки клиентов, который помогает решать вопросы с терпением и профессионализмом.",
"wizard.tutor": "Репетитор",
"wizard.tutor_desc": "Вы образовательный репетитор, который ясно объясняет концепции и адаптирует обучение к уровню ученика.",
"wizard.api_designer": "API-дизайнер",
"wizard.api_designer_desc": "Вы дизайнер API, который помогает создавать хорошо структурированные, интуитивные API по лучшим практикам.",
"wizard.meeting_notes": "Заметки к встрече",
"wizard.meeting_notes_desc": "Вы специалист по заметкам встреч, который суммирует обсуждения, извлекает задачи и отслеживает решения.",
"wizard.step_welcome": "Приветствие",
"wizard.step_provider": "Провайдер",
"wizard.step_agent": "Агент",
"wizard.step_try_it": "Попробовать",
"wizard.step_channel": "Канал",
"wizard.step_done": "Готово",
"wizard.cat_general": "Общее",
"wizard.cat_development": "Разработка",
"wizard.cat_research": "Исследования",
"wizard.cat_writing": "Написание",
"wizard.cat_business": "Бизнес",
"wizard.channel_telegram": "Telegram",
"wizard.channel_telegram_desc": "Подключите агента к Telegram-боту для обмена сообщениями.",
"wizard.channel_telegram_token": "Токен бота",
"wizard.channel_telegram_help": "Создайте бота через @BotFather в Telegram, чтобы получить токен.",
"wizard.channel_discord": "Discord",
"wizard.channel_discord_desc": "Подключите агента к Discord-серверу через токен бота.",
"wizard.channel_discord_token": "Токен бота",
"wizard.channel_discord_help": "Создайте приложение Discord на discord.com/developers и добавьте бота.",
"wizard.channel_slack": "Slack",
"wizard.channel_slack_desc": "Подключите агента к рабочему пространству Slack.",
"wizard.channel_slack_token": "Токен бота",
"wizard.channel_slack_help": "Создайте приложение Slack на api.slack.com/apps и установите его в рабочее пространство.",
"wizard.profile_minimal": "Минимальный",
"wizard.profile_minimal_desc": "Только чтение файлов",
"wizard.profile_coding": "Кодинг",
"wizard.profile_coding_desc": "Файлы + оболочка + веб-запросы",
"wizard.profile_research": "Исследование",
"wizard.profile_research_desc": "Веб-поиск + чтение/запись файлов",
"wizard.profile_balanced": "Сбалансированный",
"wizard.profile_balanced_desc": "Набор инструментов общего назначения",
"wizard.profile_precise": "Точный",
"wizard.profile_precise_desc": "Фокусированный набор инструментов для точности",
"wizard.profile_creative": "Креативный",
"wizard.profile_creative_desc": "Полный набор инструментов с творческим уклоном",
"wizard.profile_full": "Полный",
"wizard.profile_full_desc": "Все 35+ инструментов",
"wizard.enter_api_key": "Пожалуйста, введите API-ключ",
"wizard.api_key_saved": "API-ключ сохранён для",
"wizard.failed_save_key": "Не удалось сохранить ключ:",
"wizard.connected": "подключён",
"wizard.connection_failed": "Ошибка подключения",
"wizard.test_failed": "Тест не прошёл:",
"wizard.enter_agent_name": "Пожалуйста, введите имя агента",
"wizard.agent_created": "Агент создан",
"wizard.failed_create_agent": "Не удалось создать агента:",
"wizard.enter_token": "Пожалуйста, введите",
"wizard.channel_configured": "настроен и активирован.",
"wizard.failed_configure": "Ошибка:",
"wizard.suggestions.general.1": "Чем вы можете помочь?",
"wizard.suggestions.general.2": "Расскажите интересный факт",
"wizard.suggestions.general.3": "Резюмируйте последние новости AI",
"wizard.suggestions.development.1": "Напишите Python hello world",
"wizard.suggestions.development.2": "Объясните async/await",
"wizard.suggestions.development.3": "Проверьте этот фрагмент кода",
"wizard.suggestions.research.1": "Объясните квантовые вычисления просто",
"wizard.suggestions.research.2": "Сравните React и Vue",
"wizard.suggestions.research.3": "Какие последние тренды в AI?",
"wizard.suggestions.writing.1": "Помогите написать профессиональное письмо",
"wizard.suggestions.writing.2": "Улучшите этот абзац",
"wizard.suggestions.writing.3": "Напишите введение в блог об AI",
"wizard.suggestions.business.1": "Составьте повестку встречи",
"wizard.suggestions.business.2": "Как обработать жалобу?",
"wizard.suggestions.business.3": "Создайте статус-отчёт проекта",
"approvals.title": "Одобрения выполнения",
"approvals.pending": "ожидает",
"approvals.all": "Все",
"approvals.pending_tab": "Ожидающие",
"approvals.approved": "Одобрено",
"approvals.rejected": "Отклонено",
"approvals.expired": "Истекло",
"approvals.no_approvals": "Нет одобрений",
"approvals.approve": "Одобрить",
"approvals.reject": "Отклонить",
"workflows.title": "Рабочие процессы",
"workflows.visual_builder": "Визуальный конструктор",
"workflows.what_are": "Что такое рабочие процессы?",
"workflows.no_workflows": "Нет рабочих процессов",
"workflows.sequential": "Последовательный",
"workflows.fan_out": "Распределение",
"workflows.conditional": "Условный",
"workflows.loop": "Цикл",
"workflows.add_step": "+ Добавить шаг",
"workflows.execute": "Выполнить",
"workflows.result": "Результат",
"workflows.node_palette": "Палитра узлов",
"workflows.drag_nodes": "Перетащите узлы на холст",
"workflows.steps_connections": "шагов, связей",
"workflows.agent": "Агент",
"workflows.prompt_template": "Шаблон промпта",
"workflows.expression": "Выражение",
"workflows.top_port_true": "Верхний порт = истина, нижний = ложь",
"workflows.max_iterations": "Макс. итераций",
"workflows.until_stop": "До (условие остановки)",
"workflows.fan_out_count": "Количество ветвей",
"workflows.wait_all": "Ждать все",
"workflows.first_finish": "Первый завершился",
"workflows.majority_vote": "Большинство",
"workflows.connection_selected": "Связь выбрана",
"workflows.delete_connection": "Удалить связь",
"scheduler.title": "Планировщик",
"scheduler.scheduled_jobs": "Запланированные задания",
"scheduler.event_triggers": "Триггеры событий",
"scheduler.run_history": "История запусков",
"scheduler.new_job": "+ Новое задание",
"scheduler.job_name": "Название задания",
"scheduler.cron_expression": "Cron-выражение",
"scheduler.quick_presets": "Быстрые шаблоны",
"scheduler.target_agent": "Целевой агент",
"scheduler.any_agent": "Любой доступный агент",
"scheduler.message_send": "Сообщение для отправки",
"scheduler.enabled": "Включено (запустится сразу)",
"scheduler.disabled": "Отключено (создать приостановленным)",
"scheduler.active": "Активно",
"scheduler.paused": "Приостановлено",
"scheduler.cron_job": "Cron-задание",
"scheduler.trigger": "Триггер",
"scheduler.no_jobs": "Нет запланированных заданий",
"scheduler.no_triggers": "Нет триггеров событий",
"scheduler.no_history": "Нет истории запусков",
"channels.title": "Каналы",
"channels.configured": "настроено",
"channels.search": "Поиск каналов...",
"channels.setup": "Настроить",
"channels.edit": "Изменить",
"channels.configure": "Настройка",
"channels.verify": "Проверить",
"channels.ready": "Готово",
"channels.is_ready": "готово!",
"channels.get_credentials": "Как получить учётные данные",
"channels.show_advanced": "Показать расширенные",
"channels.hide_advanced": "Скрыть расширенные",
"channels.connecting": "Подключение к шлюзу WhatsApp Web...",
"channels.linked_success": "WhatsApp успешно связан!",
"channels.business_api": "Business API",
"skills.title": "Навыки и экосистема",
"skills.installed": "Установленные",
"skills.clawhub": "ClawHub",
"skills.mcp_servers": "MCP-серверы",
"skills.quick_start": "Быстрый старт",
"skills.no_installed": "Нет установленных навыков",
"skills.browse_clawhub": "Обзор ClawHub",
"skills.search_clawhub": "Поиск навыков ClawHub...",
"skills.trending": "Популярные",
"skills.most_downloaded": "Самые скачиваемые",
"skills.most_starred": "Самые оценённые",
"skills.recently_updated": "Недавно обновлённые",
"skills.categories": "КАТЕГОРИИ",
"skills.already_installed": "Уже установлено",
"skills.no_skills_found": "Навыки не найдены",
"skills.security_warnings": "Предупреждения безопасности",
"skills.security_scan": "Навыки проверяются на безопасность перед установкой",
"skills.create": "Создать навык",
"skills.created": "Создан",
"skills.cat_coding": "Кодинг и IDE",
"skills.cat_git": "Git и GitHub",
"skills.cat_frontend": "Веб и фронтенд",
"skills.cat_devops": "DevOps и облака",
"skills.cat_database": "Базы данных",
"skills.cat_security": "Безопасность",
"skills.cat_ai": "AI и ML",
"skills.cat_data": "Данные и аналитика",
"skills.cat_mobile": "Мобильная разработка",
"skills.cat_desktop": "Десктопные приложения",
"skills.cat_api": "API и интеграции",
"skills.cat_testing": "Тестирование",
"skills.cat_docs": "Документация",
"skills.cat_productivity": "Продуктивность",
"skills.cat_other": "Другое",
"skills.cat_browser": "Браузер и автоматизация",
"skills.cat_search": "Поиск и исследования",
"skills.cat_communication": "Коммуникации",
"skills.cat_media": "Медиа и стриминг",
"skills.cat_notes": "Заметки и PKM",
"skills.cat_cli": "CLI утилиты",
"skills.cat_marketing": "Маркетинг и продажи",
"skills.cat_finance": "Финансы",
"skills.cat_smarthome": "Умный дом и IoT",
"skills.uninstall_skill": "Удалить навык",
"skills.uninstall_confirm": "Удалить навык",
"skills.source_clawhub": "ClawHub",
"skills.source_openclaw": "OpenClaw",
"skills.source_builtin": "Встроенный",
"skills.source_local": "Локальный",
"hands.title": "Руки — Наборы автономных возможностей",
"hands.available": "Доступные",
"hands.active": "Активные",
"hands.ready": "Готово",
"hands.setup_needed": "Требуется настройка",
"hands.requirements": "ТРЕБОВАНИЯ",
"hands.details": "Подробности",
"hands.no_hands": "Нет доступных модулей",
"sessions.title": "Сессии",
"sessions.memory": "Память",
"sessions.delete_session": "Удалить сессию",
"sessions.delete_confirm": "Это навсегда удалит сессию и все её сообщения.",
"sessions.delete_key": "Удалить ключ",
"sessions.delete_key_confirm": "Удалить ключ",
"logs.title": "Логи",
"logs.live": "Онлайн",
"logs.audit_trail": "Аудит",
"settings.title": "Настройки",
"settings.providers": "Провайдеры",
"settings.models": "Модели",
"settings.config": "Конфигурация",
"settings.tools": "Инструменты",
"settings.migration": "Миграция",
"settings.security": "Безопасность",
"settings.network": "Сеть",
"settings.migration": "Миграция",
"settings.language": "Язык",
"settings.sec_path_traversal": "Защита от обхода пути",
"settings.sec_path_traversal_desc": "Блокирует попытки доступа к файлам за пределами рабочей директории через .. или абсолютные пути.",
"settings.sec_ssrf": "Защита от SSRF",
"settings.sec_ssrf_desc": "Предотвращает запросы агентов к внутренним IP-диапазонам (localhost, облачный метаданные, частные сети).",
"settings.sec_capability": "Управление доступом по возможностям",
"settings.sec_capability_desc": "Агенты могут получать доступ только к явно предоставленным возможностям. Нет неявного доступа к инструментам или данным.",
"settings.sec_taint": "Отслеживание заражения",
"settings.sec_taint_desc": "Отслеживает ненадёжные данные (ввод пользователя, содержимое файлов) через рассуждения агента для предотвращения инъекции промпта.",
"settings.sec_sandbox": "WASM-песочница",
"settings.sec_sandbox_desc": "Выполняет ненадёжный код в изолированных WebAssembly-песочницах с ограничениями памяти и системных вызовов.",
"settings.sec_audit": "Меркл-проверка",
"settings.sec_audit_desc": "Ведёт верифицируемый журнал аудита всех действий агентов с использованием криптографии деревьев Меркла.",
"settings.sec_workspace": "Изоляция рабочих областей",
"settings.sec_workspace_desc": "Каждый агент имеет изолированную рабочую директорию. Нет межагентного доступа к файлам без явного разрешения.",
"settings.sec_rate_limit": "Ограничение частоты",
"settings.sec_rate_limit_desc": "Устанавливает лимиты на запросы для каждого агента и глобально для предотвращения истощения ресурсов и перерасхода.",
"settings.sec_approval": "Одобрения выполнения",
"settings.sec_approval_desc": "Требует одобрения человека для рискованных действий (команды оболочки, запись файлов, внешние запросы).",
"settings.sec_enabled": "Включено",
"settings.sec_disabled": "Отключено",
"settings.sec_inherited": "Унаследовано",
"settings.sec_global": "Глобально"
}
File diff suppressed because it is too large Load Diff
@@ -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">
+34 -12
View File
@@ -161,6 +161,17 @@ var OpenFangAPI = (function() {
return fetch(BASE + path, opts).then(function(r) {
if (_connectionState !== 'connected') setConnectionState('connected');
if (!r.ok) {
// On 401, auto-show auth prompt so the user can re-enter their key
if (r.status === 401 && typeof Alpine !== 'undefined') {
try {
var store = Alpine.store('app');
if (store && !store.showAuthPrompt) {
_authToken = '';
localStorage.removeItem('openfang-api-key');
store.showAuthPrompt = true;
}
} catch(e2) { /* ignore Alpine errors */ }
}
return r.text().then(function(text) {
var msg = '';
try {
@@ -213,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');
@@ -226,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) {
@@ -254,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();
};
@@ -286,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();
+106 -7
View File
@@ -18,15 +18,49 @@ if (typeof marked !== 'undefined') {
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
return div.innerHTML.replace(/\n/g, '<br>');
}
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 */
}
});
}
+277 -137
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: '',
@@ -36,14 +53,23 @@ function agentsPage() {
'\u{2764}\uFE0F', '\u{1F31F}', '\u{1F527}', '\u{1F4DD}', '\u{1F4A1}', '\u{1F3A8}'
],
archetypeOptions: ['Assistant', 'Researcher', 'Coder', 'Writer', 'DevOps', 'Support', 'Analyst', 'Custom'],
personalityPresets: [
{ id: 'professional', label: 'Professional', soul: 'Communicate in a clear, professional tone. Be direct and structured. Use formal language and data-driven reasoning. Prioritize accuracy over personality.' },
{ id: 'friendly', label: 'Friendly', soul: 'Be warm, approachable, and conversational. Use casual language and show genuine interest in the user. Add personality to your responses while staying helpful.' },
{ id: 'technical', label: 'Technical', soul: 'Focus on technical accuracy and depth. Use precise terminology. Show your work and reasoning. Prefer code examples and structured explanations.' },
{ id: 'creative', label: 'Creative', soul: 'Be imaginative and expressive. Use vivid language, analogies, and unexpected connections. Encourage creative thinking and explore multiple perspectives.' },
{ id: 'concise', label: 'Concise', soul: 'Be extremely brief and to the point. No filler, no pleasantries. Answer in the fewest words possible while remaining accurate and complete.' },
{ id: 'mentor', label: 'Mentor', soul: 'Be patient and encouraging like a great teacher. Break down complex topics step by step. Ask guiding questions. Celebrate progress and build confidence.' }
],
_personalityPresetsLoaded: false,
personalityPresets: [], // Loaded dynamically with i18n
// Load personality presets with i18n
loadPersonalityPresets: function() {
if (this._personalityPresetsLoaded) return;
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
this.personalityPresets = [
{ id: 'professional', label: t('presets.professional'), soul: t('presets.professional_soul') },
{ id: 'friendly', label: t('presets.friendly'), soul: t('presets.friendly_soul') },
{ id: 'technical', label: t('presets.technical'), soul: t('presets.technical_soul') },
{ id: 'creative', label: t('presets.creative'), soul: t('presets.creative_soul') },
{ id: 'concise', label: t('presets.concise'), soul: t('presets.concise_soul') },
{ id: 'mentor', label: t('presets.mentor'), soul: t('presets.mentor_soul') }
];
this._personalityPresetsLoaded = true;
},
// -- Detail modal tabs --
detailTab: 'info',
@@ -62,7 +88,12 @@ function agentsPage() {
// -- Model switch --
editingModel: false,
newModelValue: '',
editingProvider: false,
newProviderValue: '',
modelSaving: false,
// -- Fallback chain --
editingFallback: false,
newFallbackValue: '',
// -- Templates state --
tplTemplates: [],
@@ -72,112 +103,36 @@ 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();
// Load personality presets with i18n
this.loadPersonalityPresets();
},
// ── Profile Descriptions ──
profileDescriptions: {
minimal: { label: 'Minimal', desc: 'Read-only file access' },
coding: { label: 'Coding', desc: 'Files + shell + web fetch' },
research: { label: 'Research', desc: 'Web search + file read/write' },
messaging: { label: 'Messaging', desc: 'Agents + memory access' },
automation: { label: 'Automation', desc: 'All tools except custom' },
balanced: { label: 'Balanced', desc: 'General-purpose tool set' },
precise: { label: 'Precise', desc: 'Focused tool set for accuracy' },
creative: { label: 'Creative', desc: 'Full tools with creative emphasis' },
full: { label: 'Full', desc: 'All 35+ tools' }
// ── Profile Descriptions (loaded dynamically with i18n) ──
_profileDescriptionsLoaded: false,
profileDescriptions: {},
loadProfileDescriptions: function() {
if (this._profileDescriptionsLoaded) return;
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
this.profileDescriptions = {
minimal: { label: t('agents.profile.minimal'), desc: t('agents.profile.minimal_desc') },
coding: { label: t('agents.profile.coding'), desc: t('agents.profile.coding_desc') },
research: { label: t('agents.profile.research'), desc: t('agents.profile.research_desc') },
messaging: { label: t('agents.profile.messaging'), desc: t('agents.profile.messaging_desc') },
automation: { label: t('agents.profile.automation'), desc: t('agents.profile.automation_desc') },
balanced: { label: t('agents.profile.balanced'), desc: t('agents.profile.balanced_desc') },
precise: { label: t('agents.profile.precise'), desc: t('agents.profile.precise_desc') },
creative: { label: t('agents.profile.creative'), desc: t('agents.profile.creative_desc') },
full: { label: t('agents.profile.full'), desc: t('agents.profile.full_desc') }
};
this._profileDescriptionsLoaded = true;
},
profileInfo: function(name) {
this.loadProfileDescriptions();
return this.profileDescriptions[name] || { label: name, desc: '' };
},
@@ -260,6 +215,9 @@ function agentsPage() {
this.loadError = '';
try {
await Alpine.store('app').refreshAgents();
await this.loadTemplates();
this.loadPersonalityPresets();
this.loadProfileDescriptions();
} catch(e) {
this.loadError = e.message || 'Could not load agents. Is the daemon running?';
}
@@ -297,10 +255,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: 'default',
model: 'default',
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 = "default"\nmodel = "default"\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: 'default',
model: 'default',
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 = "default"\nmodel = "default"\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: 'default',
model: 'default',
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 = "default"\nmodel = "default"\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: 'default',
model: 'default',
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 = "default"\nmodel = "default"\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: 'default',
model: 'default',
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 = "default"\nmodel = "default"\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: 'default',
model: 'default',
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 = "default"\nmodel = "default"\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;
@@ -316,20 +337,37 @@ function agentsPage() {
OpenFangAPI.wsDisconnect();
},
showDetail(agent) {
this.detailAgent = agent;
buildConfigForm(agent) {
var identity = (agent && agent.identity) || {};
return {
name: (agent && agent.name) || '',
system_prompt: (agent && agent.system_prompt) || '',
emoji: identity.emoji || '',
color: identity.color || '#FF5C00',
archetype: identity.archetype || '',
vibe: identity.vibe || ''
};
},
async showDetail(agent) {
this.detailTab = 'info';
this.agentFiles = [];
this.editingFile = null;
this.fileContent = '';
this.configForm = {
name: agent.name || '',
system_prompt: agent.system_prompt || '',
emoji: (agent.identity && agent.identity.emoji) || '',
color: (agent.identity && agent.identity.color) || '#FF5C00',
archetype: (agent.identity && agent.identity.archetype) || '',
vibe: (agent.identity && agent.identity.vibe) || ''
};
this.editingFallback = false;
this.newFallbackValue = '';
// Load the full detail payload before opening the modal so editable
// fields such as system_prompt and identity metadata are hydrated.
var detail = agent;
try {
var full = await OpenFangAPI.get('/api/agents/' + agent.id);
detail = Object.assign({}, agent, full, {
identity: Object.assign({}, (agent && agent.identity) || {}, (full && full.identity) || {})
});
} catch(e) { /* fall back to list payload */ }
this.detailAgent = detail;
this.detailAgent._fallbacks = detail.fallback_models || [];
this.configForm = this.buildConfigForm(detail);
this.showDetailModal = true;
},
@@ -347,6 +385,29 @@ function agentsPage() {
});
},
// Issue #1163: uninstall an agent (kill + remove ~/.openfang/agents/<name>/).
uninstallAgent(agent) {
var self = this;
OpenFangToast.confirm(
'Uninstall Agent',
'Uninstall agent "' + agent.name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
async function() {
try {
var res = await OpenFangAPI.del('/api/agents/' + agent.id + '/uninstall');
var msg = 'Agent "' + agent.name + '" uninstalled';
if (res && res.dir_removed === false) {
msg += ' (no on-disk files found)';
}
OpenFangToast.success(msg);
self.showDetailModal = false;
await Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
}
}
);
},
killAllAgents() {
var list = this.filteredAgents;
if (!list.length) return;
@@ -367,7 +428,7 @@ function agentsPage() {
},
// ── Multi-step wizard navigation ──
openSpawnWizard() {
async openSpawnWizard() {
this.showSpawnModal = true;
this.spawnStep = 1;
this.spawnMode = 'wizard';
@@ -375,8 +436,26 @@ function agentsPage() {
this.selectedPreset = '';
this.soulContent = '';
this.spawnForm.name = '';
this.spawnForm.provider = 'default';
this.spawnForm.model = 'default';
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() {
@@ -400,7 +479,7 @@ function agentsPage() {
var f = this.spawnForm;
var si = this.spawnIdentity;
var lines = [
'name = "' + f.name + '"',
'name = "' + tomlBasicEscape(f.name) + '"',
'module = "builtin:chat"'
];
if (f.profile && f.profile !== 'custom') {
@@ -409,7 +488,7 @@ function agentsPage() {
lines.push('', '[model]');
lines.push('provider = "' + f.provider + '"');
lines.push('model = "' + f.model + '"');
lines.push('system_prompt = "' + f.systemPrompt.replace(/"/g, '\\"') + '"');
lines.push('system_prompt = """\n' + tomlMultilineEscape(f.systemPrompt) + '\n"""');
if (f.profile === 'custom') {
lines.push('', '[capabilities]');
if (f.caps.memory_read) lines.push('memory_read = ["*"]');
@@ -552,15 +631,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) {
@@ -586,8 +670,9 @@ function agentsPage() {
if (!this.detailAgent || !this.newModelValue.trim()) return;
this.modelSaving = true;
try {
await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
OpenFangToast.success('Model changed (memory reset)');
var resp = await OpenFangAPI.put('/api/agents/' + this.detailAgent.id + '/model', { model: this.newModelValue.trim() });
var providerInfo = (resp && resp.provider) ? ' (provider: ' + resp.provider + ')' : '';
OpenFangToast.success('Model changed' + providerInfo + ' (memory reset)');
this.editingModel = false;
await Alpine.store('app').refreshAgents();
// Refresh detailAgent
@@ -601,6 +686,61 @@ 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;
var parts = this.newFallbackValue.trim().split('/');
var provider = parts.length > 1 ? parts[0] : this.detailAgent.model_provider;
var model = parts.length > 1 ? parts.slice(1).join('/') : parts[0];
if (!this.detailAgent._fallbacks) this.detailAgent._fallbacks = [];
this.detailAgent._fallbacks.push({ provider: provider, model: model });
try {
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
fallback_models: this.detailAgent._fallbacks
});
OpenFangToast.success('Fallback added: ' + provider + '/' + model);
} catch(e) {
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
this.detailAgent._fallbacks.pop();
}
this.editingFallback = false;
this.newFallbackValue = '';
},
async removeFallback(idx) {
if (!this.detailAgent || !this.detailAgent._fallbacks) return;
var removed = this.detailAgent._fallbacks.splice(idx, 1);
try {
await OpenFangAPI.patch('/api/agents/' + this.detailAgent.id + '/config', {
fallback_models: this.detailAgent._fallbacks
});
OpenFangToast.success('Fallback removed');
} catch(e) {
OpenFangToast.error('Failed to save fallbacks: ' + e.message);
this.detailAgent._fallbacks.splice(idx, 0, removed[0]);
}
},
// ── Tool filters ──
async loadToolFilters() {
if (!this.detailAgent) return;
@@ -651,12 +791,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;
+333 -90
View File
@@ -37,38 +37,36 @@ function chatPage() {
// Model switcher dropdown
showModelSwitcher: false,
modelSwitcherFilter: '',
modelSwitcherProviderFilter: '',
modelSwitcherIdx: 0,
modelSwitching: false,
_modelCache: null,
_modelCacheTime: 0,
slashCommands: [
{ cmd: '/help', desc: 'Show available commands' },
{ cmd: '/agents', desc: 'Switch to Agents page' },
{ cmd: '/new', desc: 'Reset session (clear history)' },
{ cmd: '/compact', desc: 'Trigger LLM session compaction' },
{ cmd: '/model', desc: 'Show or switch model (/model [name])' },
{ cmd: '/stop', desc: 'Cancel current agent run' },
{ cmd: '/usage', desc: 'Show session token usage & cost' },
{ cmd: '/think', desc: 'Toggle extended thinking (/think [on|off|stream])' },
{ cmd: '/context', desc: 'Show context window usage & pressure' },
{ cmd: '/verbose', desc: 'Cycle tool detail level (/verbose [off|on|full])' },
{ cmd: '/queue', desc: 'Check if agent is processing' },
{ cmd: '/status', desc: 'Show system status' },
{ cmd: '/clear', desc: 'Clear chat display' },
{ cmd: '/exit', desc: 'Disconnect from agent' },
{ cmd: '/budget', desc: 'Show spending limits and current costs' },
{ cmd: '/peers', desc: 'Show OFP peer network status' },
{ cmd: '/a2a', desc: 'List discovered external A2A agents' }
],
slashCommands: [], // Loaded dynamically with i18n in init()
_slashCommandsLoaded: false,
tokenCount: 0,
// ── Tip Bar ──
tipIndex: 0,
tips: ['Type / for commands', '/think on for reasoning', 'Ctrl+Shift+F for focus mode', 'Drag files to attach', '/model to switch models', '/context to check usage', '/verbose off to hide tool details'],
tips: [],
_tipsInitialized: false,
tipTimer: null,
get currentTip() {
if (localStorage.getItem('of-tips-off') === 'true') return '';
return this.tips[this.tipIndex % this.tips.length];
if (!this._tipsInitialized) {
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
this.tips = [
t('tips.commands'),
t('tips.think'),
t('tips.focus'),
'Drag files to attach',
'/model to switch models',
'/context to check usage',
'/verbose off to hide tool details'
];
this._tipsInitialized = true;
}
return this.tips[this.tipIndex % this.tips.length] || '';
},
dismissTips: function() { localStorage.setItem('of-tips-off', 'true'); },
startTipCycle: function() {
@@ -99,14 +97,25 @@ function chatPage() {
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
},
get switcherProviders() {
var seen = {};
(this._modelCache || []).forEach(function(m) { seen[m.provider] = true; });
return Object.keys(seen).sort();
},
get filteredSwitcherModels() {
var models = this._modelCache || [];
if (!this.modelSwitcherFilter) return models;
var f = this.modelSwitcherFilter.toLowerCase();
var provFilter = this.modelSwitcherProviderFilter;
var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : '';
if (!provFilter && !textFilter) return models;
return models.filter(function(m) {
return m.id.toLowerCase().indexOf(f) !== -1 ||
(m.display_name || '').toLowerCase().indexOf(f) !== -1 ||
m.provider.toLowerCase().indexOf(f) !== -1;
if (provFilter && m.provider !== provFilter) return false;
if (textFilter) {
return m.id.toLowerCase().indexOf(textFilter) !== -1 ||
(m.display_name || '').toLowerCase().indexOf(textFilter) !== -1 ||
m.provider.toLowerCase().indexOf(textFilter) !== -1;
}
return true;
});
},
@@ -125,12 +134,38 @@ function chatPage() {
init() {
var self = this;
// Initialize slash commands with i18n
this.initSlashCommands();
// Start tip cycle
this.startTipCycle();
// Fetch dynamic commands from server
this.fetchCommands();
// Observe DOM for new messages and render LaTeX
this._latexObserver = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
var bubbles = node.querySelector ? node.querySelectorAll('.message-bubble') : [];
if (node.classList && node.classList.contains('message-bubble')) {
bubbles = [node];
}
bubbles.forEach(function(bubble) {
if (bubble.textContent && hasLatexDelimiters(bubble.textContent)) {
renderLatex(bubble);
}
});
}
});
});
});
this._latexObserver.observe(document.getElementById('messages') || document.body, {
childList: true,
subtree: true
});
// Ctrl+/ keyboard shortcut
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
@@ -163,6 +198,10 @@ function chatPage() {
if (store.pendingAgent) {
self.selectAgent(store.pendingAgent);
store.pendingAgent = null;
} else {
// Restore previously active agent after page refresh (#1179).
// The agent list may not be loaded yet, so resolve once it appears.
self._restoreActiveAgent();
}
// Watch for future pending agent selections (e.g., user clicks agent while on chat)
@@ -173,6 +212,13 @@ function chatPage() {
}
});
// Re-attempt restore once the agent list arrives from the server
this.$watch('$store.app.agents', function(agents) {
if (!self.currentAgent && agents && agents.length) {
self._restoreActiveAgent();
}
});
// Watch for slash commands + model autocomplete
this.$watch('inputText', function(val) {
var modelMatch = val.match(/^\/model\s+(.*)$/i);
@@ -220,6 +266,7 @@ function chatPage() {
var now = Date.now();
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
this.modelSwitcherFilter = '';
this.modelSwitcherProviderFilter = '';
this.modelSwitcherIdx = 0;
this.showModelSwitcher = true;
this.$nextTick(function() {
@@ -234,6 +281,7 @@ function chatPage() {
self._modelCacheTime = Date.now();
self.modelPickerList = models;
self.modelSwitcherFilter = '';
self.modelSwitcherProviderFilter = '';
self.modelSwitcherIdx = 0;
self.showModelSwitcher = true;
self.$nextTick(function() {
@@ -250,34 +298,78 @@ function chatPage() {
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
var self = this;
this.modelSwitching = true;
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function() {
self.currentAgent.model_name = model.id;
self.currentAgent.model_provider = model.provider;
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function(resp) {
// Use server-resolved model/provider to stay in sync (fixes #387/#466)
self.currentAgent.model_name = (resp && resp.model) || model.id;
self.currentAgent.model_provider = (resp && resp.provider) || model.provider;
OpenFangToast.success(t('chat.model_switched') + ' ' + (model.display_name || model.id));
self.showModelSwitcher = false;
self.modelSwitching = false;
}).catch(function(e) {
OpenFangToast.error('Switch failed: ' + e.message);
OpenFangToast.error(t('chat.model_switch_failed') + ': ' + e.message);
self.modelSwitching = false;
});
},
// Fetch dynamic slash commands from server
// Initialize slash commands with i18n translations
initSlashCommands: function() {
if (this._slashCommandsLoaded) return;
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
this.slashCommands = [
{ cmd: '/help', desc: t('chat.slash.help') },
{ cmd: '/agents', desc: t('chat.slash.agents') },
{ cmd: '/new', desc: t('chat.slash.new') },
{ cmd: '/compact', desc: t('chat.slash.compact') },
{ cmd: '/model', desc: t('chat.slash.model') },
{ cmd: '/stop', desc: t('chat.slash.stop') },
{ cmd: '/usage', desc: t('chat.slash.usage') },
{ cmd: '/think', desc: t('chat.slash.think') },
{ cmd: '/context', desc: t('chat.slash.context') },
{ cmd: '/verbose', desc: t('chat.slash.verbose') },
{ cmd: '/queue', desc: t('chat.slash.queue') },
{ cmd: '/status', desc: t('chat.slash.status') },
{ cmd: '/clear', desc: t('chat.slash.clear') },
{ cmd: '/exit', desc: t('chat.slash.exit') },
{ cmd: '/budget', desc: t('chat.slash.budget') },
{ cmd: '/peers', desc: t('chat.slash.peers') },
{ cmd: '/a2a', desc: t('chat.slash.a2a') }
];
this._slashCommandsLoaded = true;
},
// Fetch slash commands from the unified registry (/api/commands?surface=web).
// Replaces the hardcoded initSlashCommands() list once loaded — ensures
// the help panel and autocomplete stay in sync with the backend registry.
fetchCommands: function() {
var self = this;
OpenFangAPI.get('/api/commands').then(function(data) {
if (data.commands && data.commands.length) {
// Build a set of known cmds to avoid duplicates
var existing = {};
self.slashCommands.forEach(function(c) { existing[c.cmd] = true; });
data.commands.forEach(function(c) {
if (!existing[c.cmd]) {
self.slashCommands.push({ cmd: c.cmd, desc: c.desc || '', source: c.source || 'server' });
existing[c.cmd] = true;
}
});
}
}).catch(function() { /* silent — use hardcoded list */ });
OpenFangAPI.get('/api/commands?surface=web').then(function(data) {
var cmds = (data && data.commands) || [];
if (!cmds.length) return;
self.slashCommands = cmds.map(function(c) {
// Prefer unified-registry shape { name, aliases, description, category, requires_agent }.
// Fall back to legacy { cmd, desc } shape so older shims keep working.
if (c.name) {
return {
cmd: '/' + c.name,
desc: c.description || '',
category: c.category || 'general',
aliases: c.aliases || [],
requires_agent: !!c.requires_agent,
source: 'registry'
};
}
return {
cmd: c.cmd,
desc: c.desc || '',
category: c.category || 'general',
aliases: c.aliases || [],
requires_agent: !!c.requires_agent,
source: c.source || 'server'
};
});
self._slashCommandsLoaded = true;
}).catch(function() { /* silent — keep hardcoded fallback list */ });
},
get filteredSlashCommands() {
@@ -288,6 +380,44 @@ function chatPage() {
});
},
// Render `/help` output grouped by category, mirroring the
// backend's render_help(Surfaces::WEB). Falls back to a flat list if
// categories are not populated (pre-fetch hardcoded list).
renderHelpText: function() {
var order = ['general', 'session', 'model', 'control', 'memory', 'info', 'automation', 'monitoring'];
var labels = {
general: 'General', session: 'Session', model: 'Model', control: 'Control',
memory: 'Memory', info: 'Info', automation: 'Automation', monitoring: 'Monitoring'
};
var anyCategorised = this.slashCommands.some(function(c) { return c.category; });
if (!anyCategorised) {
return this.slashCommands.map(function(c) {
return '`' + c.cmd + '` \u2014 ' + c.desc;
}).join('\n');
}
var groups = {};
this.slashCommands.forEach(function(c) {
var cat = c.category || 'general';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(c);
});
var lines = ['**Available commands:**'];
order.forEach(function(cat) {
var list = groups[cat];
if (!list || !list.length) return;
lines.push('');
lines.push('**' + (labels[cat] || cat) + '**');
list.forEach(function(c) {
var aliasText = '';
if (c.aliases && c.aliases.length) {
aliasText = ' (aliases: ' + c.aliases.map(function(a) { return '/' + a; }).join(', ') + ')';
}
lines.push('- `' + c.cmd + '`' + aliasText + ' \u2014 ' + c.desc);
});
});
return lines.join('\n');
},
// Clear any stuck typing indicator after 120s
_resetTypingTimeout: function() {
var self = this;
@@ -313,7 +443,7 @@ function chatPage() {
cmdArgs = cmdArgs || '';
switch (cmd) {
case '/help':
self.messages.push({ id: ++msgId, role: 'system', text: self.slashCommands.map(function(c) { return '`' + c.cmd + '` — ' + c.desc; }).join('\n'), meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: self.renderHelpText(), meta: '', tools: [] });
self.scrollToBottom();
break;
case '/agents':
@@ -377,7 +507,7 @@ function chatPage() {
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
OpenFangAPI.wsSend({ type: 'command', command: 'context', args: '' });
} else {
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected. Connect to an agent first.', meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] });
self.scrollToBottom();
}
break;
@@ -385,7 +515,7 @@ function chatPage() {
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
OpenFangAPI.wsSend({ type: 'command', command: 'verbose', args: cmdArgs });
} else {
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected. Connect to an agent first.', meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] });
self.scrollToBottom();
}
break;
@@ -393,7 +523,7 @@ function chatPage() {
if (self.currentAgent && OpenFangAPI.isWsConnected()) {
OpenFangAPI.wsSend({ type: 'command', command: 'queue', args: '' });
} else {
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected.', meta: '', tools: [] });
self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + ').', meta: '', tools: [] });
self.scrollToBottom();
}
break;
@@ -406,9 +536,13 @@ function chatPage() {
case '/model':
if (self.currentAgent) {
if (cmdArgs) {
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function() {
self.currentAgent.model_name = cmdArgs;
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + cmdArgs + '`', meta: '', tools: [] });
OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) {
// Use server-resolved model/provider (fixes #387/#466)
var resolvedModel = (resp && resp.model) || cmdArgs;
var resolvedProvider = (resp && resp.provider) || '';
self.currentAgent.model_name = resolvedModel;
if (resolvedProvider) { self.currentAgent.model_provider = resolvedProvider; }
self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + resolvedModel + '`' + (resolvedProvider ? ' (provider: `' + resolvedProvider + '`)' : ''), meta: '', tools: [] });
self.scrollToBottom();
}).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); });
} else {
@@ -428,6 +562,7 @@ function chatPage() {
self._wsAgent = null;
self.currentAgent = null;
self.messages = [];
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
window.dispatchEvent(new Event('close-chat'));
break;
case '/budget':
@@ -463,25 +598,35 @@ function chatPage() {
}
},
// Restore the previously-active agent (set in selectAgent) after a page
// refresh, so the WebSocket re-attaches to the same session and any
// in-flight tool output streams back into the chat (#1179).
_restoreActiveAgent: function() {
var storedId = null;
try { storedId = localStorage.getItem('of-active-agent'); } catch(e) { /* ignore */ }
if (!storedId) return;
var agents = (Alpine.store('app') && Alpine.store('app').agents) || [];
var match = null;
for (var i = 0; i < agents.length; i++) {
if (agents[i] && agents[i].id === storedId) { match = agents[i]; break; }
}
if (match) {
this.selectAgent(match);
}
},
selectAgent(agent) {
this.currentAgent = agent;
this.messages = [];
try { localStorage.setItem('of-active-agent', agent.id); } catch(e) { /* ignore */ }
this.connectWs(agent.id);
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
// Show welcome tips on first use
if (!localStorage.getItem('of-chat-tips-seen')) {
var localMsgId = 0;
this.messages.push({
id: ++localMsgId,
id: ++msgId,
role: 'system',
text: '**Welcome to OpenFang Chat!**\n\n' +
'- Type `/` to see available commands\n' +
'- `/help` shows all commands\n' +
'- `/think on` enables extended reasoning\n' +
'- `/context` shows context window usage\n' +
'- `/verbose off` hides tool details\n' +
'- `Ctrl+Shift+F` toggles focus mode\n' +
'- Drag & drop files to attach them\n' +
'- `Ctrl+/` opens the command palette',
text: t('chat.welcome_message'),
meta: '',
tools: []
});
@@ -500,7 +645,14 @@ function chatPage() {
try {
var data = await OpenFangAPI.get('/api/agents/' + agentId + '/session');
if (data.messages && data.messages.length) {
self.messages = data.messages.map(function(m) {
// Defense-in-depth (#935): never render system-role messages in the
// conversation history view, even if the backend somehow returns
// one. The server already filters these out by default, but we
// guard here too so a regression cannot leak the system prompt.
var visible = data.messages.filter(function(m) {
return m && m.role !== 'System' && m.role !== 'system';
});
self.messages = visible.map(function(m) {
var role = m.role === 'User' ? 'user' : (m.role === 'System' ? 'system' : 'agent');
var text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
// Sanitize any raw function-call text from history
@@ -511,13 +663,16 @@ 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
};
});
return { id: ++msgId, role: role, text: text, meta: '', tools: tools };
var images = (m.images || []).map(function(img) {
return { file_id: img.file_id, filename: img.filename || 'image' };
});
return { id: ++msgId, role: role, text: text, meta: '', tools: tools, images: images };
});
self.$nextTick(function() { self.scrollToBottom(); });
}
@@ -535,7 +690,8 @@ function chatPage() {
// Multi-session: create a new session
async createSession() {
if (!this.currentAgent) return;
var label = prompt('Session name (optional):');
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
var label = prompt(t('chat.session_name_prompt'));
if (label === null) return; // cancelled
try {
await OpenFangAPI.post('/api/agents/' + this.currentAgent.id + '/sessions', {
@@ -545,9 +701,9 @@ function chatPage() {
await this.loadSession(this.currentAgent.id);
this.messages = [];
this.scrollToBottom();
if (typeof OpenFangToast !== 'undefined') OpenFangToast.success('New session created');
if (typeof OpenFangToast !== 'undefined') OpenFangToast.success(t('chat.session_created'));
} catch(e) {
if (typeof OpenFangToast !== 'undefined') OpenFangToast.error('Failed to create session');
if (typeof OpenFangToast !== 'undefined') OpenFangToast.error(t('chat.session_create_failed'));
}
},
@@ -592,6 +748,15 @@ function chatPage() {
switch (data.type) {
case 'connected': break;
// Incoming message from server (e.g., cron trigger) — display as user message
case 'message':
if (data.content) {
var meta = data.source === 'cron' ? '[Scheduled: ' + (data.job_name || data.job_id || '') + ']' : '';
this.messages.push({ id: ++msgId, role: 'user', text: data.content, meta: meta, tools: [], images: [], ts: Date.now() });
this.scrollToBottom();
}
break;
// Legacy thinking event (backward compat)
case 'thinking':
if (!this.messages.length || !this.messages[this.messages.length - 1].thinking) {
@@ -600,8 +765,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;
@@ -614,9 +783,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') {
@@ -626,26 +797,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
@@ -666,7 +856,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
@@ -674,6 +864,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: [] });
}
@@ -681,17 +875,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) {
@@ -699,12 +896,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) {
@@ -733,6 +932,7 @@ function chatPage() {
break;
}
}
this.messages.splice(trIdx, 1, lastMsg3);
}
this.scrollToBottom();
break;
@@ -949,8 +1149,9 @@ function chatPage() {
}
// HTTP fallback
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
if (!OpenFangAPI.isWsConnected()) {
OpenFangToast.info('Using HTTP mode (no streaming)');
OpenFangToast.info(t('chat.using_http_mode'));
}
this.messages.push({ id: ++msgId, role: 'agent', text: '', meta: '', thinking: true, tools: [], ts: Date.now() });
this.scrollToBottom();
@@ -993,26 +1194,65 @@ function chatPage() {
killAgent() {
if (!this.currentAgent) return;
var self = this;
var t = typeof window.t === 'function' ? window.t : function(s) { return s; };
var name = this.currentAgent.name;
OpenFangToast.confirm('Stop Agent', 'Stop agent "' + name + '"? The agent will be shut down.', async function() {
OpenFangToast.confirm(t('chat.stop_agent_title'), t('chat.stop_agent_confirm') + ' "' + name + '"?', async function() {
try {
await OpenFangAPI.del('/api/agents/' + self.currentAgent.id);
OpenFangAPI.wsDisconnect();
self._wsAgent = null;
self.currentAgent = null;
self.messages = [];
OpenFangToast.success('Agent "' + name + '" stopped');
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
OpenFangToast.success(t('chat.agent_stopped') + ' "' + name + '"');
Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to stop agent: ' + e.message);
OpenFangToast.error(t('chat.stop_agent_failed') + ': ' + e.message);
}
});
},
// Permanently uninstall the agent: kill + remove ~/.openfang/agents/<name>/
// Issue #1163.
uninstallAgent: function() {
if (!this.currentAgent) return;
var self = this;
var name = this.currentAgent.name;
var agentId = this.currentAgent.id;
OpenFangToast.confirm(
'Uninstall Agent',
'Uninstall agent "' + name + '"? This stops the agent AND deletes its files from your workspace. This cannot be undone.',
async function() {
try {
var res = await OpenFangAPI.del('/api/agents/' + agentId + '/uninstall');
OpenFangAPI.wsDisconnect();
self._wsAgent = null;
self.currentAgent = null;
self.messages = [];
try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ }
var msg = 'Agent "' + name + '" uninstalled';
if (res && res.dir_removed === false) {
msg += ' (no on-disk files found)';
}
OpenFangToast.success(msg);
Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to uninstall agent: ' + e.message);
}
}
);
},
_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) {
@@ -1082,6 +1322,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; }
},
+1 -1
View File
@@ -39,7 +39,7 @@ function commsPage() {
startSSE() {
if (this.sseSource) this.sseSource.close();
var self = this;
var url = OpenFangAPI.baseUrl + '/api/comms/events/stream';
var url = '/api/comms/events/stream';
if (OpenFangAPI.apiKey) url += '?token=' + encodeURIComponent(OpenFangAPI.apiKey);
this.sseSource = new EventSource(url);
this.sseSource.onmessage = function(ev) {
+463 -5
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,13 +111,19 @@ 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] = '';
}
}
}
// Initialize optional instance name (for multi-instance hands).
data.instanceName = '';
this.setupWizard = data;
// Skip deps step if no requirements
var hasReqs = data.requirements && data.requirements.length > 0;
@@ -274,7 +290,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 +304,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 +343,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 +356,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,19 +386,38 @@ 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];
}
this.activatingId = handId;
try {
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', { config: config });
this.showToast('Hand "' + handId + '" activated as ' + (data.agent_name || data.instance_id));
var payload = { config: config };
var name = (this.setupWizard.instanceName || '').trim();
if (name) {
payload.instance_name = name;
}
var data = await OpenFangAPI.post('/api/hands/' + handId + '/activate', payload);
var label = data.instance_name || data.agent_name || data.instance_id;
this.showToast('Hand "' + handId + '" activated as ' + label);
this.closeSetupWizard();
await this.loadActive();
this.tab = 'active';
@@ -499,6 +581,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 }
}
}
}
}
});
}
}
}
};
}
@@ -26,13 +26,33 @@ function schedulerPage() {
cron: '',
agent_id: '',
message: '',
enabled: true
enabled: true,
delivery_targets: []
},
creating: false,
// -- Run Now state --
runningJobId: '',
// -- Delivery targets picker (create modal) --
showTargetPicker: false,
pickerType: 'channel',
draftTarget: null,
// -- Expanded job / delivery log state --
expandedJobId: '',
deliveryLog: { targets: [], entries: [] },
deliveryLogLoading: false,
deliveryLogError: '',
// -- Edit targets state (per-existing-job) --
editingTargetsJobId: '',
editingTargets: [],
savingTargets: false,
// -- Available channel types (populated from /api/channels) --
channelTypes: [],
// Cron presets
cronPresets: [
{ label: 'Every minute', cron: '* * * * *' },
@@ -55,12 +75,32 @@ function schedulerPage() {
this.loadError = '';
try {
await this.loadJobs();
// Channels are optional — failure is non-fatal for the scheduler page.
this.loadChannelTypes();
} catch(e) {
this.loadError = e.message || 'Could not load scheduler data.';
}
this.loading = false;
},
async loadChannelTypes() {
try {
var data = await OpenFangAPI.get('/api/channels');
// /api/channels returns an array of channel descriptors; pull names.
var list = Array.isArray(data) ? data : (data && data.channels) || [];
var names = [];
for (var i = 0; i < list.length; i++) {
var ch = list[i];
var name = ch && (ch.name || ch.display_name || ch.channel_type);
if (name && names.indexOf(name) === -1) names.push(name);
}
this.channelTypes = names;
} catch(e) {
// Fall through silently — the form uses a plain input as fallback.
this.channelTypes = [];
}
},
async loadJobs() {
var data = await OpenFangAPI.get('/api/cron/jobs');
var raw = data.jobs || [];
@@ -82,6 +122,7 @@ function schedulerPage() {
last_run: j.last_run,
next_run: j.next_run,
delivery: j.delivery ? j.delivery.kind || '' : '',
delivery_targets: Array.isArray(j.delivery_targets) ? j.delivery_targets : [],
created_at: j.created_at
};
});
@@ -162,9 +203,12 @@ function schedulerPage() {
delivery: { kind: 'last_channel' },
enabled: this.newJob.enabled
};
if (this.newJob.delivery_targets && this.newJob.delivery_targets.length) {
body.delivery_targets = this.newJob.delivery_targets.map(this.sanitizeTarget);
}
await OpenFangAPI.post('/api/cron/jobs', body);
this.showCreateForm = false;
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true };
this.newJob = { name: '', cron: '', agent_id: '', message: '', enabled: true, delivery_targets: [] };
OpenFangToast.success('Schedule "' + jobName + '" created');
await this.loadJobs();
} catch(e) {
@@ -201,19 +245,225 @@ 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 = '';
},
// ── Delivery target editing (create modal) ──
openTargetPicker() {
this.pickerType = 'channel';
this.draftTarget = this.blankTarget('channel');
this.showTargetPicker = true;
},
cancelTargetPicker() {
this.showTargetPicker = false;
this.draftTarget = null;
},
onPickerTypeChange() {
this.draftTarget = this.blankTarget(this.pickerType);
},
blankTarget(type) {
if (type === 'channel') {
return { type: 'channel', channel_type: '', recipient: '' };
}
if (type === 'webhook') {
return { type: 'webhook', url: '', auth_header: '' };
}
if (type === 'local_file') {
return { type: 'local_file', path: '', append: false };
}
if (type === 'email') {
return { type: 'email', to: '', subject_template: '' };
}
return null;
},
addDraftTarget() {
var err = this.validateTarget(this.draftTarget);
if (err) {
OpenFangToast.warn(err);
return;
}
if (!Array.isArray(this.newJob.delivery_targets)) this.newJob.delivery_targets = [];
this.newJob.delivery_targets.push(this.sanitizeTarget(this.draftTarget));
this.showTargetPicker = false;
this.draftTarget = null;
},
removeTarget(idx) {
if (!Array.isArray(this.newJob.delivery_targets)) return;
this.newJob.delivery_targets.splice(idx, 1);
},
validateTarget(t) {
if (!t || !t.type) return 'Pick a target type';
if (t.type === 'channel') {
if (!t.channel_type || !t.channel_type.trim()) return 'Channel type is required';
if (!t.recipient || !t.recipient.trim()) return 'Recipient is required';
} else if (t.type === 'webhook') {
if (!t.url || !t.url.trim()) return 'Webhook URL is required';
if (t.url.indexOf('http://') !== 0 && t.url.indexOf('https://') !== 0) {
return 'Webhook URL must start with http:// or https://';
}
} else if (t.type === 'local_file') {
if (!t.path || !t.path.trim()) return 'File path is required';
} else if (t.type === 'email') {
if (!t.to || !t.to.trim()) return 'Recipient email is required';
}
return null;
},
// Strip empty-string optional fields so serde accepts the payload cleanly.
sanitizeTarget(t) {
if (!t) return null;
var out = { type: t.type };
if (t.type === 'channel') {
out.channel_type = (t.channel_type || '').trim();
out.recipient = (t.recipient || '').trim();
} else if (t.type === 'webhook') {
out.url = (t.url || '').trim();
if (t.auth_header && t.auth_header.trim()) out.auth_header = t.auth_header.trim();
} else if (t.type === 'local_file') {
out.path = (t.path || '').trim();
out.append = !!t.append;
} else if (t.type === 'email') {
out.to = (t.to || '').trim();
if (t.subject_template && t.subject_template.trim()) {
out.subject_template = t.subject_template.trim();
}
}
return out;
},
// ── Chip rendering helpers ──
targetChipLabel(t) {
if (!t || !t.type) return '?';
if (t.type === 'channel') return 'CHANNEL: ' + (t.channel_type || '?');
if (t.type === 'webhook') return 'WEBHOOK';
if (t.type === 'local_file') return 'FILE: ' + this.truncate(t.path || '', 28);
if (t.type === 'email') return 'EMAIL: ' + this.truncate(t.to || '', 24);
return t.type.toUpperCase();
},
targetChipClass(t) {
if (!t || !t.type) return 'badge-dim';
if (t.type === 'channel') return 'badge-info';
if (t.type === 'webhook') return 'badge-created';
if (t.type === 'local_file') return 'badge-muted';
if (t.type === 'email') return 'badge-warn';
return 'badge-dim';
},
targetSummary(t) {
if (!t) return '';
if (t.type === 'channel') return (t.channel_type || '?') + ' -> ' + (t.recipient || '?');
if (t.type === 'webhook') return t.url || '(no url)';
if (t.type === 'local_file') return (t.append ? 'append ' : 'overwrite ') + (t.path || '');
if (t.type === 'email') {
var base = t.to || '';
if (t.subject_template) base += ' · subject: ' + t.subject_template;
return base;
}
return JSON.stringify(t);
},
// ── Expand row / delivery log ──
async toggleExpand(job) {
if (this.expandedJobId === job.id) {
this.expandedJobId = '';
return;
}
this.expandedJobId = job.id;
this.deliveryLog = { targets: [], entries: [] };
this.deliveryLogError = '';
this.deliveryLogLoading = true;
try {
var data = await OpenFangAPI.get('/api/schedules/' + job.id + '/delivery-log');
this.deliveryLog = {
targets: Array.isArray(data.targets) ? data.targets : [],
entries: Array.isArray(data.entries) ? data.entries : []
};
} catch(e) {
this.deliveryLogError = e.message || 'Could not load delivery log.';
}
this.deliveryLogLoading = false;
},
// ── Edit targets on existing job ──
startEditTargets(job) {
this.editingTargetsJobId = job.id;
// Clone so cancel doesn't mutate the loaded list.
this.editingTargets = (job.delivery_targets || []).map(function(t) {
return JSON.parse(JSON.stringify(t));
});
this.pickerType = 'channel';
this.draftTarget = null;
this.showTargetPicker = false;
},
cancelEditTargets() {
this.editingTargetsJobId = '';
this.editingTargets = [];
this.draftTarget = null;
this.showTargetPicker = false;
},
addEditTarget() {
this.pickerType = 'channel';
this.draftTarget = this.blankTarget('channel');
this.showTargetPicker = true;
},
addDraftTargetToEdit() {
var err = this.validateTarget(this.draftTarget);
if (err) {
OpenFangToast.warn(err);
return;
}
this.editingTargets.push(this.sanitizeTarget(this.draftTarget));
this.showTargetPicker = false;
this.draftTarget = null;
},
removeEditTarget(idx) {
this.editingTargets.splice(idx, 1);
},
async saveEditTargets() {
if (!this.editingTargetsJobId) return;
this.savingTargets = true;
try {
var clean = this.editingTargets.map(this.sanitizeTarget);
await OpenFangAPI.put('/api/schedules/' + this.editingTargetsJobId, {
delivery_targets: clean
});
OpenFangToast.success('Delivery targets updated');
this.cancelEditTargets();
await this.loadJobs();
} catch(e) {
OpenFangToast.error('Failed to update targets: ' + (e.message || e));
}
this.savingTargets = false;
},
// ── Trigger helpers ──
triggerType(pattern) {
@@ -374,6 +624,12 @@ function schedulerPage() {
} catch(e) { return 'never'; }
},
truncate(s, n) {
if (!s) return '';
if (s.length <= n) return s;
return s.substring(0, n - 1) + '…';
},
jobCount() {
var enabled = 0;
for (var i = 0; i < this.jobs.length; i++) {
+26 -16
View File
@@ -64,15 +64,20 @@ function sessionsPage() {
deleteSession(sessionId) {
var self = this;
OpenFangToast.confirm('Delete Session', 'This will permanently remove the session and its messages.', async function() {
try {
await OpenFangAPI.del('/api/sessions/' + sessionId);
self.sessions = self.sessions.filter(function(s) { return s.session_id !== sessionId; });
OpenFangToast.success('Session deleted');
} catch(e) {
OpenFangToast.error('Failed to delete session: ' + e.message);
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
OpenFangToast.confirm(
t('sessions.delete_session') || 'Delete Session',
t('sessions.delete_confirm') || 'This will permanently remove the session and its messages.',
async function() {
try {
await OpenFangAPI.del('/api/sessions/' + sessionId);
self.sessions = self.sessions.filter(function(s) { return s.session_id !== sessionId; });
OpenFangToast.success('Session deleted');
} catch(e) {
OpenFangToast.error('Failed to delete session: ' + e.message);
}
}
});
);
},
// -- Memory methods --
@@ -108,15 +113,20 @@ function sessionsPage() {
deleteKey(key) {
var self = this;
OpenFangToast.confirm('Delete Key', 'Delete key "' + key + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.del('/api/memory/agents/' + self.memAgentId + '/kv/' + encodeURIComponent(key));
OpenFangToast.success('Key "' + key + '" deleted');
await self.loadKv();
} catch(e) {
OpenFangToast.error('Failed to delete key: ' + e.message);
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
OpenFangToast.confirm(
t('sessions.delete_key') || 'Delete Key',
(t('sessions.delete_key_confirm') || 'Delete key') + ' "' + key + '"? This cannot be undone.',
async function() {
try {
await OpenFangAPI.del('/api/memory/agents/' + self.memAgentId + '/kv/' + encodeURIComponent(key));
OpenFangToast.success('Key "' + key + '" deleted');
await self.loadKv();
} catch(e) {
OpenFangToast.error('Failed to delete key: ' + e.message);
}
}
});
);
},
startEdit(kv) {
+150 -5
View File
@@ -25,7 +25,15 @@ function settingsPage() {
providerUrlSaving: {},
providerTesting: {},
providerTestResults: {},
providerSearch: '',
providerStatusFilter: '',
providerCategoryFilter: '',
copilotOAuth: { polling: false, userCode: '', verificationUri: '', pollId: '', interval: 5 },
customProviderName: '',
customProviderUrl: '',
customProviderKey: '',
customProviderStatus: '',
addingCustomProvider: false,
loading: true,
loadError: '',
@@ -258,6 +266,17 @@ function settingsPage() {
}
},
async deleteCustomModel(modelId) {
if (!confirm('Delete custom model "' + modelId + '"?')) return;
try {
await OpenFangAPI.del('/api/models/custom/' + encodeURIComponent(modelId));
OpenFangToast.success('Model deleted');
await this.loadModels();
} catch(e) {
OpenFangToast.error('Failed to delete: ' + (e.message || 'Unknown error'));
}
},
async loadConfigSchema() {
try {
var results = await Promise.all([
@@ -279,11 +298,14 @@ function settingsPage() {
async saveConfigField(section, field, value) {
var key = section + '.' + field;
// Root-level fields (api_key, api_listen, log_level) use just the field name
var sectionMeta = this.configSchema && this.configSchema[section];
var path = (sectionMeta && sectionMeta.root_level) ? field : key;
this.configSaving[key] = true;
try {
await OpenFangAPI.post('/api/config/set', { path: key, value: value });
await OpenFangAPI.post('/api/config/set', { path: path, value: value });
this.configDirty[key] = false;
OpenFangToast.success('Saved ' + key);
OpenFangToast.success('Saved ' + field);
} catch(e) {
OpenFangToast.error('Failed to save: ' + e.message);
}
@@ -319,6 +341,94 @@ function settingsPage() {
return Object.keys(seen).sort();
},
/// Coarse category for a provider used to group the Providers tab.
/// Returns: 'frontier' | 'oss' | 'local' | 'aggregator' | 'regional' | 'other'.
providerCategory(p) {
if (!p) return 'other';
if (p.is_local || p.key_required === false) return 'local';
var id = (p.id || '').toLowerCase();
var FRONTIER = ['anthropic','openai','gemini','google','xai','bedrock','azure','vertex'];
var OSS = ['groq','together','fireworks','cerebras','sambanova','deepseek','mistral','perplexity','cohere','ai21','huggingface','replicate','nvidia','venice','novita','chutes'];
var AGG = ['openrouter','litellm','github-copilot','claude-code'];
var REGIONAL = ['qwen','minimax','zhipu','zai','moonshot','qianfan','volcengine','kimi'];
if (FRONTIER.indexOf(id) !== -1) return 'frontier';
if (REGIONAL.indexOf(id) !== -1) return 'regional';
if (AGG.indexOf(id) !== -1) return 'aggregator';
if (OSS.indexOf(id) !== -1) return 'oss';
return 'other';
},
providerCategoryLabel(cat) {
switch (cat) {
case 'frontier': return 'Frontier (Anthropic, OpenAI, Google, xAI, Bedrock)';
case 'oss': return 'Open-Weight Hosts (Groq, Together, Fireworks, DeepSeek, etc.)';
case 'aggregator': return 'Aggregators & Gateways (OpenRouter, GitHub Copilot)';
case 'regional': return 'Regional / China (Qwen, Zhipu, Moonshot, MiniMax)';
case 'local': return 'Local / Self-Hosted (Ollama, vLLM, LM Studio, Lemonade)';
default: return 'Other Providers';
}
},
/// Stable category order for grouped rendering.
get providerCategoriesOrdered() {
return ['frontier', 'oss', 'aggregator', 'regional', 'local', 'other'];
},
/// Returns filter-matched providers grouped by category, preserving order.
/// Each entry: { category, label, items: [...] }. Empty groups are omitted.
get providersGrouped() {
var self = this;
var filtered = this.filteredProviders;
var by = {};
filtered.forEach(function(p) {
var c = self.providerCategory(p);
if (!by[c]) by[c] = [];
by[c].push(p);
});
// Sort each group: configured first, then alphabetical
Object.keys(by).forEach(function(c) {
by[c].sort(function(a, b) {
var ac = a.auth_status === 'configured' ? 0 : 1;
var bc = b.auth_status === 'configured' ? 0 : 1;
if (ac !== bc) return ac - bc;
return (a.display_name || a.id).localeCompare(b.display_name || b.id);
});
});
var out = [];
this.providerCategoriesOrdered.forEach(function(c) {
if (by[c] && by[c].length) {
out.push({ category: c, label: self.providerCategoryLabel(c), items: by[c] });
}
});
return out;
},
get filteredProviders() {
var self = this;
return this.providers.filter(function(p) {
if (self.providerStatusFilter === 'configured' && p.auth_status !== 'configured') return false;
if (self.providerStatusFilter === 'unconfigured' && p.auth_status === 'configured') return false;
if (self.providerCategoryFilter && self.providerCategory(p) !== self.providerCategoryFilter) return false;
if (self.providerSearch) {
var q = self.providerSearch.toLowerCase();
if ((p.display_name || '').toLowerCase().indexOf(q) === -1 &&
(p.id || '').toLowerCase().indexOf(q) === -1 &&
(p.api_key_env || '').toLowerCase().indexOf(q) === -1) return false;
}
return true;
});
},
get configuredProviderCount() {
return this.providers.filter(function(p) { return p.auth_status === 'configured'; }).length;
},
clearProviderFilters() {
this.providerSearch = '';
this.providerStatusFilter = '';
this.providerCategoryFilter = '';
},
get uniqueTiers() {
var seen = {};
this.models.forEach(function(m) { if (m.tier) seen[m.tier] = true; });
@@ -333,7 +443,10 @@ function settingsPage() {
providerAuthText(p) {
if (p.auth_status === 'configured') return 'Configured';
if (p.auth_status === 'not_set' || p.auth_status === 'missing') return 'Not Set';
if (p.auth_status === 'not_set' || p.auth_status === 'missing') {
if (p.id === 'claude-code') return 'Not Installed';
return 'Not Set';
}
return 'No Key Needed';
},
@@ -379,8 +492,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();
@@ -488,6 +605,34 @@ function settingsPage() {
this.providerUrlSaving[provider.id] = false;
},
async addCustomProvider() {
var name = this.customProviderName.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
if (!name) { OpenFangToast.error('Please enter a provider name'); return; }
var url = this.customProviderUrl.trim();
if (!url) { OpenFangToast.error('Please enter a base URL'); return; }
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
OpenFangToast.error('URL must start with http:// or https://'); return;
}
this.addingCustomProvider = true;
this.customProviderStatus = '';
try {
var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(name) + '/url', { base_url: url });
if (this.customProviderKey.trim()) {
await OpenFangAPI.post('/api/providers/' + encodeURIComponent(name) + '/key', { key: this.customProviderKey.trim() });
}
this.customProviderName = '';
this.customProviderUrl = '';
this.customProviderKey = '';
this.customProviderStatus = '';
OpenFangToast.success('Provider "' + name + '" added' + (result.reachable ? ' (reachable)' : ' (not reachable yet)'));
await this.loadProviders();
} catch(e) {
this.customProviderStatus = 'Error: ' + (e.message || 'Failed');
OpenFangToast.error('Failed to add provider: ' + e.message);
}
this.addingCustomProvider = false;
},
// -- Security methods --
async loadSecurity() {
this.secLoading = true;
+204 -30
View File
@@ -30,31 +30,44 @@ function skillsPage() {
skillCodeFilename: '',
skillCodeLoading: false,
// Skill config modal (local skill configuration from SKILL.md frontmatter)
configSkill: null, // skill object whose config is being edited
configDeclared: {}, // { var_name: { description, env, default, required } }
configResolved: {}, // { var_name: { value, source, is_secret } }
configDraft: {}, // { var_name: user-edited string value }
configRevealed: {}, // { var_name: bool } — toggle password reveal per row
configLoading: false,
configSaving: false,
configError: '',
// MCP servers
mcpServers: [],
mcpLoading: false,
// Category definitions from the OpenClaw ecosystem
categories: [
{ id: 'coding', name: 'Coding & IDEs' },
{ id: 'git', name: 'Git & GitHub' },
{ id: 'web', name: 'Web & Frontend' },
{ id: 'devops', name: 'DevOps & Cloud' },
{ id: 'browser', name: 'Browser & Automation' },
{ id: 'search', name: 'Search & Research' },
{ id: 'ai', name: 'AI & LLMs' },
{ id: 'data', name: 'Data & Analytics' },
{ id: 'productivity', name: 'Productivity' },
{ id: 'communication', name: 'Communication' },
{ id: 'media', name: 'Media & Streaming' },
{ id: 'notes', name: 'Notes & PKM' },
{ id: 'security', name: 'Security' },
{ id: 'cli', name: 'CLI Utilities' },
{ id: 'marketing', name: 'Marketing & Sales' },
{ id: 'finance', name: 'Finance' },
{ id: 'smart-home', name: 'Smart Home & IoT' },
{ id: 'docs', name: 'PDF & Documents' },
],
// Category definitions from the OpenClaw ecosystem (loaded from i18n)
get categories() {
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
return [
{ id: 'coding', name: t('skills.cat_coding') || 'Coding & IDEs' },
{ id: 'git', name: t('skills.cat_git') || 'Git & GitHub' },
{ id: 'web', name: t('skills.cat_frontend') || 'Web & Frontend' },
{ id: 'devops', name: t('skills.cat_devops') || 'DevOps & Cloud' },
{ id: 'browser', name: t('skills.cat_browser') || 'Browser & Automation' },
{ id: 'search', name: t('skills.cat_search') || 'Search & Research' },
{ id: 'ai', name: t('skills.cat_ai') || 'AI & ML' },
{ id: 'data', name: t('skills.cat_data') || 'Data & Analytics' },
{ id: 'productivity', name: t('skills.cat_productivity') || 'Productivity' },
{ id: 'communication', name: t('skills.cat_communication') || 'Communication' },
{ id: 'media', name: t('skills.cat_media') || 'Media & Streaming' },
{ id: 'notes', name: t('skills.cat_notes') || 'Notes & PKM' },
{ id: 'security', name: t('skills.cat_security') || 'Security' },
{ id: 'cli', name: t('skills.cat_cli') || 'CLI Utilities' },
{ id: 'marketing', name: t('skills.cat_marketing') || 'Marketing & Sales' },
{ id: 'finance', name: t('skills.cat_finance') || 'Finance' },
{ id: 'smart-home', name: t('skills.cat_smarthome') || 'Smart Home & IoT' },
{ id: 'docs', name: t('skills.cat_docs') || 'Documentation' },
];
},
runtimeBadge: function(rt) {
var r = (rt || '').toLowerCase();
@@ -98,7 +111,8 @@ function skillsPage() {
tags: s.tags || [],
enabled: s.enabled !== false,
source: s.source || { type: 'local' },
has_prompt_context: !!s.has_prompt_context
has_prompt_context: !!s.has_prompt_context,
config_declared_count: s.config_declared_count || 0
};
});
} catch(e) {
@@ -108,6 +122,161 @@ function skillsPage() {
this.loading = false;
},
// ── Skill config editing ────────────────────────────────────────────
async openSkillConfig(skill) {
this.configSkill = skill;
this.configDeclared = {};
this.configResolved = {};
this.configDraft = {};
this.configRevealed = {};
this.configError = '';
this.configLoading = true;
try {
var data = await OpenFangAPI.get('/api/skills/' + encodeURIComponent(skill.name) + '/config');
this.configDeclared = data.declared || {};
this.configResolved = data.resolved || {};
// Pre-populate draft values only for vars the user has already
// overridden — never copy redacted responses back into inputs or
// they'd be re-saved as "****redacted****" strings.
var names = Object.keys(this.configDeclared);
for (var i = 0; i < names.length; i++) {
var n = names[i];
var res = this.configResolved[n] || {};
if (res.source === 'user' && !res.is_secret) {
this.configDraft[n] = res.value == null ? '' : String(res.value);
} else {
this.configDraft[n] = '';
}
}
} catch(e) {
this.configError = e.message || 'Failed to load skill config.';
}
this.configLoading = false;
},
closeSkillConfig() {
this.configSkill = null;
this.configDeclared = {};
this.configResolved = {};
this.configDraft = {};
this.configRevealed = {};
this.configError = '';
},
configRowInvalid(name) {
// A required var is invalid iff the user hasn't entered anything AND
// no env/default resolves it. Source from the server tells us where
// the current value came from; if it's "unresolved" and the draft is
// blank, the save would write an empty string over the required var.
var decl = this.configDeclared[name] || {};
if (!decl.required) return false;
var draft = (this.configDraft[name] || '').trim();
if (draft) return false;
var res = this.configResolved[name] || {};
if (res.source === 'env' || res.source === 'default') return false;
// If the user has an existing secret override we don't want to force
// them to re-type it — treat that as "currently resolved".
if (res.source === 'user') return false;
return true;
},
hasInvalidConfig() {
var names = Object.keys(this.configDeclared);
for (var i = 0; i < names.length; i++) {
if (this.configRowInvalid(names[i])) return true;
}
return false;
},
toggleReveal(name) {
this.configRevealed[name] = !this.configRevealed[name];
},
sourceBadgeClass(source) {
switch (source) {
case 'user': return 'badge-success';
case 'env': return 'badge-info';
case 'default': return 'badge-dim';
default: return 'badge-danger';
}
},
sourceBadgeLabel(res) {
if (!res) return 'unresolved';
switch (res.source) {
case 'user': return 'user override';
case 'env': return 'env' + ((this.configDeclared[res.__name] && this.configDeclared[res.__name].env) ? ':' + this.configDeclared[res.__name].env : '');
case 'default': return 'default';
default: return 'unresolved';
}
},
async saveSkillConfig() {
if (!this.configSkill) return;
if (this.hasInvalidConfig()) {
OpenFangToast.error('Fill in all required variables before saving.');
return;
}
this.configSaving = true;
this.configError = '';
// Only PUT values the user actually typed. Empty strings are dropped
// so we don't silently clobber an env/default with "".
var payload = {};
var names = Object.keys(this.configDeclared);
for (var i = 0; i < names.length; i++) {
var n = names[i];
var v = (this.configDraft[n] || '').trim();
if (v.length > 0) payload[n] = v;
}
try {
await OpenFangAPI.put('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config', { values: payload });
OpenFangToast.success('Saved, reloading agents\u2026');
// Refresh the modal contents so the new source/value shows up.
var refreshed = this.configSkill;
await this.loadSkills();
this.closeSkillConfig();
// Find the possibly-refreshed skill object and reopen.
var self = this;
var updated = this.skills.find(function(s) { return s.name === refreshed.name; });
if (updated) await self.openSkillConfig(updated);
} catch(e) {
this.configError = e.message || 'Save failed.';
OpenFangToast.error('Save failed: ' + (e.message || 'unknown error'));
}
this.configSaving = false;
},
async resetSkillConfigVar(name) {
if (!this.configSkill) return;
var decl = this.configDeclared[name] || {};
var res = this.configResolved[name] || {};
// If the server is already reporting a non-user source there's nothing
// to remove; just clear the draft so the input disappears.
if (res.source !== 'user') {
this.configDraft[name] = '';
return;
}
try {
await OpenFangAPI.del('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config/' + encodeURIComponent(name));
OpenFangToast.success('Reset ' + name);
// Refresh modal state from server.
var data = await OpenFangAPI.get('/api/skills/' + encodeURIComponent(this.configSkill.name) + '/config');
this.configResolved = data.resolved || {};
this.configDraft[name] = '';
} catch(e) {
var msg = e.message || 'Reset failed';
if (msg.indexOf('required') !== -1 || msg.indexOf('409') !== -1) {
OpenFangToast.error('Cannot reset: ' + decl.description + ' is required with no fallback.');
} else {
OpenFangToast.error('Reset failed: ' + msg);
}
}
},
get configDeclaredNames() {
return Object.keys(this.configDeclared).sort();
},
async loadData() {
await this.loadSkills();
},
@@ -264,15 +433,20 @@ function skillsPage() {
// Uninstall
uninstallSkill: function(name) {
var self = this;
OpenFangToast.confirm('Uninstall Skill', 'Uninstall skill "' + name + '"? This cannot be undone.', async function() {
try {
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
OpenFangToast.success('Skill "' + name + '" uninstalled');
await self.loadSkills();
} catch(e) {
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
OpenFangToast.confirm(
t('skills.uninstall_skill') || 'Uninstall Skill',
t('skills.uninstall_confirm') + ' "' + name + '"? This cannot be undone.',
async function() {
try {
await OpenFangAPI.post('/api/skills/uninstall', { name: name });
OpenFangToast.success('Skill "' + name + '" uninstalled');
await self.loadSkills();
} catch(e) {
OpenFangToast.error('Failed to uninstall skill: ' + e.message);
}
}
});
);
},
// Create prompt-only skill
@@ -191,6 +191,33 @@ function analyticsPage() {
return segments;
},
donutSegmentsSvg() {
var segments = this.donutSegments();
if (!segments.length) return '';
function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
var out = [];
for (var i = 0; i < segments.length; i++) {
var seg = segments[i];
var title = seg.provider + ': ' + seg.percent + '% (' + this.formatCost(seg.cost) + ')';
out.push(
'<circle cx="80" cy="80" r="60" fill="none" stroke="' + escapeXml(seg.color) + '" stroke-width="24" stroke-dasharray="' + escapeXml(seg.dasharray) + '" stroke-dashoffset="' + escapeXml(seg.dashoffset) + '" transform="rotate(-90 80 80)" class="donut-segment">' +
'<title>' + escapeXml(title) + '</title>' +
'</circle>'
);
}
return out.join('');
},
// ── Bar chart (last 7 days) ──
barChartData() {
@@ -218,6 +245,42 @@ function analyticsPage() {
return result;
},
barChartSvg() {
var bars = this.barChartData();
if (!bars.length) return '';
function escapeXml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
var out = [];
for (var i = 0; i < bars.length; i++) {
var bar = bars[i];
var x = i * 50 + 18;
var labelX = i * 50 + 30;
var y = 150 - bar.barHeight;
var costLabelY = y - 4;
var title = bar.date + ': ' + this.formatCost(bar.cost) + ' (' + bar.calls + ' calls)';
out.push(
'<g>' +
'<rect x="' + x + '" y="' + y + '" width="24" height="' + bar.barHeight + '" rx="3" fill="var(--accent)" class="cost-bar" style="opacity:0.85">' +
'<title>' + escapeXml(title) + '</title>' +
'</rect>' +
'<text x="' + labelX + '" y="166" text-anchor="middle" fill="var(--text-muted)" style="font-size:9px;font-family:var(--font-mono)">' + escapeXml(bar.dayName) + '</text>' +
'<text x="' + labelX + '" y="' + costLabelY + '" text-anchor="middle" fill="var(--text-dim)" style="font-size:8px;font-family:var(--font-mono)">' + escapeXml(this.formatCost(bar.cost)) + '</text>' +
'</g>'
);
}
return out.join('');
},
// ── Cost by model table (sorted by cost descending) ──
costByModelSorted() {
+152 -80
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,
@@ -148,15 +158,17 @@ function wizardPage() {
return this.templates.filter(function(t) { return t.category === cat; });
},
// Step 3: Profile/tool descriptions
profileDescriptions: {
minimal: { label: 'Minimal', desc: 'Read-only file access' },
coding: { label: 'Coding', desc: 'Files + shell + web fetch' },
research: { label: 'Research', desc: 'Web search + file read/write' },
balanced: { label: 'Balanced', desc: 'General-purpose tool set' },
precise: { label: 'Precise', desc: 'Focused tool set for accuracy' },
creative: { label: 'Creative', desc: 'Full tools with creative emphasis' },
full: { label: 'Full', desc: 'All 35+ tools' }
// Step 3: Profile/tool descriptions (loaded from i18n)
get profileDescriptions() {
return {
minimal: { label: window.i18n ? window.i18n.t('wizard.profile_minimal') : 'Minimal', desc: window.i18n ? window.i18n.t('wizard.profile_minimal_desc') : 'Read-only file access' },
coding: { label: window.i18n ? window.i18n.t('wizard.profile_coding') : 'Coding', desc: window.i18n ? window.i18n.t('wizard.profile_coding_desc') : 'Files + shell + web fetch' },
research: { label: window.i18n ? window.i18n.t('wizard.profile_research') : 'Research', desc: window.i18n ? window.i18n.t('wizard.profile_research_desc') : 'Web search + file read/write' },
balanced: { label: window.i18n ? window.i18n.t('wizard.profile_balanced') : 'Balanced', desc: window.i18n ? window.i18n.t('wizard.profile_balanced_desc') : 'General-purpose tool set' },
precise: { label: window.i18n ? window.i18n.t('wizard.profile_precise') : 'Precise', desc: window.i18n ? window.i18n.t('wizard.profile_precise_desc') : 'Focused tool set for accuracy' },
creative: { label: window.i18n ? window.i18n.t('wizard.profile_creative') : 'Creative', desc: window.i18n ? window.i18n.t('wizard.profile_creative_desc') : 'Full tools with creative emphasis' },
full: { label: window.i18n ? window.i18n.t('wizard.profile_full') : 'Full', desc: window.i18n ? window.i18n.t('wizard.profile_full_desc') : 'All 35+ tools' }
};
},
profileInfo: function(name) { return this.profileDescriptions[name] || { label: name, desc: '' }; },
@@ -164,12 +176,35 @@ function wizardPage() {
tryItMessages: [],
tryItInput: '',
tryItSending: false,
suggestedMessages: {
'General': ['What can you help me with?', 'Tell me a fun fact', 'Summarize the latest AI news'],
'Development': ['Write a Python hello world', 'Explain async/await', 'Review this code snippet'],
'Research': ['Explain quantum computing simply', 'Compare React vs Vue', 'What are the latest trends in AI?'],
'Writing': ['Help me write a professional email', 'Improve this paragraph', 'Write a blog intro about AI'],
'Business': ['Draft a meeting agenda', 'How do I handle a complaint?', 'Create a project status update']
get suggestedMessages() {
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
return {
'General': [
t('wizard.suggestions.general.1') || 'What can you help me with?',
t('wizard.suggestions.general.2') || 'Tell me a fun fact',
t('wizard.suggestions.general.3') || 'Summarize the latest AI news'
],
'Development': [
t('wizard.suggestions.development.1') || 'Write a Python hello world',
t('wizard.suggestions.development.2') || 'Explain async/await',
t('wizard.suggestions.development.3') || 'Review this code snippet'
],
'Research': [
t('wizard.suggestions.research.1') || 'Explain quantum computing simply',
t('wizard.suggestions.research.2') || 'Compare React vs Vue',
t('wizard.suggestions.research.3') || 'What are the latest trends in AI?'
],
'Writing': [
t('wizard.suggestions.writing.1') || 'Help me write a professional email',
t('wizard.suggestions.writing.2') || 'Improve this paragraph',
t('wizard.suggestions.writing.3') || 'Write a blog intro about AI'
],
'Business': [
t('wizard.suggestions.business.1') || 'Draft a meeting agenda',
t('wizard.suggestions.business.2') || 'How do I handle a complaint?',
t('wizard.suggestions.business.3') || 'Create a project status update'
]
};
},
get currentSuggestions() {
var tpl = this.templates[this.selectedTemplate];
@@ -194,38 +229,41 @@ function wizardPage() {
// Step 5: Channel setup (optional)
channelType: '',
channelOptions: [
{
name: 'telegram',
display_name: 'Telegram',
icon: 'TG',
description: 'Connect your agent to a Telegram bot for messaging.',
token_label: 'Bot Token',
token_placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
token_env: 'TELEGRAM_BOT_TOKEN',
help: 'Create a bot via @BotFather on Telegram to get your token.'
},
{
name: 'discord',
display_name: 'Discord',
icon: 'DC',
description: 'Connect your agent to a Discord server via bot token.',
token_label: 'Bot Token',
token_placeholder: 'MTIz...abc',
token_env: 'DISCORD_BOT_TOKEN',
help: 'Create a Discord application at discord.com/developers and add a bot.'
},
{
name: 'slack',
display_name: 'Slack',
icon: 'SL',
description: 'Connect your agent to a Slack workspace.',
token_label: 'Bot Token',
token_placeholder: 'xoxb-...',
token_env: 'SLACK_BOT_TOKEN',
help: 'Create a Slack app at api.slack.com/apps and install it to your workspace.'
}
],
get channelOptions() {
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
return [
{
name: 'telegram',
display_name: t('wizard.channel_telegram') || 'Telegram',
icon: 'TG',
description: t('wizard.channel_telegram_desc') || 'Connect your agent to a Telegram bot for messaging.',
token_label: t('wizard.channel_telegram_token') || 'Bot Token',
token_placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
token_env: 'TELEGRAM_BOT_TOKEN',
help: t('wizard.channel_telegram_help') || 'Create a bot via @BotFather on Telegram to get your token.'
},
{
name: 'discord',
display_name: t('wizard.channel_discord') || 'Discord',
icon: 'DC',
description: t('wizard.channel_discord_desc') || 'Connect your agent to a Discord server via bot token.',
token_label: t('wizard.channel_discord_token') || 'Bot Token',
token_placeholder: 'MTIz...abc',
token_env: 'DISCORD_BOT_TOKEN',
help: t('wizard.channel_discord_help') || 'Create a Discord application at discord.com/developers and add a bot.'
},
{
name: 'slack',
display_name: t('wizard.channel_slack') || 'Slack',
icon: 'SL',
description: t('wizard.channel_slack_desc') || 'Connect your agent to a Slack workspace.',
token_label: t('wizard.channel_slack_token') || 'Bot Token',
token_placeholder: 'xoxb-...',
token_env: 'SLACK_BOT_TOKEN',
help: t('wizard.channel_slack_help') || 'Create a Slack app at api.slack.com/apps and install it to your workspace.'
}
];
},
channelToken: '',
configuringChannel: false,
channelConfigured: false,
@@ -244,6 +282,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.';
}
@@ -278,16 +325,26 @@ function wizardPage() {
},
stepLabel(n) {
var labels = ['Welcome', 'Provider', 'Agent', 'Try It', 'Channel', 'Done'];
var t = window.i18n ? window.i18n.t.bind(window.i18n) : function(k) { return k; };
var labels = [
t('wizard.step_welcome') || 'Welcome',
t('wizard.step_provider') || 'Provider',
t('wizard.step_agent') || 'Agent',
t('wizard.step_try_it') || 'Try It',
t('wizard.step_channel') || 'Channel',
t('wizard.step_done') || 'Done'
];
return labels[n - 1] || '';
},
get canGoNext() {
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider;
if (this.step === 2) return this.keySaved || this.hasConfiguredProvider || this.claudeCodeDetected;
if (this.step === 3) return this.agentName.trim().length > 0;
return true;
},
claudeCodeDetected: false,
get hasConfiguredProvider() {
var self = this;
return this.providers.some(function(p) {
@@ -301,15 +358,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 = []; }
},
@@ -320,7 +368,7 @@ function wizardPage() {
},
get popularProviders() {
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
return this.providers.filter(function(p) {
return popular.indexOf(p.id) >= 0;
}).sort(function(a, b) {
@@ -329,7 +377,7 @@ function wizardPage() {
},
get otherProviders() {
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter'];
var popular = ['anthropic', 'openai', 'gemini', 'groq', 'deepseek', 'openrouter', 'claude-code'];
return this.providers.filter(function(p) {
return popular.indexOf(p.id) < 0;
});
@@ -355,7 +403,8 @@ function wizardPage() {
fireworks: { url: 'https://fireworks.ai/account/api-keys', text: 'Get your key from Fireworks AI' },
perplexity: { url: 'https://www.perplexity.ai/settings/api', text: 'Get your key from Perplexity Settings' },
cohere: { url: 'https://dashboard.cohere.com/api-keys', text: 'Get your key from the Cohere Dashboard' },
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' }
xai: { url: 'https://console.x.ai/', text: 'Get your key from the xAI Console' },
'claude-code': { url: 'https://docs.anthropic.com/en/docs/claude-code', text: 'Install: npm install -g @anthropic-ai/claude-code && claude auth (no API key needed)' }
};
return help[id] || null;
},
@@ -369,7 +418,7 @@ function wizardPage() {
if (!provider) return;
var key = this.apiKeyInput.trim();
if (!key) {
OpenFangToast.error('Please enter an API key');
OpenFangToast.error(window.i18n ? window.i18n.t('wizard.enter_api_key') : 'Please enter an API key');
return;
}
this.savingKey = true;
@@ -378,12 +427,12 @@ function wizardPage() {
this.apiKeyInput = '';
this.keySaved = true;
this.setupSummary.provider = provider.display_name;
OpenFangToast.success('API key saved for ' + provider.display_name);
OpenFangToast.success((window.i18n ? window.i18n.t('wizard.api_key_saved') : 'API key saved for') + ' ' + provider.display_name);
await this.loadProviders();
// Auto-test after saving
await this.testKey();
} catch(e) {
OpenFangToast.error('Failed to save key: ' + e.message);
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_save_key') : 'Failed to save key:') + ' ' + e.message);
}
this.savingKey = false;
},
@@ -397,13 +446,35 @@ function wizardPage() {
var result = await OpenFangAPI.post('/api/providers/' + encodeURIComponent(provider.id) + '/test', {});
this.testResult = result;
if (result.status === 'ok') {
OpenFangToast.success(provider.display_name + ' connected (' + (result.latency_ms || '?') + 'ms)');
OpenFangToast.success(provider.display_name + ' ' + (window.i18n ? window.i18n.t('wizard.connected') : 'connected') + ' (' + (result.latency_ms || '?') + 'ms)');
} else {
OpenFangToast.error(provider.display_name + ': ' + (result.error || 'Connection failed'));
OpenFangToast.error(provider.display_name + ': ' + (result.error || (window.i18n ? window.i18n.t('wizard.connection_failed') : 'Connection failed')));
}
} catch(e) {
this.testResult = { status: 'error', error: e.message };
OpenFangToast.error('Test failed: ' + e.message);
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.test_failed') : 'Test failed:') + ' ' + e.message);
}
this.testingProvider = false;
},
async detectClaudeCode() {
this.testingProvider = true;
this.testResult = null;
try {
var result = await OpenFangAPI.post('/api/providers/claude-code/test', {});
this.testResult = result;
if (result.status === 'ok') {
this.claudeCodeDetected = true;
this.keySaved = true;
this.setupSummary.provider = 'Claude Code';
OpenFangToast.success('Claude Code detected (' + (result.latency_ms || '?') + 'ms)');
} else {
this.testResult = { status: 'error', error: 'Claude Code CLI not detected' };
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
}
} catch(e) {
this.testResult = { status: 'error', error: e.message };
OpenFangToast.error('Claude Code CLI not detected. Make sure you\'ve run: npm install -g @anthropic-ai/claude-code && claude auth');
}
this.testingProvider = false;
},
@@ -423,7 +494,7 @@ function wizardPage() {
if (!tpl) return;
var name = this.agentName.trim();
if (!name) {
OpenFangToast.error('Please enter a name for your agent');
OpenFangToast.error(window.i18n ? window.i18n.t('wizard.enter_agent_name') : 'Please enter a name for your agent');
return;
}
@@ -437,12 +508,12 @@ function wizardPage() {
}
var toml = '[agent]\n';
toml += 'name = "' + name.replace(/"/g, '\\"') + '"\n';
toml += 'description = "' + tpl.description.replace(/"/g, '\\"') + '"\n';
toml += 'name = "' + wizardTomlBasicEscape(name) + '"\n';
toml += 'description = "' + wizardTomlBasicEscape(tpl.description) + '"\n';
toml += 'profile = "' + tpl.profile + '"\n\n';
toml += '[model]\nprovider = "' + provider + '"\n';
toml += 'name = "' + model + '"\n\n';
toml += '[prompt]\nsystem = """\n' + tpl.system_prompt + '\n"""\n';
toml += 'model = "' + model + '"\n';
toml += 'system_prompt = """\n' + wizardTomlMultilineEscape(tpl.system_prompt) + '\n"""\n';
this.creatingAgent = true;
try {
@@ -450,13 +521,13 @@ function wizardPage() {
if (res.agent_id) {
this.createdAgent = { id: res.agent_id, name: res.name || name };
this.setupSummary.agent = res.name || name;
OpenFangToast.success('Agent "' + (res.name || name) + '" created');
OpenFangToast.success((window.i18n ? window.i18n.t('wizard.agent_created') : 'Agent') + ' "' + (res.name || name) + '" ' + (window.i18n ? window.i18n.t('wizard.agent_created_suffix') || 'created' : 'created'));
await Alpine.store('app').refreshAgents();
} else {
OpenFangToast.error('Failed: ' + (res.error || 'Unknown error'));
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_create_agent') : 'Failed:') + ' ' + (res.error || 'Unknown error'));
}
} catch(e) {
OpenFangToast.error('Failed to create agent: ' + e.message);
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_create_agent') : 'Failed to create agent:') + ' ' + e.message);
}
this.creatingAgent = false;
},
@@ -468,13 +539,14 @@ function wizardPage() {
gemini: 'gemini-2.5-flash',
groq: 'llama-3.3-70b-versatile',
deepseek: 'deepseek-chat',
openrouter: 'openrouter/auto',
openrouter: 'openrouter/google/gemini-2.5-flash',
mistral: 'mistral-large-latest',
together: 'meta-llama/Llama-3-70b-chat-hf',
fireworks: 'accounts/fireworks/models/llama-v3p1-70b-instruct',
perplexity: 'llama-3.1-sonar-large-128k-online',
cohere: 'command-r-plus',
xai: 'grok-2'
xai: 'grok-2',
'claude-code': 'claude-code/sonnet'
};
return defaults[providerId] || '';
},
@@ -502,7 +574,7 @@ function wizardPage() {
if (!ch) return;
var token = this.channelToken.trim();
if (!token) {
OpenFangToast.error('Please enter the ' + ch.token_label);
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.enter_token') : 'Please enter the') + ' ' + ch.token_label);
return;
}
this.configuringChannel = true;
@@ -513,9 +585,9 @@ function wizardPage() {
await OpenFangAPI.post('/api/channels/' + ch.name + '/configure', { fields: fields });
this.channelConfigured = true;
this.setupSummary.channel = ch.display_name;
OpenFangToast.success(ch.display_name + ' configured and activated.');
OpenFangToast.success(ch.display_name + ' ' + (window.i18n ? window.i18n.t('wizard.channel_configured') : 'configured and activated.'));
} catch(e) {
OpenFangToast.error('Failed: ' + (e.message || 'Unknown error'));
OpenFangToast.error((window.i18n ? window.i18n.t('wizard.failed_configure') : 'Failed:') + ' ' + (e.message || 'Unknown error'));
}
this.configuringChannel = false;
},
@@ -38,6 +38,11 @@ function workflowBuilder() {
],
_renderScheduled: false,
_lastClickNodeId: null,
_lastClickTime: 0,
_didDrag: false,
_didConnect: false,
_didPan: false,
async init() {
var self = this;
@@ -334,8 +339,23 @@ function workflowBuilder() {
onNodeMouseDown: function(node, e) {
e.stopPropagation();
// Detect double-click manually — the native dblclick event never fires
// because scheduleRender() destroys and recreates all SVG elements between
// the first and second click, so the browser loses the DOM target for dblclick.
var now = Date.now();
if (this._lastClickNodeId === node.id && (now - this._lastClickTime) < 350) {
// Double-click detected — open editor instead of starting drag
this._lastClickNodeId = null;
this._lastClickTime = 0;
this.editNode(node);
return;
}
this._lastClickNodeId = node.id;
this._lastClickTime = now;
this.selectedNode = node;
this.selectedConnection = null;
this._didDrag = false;
this.dragging = node.id;
var rect = this._getCanvasRect();
this.dragOffset = {
@@ -350,6 +370,7 @@ function workflowBuilder() {
this.selectedConnection = null;
this.showNodeEditor = false;
// Start canvas pan
this._didPan = false;
this.canvasDragging = true;
this.canvasDragStart = { x: e.clientX - this.canvasOffset.x * this.zoom, y: e.clientY - this.canvasOffset.y * this.zoom };
},
@@ -357,6 +378,7 @@ function workflowBuilder() {
onCanvasMouseMove: function(e) {
var rect = this._getCanvasRect();
if (this.dragging) {
this._didDrag = true;
var node = this.getNode(this.dragging);
if (node) {
node.x = Math.max(0, (e.clientX - rect.left) / this.zoom - this.canvasOffset.x - this.dragOffset.x);
@@ -364,12 +386,14 @@ function workflowBuilder() {
}
this.scheduleRender();
} else if (this.connecting) {
this._didConnect = true;
this.connectPreview = {
x: (e.clientX - rect.left) / this.zoom - this.canvasOffset.x,
y: (e.clientY - rect.top) / this.zoom - this.canvasOffset.y
};
this.scheduleRender();
} else if (this.canvasDragging) {
this._didPan = true;
this.canvasOffset = {
x: (e.clientX - this.canvasDragStart.x) / this.zoom,
y: (e.clientY - this.canvasDragStart.y) / this.zoom
@@ -378,11 +402,19 @@ function workflowBuilder() {
},
onCanvasMouseUp: function() {
// Only re-render if something actually moved. Rendering on every mouseup
// destroys SVG elements between clicks, which prevents dblclick detection.
var needsRender = this._didDrag || this._didConnect || this._didPan;
this.dragging = null;
this.connecting = null;
this.connectPreview = null;
this.canvasDragging = false;
this.scheduleRender();
this._didDrag = false;
this._didConnect = false;
this._didPan = false;
if (needsRender) {
this.scheduleRender();
}
},
onCanvasWheel: function(e) {
@@ -427,6 +459,12 @@ function workflowBuilder() {
editNode: function(node) {
this.selectedNode = node;
this.showNodeEditor = true;
this.scheduleRender();
},
// Called from editor panel inputs to reflect changes on the canvas SVG
applyNodeEdit: function() {
this.scheduleRender();
},
// ── TOML Generation ──────────────────────────────────
@@ -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);
}
}
};
}
+12
View File
@@ -0,0 +1,12 @@
{
"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": "128x128", "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
File diff suppressed because it is too large Load Diff
@@ -98,6 +98,7 @@ async fn test_full_daemon_lifecycle() {
model: "test".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
@@ -114,6 +115,8 @@ async fn test_full_daemon_lifecycle() {
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
@@ -223,6 +226,7 @@ async fn test_server_immediate_responsiveness() {
model: "test".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
@@ -238,6 +242,8 @@ async fn test_server_immediate_responsiveness() {
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
+4 -1
View File
@@ -42,6 +42,7 @@ async fn start_test_server() -> TestServer {
model: "test-model".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
@@ -58,6 +59,8 @@ async fn start_test_server() -> TestServer {
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
@@ -541,7 +544,7 @@ async fn load_spawn_kill_cycle() {
.await
.unwrap();
let remaining = agents.as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(remaining, 0, "All agents should be killed");
assert_eq!(remaining, 1, "Only default assistant should remain");
}
/// Test: Prometheus metrics endpoint under sustained load.
@@ -0,0 +1,385 @@
//! Integration tests for the `/api/skills/{id}/config` surface.
//!
//! These boot a real kernel, start a real axum server on a random port, plant
//! a synthetic skill on disk whose SKILL.md declares a `config:` section, and
//! exercise GET / PUT / DELETE end to end. Bundled skills currently declare
//! no runtime config, so the synthetic skill fixture is what lets us prove
//! the wire contract.
//!
//! Run: cargo test -p openfang-api --test skill_config_api_test -- --nocapture
use axum::Router;
use openfang_api::middleware;
use openfang_api::routes::{self, AppState};
use openfang_kernel::OpenFangKernel;
use openfang_types::config::{DefaultModelConfig, KernelConfig};
use std::sync::Arc;
use std::time::Instant;
use tower_http::cors::CorsLayer;
// ---------------------------------------------------------------------------
// Test server harness
// ---------------------------------------------------------------------------
struct TestServer {
base_url: String,
home_dir: std::path::PathBuf,
#[allow(dead_code)]
state: Arc<AppState>,
_tmp: tempfile::TempDir,
}
impl Drop for TestServer {
fn drop(&mut self) {
self.state.kernel.shutdown();
}
}
/// Write a skill fixture under `<home>/skills/<name>/SKILL.md` that declares
/// a `config:` section. This matches the on-disk format that OpenClaw skills
/// use, so the loader's real `parse_skillmd_str` path is exercised.
fn plant_skill_with_config(home: &std::path::Path, skill_name: &str) {
let skill_dir = home.join("skills").join(skill_name);
std::fs::create_dir_all(&skill_dir).unwrap();
// Leading four spaces inside YAML lists matter — keep them.
let skillmd = format!(
"---
name: {skill_name}
description: Synthetic skill for config endpoint tests
config:
github_token:
description: GitHub personal access token
env: OPENFANG_TEST_SKILLCFG_GH_TOKEN
required: true
default_branch:
description: Default branch name
default: main
required: false
---
# Test Skill
Placeholder body so the parser accepts this as a valid prompt-only skill.
"
);
std::fs::write(skill_dir.join("SKILL.md"), skillmd).unwrap();
}
async fn start_test_server() -> TestServer {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().to_path_buf();
let config = KernelConfig {
home_dir: home.clone(),
data_dir: home.join("data"),
default_model: DefaultModelConfig {
provider: "ollama".to_string(),
model: "test-model".to_string(),
api_key_env: "OLLAMA_API_KEY".to_string(),
base_url: None,
subprocess_timeout_secs: None,
},
..KernelConfig::default()
};
// Plant synthetic skill BEFORE booting so the initial skill load picks it up.
plant_skill_with_config(&home, "test-config-skill");
let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boot");
let kernel = Arc::new(kernel);
kernel.set_self_handle();
let state = Arc::new(AppState {
kernel,
started_at: Instant::now(),
peer_registry: None,
bridge_manager: tokio::sync::Mutex::new(None),
channels_config: tokio::sync::RwLock::new(Default::default()),
shutdown_notify: Arc::new(tokio::sync::Notify::new()),
clawhub_cache: dashmap::DashMap::new(),
provider_probe_cache: openfang_runtime::provider_health::ProbeCache::new(),
budget_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
});
let app = Router::new()
.route("/api/skills", axum::routing::get(routes::list_skills))
.route(
"/api/skills/{id}/config",
axum::routing::get(routes::get_skill_config).put(routes::put_skill_config),
)
.route(
"/api/skills/{id}/config/{var_name}",
axum::routing::delete(routes::delete_skill_config_var),
)
.layer(axum::middleware::from_fn(middleware::request_logging))
.layer(CorsLayer::permissive())
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test port");
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
TestServer {
base_url: format!("http://{}", addr),
home_dir: home,
state,
_tmp: tmp,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn get_config_returns_declared_and_resolved() {
// Make sure no host leak from prior tests interferes.
// SAFETY: single-threaded test, env var is unique to this test suite.
unsafe { std::env::remove_var("OPENFANG_TEST_SKILLCFG_GH_TOKEN") };
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["skill"], "test-config-skill");
// Both vars declared
assert!(body["declared"]["github_token"].is_object());
assert!(body["declared"]["default_branch"].is_object());
assert_eq!(body["declared"]["github_token"]["required"], true);
assert_eq!(body["declared"]["default_branch"]["required"], false);
// github_token has no user override, no env, no default -> unresolved.
assert_eq!(
body["resolved"]["github_token"]["source"], "unresolved",
"github_token should be unresolved without env"
);
assert!(body["resolved"]["github_token"]["is_secret"]
.as_bool()
.unwrap());
// default_branch falls back to default "main".
assert_eq!(body["resolved"]["default_branch"]["source"], "default");
assert_eq!(body["resolved"]["default_branch"]["value"], "main");
}
#[tokio::test]
async fn get_config_redacts_secret_values_after_put() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Write a real-looking token via PUT.
let payload = serde_json::json!({
"values": {
"github_token": "ghp_realsecretvalue_DO_NOT_LEAK",
"default_branch": "develop"
}
});
let resp = client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&payload)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "PUT should succeed");
// GET back and confirm the secret is redacted and non-secret is visible.
let resp = client
.get(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.send()
.await
.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
let returned_token = body["resolved"]["github_token"]["value"]
.as_str()
.unwrap()
.to_string();
assert!(
!returned_token.contains("realsecretvalue"),
"secret leaked on the wire: {returned_token}"
);
assert!(
returned_token.contains("redacted"),
"expected redaction marker, got: {returned_token}"
);
assert_eq!(body["resolved"]["github_token"]["source"], "user");
// Non-secret var kept as-is.
assert_eq!(body["resolved"]["default_branch"]["value"], "develop");
assert_eq!(body["resolved"]["default_branch"]["source"], "user");
// config.toml persisted the change, including the full secret value
// (redaction is only on the wire — disk is the source of truth).
let cfg = std::fs::read_to_string(server.home_dir.join("config.toml")).unwrap();
assert!(
cfg.contains("[skills.test-config-skill]"),
"skills section missing: {cfg}"
);
assert!(cfg.contains("realsecretvalue"));
assert!(cfg.contains("develop"));
}
#[tokio::test]
async fn put_rejects_unknown_variable() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let payload = serde_json::json!({
"values": { "nonexistent_var": "value" }
});
let resp = client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&payload)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["error"].as_str().unwrap().contains("nonexistent_var"));
}
#[tokio::test]
async fn delete_override_reverts_to_default() {
let server = start_test_server().await;
let client = reqwest::Client::new();
// Set override first.
client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&serde_json::json!({
"values": { "default_branch": "develop" }
}))
.send()
.await
.unwrap();
// Remove it.
let resp = client
.delete(format!(
"{}/api/skills/test-config-skill/config/default_branch",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// Now source should be "default" again with value "main".
let body: serde_json::Value = client
.get(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(body["resolved"]["default_branch"]["source"], "default");
assert_eq!(body["resolved"]["default_branch"]["value"], "main");
}
#[tokio::test]
async fn delete_refuses_to_strand_required_var() {
// github_token is required, has no default, and no env — so removing an
// override would leave it unresolvable. The endpoint must refuse.
// SAFETY: single-threaded test.
unsafe { std::env::remove_var("OPENFANG_TEST_SKILLCFG_GH_TOKEN") };
let server = start_test_server().await;
let client = reqwest::Client::new();
// Set an override.
client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&serde_json::json!({
"values": { "github_token": "ghp_value" }
}))
.send()
.await
.unwrap();
// Try to delete — should 409.
let resp = client
.delete(format!(
"{}/api/skills/test-config-skill/config/github_token",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 409);
}
#[tokio::test]
async fn get_unknown_skill_returns_404() {
let server = start_test_server().await;
let client = reqwest::Client::new();
let resp = client
.get(format!(
"{}/api/skills/this-skill-does-not-exist/config",
server.base_url
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn put_reloads_registry_so_agents_see_change() {
let server = start_test_server().await;
let client = reqwest::Client::new();
client
.put(format!(
"{}/api/skills/test-config-skill/config",
server.base_url
))
.json(&serde_json::json!({
"values": {
"github_token": "ghp_new",
"default_branch": "release"
}
}))
.send()
.await
.unwrap();
// The kernel's live override map must now hold the new values.
let guard = server.state.kernel.skill_config_overrides.read().unwrap();
let overrides = guard.as_ref().expect("override map set after PUT");
let skill_cfg = overrides.get("test-config-skill").expect("skill present");
assert_eq!(skill_cfg.get("github_token").unwrap(), "ghp_new");
assert_eq!(skill_cfg.get("default_branch").unwrap(), "release");
}
+8
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,13 +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 }
+7 -3
View File
@@ -215,7 +215,7 @@ impl BlueskyAdapter {
let chunks = split_message(text, MAX_MESSAGE_LEN);
for chunk in chunks {
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let mut record = serde_json::json!({
"$type": "app.bsky.feed.post",
@@ -435,7 +435,11 @@ impl ChannelAdapter for BlueskyAdapter {
service_url
);
if let Some(ref seen) = last_seen_at {
url.push_str(&format!("&seenAt={}", seen));
let encoded: String = url::form_urlencoded::Serializer::new(String::new())
.append_pair("seenAt", seen)
.finish();
url.push('&');
url.push_str(&encoded);
}
let resp = match client.get(&url).bearer_auth(&token).send().await {
@@ -492,7 +496,7 @@ impl ChannelAdapter for BlueskyAdapter {
if last_seen_at.is_some() {
let mark_url = format!("{}/xrpc/app.bsky.notification.updateSeen", service_url);
let mark_body = serde_json::json!({
"seenAt": Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(),
"seenAt": Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
});
let _ = client
.post(&mark_url)

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