Compare commits

...

34 Commits

Author SHA1 Message Date
argenis de la rosa 8144938ac5 feat(web): electric blue dashboard restyle with animations and logo
Restyle the entire web dashboard with an electric blue theme featuring
glassmorphism cards, smooth animations, and the ZeroClaw logo. Remove
duplicate Vite dev server infrastructure to ensure a single gateway.

- Add electric blue color palette and glassmorphism styling system
- Add 10+ keyframe animations (fade, slide, pulse-glow, shimmer, float)
- Restyle all 10 pages with glass cards and electric components
- Add ZeroClaw logo to sidebar, pairing screen, and favicon
- Remove Vite dev/preview scripts and proxy config (build-only now)
- Update pairing dialog with ambient glow and animated elements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:06:54 -04:00
argenis de la rosa 7e0570abd6 Merge remote-tracking branch 'origin/master' 2026-03-13 09:38:00 -04:00
Argenis 5d1543100d fix: support Linq 2026-02-03 webhook payload shape (#3337) (#3410)
Closes #3337

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:31:53 -04:00
Argenis e3a91bc805 fix: gate prometheus and fix AtomicU64 for 32-bit targets (#3409)
* fix: gate prometheus and fix AtomicU64 for 32-bit targets (#3335)

Closes #3335

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

* style: fix import ordering for cfg-gated atomics

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:31:25 -04:00
Argenis 833fdefbe5 fix: restore MCP support missing from master branch (#3412)
MCP (Model Context Protocol) config and tool modules were added on the
old `main` branch but never made it to `master`. This restores the full
MCP subsystem: config schema, transport layer (stdio/HTTP/SSE), client
registry, tool wrapper, config validation, and channel wiring.

Closes #3379

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:20:37 -04:00
Argenis 13f74f0ecc fix: restore web dashboard by auto-building frontend in build.rs (#3408)
The build.rs was reduced to only creating an empty web/dist/ directory,
which meant rust-embed would embed no files and the SPA fallback handler
would return 404 for every request including `/`. This is a regression
from v0.1.8 where web/dist/ was still tracked in git.

Update build.rs to detect when web/dist/index.html is missing and
automatically run `npm ci && npm run build` if npm is available. The
build is best-effort: when Node.js is absent the Rust build still
succeeds with an empty dist directory (release workflows pre-build the
frontend separately).

Closes #3386

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:20:34 -04:00
Argenis 9ff045d2e9 fix: resolve install.sh prebuilt download 404 by querying releases API (#3406)
The /releases/latest/download/ URL only resolves to the latest non-prerelease,
non-draft release. When that release has no binary assets (e.g. v0.1.9a),
--prebuilt-only fails with a 404. This adds resolve_asset_url() which queries
the GitHub releases API for the newest release (including prereleases) that
actually contains the requested asset, falling back to /releases/latest/ if
the API call fails.

Closes #3389

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:20:31 -04:00
Argenis 6fe8e3a5bb fix: gracefully handle reasoning_enabled for unsupported Ollama models (#3411)
When reasoning_enabled is configured, the Ollama provider sends
think=true to all models. Models that don't support the think parameter
(e.g. qwen3.5:0.8b) cause request failures that the reliable provider
classifies as retryable, leading to an infinite retry loop.

Fix: when a request with think=true fails, automatically retry once
with think omitted. This lets the call succeed on models that lack
reasoning support while preserving thinking for capable models.

Closes #3183
Related #850

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:16:03 -04:00
Argenis 5dc1750df7 fix: add crypto.randomUUID fallback for older browsers (#3407)
Replace direct `crypto.randomUUID()` calls in the web dashboard with a
`generateUUID()` utility that falls back to a manual UUID v4 implementation
using `crypto.getRandomValues()` when `randomUUID` is unavailable (older
Safari, some Electron builds, Raspberry Pi browsers).

Closes #3303
Closes #3261

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:16:00 -04:00
argenis de la rosa 0931b140cf Merge origin/master into master
Made-with: Cursor
2026-03-13 09:08:46 -04:00
SimianAstronaut7 b40c9e77af Merge pull request #3365 from zeroclaw-labs/ci/fix-glibc-cache-mismatch
ci: pin release workflows to ubuntu-latest to fix glibc cache mismatch
2026-03-12 17:44:42 -04:00
simianastronaut 34cac3d9dd ci: pin release workflows to ubuntu-latest to fix glibc cache mismatch
CI workflows use ubuntu-latest (24.04, glibc 2.39) while release
workflows used ubuntu-22.04 (glibc 2.35). Swatinem/rust-cache keys
on runner.os ("Linux"), not the specific version, so cached build
scripts compiled on 24.04 would fail on 22.04 with GLIBC_2.39 not
found errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:37:31 -04:00
SimianAstronaut7 badf96dcab Merge pull request #3363 from zeroclaw-labs/ci/faster-apple-build
ci: use thin LTO profile for faster CI builds
2026-03-12 17:31:29 -04:00
simianastronaut c1e1228fb0 ci: use thin LTO profile for faster CI builds
The release profile uses fat LTO + codegen-units=1, which is
optimal for distribution binaries but unnecessarily slow for CI
validation builds. Add a dedicated `ci` profile with thin LTO and
codegen-units=16, and use it in both CI workflows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:18:36 -04:00
SimianAstronaut7 d2b923ae07 Merge pull request #3322 from zeroclaw-labs/work-issues/2984-fix-cli-chinese-input-crash
fix(agent): use byte-level stdin reads to prevent CJK input crash
2026-03-12 16:58:46 +00:00
SimianAstronaut7 21fdef95f4 Merge pull request #3324 from zeroclaw-labs/work-issues/2907-channel-send-message
feat(channel): add `channel send` CLI command for outbound messages
2026-03-12 16:58:44 +00:00
SimianAstronaut7 d02fbf2d76 Merge pull request #3326 from zeroclaw-labs/work-issues/2978-tool-call-dedup-exempt
feat(agent): add tool_call_dedup_exempt config to bypass within-turn dedup
2026-03-12 16:58:41 +00:00
SimianAstronaut7 05cede29a8 Merge pull request #3328 from zeroclaw-labs/fix/2926-configurable-provider-timeout
feat(provider): make HTTP request timeout configurable
2026-03-12 16:58:38 +00:00
SimianAstronaut7 d34a2e6d3f Merge pull request #3329 from zeroclaw-labs/work-issues/2443-approval-manager-shadowed-binding
fix(channel): remove shadowed variable bindings in test functions
2026-03-12 16:58:35 +00:00
SimianAstronaut7 576d22fedd Merge pull request #3330 from zeroclaw-labs/work-issues/2884-ws-token-query-param
fix(gateway): restore multi-source WebSocket auth token extraction
2026-03-12 16:58:32 +00:00
SimianAstronaut7 d5455c694c Merge pull request #3332 from zeroclaw-labs/work-issues/2896-discord-ws-silent-stop
fix(channel): handle websocket Ping frames and read errors in Discord gateway
2026-03-12 16:58:30 +00:00
SimianAstronaut7 90275b057e Merge pull request #3339 from zeroclaw-labs/work-issues/2880-fix-workspace-path-blocked
fix(security): allow absolute paths within workspace when workspace_only is set
2026-03-12 16:58:27 +00:00
SimianAstronaut7 d46b4f29d2 Merge pull request #3341 from zeroclaw-labs/work-issues/2403-telegram-photo-duplicate
fix(channel): prevent first-turn photo duplication in memory context
2026-03-12 16:58:23 +00:00
simianastronaut f25835f98c fix(channel): prevent first-turn photo duplication in memory context (#2403)
When auto_save is enabled and a photo is sent on the first turn of a
Telegram session, the [IMAGE:] marker was duplicated because:

1. auto_save stores the photo message (including the marker) to memory
2. build_memory_context recalls the just-saved entry as relevant context
3. The recalled marker is prepended to the original message content

Fix: skip memory context entries containing [IMAGE:] markers in
should_skip_memory_context_entry so auto-saved photo messages are not
re-injected through memory context enrichment.

Closes #2403

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:03:29 -04:00
simianastronaut 376579f9fa fix(security): allow absolute paths within workspace when workspace_only is set (#2880)
When workspace_only=true, is_path_allowed() blanket-rejected all
absolute paths.  This blocked legitimate tool calls that referenced
files inside the workspace using an absolute path (e.g. saving a
screenshot to /home/user/.zeroclaw/workspace/images/example.png).

The fix checks whether an absolute path falls within workspace_dir or
any configured allowed_root before rejecting it, mirroring the priority
order already used by is_resolved_path_allowed().  Paths outside the
workspace and allowed roots are still blocked, and the forbidden-paths
list continues to apply to all other absolute paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:59:35 -04:00
simianastronaut b620fd6bba fix(channel): handle websocket Ping frames and read errors in Discord gateway
The Discord gateway event loop silently dropped websocket Ping frames
(via a catch-all `_ => continue`) without responding with Pong. After
splitting the websocket stream into read/write halves, automatic
Ping/Pong handling is disabled, so the server-side (Cloudflare/Discord)
eventually considers the client unresponsive and stops sending events.

Additionally, websocket read errors (`Some(Err(_))`) were silently
swallowed by the same catch-all, preventing reconnection on transient
failures.

This patch:
- Responds to `Message::Ping` with `Message::Pong` to maintain the
  websocket keepalive contract
- Breaks the event loop on `Some(Err(_))` with a warning log, allowing
  the supervisor to reconnect

Closes #2896

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:15:53 -04:00
simianastronaut 98d6c5af9e fix(gateway): restore multi-source WebSocket auth token extraction (#2884)
The Electric Blue dashboard (PR #2804) sends the pairing token as a
?token= query parameter, but the WS handler only checked that single
source. Earlier PR #2193 had established a three-source precedence
chain (header > subprotocol > query param) which was lost.

Add extract_ws_token() with the documented precedence:
  1. Authorization: Bearer <token> header
  2. Sec-WebSocket-Protocol: bearer.<token> subprotocol
  3. ?token=<token> query parameter

This ensures browser-based clients (which cannot set custom headers)
can authenticate via query param or subprotocol, while non-browser
clients can use the standard Authorization header.

Includes 9 unit tests covering each source, precedence ordering,
and empty-value fallthrough.

Closes #2884

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:01:26 -04:00
simianastronaut c51ca19dc1 fix(channel): remove shadowed variable bindings in test functions (#2443)
Rename shadowed `histories` and `store` bindings in three test functions
to eliminate variable shadows that are flagged under stricter lint
configurations (clippy::shadow_unrelated). The initial bindings are
consumed by struct initialization; the second bindings that lock the
mutex guard are now named distinctly (`locked_histories`, `cleanup_store`).

Closes #2443

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:00:31 -04:00
simianastronaut ea6abc9f42 feat(provider): make HTTP request timeout configurable (#2926)
The provider HTTP request timeout was hardcoded at 120 seconds in
`OpenAiCompatibleProvider::http_client()`. This makes it configurable
via the `provider_timeout_secs` config key and the
`ZEROCLAW_PROVIDER_TIMEOUT_SECS` environment variable, defaulting
to 120s for backward compatibility.

Changes:
- Add `provider_timeout_secs` field to Config with serde default
- Add `ZEROCLAW_PROVIDER_TIMEOUT_SECS` env var override
- Add `timeout_secs` field and `with_timeout_secs()` builder on
  `OpenAiCompatibleProvider`
- Add `provider_timeout_secs` to `ProviderRuntimeOptions`
- Thread config value through agent loop, channels, gateway, and tools
- Use `compat()` closure in provider factory to apply timeout to all
  compatible providers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:40:18 -04:00
simianastronaut e2f6f20bfb feat(agent): add tool_call_dedup_exempt config to bypass within-turn dedup (#2978)
Add `agent.tool_call_dedup_exempt` config key (list of tool names) to
allow specific tools to bypass the within-turn identical-signature
deduplication check in run_tool_call_loop. This fixes the browser
snapshot polling use case where repeated calls with identical arguments
are legitimate and should not be suppressed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:28:42 -04:00
simianastronaut 88df3d4b2e feat(channel): add channel send CLI command for outbound messages (#2907)
Add a `zeroclaw channel send` subcommand that sends a one-off message
through a configured channel without starting the full agent loop.
This enables hardware sensor triggers (e.g., range sensors on
Raspberry Pi) to push notifications to Telegram and other platforms.

Usage:
  zeroclaw channel send 'Alert!' --channel-id telegram --recipient <chat_id>

Supported channels: telegram, discord, slack.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:18:04 -04:00
simianastronaut 0dba55959d fix(agent): use byte-level stdin reads to prevent CJK input crash
When running through a PTY chain (kubectl exec, SSH, remote terminals),
the transport layer may split data frames at space (0x20) boundaries,
interrupting multi-byte UTF-8 characters mid-sequence. Rust's
BufRead::read_line requires valid UTF-8 and returns InvalidData
immediately, crashing the interactive agent loop.

Replace stdin().read_line() with byte-level read_until(b'\n') followed
by String::from_utf8_lossy() in both the main input loop and the
/clear confirmation prompt. This reads raw bytes without UTF-8
validation during transport, then does lossy conversion (replacing any
truly invalid bytes with U+FFFD instead of crashing).

Also set ENV LANG=C.UTF-8 in both Dockerfile stages as defense-in-depth
to ensure the container locale defaults to UTF-8.

Closes #2984

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:08:49 -04:00
argenis de la rosa e46a334f6d Merge remote-tracking branch 'refs/remotes/origin/master' 2026-03-11 17:05:46 -04:00
argenis de la rosa 76a6ab5b12 chore(github): update review ownership routing
Remove JordanTheJet from CODEOWNERS review routing and align the workflow review-policy docs with the current approver fallback.

This keeps protected paths owned by theonlyhennygod and SimianAstronaut7 without pulling in unrelated README edits.
2026-03-11 15:35:15 -04:00
57 changed files with 3740 additions and 747 deletions
+1 -1
View File
@@ -96,7 +96,7 @@ jobs:
- name: Build release
shell: bash
run: cargo build --release --locked --target ${{ matrix.target }}
run: cargo build --profile ci --locked --target ${{ matrix.target }}
env:
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
+1 -1
View File
@@ -124,7 +124,7 @@ jobs:
- name: Build release
shell: bash
run: cargo build --release --locked --target ${{ matrix.target }}
run: cargo build --profile ci --locked --target ${{ matrix.target }}
env:
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
+2 -2
View File
@@ -65,11 +65,11 @@ jobs:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact: zeroclaw
ext: tar.gz
- os: ubuntu-22.04
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
artifact: zeroclaw
ext: tar.gz
+2 -2
View File
@@ -83,11 +83,11 @@ jobs:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact: zeroclaw
ext: tar.gz
- os: ubuntu-22.04
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
artifact: zeroclaw
ext: tar.gz
+9 -2
View File
@@ -48,8 +48,8 @@ schemars = "1.2"
tracing = { version = "0.1", default-features = false }
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi", "env-filter"] }
# Observability - Prometheus metrics
prometheus = { version = "0.14", default-features = false }
# Observability - Prometheus metrics (optional; requires AtomicU64, unavailable on 32-bit)
prometheus = { version = "0.14", default-features = false, optional = true }
# Base64 encoding (screenshots, image data)
base64 = "0.22"
@@ -205,6 +205,8 @@ sandbox-landlock = ["dep:landlock"]
sandbox-bubblewrap = []
# Backward-compatible alias for older invocations
landlock = ["sandbox-landlock"]
# Prometheus metrics observer (requires 64-bit atomics; disable on 32-bit targets)
metrics = ["dep:prometheus"]
# probe = probe-rs for Nucleo memory read (adds ~50 deps; optional)
probe = ["dep:probe-rs"]
# rag-pdf = PDF ingestion for datasheet RAG
@@ -225,6 +227,11 @@ inherits = "release"
codegen-units = 8 # Parallel codegen for faster builds on powerful machines (16GB+ RAM recommended)
# Use: cargo build --profile release-fast
[profile.ci]
inherits = "release"
lto = "thin" # Much faster than fat LTO; still catches release-mode issues
codegen-units = 16 # Full parallelism for CI runners
[profile.dist]
inherits = "release"
opt-level = "z"
+4
View File
@@ -90,6 +90,8 @@ COPY dev/config.template.toml /zeroclaw-data/.zeroclaw/config.toml
RUN chown 65534:65534 /zeroclaw-data/.zeroclaw/config.toml
# Environment setup
# Ensure UTF-8 locale so CJK / multibyte input is handled correctly
ENV LANG=C.UTF-8
# Use consistent workspace path
ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace
ENV HOME=/zeroclaw-data
@@ -114,6 +116,8 @@ COPY --from=builder /app/zeroclaw /usr/local/bin/zeroclaw
COPY --from=builder /zeroclaw-data /zeroclaw-data
# Environment setup
# Ensure UTF-8 locale so CJK / multibyte input is handled correctly
ENV LANG=C.UTF-8
ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace
ENV HOME=/zeroclaw-data
# Default provider and model are set in config.toml, not here,
+107 -3
View File
@@ -1,6 +1,110 @@
use std::path::Path;
use std::process::Command;
fn main() {
let dir = std::path::Path::new("web/dist");
if !dir.exists() {
std::fs::create_dir_all(dir).expect("failed to create web/dist/");
let dist_dir = Path::new("web/dist");
let web_dir = Path::new("web");
// Tell Cargo to re-run this script when web source files change.
println!("cargo:rerun-if-changed=web/src");
println!("cargo:rerun-if-changed=web/index.html");
println!("cargo:rerun-if-changed=web/package.json");
println!("cargo:rerun-if-changed=web/vite.config.ts");
// Attempt to build the web frontend if npm is available and web/dist is
// missing or stale. The build is best-effort: when Node.js is not
// installed (e.g. CI containers, cross-compilation, minimal dev setups)
// we fall back to the existing stub/empty dist directory so the Rust
// build still succeeds.
let needs_build = !dist_dir.join("index.html").exists();
if needs_build && web_dir.join("package.json").exists() {
if let Ok(npm) = which_npm() {
eprintln!("cargo:warning=Building web frontend (web/dist is missing or stale)...");
// npm ci / npm install
let install_status = Command::new(&npm)
.args(["ci", "--ignore-scripts"])
.current_dir(web_dir)
.status();
match install_status {
Ok(s) if s.success() => {}
Ok(s) => {
// Fall back to `npm install` if `npm ci` fails (no lockfile, etc.)
eprintln!("cargo:warning=npm ci exited with {s}, trying npm install...");
let fallback = Command::new(&npm)
.args(["install"])
.current_dir(web_dir)
.status();
if !matches!(fallback, Ok(s) if s.success()) {
eprintln!("cargo:warning=npm install failed — skipping web build");
ensure_dist_dir(dist_dir);
return;
}
}
Err(e) => {
eprintln!("cargo:warning=Could not run npm: {e} — skipping web build");
ensure_dist_dir(dist_dir);
return;
}
}
// npm run build
let build_status = Command::new(&npm)
.args(["run", "build"])
.current_dir(web_dir)
.status();
match build_status {
Ok(s) if s.success() => {
eprintln!("cargo:warning=Web frontend built successfully.");
}
Ok(s) => {
eprintln!(
"cargo:warning=npm run build exited with {s} — web dashboard may be unavailable"
);
}
Err(e) => {
eprintln!(
"cargo:warning=Could not run npm build: {e} — web dashboard may be unavailable"
);
}
}
}
}
ensure_dist_dir(dist_dir);
}
/// Ensure the dist directory exists so `rust-embed` does not fail at compile
/// time even when the web frontend is not built.
fn ensure_dist_dir(dist_dir: &Path) {
if !dist_dir.exists() {
std::fs::create_dir_all(dist_dir).expect("failed to create web/dist/");
}
}
/// Locate the `npm` binary on the system PATH.
fn which_npm() -> Result<String, ()> {
let cmd = if cfg!(target_os = "windows") {
"where"
} else {
"which"
};
Command::new(cmd)
.arg("npm")
.output()
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout)
.ok()
.map(|s| s.lines().next().unwrap_or("npm").trim().to_string())
} else {
None
}
})
.ok_or(())
}
+2
View File
@@ -70,6 +70,7 @@ Lưu ý cho người dùng container:
| `max_history_messages` | `50` | Số tin nhắn lịch sử tối đa giữ lại mỗi phiên |
| `parallel_tools` | `false` | Bật thực thi tool song song trong một lượt |
| `tool_dispatcher` | `auto` | Chiến lược dispatch tool |
| `tool_call_dedup_exempt` | `[]` | Tên tool được miễn kiểm tra trùng lặp trong cùng một lượt |
Lưu ý:
@@ -77,6 +78,7 @@ Lưu ý:
- Nếu tin nhắn kênh vượt giá trị này, runtime trả về: `Agent exceeded maximum tool iterations (<value>)`.
- Trong vòng lặp tool của CLI, gateway và channel, các lời gọi tool độc lập được thực thi đồng thời mặc định khi không cần phê duyệt; thứ tự kết quả giữ ổn định.
- `parallel_tools` áp dụng cho API `Agent::turn()`. Không ảnh hưởng đến vòng lặp runtime của CLI, gateway hay channel.
- `tool_call_dedup_exempt` nhận mảng tên tool chính xác. Các tool trong danh sách được phép gọi nhiều lần với cùng tham số trong một lượt. Ví dụ: `tool_call_dedup_exempt = ["browser"]`.
## `[agents.<name>]`
+2
View File
@@ -81,6 +81,7 @@ Operational note for container users:
| `max_history_messages` | `50` | Maximum conversation history messages retained per session |
| `parallel_tools` | `false` | Enable parallel tool execution within a single iteration |
| `tool_dispatcher` | `auto` | Tool dispatch strategy |
| `tool_call_dedup_exempt` | `[]` | Tool names exempt from within-turn duplicate-call suppression |
Notes:
@@ -88,6 +89,7 @@ Notes:
- If a channel message exceeds this value, the runtime returns: `Agent exceeded maximum tool iterations (<value>)`.
- In CLI, gateway, and channel tool loops, multiple independent tool calls are executed concurrently by default when the pending calls do not require approval gating; result order remains stable.
- `parallel_tools` applies to the `Agent::turn()` API surface. It does not gate the runtime loop used by CLI, gateway, or channel handlers.
- `tool_call_dedup_exempt` accepts an array of exact tool names. Tools listed here are allowed to be called multiple times with identical arguments in the same turn, bypassing the dedup check. Example: `tool_call_dedup_exempt = ["browser"]`.
## `[security.otp]`
+2
View File
@@ -70,6 +70,7 @@ Lưu ý cho người dùng container:
| `max_history_messages` | `50` | Số tin nhắn lịch sử tối đa giữ lại mỗi phiên |
| `parallel_tools` | `false` | Bật thực thi tool song song trong một lượt |
| `tool_dispatcher` | `auto` | Chiến lược dispatch tool |
| `tool_call_dedup_exempt` | `[]` | Tên tool được miễn kiểm tra trùng lặp trong cùng một lượt |
Lưu ý:
@@ -77,6 +78,7 @@ Lưu ý:
- Nếu tin nhắn kênh vượt giá trị này, runtime trả về: `Agent exceeded maximum tool iterations (<value>)`.
- Trong vòng lặp tool của CLI, gateway và channel, các lời gọi tool độc lập được thực thi đồng thời mặc định khi không cần phê duyệt; thứ tự kết quả giữ ổn định.
- `parallel_tools` áp dụng cho API `Agent::turn()`. Không ảnh hưởng đến vòng lặp runtime của CLI, gateway hay channel.
- `tool_call_dedup_exempt` nhận mảng tên tool chính xác. Các tool trong danh sách được phép gọi nhiều lần với cùng tham số trong một lượt. Ví dụ: `tool_call_dedup_exempt = ["browser"]`.
## `[agents.<name>]`
+38 -3
View File
@@ -211,8 +211,35 @@ should_attempt_prebuilt_for_resources() {
return 1
}
resolve_asset_url() {
local asset_name="$1"
local api_url="https://api.github.com/repos/zeroclaw-labs/zeroclaw/releases"
local releases_json download_url
# Fetch up to 10 recent releases (includes prereleases) and find the first
# one that contains the requested asset.
releases_json="$(curl -fsSL "${api_url}?per_page=10" 2>/dev/null || true)"
if [[ -z "$releases_json" ]]; then
return 1
fi
# Parse with simple grep/sed — avoids jq dependency.
download_url="$(printf '%s\n' "$releases_json" \
| tr ',' '\n' \
| grep '"browser_download_url"' \
| sed 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' \
| grep "/${asset_name}\$" \
| head -n 1)"
if [[ -z "$download_url" ]]; then
return 1
fi
echo "$download_url"
}
install_prebuilt_binary() {
local target archive_url temp_dir archive_path extracted_bin install_dir
local target archive_url temp_dir archive_path extracted_bin install_dir asset_name
if ! have_cmd curl; then
warn "curl is required for pre-built binary installation."
@@ -229,9 +256,17 @@ install_prebuilt_binary() {
return 1
fi
archive_url="https://github.com/zeroclaw-labs/zeroclaw/releases/latest/download/zeroclaw-${target}.tar.gz"
asset_name="zeroclaw-${target}.tar.gz"
# Try the GitHub API first to find the newest release (including prereleases)
# that actually contains the asset, then fall back to /releases/latest/.
archive_url="$(resolve_asset_url "$asset_name" || true)"
if [[ -z "$archive_url" ]]; then
archive_url="https://github.com/zeroclaw-labs/zeroclaw/releases/latest/download/${asset_name}"
fi
temp_dir="$(mktemp -d -t zeroclaw-prebuilt-XXXXXX)"
archive_path="$temp_dir/zeroclaw-${target}.tar.gz"
archive_path="$temp_dir/${asset_name}"
info "Attempting pre-built binary install for target: $target"
if ! curl -fsSL "$archive_url" -o "$archive_path"; then
+165 -5
View File
@@ -1966,6 +1966,7 @@ pub(crate) async fn agent_turn(
None,
None,
&[],
&[],
)
.await
}
@@ -2165,6 +2166,7 @@ pub(crate) async fn run_tool_call_loop(
on_delta: Option<tokio::sync::mpsc::Sender<String>>,
hooks: Option<&crate::hooks::HookRunner>,
excluded_tools: &[String],
dedup_exempt_tools: &[String],
) -> Result<String> {
let max_iterations = if max_tool_iterations == 0 {
DEFAULT_MAX_TOOL_ITERATIONS
@@ -2574,7 +2576,8 @@ pub(crate) async fn run_tool_call_loop(
}
let signature = tool_call_signature(&tool_name, &tool_args);
if !seen_tool_signatures.insert(signature) {
let dedup_exempt = dedup_exempt_tools.iter().any(|e| e == &tool_name);
if !dedup_exempt && !seen_tool_signatures.insert(signature) {
let duplicate = format!(
"Skipped duplicate tool call '{tool_name}' with identical arguments in this turn."
);
@@ -2888,6 +2891,7 @@ pub async fn run(
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
secrets_encrypt: config.secrets.encrypt,
reasoning_enabled: config.runtime.reasoning_enabled,
provider_timeout_secs: Some(config.provider_timeout_secs),
};
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
@@ -3117,6 +3121,7 @@ pub async fn run(
None,
None,
&[],
&config.agent.tool_call_dedup_exempt,
)
.await?;
final_output = response.clone();
@@ -3134,8 +3139,11 @@ pub async fn run(
print!("> ");
let _ = std::io::stdout().flush();
let mut input = String::new();
match std::io::stdin().read_line(&mut input) {
// Read raw bytes to avoid UTF-8 validation errors when PTY
// transport splits multi-byte characters at frame boundaries
// (e.g. CJK input with spaces over kubectl exec / SSH).
let mut raw = Vec::new();
match std::io::BufRead::read_until(&mut std::io::stdin().lock(), b'\n', &mut raw) {
Ok(0) => break,
Ok(_) => {}
Err(e) => {
@@ -3143,6 +3151,7 @@ pub async fn run(
break;
}
}
let input = String::from_utf8_lossy(&raw).into_owned();
let user_input = input.trim().to_string();
if user_input.is_empty() {
@@ -3165,10 +3174,17 @@ pub async fn run(
print!("Continue? [y/N] ");
let _ = std::io::stdout().flush();
let mut confirm = String::new();
if std::io::stdin().read_line(&mut confirm).is_err() {
let mut confirm_raw = Vec::new();
if std::io::BufRead::read_until(
&mut std::io::stdin().lock(),
b'\n',
&mut confirm_raw,
)
.is_err()
{
continue;
}
let confirm = String::from_utf8_lossy(&confirm_raw);
if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") {
println!("Cancelled.\n");
continue;
@@ -3239,6 +3255,7 @@ pub async fn run(
None,
None,
&[],
&config.agent.tool_call_dedup_exempt,
)
.await
{
@@ -3346,6 +3363,7 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
secrets_encrypt: config.secrets.encrypt,
reasoning_enabled: config.runtime.reasoning_enabled,
provider_timeout_secs: Some(config.provider_timeout_secs),
};
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
provider_name,
@@ -3783,6 +3801,7 @@ mod tests {
None,
None,
&[],
&[],
)
.await
.expect_err("provider without vision support should fail");
@@ -3829,6 +3848,7 @@ mod tests {
None,
None,
&[],
&[],
)
.await
.expect_err("oversized payload must fail");
@@ -3869,6 +3889,7 @@ mod tests {
None,
None,
&[],
&[],
)
.await
.expect("valid multimodal payload should pass");
@@ -3995,6 +4016,7 @@ mod tests {
None,
None,
&[],
&[],
)
.await
.expect("parallel execution should complete");
@@ -4064,6 +4086,7 @@ mod tests {
None,
None,
&[],
&[],
)
.await
.expect("loop should finish after deduplicating repeated calls");
@@ -4083,6 +4106,142 @@ mod tests {
assert!(tool_results.content.contains("Skipped duplicate tool call"));
}
#[tokio::test]
async fn run_tool_call_loop_dedup_exempt_allows_repeated_calls() {
let provider = ScriptedProvider::from_text_responses(vec![
r#"<tool_call>
{"name":"count_tool","arguments":{"value":"A"}}
</tool_call>
<tool_call>
{"name":"count_tool","arguments":{"value":"A"}}
</tool_call>"#,
"done",
]);
let invocations = Arc::new(AtomicUsize::new(0));
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
"count_tool",
Arc::clone(&invocations),
))];
let mut history = vec![
ChatMessage::system("test-system"),
ChatMessage::user("run tool calls"),
];
let observer = NoopObserver;
let exempt = vec!["count_tool".to_string()];
let result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
&observer,
"mock-provider",
"mock-model",
0.0,
true,
None,
"cli",
&crate::config::MultimodalConfig::default(),
4,
None,
None,
None,
&[],
&exempt,
)
.await
.expect("loop should finish with exempt tool executing twice");
assert_eq!(result, "done");
assert_eq!(
invocations.load(Ordering::SeqCst),
2,
"exempt tool should execute both duplicate calls"
);
let tool_results = history
.iter()
.find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
.expect("prompt-mode tool result payload should be present");
assert!(
!tool_results.content.contains("Skipped duplicate tool call"),
"exempt tool calls should not be suppressed"
);
}
#[tokio::test]
async fn run_tool_call_loop_dedup_exempt_only_affects_listed_tools() {
let provider = ScriptedProvider::from_text_responses(vec![
r#"<tool_call>
{"name":"count_tool","arguments":{"value":"A"}}
</tool_call>
<tool_call>
{"name":"count_tool","arguments":{"value":"A"}}
</tool_call>
<tool_call>
{"name":"other_tool","arguments":{"value":"B"}}
</tool_call>
<tool_call>
{"name":"other_tool","arguments":{"value":"B"}}
</tool_call>"#,
"done",
]);
let count_invocations = Arc::new(AtomicUsize::new(0));
let other_invocations = Arc::new(AtomicUsize::new(0));
let tools_registry: Vec<Box<dyn Tool>> = vec![
Box::new(CountingTool::new(
"count_tool",
Arc::clone(&count_invocations),
)),
Box::new(CountingTool::new(
"other_tool",
Arc::clone(&other_invocations),
)),
];
let mut history = vec![
ChatMessage::system("test-system"),
ChatMessage::user("run tool calls"),
];
let observer = NoopObserver;
let exempt = vec!["count_tool".to_string()];
let _result = run_tool_call_loop(
&provider,
&mut history,
&tools_registry,
&observer,
"mock-provider",
"mock-model",
0.0,
true,
None,
"cli",
&crate::config::MultimodalConfig::default(),
4,
None,
None,
None,
&[],
&exempt,
)
.await
.expect("loop should complete");
assert_eq!(
count_invocations.load(Ordering::SeqCst),
2,
"exempt tool should execute both calls"
);
assert_eq!(
other_invocations.load(Ordering::SeqCst),
1,
"non-exempt tool should still be deduped"
);
}
#[tokio::test]
async fn run_tool_call_loop_native_mode_preserves_fallback_tool_call_ids() {
let provider = ScriptedProvider::from_text_responses(vec![
@@ -4120,6 +4279,7 @@ mod tests {
None,
None,
&[],
&[],
)
.await
.expect("native fallback id flow should complete");
+11
View File
@@ -622,7 +622,18 @@ impl Channel for DiscordChannel {
msg = read.next() => {
let msg = match msg {
Some(Ok(Message::Text(t))) => t,
Some(Ok(Message::Ping(payload))) => {
if write.send(Message::Pong(payload)).await.is_err() {
tracing::warn!("Discord: pong send failed, reconnecting");
break;
}
continue;
}
Some(Ok(Message::Close(_))) | None => break,
Some(Err(e)) => {
tracing::warn!("Discord: websocket read error: {e}, reconnecting");
break;
}
_ => continue,
};
+8 -1
View File
@@ -1,6 +1,10 @@
use crate::channels::traits::{Channel, ChannelMessage, SendMessage};
use async_trait::async_trait;
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(not(target_has_atomic = "64"))]
use std::sync::atomic::AtomicU32;
#[cfg(target_has_atomic = "64")]
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, Mutex};
@@ -13,7 +17,10 @@ use tokio_rustls::rustls;
const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// Monotonic counter to ensure unique message IDs under burst traffic.
#[cfg(target_has_atomic = "64")]
static MSG_SEQ: AtomicU64 = AtomicU64::new(0);
#[cfg(not(target_has_atomic = "64"))]
static MSG_SEQ: AtomicU32 = AtomicU32::new(0);
/// IRC over TLS channel.
///
+289 -22
View File
@@ -61,20 +61,33 @@ impl LinqChannel {
/// Parse an incoming webhook payload from Linq and extract messages.
///
/// Linq webhook envelope:
/// Supports two webhook formats:
///
/// **New format (webhook_version 2026-02-03):**
/// ```json
/// {
/// "api_version": "v3",
/// "webhook_version": "2026-02-03",
/// "event_type": "message.received",
/// "data": {
/// "id": "msg-...",
/// "direction": "inbound",
/// "sender_handle": { "handle": "+1...", "is_me": false },
/// "chat": { "id": "chat-..." },
/// "parts": [{ "type": "text", "value": "..." }]
/// }
/// }
/// ```
///
/// **Legacy format (webhook_version 2025-01-01):**
/// ```json
/// {
/// "api_version": "v3",
/// "event_type": "message.received",
/// "event_id": "...",
/// "created_at": "...",
/// "trace_id": "...",
/// "data": {
/// "chat_id": "...",
/// "from": "+1...",
/// "recipient_phone": "+1...",
/// "is_from_me": false,
/// "service": "iMessage",
/// "message": {
/// "id": "...",
/// "parts": [{ "type": "text", "value": "..." }]
@@ -99,18 +112,44 @@ impl LinqChannel {
return messages;
};
// Detect format: new format has `sender_handle`, legacy has `from`.
let is_new_format = data.get("sender_handle").is_some();
// Skip messages sent by the bot itself
if data
.get("is_from_me")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let is_from_me = if is_new_format {
// New format: data.sender_handle.is_me or data.direction == "outbound"
data.get("sender_handle")
.and_then(|sh| sh.get("is_me"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
|| data
.get("direction")
.and_then(|d| d.as_str())
.is_some_and(|d| d == "outbound")
} else {
// Legacy format: data.is_from_me
data.get("is_from_me")
.and_then(|v| v.as_bool())
.unwrap_or(false)
};
if is_from_me {
tracing::debug!("Linq: skipping is_from_me message");
return messages;
}
// Get sender phone number
let Some(from) = data.get("from").and_then(|f| f.as_str()) else {
let from = if is_new_format {
// New format: data.sender_handle.handle
data.get("sender_handle")
.and_then(|sh| sh.get("handle"))
.and_then(|h| h.as_str())
} else {
// Legacy format: data.from
data.get("from").and_then(|f| f.as_str())
};
let Some(from) = from else {
return messages;
};
@@ -132,18 +171,33 @@ impl LinqChannel {
}
// Get chat_id for reply routing
let chat_id = data
.get("chat_id")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string();
// Extract text from message parts
let Some(message) = data.get("message") else {
return messages;
let chat_id = if is_new_format {
// New format: data.chat.id
data.get("chat")
.and_then(|c| c.get("id"))
.and_then(|id| id.as_str())
.unwrap_or("")
.to_string()
} else {
// Legacy format: data.chat_id
data.get("chat_id")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string()
};
let Some(parts) = message.get("parts").and_then(|p| p.as_array()) else {
// Extract message parts
let parts = if is_new_format {
// New format: data.parts (directly on data)
data.get("parts").and_then(|p| p.as_array())
} else {
// Legacy format: data.message.parts
data.get("message")
.and_then(|m| m.get("parts"))
.and_then(|p| p.as_array())
};
let Some(parts) = parts else {
return messages;
};
@@ -790,4 +844,217 @@ mod tests {
let ch = make_channel();
assert_eq!(ch.phone_number(), "+15551234567");
}
// ---- New format (2026-02-03) tests ----
#[test]
fn linq_parse_new_format_text_message() {
let ch = make_channel();
let payload = serde_json::json!({
"api_version": "v3",
"webhook_version": "2026-02-03",
"event_type": "message.received",
"event_id": "evt-123",
"created_at": "2026-03-01T12:00:00Z",
"trace_id": "trace-456",
"data": {
"id": "msg-abc",
"direction": "inbound",
"sender_handle": {
"handle": "+1234567890",
"is_me": false
},
"chat": { "id": "chat-789" },
"service": "iMessage",
"parts": [{
"type": "text",
"value": "Hello from new format!"
}]
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].sender, "+1234567890");
assert_eq!(msgs[0].content, "Hello from new format!");
assert_eq!(msgs[0].channel, "linq");
assert_eq!(msgs[0].reply_target, "chat-789");
}
#[test]
fn linq_parse_new_format_skip_is_me() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
let payload = serde_json::json!({
"event_type": "message.received",
"webhook_version": "2026-02-03",
"data": {
"id": "msg-abc",
"direction": "outbound",
"sender_handle": {
"handle": "+15551234567",
"is_me": true
},
"chat": { "id": "chat-789" },
"parts": [{ "type": "text", "value": "My own message" }]
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(
msgs.is_empty(),
"is_me messages should be skipped in new format"
);
}
#[test]
fn linq_parse_new_format_skip_outbound_direction() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
let payload = serde_json::json!({
"event_type": "message.received",
"webhook_version": "2026-02-03",
"data": {
"id": "msg-abc",
"direction": "outbound",
"sender_handle": {
"handle": "+15551234567",
"is_me": false
},
"chat": { "id": "chat-789" },
"parts": [{ "type": "text", "value": "Outbound" }]
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty(), "outbound direction should be skipped");
}
#[test]
fn linq_parse_new_format_unauthorized_sender() {
let ch = make_channel();
let payload = serde_json::json!({
"event_type": "message.received",
"webhook_version": "2026-02-03",
"data": {
"id": "msg-abc",
"direction": "inbound",
"sender_handle": {
"handle": "+9999999999",
"is_me": false
},
"chat": { "id": "chat-789" },
"parts": [{ "type": "text", "value": "Spam" }]
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(
msgs.is_empty(),
"Unauthorized senders should be filtered in new format"
);
}
#[test]
fn linq_parse_new_format_media_image() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
let payload = serde_json::json!({
"event_type": "message.received",
"webhook_version": "2026-02-03",
"data": {
"id": "msg-abc",
"direction": "inbound",
"sender_handle": {
"handle": "+1234567890",
"is_me": false
},
"chat": { "id": "chat-789" },
"parts": [{
"type": "media",
"url": "https://example.com/photo.png",
"mime_type": "image/png"
}]
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].content, "[IMAGE:https://example.com/photo.png]");
}
#[test]
fn linq_parse_new_format_multiple_parts() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
let payload = serde_json::json!({
"event_type": "message.received",
"webhook_version": "2026-02-03",
"data": {
"id": "msg-abc",
"direction": "inbound",
"sender_handle": {
"handle": "+1234567890",
"is_me": false
},
"chat": { "id": "chat-789" },
"parts": [
{ "type": "text", "value": "Check this out" },
{ "type": "media", "url": "https://example.com/img.jpg", "mime_type": "image/jpeg" }
]
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert_eq!(msgs.len(), 1);
assert_eq!(
msgs[0].content,
"Check this out\n[IMAGE:https://example.com/img.jpg]"
);
}
#[test]
fn linq_parse_new_format_fallback_reply_target_when_no_chat() {
let ch = LinqChannel::new("tok".into(), "+15551234567".into(), vec!["*".into()]);
let payload = serde_json::json!({
"event_type": "message.received",
"webhook_version": "2026-02-03",
"data": {
"id": "msg-abc",
"direction": "inbound",
"sender_handle": {
"handle": "+1234567890",
"is_me": false
},
"parts": [{ "type": "text", "value": "Hi" }]
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].reply_target, "+1234567890");
}
#[test]
fn linq_parse_new_format_normalizes_phone() {
let ch = LinqChannel::new(
"tok".into(),
"+15551234567".into(),
vec!["+1234567890".into()],
);
let payload = serde_json::json!({
"event_type": "message.received",
"webhook_version": "2026-02-03",
"data": {
"id": "msg-abc",
"direction": "inbound",
"sender_handle": {
"handle": "1234567890",
"is_me": false
},
"chat": { "id": "chat-789" },
"parts": [{ "type": "text", "value": "Hi" }]
}
});
let msgs = ch.parse_webhook_payload(&payload);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].sender, "+1234567890");
}
}
+270 -10
View File
@@ -89,7 +89,11 @@ use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
#[cfg(not(target_has_atomic = "64"))]
use std::sync::atomic::AtomicU32;
#[cfg(target_has_atomic = "64")]
use std::sync::atomic::AtomicU64;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime};
use tokio_util::sync::CancellationToken;
@@ -290,6 +294,7 @@ struct ChannelRuntimeContext {
multimodal: crate::config::MultimodalConfig,
hooks: Option<Arc<crate::hooks::HookRunner>>,
non_cli_excluded_tools: Arc<Vec<String>>,
tool_call_dedup_exempt: Arc<Vec<String>>,
model_routes: Arc<Vec<crate::config::ModelRouteConfig>>,
}
@@ -943,6 +948,14 @@ fn should_skip_memory_context_entry(key: &str, content: &str) -> bool {
return true;
}
// Skip entries containing image markers to prevent duplication.
// When auto_save stores a photo message to memory, a subsequent
// memory recall on the same turn would surface the marker again,
// causing two identical image blocks in the provider request.
if content.contains("[IMAGE:") {
return true;
}
content.chars().count() > MEMORY_CONTEXT_MAX_CHARS
}
@@ -1919,6 +1932,7 @@ async fn process_channel_message(
} else {
ctx.non_cli_excluded_tools.as_ref()
},
ctx.tool_call_dedup_exempt.as_ref(),
),
) => LlmExecutionResult::Completed(result),
};
@@ -2295,7 +2309,10 @@ async fn run_message_dispatch_loop(
String,
InFlightSenderTaskState,
>::new()));
#[cfg(target_has_atomic = "64")]
let task_sequence = Arc::new(AtomicU64::new(1));
#[cfg(not(target_has_atomic = "64"))]
let task_sequence = Arc::new(AtomicU32::new(1));
while let Some(msg) = rx.recv().await {
let permit = match Arc::clone(&semaphore).acquire_owned().await {
@@ -2313,7 +2330,7 @@ async fn run_message_dispatch_loop(
let sender_scope_key = interruption_scope_key(&msg);
let cancellation_token = CancellationToken::new();
let completion = Arc::new(InFlightTaskCompletion::new());
let task_id = task_sequence.fetch_add(1, Ordering::Relaxed);
let task_id = task_sequence.fetch_add(1, Ordering::Relaxed) as u64;
if interrupt_enabled {
let previous = {
@@ -2831,9 +2848,86 @@ pub(crate) async fn handle_command(command: crate::ChannelCommands, config: &Con
crate::ChannelCommands::BindTelegram { identity } => {
bind_telegram_identity(config, &identity).await
}
crate::ChannelCommands::Send {
message,
channel_id,
recipient,
} => send_channel_message(config, &channel_id, &recipient, &message).await,
}
}
/// Build a single channel instance by config section name (e.g. "telegram").
fn build_channel_by_id(config: &Config, channel_id: &str) -> Result<Arc<dyn Channel>> {
match channel_id {
"telegram" => {
let tg = config
.channels_config
.telegram
.as_ref()
.context("Telegram channel is not configured")?;
Ok(Arc::new(
TelegramChannel::new(
tg.bot_token.clone(),
tg.allowed_users.clone(),
tg.mention_only,
)
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
.with_transcription(config.transcription.clone())
.with_workspace_dir(config.workspace_dir.clone()),
))
}
"discord" => {
let dc = config
.channels_config
.discord
.as_ref()
.context("Discord channel is not configured")?;
Ok(Arc::new(DiscordChannel::new(
dc.bot_token.clone(),
dc.guild_id.clone(),
dc.allowed_users.clone(),
dc.listen_to_bots,
dc.mention_only,
)))
}
"slack" => {
let sl = config
.channels_config
.slack
.as_ref()
.context("Slack channel is not configured")?;
Ok(Arc::new(
SlackChannel::new(
sl.bot_token.clone(),
sl.app_token.clone(),
sl.channel_id.clone(),
Vec::new(),
sl.allowed_users.clone(),
)
.with_workspace_dir(config.workspace_dir.clone()),
))
}
other => anyhow::bail!("Unknown channel '{other}'. Supported: telegram, discord, slack"),
}
}
/// Send a one-off message to a configured channel.
async fn send_channel_message(
config: &Config,
channel_id: &str,
recipient: &str,
message: &str,
) -> Result<()> {
let channel = build_channel_by_id(config, channel_id)?;
let msg = SendMessage::new(message, recipient);
channel
.send(&msg)
.await
.with_context(|| format!("Failed to send message via {channel_id}"))?;
println!("Message sent via {channel_id}.");
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChannelHealthState {
Healthy,
@@ -3217,6 +3311,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
secrets_encrypt: config.secrets.encrypt,
reasoning_enabled: config.runtime.reasoning_enabled,
provider_timeout_secs: Some(config.provider_timeout_secs),
};
let provider: Arc<dyn Provider> = Arc::from(
create_resilient_provider_nonblocking(
@@ -3276,7 +3371,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
};
// Build system prompt from workspace identity files + skills
let workspace = config.workspace_dir.clone();
let tools_registry = Arc::new(tools::all_tools_with_runtime(
let mut built_tools = tools::all_tools_with_runtime(
Arc::new(config.clone()),
&security,
runtime,
@@ -3290,7 +3385,44 @@ pub async fn start_channels(config: Config) -> Result<()> {
&config.agents,
config.api_key.as_deref(),
&config,
));
);
// Wire MCP tools into the registry before freezing — non-fatal.
if config.mcp.enabled && !config.mcp.servers.is_empty() {
tracing::info!(
"Initializing MCP client — {} server(s) configured",
config.mcp.servers.len()
);
match crate::tools::mcp_client::McpRegistry::connect_all(&config.mcp.servers).await {
Ok(registry) => {
let registry = std::sync::Arc::new(registry);
let names = registry.tool_names();
let mut registered = 0usize;
for name in names {
if let Some(def) = registry.get_tool_def(&name).await {
let wrapper = crate::tools::mcp_tool::McpToolWrapper::new(
name,
def,
std::sync::Arc::clone(&registry),
);
built_tools.push(Box::new(wrapper));
registered += 1;
}
}
tracing::info!(
"MCP: {} tool(s) registered from {} server(s)",
registered,
registry.server_count()
);
}
Err(e) => {
// Non-fatal — daemon continues with the tools registered above.
tracing::error!("MCP registry failed to initialize: {e:#}");
}
}
}
let tools_registry = Arc::new(built_tools);
let skills = crate::skills::load_skills_with_config(&workspace, &config);
@@ -3514,6 +3646,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
None
},
non_cli_excluded_tools: Arc::new(config.autonomy.non_cli_excluded_tools.clone()),
tool_call_dedup_exempt: Arc::new(config.agent.tool_call_dedup_exempt.clone()),
model_routes: Arc::new(config.model_routes.clone()),
});
@@ -3614,6 +3747,22 @@ mod tests {
"fabricated memory"
));
assert!(!should_skip_memory_context_entry("telegram_123_45", "hi"));
// Entries containing image markers must be skipped to prevent
// auto-saved photo messages from duplicating image blocks (#2403).
assert!(should_skip_memory_context_entry(
"telegram_user_msg_99",
"[IMAGE:/tmp/workspace/photo_1_2.jpg]"
));
assert!(should_skip_memory_context_entry(
"telegram_user_msg_100",
"[IMAGE:/tmp/workspace/photo_1_2.jpg]\n\nCheck this screenshot"
));
// Plain text without image markers should not be skipped.
assert!(!should_skip_memory_context_entry(
"telegram_user_msg_101",
"Please describe the image"
));
}
#[test]
@@ -3728,16 +3877,17 @@ mod tests {
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
};
assert!(compact_sender_history(&ctx, &sender));
let histories = ctx
let locked_histories = ctx
.conversation_histories
.lock()
.unwrap_or_else(|e| e.into_inner());
let kept = histories
let kept = locked_histories
.get(&sender)
.expect("sender history should remain");
assert_eq!(kept.len(), CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES);
@@ -3778,6 +3928,7 @@ mod tests {
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
};
@@ -3831,16 +3982,17 @@ mod tests {
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
};
assert!(rollback_orphan_user_turn(&ctx, &sender, "pending"));
let histories = ctx
let locked_histories = ctx
.conversation_histories
.lock()
.unwrap_or_else(|e| e.into_inner());
let turns = histories
let turns = locked_histories
.get(&sender)
.expect("sender history should remain");
assert_eq!(turns.len(), 2);
@@ -4305,6 +4457,7 @@ BTC is currently around $65,000 based on latest tool output."#
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
model_routes: Arc::new(Vec::new()),
@@ -4366,6 +4519,7 @@ BTC is currently around $65,000 based on latest tool output."#
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
model_routes: Arc::new(Vec::new()),
@@ -4443,6 +4597,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -4503,6 +4658,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -4573,6 +4729,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -4663,6 +4820,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -4735,6 +4893,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -4822,6 +4981,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -4841,10 +5001,10 @@ BTC is currently around $65,000 based on latest tool output."#
.await;
{
let mut store = runtime_config_store()
let mut cleanup_store = runtime_config_store()
.lock()
.unwrap_or_else(|e| e.into_inner());
store.remove(&config_path);
cleanup_store.remove(&config_path);
}
assert_eq!(provider_impl.call_count.load(Ordering::SeqCst), 1);
@@ -4894,6 +5054,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -4956,6 +5117,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -5129,6 +5291,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -5210,6 +5373,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -5303,6 +5467,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -5378,6 +5543,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -5438,6 +5604,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -5919,6 +6086,47 @@ BTC is currently around $65,000 based on latest tool output."#
assert!(context.contains("Age is 45"));
}
/// Auto-saved photo messages must not surface through memory context,
/// otherwise the image marker gets duplicated in the provider request (#2403).
#[tokio::test]
async fn build_memory_context_excludes_image_marker_entries() {
let tmp = TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
// Simulate auto-save of a photo message containing an [IMAGE:] marker.
mem.store(
"telegram_user_msg_photo",
"[IMAGE:/tmp/workspace/photo_1_2.jpg]\n\nDescribe this screenshot",
MemoryCategory::Conversation,
None,
)
.await
.unwrap();
// Also store a plain text entry that shares a word with the query
// so the FTS recall returns both entries.
mem.store(
"screenshot_preference",
"User prefers screenshot descriptions to be concise",
MemoryCategory::Conversation,
None,
)
.await
.unwrap();
let context = build_memory_context(&mem, "screenshot", 0.0).await;
// The image-marker entry must be excluded to prevent duplication.
assert!(
!context.contains("[IMAGE:"),
"memory context must not contain image markers, got: {context}"
);
// Plain text entries should still be included.
assert!(
context.contains("screenshot descriptions"),
"plain text entry should remain in context, got: {context}"
);
}
#[tokio::test]
async fn process_channel_message_restores_per_sender_history_on_follow_ups() {
let channel_impl = Arc::new(RecordingChannel::default());
@@ -5955,6 +6163,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -6041,6 +6250,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -6127,6 +6337,7 @@ BTC is currently around $65,000 based on latest tool output."#
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -6677,6 +6888,7 @@ This is an example JSON object for profile settings."#;
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -6744,6 +6956,7 @@ This is an example JSON object for profile settings."#;
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
@@ -6808,4 +7021,51 @@ This is an example JSON object for profile settings."#;
"failed vision turn must not persist image marker content"
);
}
#[test]
fn build_channel_by_id_unknown_channel_returns_error() {
let config = Config::default();
match build_channel_by_id(&config, "nonexistent") {
Err(e) => {
let err_msg = e.to_string();
assert!(
err_msg.contains("Unknown channel"),
"expected 'Unknown channel' in error, got: {err_msg}"
);
}
Ok(_) => panic!("should fail for unknown channel"),
}
}
#[test]
fn build_channel_by_id_unconfigured_telegram_returns_error() {
let config = Config::default();
match build_channel_by_id(&config, "telegram") {
Err(e) => {
let err_msg = e.to_string();
assert!(
err_msg.contains("not configured"),
"expected 'not configured' in error, got: {err_msg}"
);
}
Ok(_) => panic!("should fail when telegram is not configured"),
}
}
#[test]
fn build_channel_by_id_configured_telegram_succeeds() {
let mut config = Config::default();
config.channels_config.telegram = Some(crate::config::schema::TelegramConfig {
bot_token: "test-token".to_string(),
allowed_users: vec![],
stream_mode: crate::config::StreamMode::Off,
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
});
match build_channel_by_id(&config, "telegram") {
Ok(channel) => assert_eq!(channel.name(), "telegram"),
Err(e) => panic!("should succeed when telegram is configured: {e}"),
}
}
}
+7 -6
View File
@@ -10,12 +10,13 @@ pub use schema::{
CronConfig, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, EdgeTtsConfig,
ElevenLabsTtsConfig, EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig,
GoogleTtsConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig,
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig,
ModelRouteConfig, MultimodalConfig, NextcloudTalkConfig, ObservabilityConfig, OpenAiTtsConfig,
OtpConfig, OtpMethod, PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope,
QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig,
RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, McpConfig,
McpServerConfig, McpTransport, MemoryConfig, ModelRouteConfig, MultimodalConfig,
NextcloudTalkConfig, ObservabilityConfig, OpenAiTtsConfig, OtpConfig, OtpMethod,
PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QdrantConfig,
QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SkillsConfig,
SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
StorageProviderSection, StreamMode, TelegramConfig, TranscriptionConfig, TtsConfig,
TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
};
+168
View File
@@ -90,6 +90,13 @@ pub struct Config {
)]
pub default_temperature: f64,
/// HTTP request timeout in seconds for LLM provider API calls. Default: `120`.
///
/// Increase for slower backends (e.g., llama.cpp on constrained hardware)
/// that need more time processing large contexts.
#[serde(default = "default_provider_timeout_secs")]
pub provider_timeout_secs: u64,
/// Observability backend configuration (`[observability]`).
#[serde(default)]
pub observability: ObservabilityConfig,
@@ -225,6 +232,10 @@ pub struct Config {
/// Text-to-Speech configuration (`[tts]`).
#[serde(default)]
pub tts: TtsConfig,
/// External MCP server connections (`[mcp]`).
#[serde(default, alias = "mcpServers")]
pub mcp: McpConfig,
}
/// Named provider profile definition compatible with Codex app-server style config.
@@ -295,6 +306,13 @@ fn default_temperature() -> f64 {
DEFAULT_TEMPERATURE
}
/// Default provider HTTP request timeout: 120 seconds.
const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 120;
fn default_provider_timeout_secs() -> u64 {
DEFAULT_PROVIDER_TIMEOUT_SECS
}
/// Validate that a temperature value is within the allowed range.
pub fn validate_temperature(value: f64) -> std::result::Result<f64, String> {
if TEMPERATURE_RANGE.contains(&value) {
@@ -441,6 +459,60 @@ impl Default for TranscriptionConfig {
}
}
// ── MCP ─────────────────────────────────────────────────────────
/// Transport type for MCP server connections.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum McpTransport {
/// Spawn a local process and communicate over stdin/stdout.
#[default]
Stdio,
/// Connect via HTTP POST.
Http,
/// Connect via HTTP + Server-Sent Events.
Sse,
}
/// Configuration for a single external MCP server.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct McpServerConfig {
/// Display name used as a tool prefix (`<server>__<tool>`).
pub name: String,
/// Transport type (default: stdio).
#[serde(default)]
pub transport: McpTransport,
/// URL for HTTP/SSE transports.
#[serde(default)]
pub url: Option<String>,
/// Executable to spawn for stdio transport.
#[serde(default)]
pub command: String,
/// Command arguments for stdio transport.
#[serde(default)]
pub args: Vec<String>,
/// Optional environment variables for stdio transport.
#[serde(default)]
pub env: HashMap<String, String>,
/// Optional HTTP headers for HTTP/SSE transports.
#[serde(default)]
pub headers: HashMap<String, String>,
/// Optional per-call timeout in seconds (hard capped in validation).
#[serde(default)]
pub tool_timeout_secs: Option<u64>,
}
/// External MCP client configuration (`[mcp]` section).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct McpConfig {
/// Enable MCP tool loading.
#[serde(default)]
pub enabled: bool,
/// Configured MCP servers.
#[serde(default, alias = "mcpServers")]
pub servers: Vec<McpServerConfig>,
}
// ── TTS (Text-to-Speech) ─────────────────────────────────────────
fn default_tts_provider() -> String {
@@ -604,6 +676,9 @@ pub struct AgentConfig {
/// Tool dispatch strategy (e.g. `"auto"`). Default: `"auto"`.
#[serde(default = "default_agent_tool_dispatcher")]
pub tool_dispatcher: String,
/// Tools exempt from the within-turn duplicate-call dedup check. Default: `[]`.
#[serde(default)]
pub tool_call_dedup_exempt: Vec<String>,
}
fn default_agent_max_tool_iterations() -> usize {
@@ -626,6 +701,7 @@ impl Default for AgentConfig {
max_history_messages: default_agent_max_history_messages(),
parallel_tools: false,
tool_dispatcher: default_agent_tool_dispatcher(),
tool_call_dedup_exempt: Vec::new(),
}
}
}
@@ -1616,6 +1692,65 @@ fn service_selector_matches(selector: &str, service_key: &str) -> bool {
false
}
const MCP_MAX_TOOL_TIMEOUT_SECS: u64 = 600;
fn validate_mcp_config(config: &McpConfig) -> Result<()> {
let mut seen_names = std::collections::HashSet::new();
for (i, server) in config.servers.iter().enumerate() {
let name = server.name.trim();
if name.is_empty() {
anyhow::bail!("mcp.servers[{i}].name must not be empty");
}
if !seen_names.insert(name.to_ascii_lowercase()) {
anyhow::bail!("mcp.servers contains duplicate name: {name}");
}
if let Some(timeout) = server.tool_timeout_secs {
if timeout == 0 {
anyhow::bail!("mcp.servers[{i}].tool_timeout_secs must be greater than 0");
}
if timeout > MCP_MAX_TOOL_TIMEOUT_SECS {
anyhow::bail!(
"mcp.servers[{i}].tool_timeout_secs exceeds max {MCP_MAX_TOOL_TIMEOUT_SECS}"
);
}
}
match server.transport {
McpTransport::Stdio => {
if server.command.trim().is_empty() {
anyhow::bail!(
"mcp.servers[{i}] with transport=stdio requires non-empty command"
);
}
}
McpTransport::Http | McpTransport::Sse => {
let url = server
.url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"mcp.servers[{i}] with transport={} requires url",
match server.transport {
McpTransport::Http => "http",
McpTransport::Sse => "sse",
McpTransport::Stdio => "stdio",
}
)
})?;
let parsed = reqwest::Url::parse(url)
.with_context(|| format!("mcp.servers[{i}].url is not a valid URL"))?;
if !matches!(parsed.scheme(), "http" | "https") {
anyhow::bail!("mcp.servers[{i}].url must use http/https");
}
}
}
}
Ok(())
}
fn validate_proxy_url(field: &str, url: &str) -> Result<()> {
let parsed = reqwest::Url::parse(url)
.with_context(|| format!("Invalid {field} URL: '{url}' is not a valid URL"))?;
@@ -3832,6 +3967,7 @@ impl Default for Config {
default_model: Some("anthropic/claude-sonnet-4.6".to_string()),
model_providers: HashMap::new(),
default_temperature: default_temperature(),
provider_timeout_secs: default_provider_timeout_secs(),
observability: ObservabilityConfig::default(),
autonomy: AutonomyConfig::default(),
security: SecurityConfig::default(),
@@ -3866,6 +4002,7 @@ impl Default for Config {
query_classification: QueryClassificationConfig::default(),
transcription: TranscriptionConfig::default(),
tts: TtsConfig::default(),
mcp: McpConfig::default(),
}
}
}
@@ -4825,6 +4962,11 @@ impl Config {
}
}
// MCP
if self.mcp.enabled {
validate_mcp_config(&self.mcp)?;
}
// Proxy (delegate to existing validation)
self.proxy.validate()?;
@@ -4888,6 +5030,15 @@ impl Config {
}
}
// Provider HTTP timeout: ZEROCLAW_PROVIDER_TIMEOUT_SECS
if let Ok(timeout_secs) = std::env::var("ZEROCLAW_PROVIDER_TIMEOUT_SECS") {
if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
if timeout_secs > 0 {
self.provider_timeout_secs = timeout_secs;
}
}
}
// Apply named provider profile remapping (Codex app-server compatibility).
self.apply_named_model_provider_profile();
@@ -5525,6 +5676,7 @@ mod tests {
c.skills.prompt_injection_mode,
SkillsPromptInjectionMode::Full
);
assert_eq!(c.provider_timeout_secs, 120);
assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
assert!(c.config_path.to_string_lossy().contains("config.toml"));
}
@@ -5729,6 +5881,7 @@ default_temperature = 0.7
default_model: Some("gpt-4o".into()),
model_providers: HashMap::new(),
default_temperature: 0.5,
provider_timeout_secs: 120,
observability: ObservabilityConfig {
backend: "log".into(),
..ObservabilityConfig::default()
@@ -5820,6 +5973,7 @@ default_temperature = 0.7
hardware: HardwareConfig::default(),
transcription: TranscriptionConfig::default(),
tts: TtsConfig::default(),
mcp: McpConfig::default(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
@@ -5869,6 +6023,18 @@ default_temperature = 0.7
assert_eq!(parsed.memory.archive_after_days, 7);
assert_eq!(parsed.memory.purge_after_days, 30);
assert_eq!(parsed.memory.conversation_retention_days, 30);
// provider_timeout_secs defaults to 120 when not specified
assert_eq!(parsed.provider_timeout_secs, 120);
}
#[test]
async fn provider_timeout_secs_parses_from_toml() {
let raw = r#"
default_temperature = 0.7
provider_timeout_secs = 300
"#;
let parsed: Config = toml::from_str(raw).unwrap();
assert_eq!(parsed.provider_timeout_secs, 300);
}
#[test]
@@ -5969,6 +6135,7 @@ tool_dispatcher = "xml"
default_model: Some("test-model".into()),
model_providers: HashMap::new(),
default_temperature: 0.9,
provider_timeout_secs: 120,
observability: ObservabilityConfig::default(),
autonomy: AutonomyConfig::default(),
security: SecurityConfig::default(),
@@ -6003,6 +6170,7 @@ tool_dispatcher = "xml"
hardware: HardwareConfig::default(),
transcription: TranscriptionConfig::default(),
tts: TtsConfig::default(),
mcp: McpConfig::default(),
};
config.save().await.unwrap();
+21 -9
View File
@@ -351,6 +351,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
secrets_encrypt: config.secrets.encrypt,
reasoning_enabled: config.runtime.reasoning_enabled,
provider_timeout_secs: Some(config.provider_timeout_secs),
},
)?);
let model = config
@@ -747,15 +748,25 @@ const PROMETHEUS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"
/// GET /metrics — Prometheus text exposition format
async fn handle_metrics(State(state): State<AppState>) -> impl IntoResponse {
let body = if let Some(prom) = state
.observer
.as_ref()
.as_any()
.downcast_ref::<crate::observability::PrometheusObserver>()
{
prom.encode()
} else {
String::from("# Prometheus backend not enabled. Set [observability] backend = \"prometheus\" in config.\n")
let body = {
#[cfg(feature = "metrics")]
{
if let Some(prom) = state
.observer
.as_ref()
.as_any()
.downcast_ref::<crate::observability::PrometheusObserver>()
{
prom.encode()
} else {
String::from("# Prometheus backend not enabled. Set [observability] backend = \"prometheus\" in config.\n")
}
}
#[cfg(not(feature = "metrics"))]
{
let _ = &state;
String::from("# Prometheus backend not enabled. Set [observability] backend = \"prometheus\" in config.\n")
}
};
(
@@ -1738,6 +1749,7 @@ mod tests {
assert!(text.contains("Prometheus backend not enabled"));
}
#[cfg(feature = "metrics")]
#[tokio::test]
async fn metrics_endpoint_renders_prometheus_output() {
let prom = Arc::new(crate::observability::PrometheusObserver::new());
+136 -4
View File
@@ -15,7 +15,7 @@ use axum::{
ws::{Message, WebSocket},
Query, State, WebSocketUpgrade,
},
http::HeaderMap,
http::{header, HeaderMap},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
@@ -24,12 +24,62 @@ use serde::Deserialize;
/// The sub-protocol we support for the chat WebSocket.
const WS_PROTOCOL: &str = "zeroclaw.v1";
/// Prefix used in `Sec-WebSocket-Protocol` to carry a bearer token.
const BEARER_SUBPROTO_PREFIX: &str = "bearer.";
#[derive(Deserialize)]
pub struct WsQuery {
pub token: Option<String>,
pub session_id: Option<String>,
}
/// Extract a bearer token from WebSocket-compatible sources.
///
/// Precedence (first non-empty wins):
/// 1. `Authorization: Bearer <token>` header
/// 2. `Sec-WebSocket-Protocol: bearer.<token>` subprotocol
/// 3. `?token=<token>` query parameter
///
/// Browsers cannot set custom headers on `new WebSocket(url)`, so the query
/// parameter and subprotocol paths are required for browser-based clients.
fn extract_ws_token<'a>(headers: &'a HeaderMap, query_token: Option<&'a str>) -> Option<&'a str> {
// 1. Authorization header
if let Some(t) = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|auth| auth.strip_prefix("Bearer "))
{
if !t.is_empty() {
return Some(t);
}
}
// 2. Sec-WebSocket-Protocol: bearer.<token>
if let Some(t) = headers
.get("sec-websocket-protocol")
.and_then(|v| v.to_str().ok())
.and_then(|protos| {
protos
.split(',')
.map(|p| p.trim())
.find_map(|p| p.strip_prefix(BEARER_SUBPROTO_PREFIX))
})
{
if !t.is_empty() {
return Some(t);
}
}
// 3. ?token= query parameter
if let Some(t) = query_token {
if !t.is_empty() {
return Some(t);
}
}
None
}
/// GET /ws/chat — WebSocket upgrade for agent chat
pub async fn handle_ws_chat(
State(state): State<AppState>,
@@ -37,13 +87,13 @@ pub async fn handle_ws_chat(
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
// Auth via query param (browser WebSocket limitation)
// Auth: check header, subprotocol, then query param (precedence order)
if state.pairing.require_pairing() {
let token = params.token.as_deref().unwrap_or("");
let token = extract_ws_token(&headers, params.token.as_deref()).unwrap_or("");
if !state.pairing.is_authenticated(token) {
return (
axum::http::StatusCode::UNAUTHORIZED,
"Unauthorized — provide ?token=<bearer_token>",
"Unauthorized — provide Authorization header, Sec-WebSocket-Protocol bearer, or ?token= query param",
)
.into_response();
}
@@ -183,3 +233,85 @@ async fn handle_socket(socket: WebSocket, state: AppState, _session_id: Option<S
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderMap;
#[test]
fn extract_ws_token_from_authorization_header() {
let mut headers = HeaderMap::new();
headers.insert("authorization", "Bearer zc_test123".parse().unwrap());
assert_eq!(extract_ws_token(&headers, None), Some("zc_test123"));
}
#[test]
fn extract_ws_token_from_subprotocol() {
let mut headers = HeaderMap::new();
headers.insert(
"sec-websocket-protocol",
"zeroclaw.v1, bearer.zc_sub456".parse().unwrap(),
);
assert_eq!(extract_ws_token(&headers, None), Some("zc_sub456"));
}
#[test]
fn extract_ws_token_from_query_param() {
let headers = HeaderMap::new();
assert_eq!(
extract_ws_token(&headers, Some("zc_query789")),
Some("zc_query789")
);
}
#[test]
fn extract_ws_token_precedence_header_over_subprotocol() {
let mut headers = HeaderMap::new();
headers.insert("authorization", "Bearer zc_header".parse().unwrap());
headers.insert("sec-websocket-protocol", "bearer.zc_sub".parse().unwrap());
assert_eq!(
extract_ws_token(&headers, Some("zc_query")),
Some("zc_header")
);
}
#[test]
fn extract_ws_token_precedence_subprotocol_over_query() {
let mut headers = HeaderMap::new();
headers.insert("sec-websocket-protocol", "bearer.zc_sub".parse().unwrap());
assert_eq!(extract_ws_token(&headers, Some("zc_query")), Some("zc_sub"));
}
#[test]
fn extract_ws_token_returns_none_when_empty() {
let headers = HeaderMap::new();
assert_eq!(extract_ws_token(&headers, None), None);
}
#[test]
fn extract_ws_token_skips_empty_header_value() {
let mut headers = HeaderMap::new();
headers.insert("authorization", "Bearer ".parse().unwrap());
assert_eq!(
extract_ws_token(&headers, Some("zc_fallback")),
Some("zc_fallback")
);
}
#[test]
fn extract_ws_token_skips_empty_query_param() {
let headers = HeaderMap::new();
assert_eq!(extract_ws_token(&headers, Some("")), None);
}
#[test]
fn extract_ws_token_subprotocol_with_multiple_entries() {
let mut headers = HeaderMap::new();
headers.insert(
"sec-websocket-protocol",
"zeroclaw.v1, bearer.zc_tok, other".parse().unwrap(),
);
assert_eq!(extract_ws_token(&headers, None), Some("zc_tok"));
}
}
+25
View File
@@ -202,6 +202,31 @@ Examples:
/// Telegram identity to allow (username without '@' or numeric user ID)
identity: String,
},
/// Send a message to a configured channel
#[command(long_about = "\
Send a one-off message to a configured channel.
Sends a text message through the specified channel without starting \
the full agent loop. Useful for scripted notifications, hardware \
sensor alerts, and automation pipelines.
The --channel-id selects the channel by its config section name \
(e.g. 'telegram', 'discord', 'slack'). The --recipient is the \
platform-specific destination (e.g. a Telegram chat ID).
Examples:
zeroclaw channel send 'Someone is near your device.' --channel-id telegram --recipient 123456789
zeroclaw channel send 'Build succeeded!' --channel-id discord --recipient 987654321")]
Send {
/// Message text to send
message: String,
/// Channel config name (e.g. telegram, discord, slack)
#[arg(long)]
channel_id: String,
/// Recipient identifier (platform-specific, e.g. Telegram chat ID)
#[arg(long)]
recipient: String,
},
}
/// Skills management subcommands
+3 -2
View File
@@ -324,7 +324,7 @@ Examples:
#[command(long_about = "\
Manage communication channels.
Add, remove, list, and health-check channels that connect ZeroClaw \
Add, remove, list, send, and health-check channels that connect ZeroClaw \
to messaging platforms. Supported channel types: telegram, discord, \
slack, whatsapp, matrix, imessage, email.
@@ -333,7 +333,8 @@ Examples:
zeroclaw channel doctor
zeroclaw channel add telegram '{\"bot_token\":\"...\",\"name\":\"my-bot\"}'
zeroclaw channel remove my-bot
zeroclaw channel bind-telegram zeroclaw_user")]
zeroclaw channel bind-telegram zeroclaw_user
zeroclaw channel send 'Alert!' --channel-id telegram --recipient 123456789")]
Channel {
#[command(subcommand)]
channel_command: ChannelCommands,
+21 -2
View File
@@ -3,6 +3,7 @@ pub mod multi;
pub mod noop;
#[cfg(feature = "observability-otel")]
pub mod otel;
#[cfg(feature = "metrics")]
pub mod prometheus;
pub mod runtime_trace;
pub mod traits;
@@ -15,6 +16,7 @@ pub use self::multi::MultiObserver;
pub use noop::NoopObserver;
#[cfg(feature = "observability-otel")]
pub use otel::OtelObserver;
#[cfg(feature = "metrics")]
pub use prometheus::PrometheusObserver;
pub use traits::{Observer, ObserverEvent};
#[allow(unused_imports)]
@@ -26,7 +28,19 @@ use crate::config::ObservabilityConfig;
pub fn create_observer(config: &ObservabilityConfig) -> Box<dyn Observer> {
match config.backend.as_str() {
"log" => Box::new(LogObserver::new()),
"prometheus" => Box::new(PrometheusObserver::new()),
"prometheus" => {
#[cfg(feature = "metrics")]
{
Box::new(PrometheusObserver::new())
}
#[cfg(not(feature = "metrics"))]
{
tracing::warn!(
"Prometheus backend requested but this build was compiled without `metrics`; falling back to noop."
);
Box::new(NoopObserver)
}
}
"otel" | "opentelemetry" | "otlp" => {
#[cfg(feature = "observability-otel")]
match OtelObserver::new(
@@ -104,7 +118,12 @@ mod tests {
backend: "prometheus".into(),
..ObservabilityConfig::default()
};
assert_eq!(create_observer(&cfg).name(), "prometheus");
let expected = if cfg!(feature = "metrics") {
"prometheus"
} else {
"noop"
};
assert_eq!(create_observer(&cfg).name(), expected);
}
#[test]
+4
View File
@@ -138,6 +138,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
default_model: Some(model),
model_providers: std::collections::HashMap::new(),
default_temperature: 0.7,
provider_timeout_secs: 120,
observability: ObservabilityConfig::default(),
autonomy: AutonomyConfig::default(),
security: crate::config::SecurityConfig::default(),
@@ -172,6 +173,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
query_classification: crate::config::QueryClassificationConfig::default(),
transcription: crate::config::TranscriptionConfig::default(),
tts: crate::config::TtsConfig::default(),
mcp: crate::config::McpConfig::default(),
};
println!(
@@ -490,6 +492,7 @@ async fn run_quick_setup_with_home(
default_model: Some(model.clone()),
model_providers: std::collections::HashMap::new(),
default_temperature: 0.7,
provider_timeout_secs: 120,
observability: ObservabilityConfig::default(),
autonomy: AutonomyConfig::default(),
security: crate::config::SecurityConfig::default(),
@@ -524,6 +527,7 @@ async fn run_quick_setup_with_home(
query_classification: crate::config::QueryClassificationConfig::default(),
transcription: crate::config::TranscriptionConfig::default(),
tts: crate::config::TtsConfig::default(),
mcp: crate::config::McpConfig::default(),
};
config.save().await?;
+24 -2
View File
@@ -37,6 +37,8 @@ pub struct OpenAiCompatibleProvider {
/// Whether this provider supports OpenAI-style native tool calling.
/// When false, tools are injected into the system prompt as text.
native_tool_calling: bool,
/// HTTP request timeout in seconds for LLM API calls. Default: 120.
timeout_secs: u64,
}
/// How the provider expects the API key to be sent.
@@ -170,9 +172,16 @@ impl OpenAiCompatibleProvider {
user_agent: user_agent.map(ToString::to_string),
merge_system_into_user,
native_tool_calling: !merge_system_into_user,
timeout_secs: 120,
}
}
/// Override the HTTP request timeout for LLM API calls.
pub fn with_timeout_secs(mut self, timeout_secs: u64) -> Self {
self.timeout_secs = timeout_secs;
self
}
/// Collect all `system` role messages, concatenate their content,
/// and prepend to the first `user` message. Drop all system messages.
/// Used for providers (e.g. MiniMax) that reject `role: system`.
@@ -205,6 +214,7 @@ impl OpenAiCompatibleProvider {
}
fn http_client(&self) -> Client {
let timeout = self.timeout_secs;
if let Some(ua) = self.user_agent.as_deref() {
let mut headers = HeaderMap::new();
if let Ok(value) = HeaderValue::from_str(ua) {
@@ -212,7 +222,7 @@ impl OpenAiCompatibleProvider {
}
let builder = Client::builder()
.timeout(std::time::Duration::from_secs(120))
.timeout(std::time::Duration::from_secs(timeout))
.connect_timeout(std::time::Duration::from_secs(10))
.default_headers(headers);
let builder =
@@ -224,7 +234,7 @@ impl OpenAiCompatibleProvider {
});
}
crate::config::build_runtime_proxy_client_with_timeouts("provider.compatible", 120, 10)
crate::config::build_runtime_proxy_client_with_timeouts("provider.compatible", timeout, 10)
}
/// Build the full URL for chat completions, detecting if base_url already includes the path.
@@ -2899,4 +2909,16 @@ mod tests {
);
assert!(json.contains("thinking..."));
}
#[test]
fn default_timeout_is_120s() {
let p = make_provider("test", "https://example.com", None);
assert_eq!(p.timeout_secs, 120);
}
#[test]
fn with_timeout_secs_overrides_default() {
let p = make_provider("test", "https://example.com", None).with_timeout_secs(300);
assert_eq!(p.timeout_secs, 300);
}
}
+48 -32
View File
@@ -677,6 +677,9 @@ pub struct ProviderRuntimeOptions {
pub zeroclaw_dir: Option<PathBuf>,
pub secrets_encrypt: bool,
pub reasoning_enabled: Option<bool>,
/// HTTP request timeout in seconds for LLM provider API calls.
/// `None` uses the provider's built-in default (120s for compatible providers).
pub provider_timeout_secs: Option<u64>,
}
impl Default for ProviderRuntimeOptions {
@@ -687,6 +690,7 @@ impl Default for ProviderRuntimeOptions {
zeroclaw_dir: None,
secrets_encrypt: true,
reasoning_enabled: None,
provider_timeout_secs: None,
}
}
}
@@ -993,6 +997,18 @@ fn create_provider_with_url_and_options(
api_url: Option<&str>,
options: &ProviderRuntimeOptions,
) -> anyhow::Result<Box<dyn Provider>> {
// Closure to optionally apply the configured provider timeout to
// OpenAI-compatible providers before boxing them as trait objects.
let compat = {
let timeout = options.provider_timeout_secs;
move |p: OpenAiCompatibleProvider| -> Box<dyn Provider> {
match timeout {
Some(t) => Box::new(p.with_timeout_secs(t)),
None => Box::new(p),
}
}
};
let qwen_oauth_context = is_qwen_oauth_alias(name).then(|| resolve_qwen_oauth_context(api_key));
// Resolve credential and break static-analysis taint chain from the
@@ -1066,28 +1082,28 @@ fn create_provider_with_url_and_options(
"telnyx" => Ok(Box::new(telnyx::TelnyxProvider::new(key))),
// ── OpenAI-compatible providers ──────────────────────
"venice" => Ok(Box::new(OpenAiCompatibleProvider::new(
"venice" => Ok(compat(OpenAiCompatibleProvider::new(
"Venice", "https://api.venice.ai", key, AuthStyle::Bearer,
))),
"vercel" | "vercel-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"vercel" | "vercel-ai" => Ok(compat(OpenAiCompatibleProvider::new(
"Vercel AI Gateway",
VERCEL_AI_GATEWAY_BASE_URL,
key,
AuthStyle::Bearer,
))),
"cloudflare" | "cloudflare-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"cloudflare" | "cloudflare-ai" => Ok(compat(OpenAiCompatibleProvider::new(
"Cloudflare AI Gateway",
"https://gateway.ai.cloudflare.com/v1",
key,
AuthStyle::Bearer,
))),
name if moonshot_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new(
name if moonshot_base_url(name).is_some() => Ok(compat(OpenAiCompatibleProvider::new(
"Moonshot",
moonshot_base_url(name).expect("checked in guard"),
key,
AuthStyle::Bearer,
))),
"kimi-code" | "kimi_coding" | "kimi_for_coding" => Ok(Box::new(
"kimi-code" | "kimi_coding" | "kimi_for_coding" => Ok(compat(
OpenAiCompatibleProvider::new_with_user_agent(
"Kimi Code",
"https://api.kimi.com/coding/v1",
@@ -1096,30 +1112,30 @@ fn create_provider_with_url_and_options(
"KimiCLI/0.77",
),
)),
"synthetic" => Ok(Box::new(OpenAiCompatibleProvider::new(
"synthetic" => Ok(compat(OpenAiCompatibleProvider::new(
"Synthetic", "https://api.synthetic.new/openai/v1", key, AuthStyle::Bearer,
))),
"opencode" | "opencode-zen" => Ok(Box::new(OpenAiCompatibleProvider::new(
"opencode" | "opencode-zen" => Ok(compat(OpenAiCompatibleProvider::new(
"OpenCode Zen", "https://opencode.ai/zen/v1", key, AuthStyle::Bearer,
))),
"opencode-go" => Ok(Box::new(OpenAiCompatibleProvider::new(
"opencode-go" => Ok(compat(OpenAiCompatibleProvider::new(
"OpenCode Go", "https://opencode.ai/zen/go/v1", key, AuthStyle::Bearer,
))),
name if zai_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new(
name if zai_base_url(name).is_some() => Ok(compat(OpenAiCompatibleProvider::new(
"Z.AI",
zai_base_url(name).expect("checked in guard"),
key,
AuthStyle::Bearer,
))),
name if glm_base_url(name).is_some() => {
Ok(Box::new(OpenAiCompatibleProvider::new_no_responses_fallback(
Ok(compat(OpenAiCompatibleProvider::new_no_responses_fallback(
"GLM",
glm_base_url(name).expect("checked in guard"),
key,
AuthStyle::Bearer,
)))
}
name if minimax_base_url(name).is_some() => Ok(Box::new(
name if minimax_base_url(name).is_some() => Ok(compat(
OpenAiCompatibleProvider::new_merge_system_into_user(
"MiniMax",
minimax_base_url(name).expect("checked in guard"),
@@ -1149,7 +1165,7 @@ fn create_provider_with_url_and_options(
.or_else(|| qwen_oauth_context.as_ref().and_then(|context| context.base_url.clone()))
.unwrap_or_else(|| QWEN_OAUTH_BASE_FALLBACK_URL.to_string());
Ok(Box::new(
Ok(compat(
OpenAiCompatibleProvider::new_with_user_agent_and_vision(
"Qwen Code",
&base_url,
@@ -1159,16 +1175,16 @@ fn create_provider_with_url_and_options(
true,
)))
}
name if is_qianfan_alias(name) => Ok(Box::new(OpenAiCompatibleProvider::new(
name if is_qianfan_alias(name) => Ok(compat(OpenAiCompatibleProvider::new(
"Qianfan", "https://aip.baidubce.com", key, AuthStyle::Bearer,
))),
name if is_doubao_alias(name) => Ok(Box::new(OpenAiCompatibleProvider::new(
name if is_doubao_alias(name) => Ok(compat(OpenAiCompatibleProvider::new(
"Doubao",
"https://ark.cn-beijing.volces.com/api/v3",
key,
AuthStyle::Bearer,
))),
name if qwen_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new_with_vision(
name if qwen_base_url(name).is_some() => Ok(compat(OpenAiCompatibleProvider::new_with_vision(
"Qwen",
qwen_base_url(name).expect("checked in guard"),
key,
@@ -1177,31 +1193,31 @@ fn create_provider_with_url_and_options(
))),
// ── Extended ecosystem (community favorites) ─────────
"groq" => Ok(Box::new(OpenAiCompatibleProvider::new(
"groq" => Ok(compat(OpenAiCompatibleProvider::new(
"Groq", "https://api.groq.com/openai/v1", key, AuthStyle::Bearer,
))),
"mistral" => Ok(Box::new(OpenAiCompatibleProvider::new(
"mistral" => Ok(compat(OpenAiCompatibleProvider::new(
"Mistral", "https://api.mistral.ai/v1", key, AuthStyle::Bearer,
))),
"xai" | "grok" => Ok(Box::new(OpenAiCompatibleProvider::new(
"xai" | "grok" => Ok(compat(OpenAiCompatibleProvider::new(
"xAI", "https://api.x.ai", key, AuthStyle::Bearer,
))),
"deepseek" => Ok(Box::new(OpenAiCompatibleProvider::new(
"deepseek" => Ok(compat(OpenAiCompatibleProvider::new(
"DeepSeek", "https://api.deepseek.com", key, AuthStyle::Bearer,
))),
"together" | "together-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"together" | "together-ai" => Ok(compat(OpenAiCompatibleProvider::new(
"Together AI", "https://api.together.xyz", key, AuthStyle::Bearer,
))),
"fireworks" | "fireworks-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"fireworks" | "fireworks-ai" => Ok(compat(OpenAiCompatibleProvider::new(
"Fireworks AI", "https://api.fireworks.ai/inference/v1", key, AuthStyle::Bearer,
))),
"novita" => Ok(Box::new(OpenAiCompatibleProvider::new(
"novita" => Ok(compat(OpenAiCompatibleProvider::new(
"Novita AI", "https://api.novita.ai/openai", key, AuthStyle::Bearer,
))),
"perplexity" => Ok(Box::new(OpenAiCompatibleProvider::new(
"perplexity" => Ok(compat(OpenAiCompatibleProvider::new(
"Perplexity", "https://api.perplexity.ai", key, AuthStyle::Bearer,
))),
"cohere" => Ok(Box::new(OpenAiCompatibleProvider::new(
"cohere" => Ok(compat(OpenAiCompatibleProvider::new(
"Cohere", "https://api.cohere.com/compatibility", key, AuthStyle::Bearer,
))),
"copilot" | "github-copilot" => Ok(Box::new(copilot::CopilotProvider::new(key))),
@@ -1210,7 +1226,7 @@ fn create_provider_with_url_and_options(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("lm-studio");
Ok(Box::new(OpenAiCompatibleProvider::new(
Ok(compat(OpenAiCompatibleProvider::new(
"LM Studio",
"http://localhost:1234/v1",
Some(lm_studio_key),
@@ -1226,7 +1242,7 @@ fn create_provider_with_url_and_options(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("llama.cpp");
Ok(Box::new(OpenAiCompatibleProvider::new(
Ok(compat(OpenAiCompatibleProvider::new(
"llama.cpp",
base_url,
Some(llama_cpp_key),
@@ -1238,7 +1254,7 @@ fn create_provider_with_url_and_options(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("http://localhost:30000/v1");
Ok(Box::new(OpenAiCompatibleProvider::new(
Ok(compat(OpenAiCompatibleProvider::new(
"SGLang",
base_url,
key,
@@ -1250,7 +1266,7 @@ fn create_provider_with_url_and_options(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("http://localhost:8000/v1");
Ok(Box::new(OpenAiCompatibleProvider::new(
Ok(compat(OpenAiCompatibleProvider::new(
"vLLM",
base_url,
key,
@@ -1266,14 +1282,14 @@ fn create_provider_with_url_and_options(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("osaurus");
Ok(Box::new(OpenAiCompatibleProvider::new(
Ok(compat(OpenAiCompatibleProvider::new(
"Osaurus",
base_url,
Some(osaurus_key),
AuthStyle::Bearer,
)))
}
"nvidia" | "nvidia-nim" | "build.nvidia.com" => Ok(Box::new(
"nvidia" | "nvidia-nim" | "build.nvidia.com" => Ok(compat(
OpenAiCompatibleProvider::new_no_responses_fallback(
"NVIDIA NIM",
"https://integrate.api.nvidia.com/v1",
@@ -1283,7 +1299,7 @@ fn create_provider_with_url_and_options(
)),
// ── AI inference routers ─────────────────────────────
"astrai" => Ok(Box::new(OpenAiCompatibleProvider::new(
"astrai" => Ok(compat(OpenAiCompatibleProvider::new(
"Astrai", "https://as-trai.com/v1", key, AuthStyle::Bearer,
))),
@@ -1301,7 +1317,7 @@ fn create_provider_with_url_and_options(
"Custom provider",
"custom:https://your-api.com",
)?;
Ok(Box::new(OpenAiCompatibleProvider::new_with_vision(
Ok(compat(OpenAiCompatibleProvider::new_with_vision(
"Custom",
&base_url,
key,
+81 -9
View File
@@ -27,7 +27,7 @@ struct ChatRequest {
tools: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
struct Message {
role: String,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -40,14 +40,14 @@ struct Message {
tool_name: Option<String>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
struct OutgoingToolCall {
#[serde(rename = "type")]
kind: String,
function: OutgoingFunction,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
struct OutgoingFunction {
name: String,
arguments: serde_json::Value,
@@ -258,13 +258,31 @@ impl OllamaProvider {
model: &str,
temperature: f64,
tools: Option<&[serde_json::Value]>,
) -> ChatRequest {
self.build_chat_request_with_think(
messages,
model,
temperature,
tools,
self.reasoning_enabled,
)
}
/// Build a chat request with an explicit `think` value.
fn build_chat_request_with_think(
&self,
messages: Vec<Message>,
model: &str,
temperature: f64,
tools: Option<&[serde_json::Value]>,
think: Option<bool>,
) -> ChatRequest {
ChatRequest {
model: model.to_string(),
messages,
stream: false,
options: Options { temperature },
think: self.reasoning_enabled,
think,
tools: tools.map(|t| t.to_vec()),
}
}
@@ -396,17 +414,18 @@ impl OllamaProvider {
.collect()
}
/// Send a request to Ollama and get the parsed response.
/// Pass `tools` to enable native function-calling for models that support it.
async fn send_request(
/// Send a single HTTP request to Ollama and parse the response.
async fn send_request_inner(
&self,
messages: Vec<Message>,
messages: &[Message],
model: &str,
temperature: f64,
should_auth: bool,
tools: Option<&[serde_json::Value]>,
think: Option<bool>,
) -> anyhow::Result<ApiChatResponse> {
let request = self.build_chat_request(messages, model, temperature, tools);
let request =
self.build_chat_request_with_think(messages.to_vec(), model, temperature, tools, think);
let url = format!("{}/api/chat", self.base_url);
@@ -466,6 +485,59 @@ impl OllamaProvider {
Ok(chat_response)
}
/// Send a request to Ollama and get the parsed response.
/// Pass `tools` to enable native function-calling for models that support it.
///
/// When `reasoning_enabled` (`think`) is set to `true`, the first request
/// includes `think: true`. If that request fails (the model may not support
/// the `think` parameter), we automatically retry once with `think` omitted
/// so the call succeeds instead of entering an infinite retry loop.
async fn send_request(
&self,
messages: Vec<Message>,
model: &str,
temperature: f64,
should_auth: bool,
tools: Option<&[serde_json::Value]>,
) -> anyhow::Result<ApiChatResponse> {
let result = self
.send_request_inner(
&messages,
model,
temperature,
should_auth,
tools,
self.reasoning_enabled,
)
.await;
match result {
Ok(resp) => Ok(resp),
Err(first_err) if self.reasoning_enabled == Some(true) => {
tracing::warn!(
model = model,
error = %first_err,
"Ollama request failed with think=true; retrying without reasoning \
(model may not support it)"
);
// Retry with think omitted from the request entirely.
self.send_request_inner(&messages, model, temperature, should_auth, tools, None)
.await
.map_err(|retry_err| {
// Both attempts failed — return the original error for clarity.
tracing::error!(
model = model,
original_error = %first_err,
retry_error = %retry_err,
"Ollama request also failed without think; returning original error"
);
first_err
})
}
Err(e) => Err(e),
}
}
/// Convert Ollama tool calls to the JSON format expected by parse_tool_calls in loop_.rs
///
/// Handles quirky model behavior where tool calls are wrapped:
+1
View File
@@ -1017,6 +1017,7 @@ data: [DONE]
secrets_encrypt: false,
auth_profile_override: None,
reasoning_enabled: None,
provider_timeout_secs: None,
};
let provider =
OpenAiCodexProvider::new(&options, None).expect("provider should initialize");
+54 -4
View File
@@ -922,9 +922,28 @@ impl SecurityPolicy {
// Expand "~" for consistent matching with forbidden paths and allowlists.
let expanded_path = expand_user_path(path);
// Block absolute paths when workspace_only is set
if self.workspace_only && expanded_path.is_absolute() {
return false;
// When workspace_only is set and the path is absolute, only allow it
// if it falls within the workspace directory or an explicit allowed
// root. The workspace/allowed-root check runs BEFORE the forbidden
// prefix list so that workspace paths under broad defaults like
// "/home" are not rejected. This mirrors the priority order in
// `is_resolved_path_allowed`. See #2880.
if expanded_path.is_absolute() {
let in_workspace = expanded_path.starts_with(&self.workspace_dir);
let in_allowed_root = self
.allowed_roots
.iter()
.any(|root| expanded_path.starts_with(root));
if in_workspace || in_allowed_root {
return true;
}
// Absolute path outside workspace/allowed roots — block when
// workspace_only, or fall through to forbidden-prefix check.
if self.workspace_only {
return false;
}
}
// Block forbidden paths using path-component-aware matching
@@ -1384,6 +1403,37 @@ mod tests {
assert!(!p.is_path_allowed("/tmp/file.txt"));
}
#[test]
fn absolute_path_inside_workspace_allowed_when_workspace_only() {
let p = SecurityPolicy {
workspace_dir: PathBuf::from("/home/user/.zeroclaw/workspace"),
workspace_only: true,
..SecurityPolicy::default()
};
// Absolute path inside workspace should be allowed
assert!(p.is_path_allowed("/home/user/.zeroclaw/workspace/images/example.png"));
assert!(p.is_path_allowed("/home/user/.zeroclaw/workspace/file.txt"));
// Absolute path outside workspace should still be blocked
assert!(!p.is_path_allowed("/home/user/other/file.txt"));
assert!(!p.is_path_allowed("/tmp/file.txt"));
}
#[test]
fn absolute_path_in_allowed_root_permitted_when_workspace_only() {
let p = SecurityPolicy {
workspace_dir: PathBuf::from("/home/user/.zeroclaw/workspace"),
workspace_only: true,
allowed_roots: vec![PathBuf::from("/home/user/.zeroclaw/shared")],
..SecurityPolicy::default()
};
// Path in allowed root should be permitted
assert!(p.is_path_allowed("/home/user/.zeroclaw/shared/data.txt"));
// Path in workspace should still be permitted
assert!(p.is_path_allowed("/home/user/.zeroclaw/workspace/file.txt"));
// Path outside both should still be blocked
assert!(!p.is_path_allowed("/home/user/other/file.txt"));
}
#[test]
fn absolute_paths_allowed_when_not_workspace_only() {
let p = SecurityPolicy {
@@ -2122,7 +2172,7 @@ mod tests {
}
#[test]
fn checklist_workspace_only_blocks_all_absolute() {
fn checklist_workspace_only_blocks_absolute_outside_workspace() {
let p = SecurityPolicy {
workspace_only: true,
..SecurityPolicy::default()
+1
View File
@@ -411,6 +411,7 @@ impl DelegateTool {
None,
None,
&[],
&[],
),
)
.await;
+357
View File
@@ -0,0 +1,357 @@
//! MCP (Model Context Protocol) client — connects to external tool servers.
//!
//! Supports multiple transports: stdio (spawn local process), HTTP, and SSE.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use serde_json::json;
use tokio::sync::Mutex;
use tokio::time::{timeout, Duration};
use crate::config::schema::McpServerConfig;
use crate::tools::mcp_protocol::{
JsonRpcRequest, McpToolDef, McpToolsListResult, MCP_PROTOCOL_VERSION,
};
use crate::tools::mcp_transport::{create_transport, McpTransportConn};
/// Timeout for receiving a response from an MCP server during init/list.
/// Prevents a hung server from blocking the daemon indefinitely.
const RECV_TIMEOUT_SECS: u64 = 30;
/// Default timeout for tool calls (seconds) when not configured per-server.
const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 180;
/// Maximum allowed tool call timeout (seconds) — hard safety ceiling.
const MAX_TOOL_TIMEOUT_SECS: u64 = 600;
// ── Internal server state ──────────────────────────────────────────────────
struct McpServerInner {
config: McpServerConfig,
transport: Box<dyn McpTransportConn>,
next_id: AtomicU64,
tools: Vec<McpToolDef>,
}
// ── McpServer ──────────────────────────────────────────────────────────────
/// A live connection to one MCP server (any transport).
#[derive(Clone)]
pub struct McpServer {
inner: Arc<Mutex<McpServerInner>>,
}
impl McpServer {
/// Connect to the server, perform the initialize handshake, and fetch the tool list.
pub async fn connect(config: McpServerConfig) -> Result<Self> {
// Create transport based on config
let mut transport = create_transport(&config).with_context(|| {
format!(
"failed to create transport for MCP server `{}`",
config.name
)
})?;
// Initialize handshake
let id = 1u64;
let init_req = JsonRpcRequest::new(
id,
"initialize",
json!({
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {
"name": "zeroclaw",
"version": env!("CARGO_PKG_VERSION")
}
}),
);
let init_resp = timeout(
Duration::from_secs(RECV_TIMEOUT_SECS),
transport.send_and_recv(&init_req),
)
.await
.with_context(|| {
format!(
"MCP server `{}` timed out after {}s waiting for initialize response",
config.name, RECV_TIMEOUT_SECS
)
})??;
if init_resp.error.is_some() {
bail!(
"MCP server `{}` rejected initialize: {:?}",
config.name,
init_resp.error
);
}
// Notify server that client is initialized (no response expected for notifications)
let notif = JsonRpcRequest::notification("notifications/initialized", json!({}));
// Best effort - ignore errors for notifications
let _ = transport.send_and_recv(&notif).await;
// Fetch available tools
let id = 2u64;
let list_req = JsonRpcRequest::new(id, "tools/list", json!({}));
let list_resp = timeout(
Duration::from_secs(RECV_TIMEOUT_SECS),
transport.send_and_recv(&list_req),
)
.await
.with_context(|| {
format!(
"MCP server `{}` timed out after {}s waiting for tools/list response",
config.name, RECV_TIMEOUT_SECS
)
})??;
let result = list_resp
.result
.ok_or_else(|| anyhow!("tools/list returned no result from `{}`", config.name))?;
let tool_list: McpToolsListResult = serde_json::from_value(result)
.with_context(|| format!("failed to parse tools/list from `{}`", config.name))?;
let tool_count = tool_list.tools.len();
let inner = McpServerInner {
config,
transport,
next_id: AtomicU64::new(3), // Start at 3 since we used 1 and 2
tools: tool_list.tools,
};
tracing::info!(
"MCP server `{}` connected — {} tool(s) available",
inner.config.name,
tool_count
);
Ok(Self {
inner: Arc::new(Mutex::new(inner)),
})
}
/// Tools advertised by this server.
pub async fn tools(&self) -> Vec<McpToolDef> {
self.inner.lock().await.tools.clone()
}
/// Server display name.
#[allow(dead_code)]
pub async fn name(&self) -> String {
self.inner.lock().await.config.name.clone()
}
/// Call a tool on this server. Returns the raw JSON result.
pub async fn call_tool(
&self,
tool_name: &str,
arguments: serde_json::Value,
) -> Result<serde_json::Value> {
let mut inner = self.inner.lock().await;
let id = inner.next_id.fetch_add(1, Ordering::Relaxed);
let req = JsonRpcRequest::new(
id,
"tools/call",
json!({ "name": tool_name, "arguments": arguments }),
);
// Use per-server tool timeout if configured, otherwise default.
// Cap at MAX_TOOL_TIMEOUT_SECS for safety.
let tool_timeout = inner
.config
.tool_timeout_secs
.unwrap_or(DEFAULT_TOOL_TIMEOUT_SECS)
.min(MAX_TOOL_TIMEOUT_SECS);
let resp = timeout(
Duration::from_secs(tool_timeout),
inner.transport.send_and_recv(&req),
)
.await
.map_err(|_| {
anyhow!(
"MCP server `{}` timed out after {}s during tool call `{tool_name}`",
inner.config.name,
tool_timeout
)
})?
.with_context(|| {
format!(
"MCP server `{}` error during tool call `{tool_name}`",
inner.config.name
)
})?;
if let Some(err) = resp.error {
bail!("MCP tool `{tool_name}` error {}: {}", err.code, err.message);
}
Ok(resp.result.unwrap_or(serde_json::Value::Null))
}
}
// ── McpRegistry ───────────────────────────────────────────────────────────
/// Registry of all connected MCP servers, with a flat tool index.
pub struct McpRegistry {
servers: Vec<McpServer>,
/// prefixed_name -> (server_index, original_tool_name)
tool_index: HashMap<String, (usize, String)>,
}
impl McpRegistry {
/// Connect to all configured servers. Non-fatal: failures are logged and skipped.
pub async fn connect_all(configs: &[McpServerConfig]) -> Result<Self> {
let mut servers = Vec::new();
let mut tool_index = HashMap::new();
for config in configs {
match McpServer::connect(config.clone()).await {
Ok(server) => {
let server_idx = servers.len();
// Collect tools while holding the lock once, then release
let tools = server.tools().await;
for tool in &tools {
// Prefix prevents name collisions across servers
let prefixed = format!("{}__{}", config.name, tool.name);
tool_index.insert(prefixed, (server_idx, tool.name.clone()));
}
servers.push(server);
}
// Non-fatal — log and continue with remaining servers
Err(e) => {
tracing::error!("Failed to connect to MCP server `{}`: {:#}", config.name, e);
}
}
}
Ok(Self {
servers,
tool_index,
})
}
/// All prefixed tool names across all connected servers.
pub fn tool_names(&self) -> Vec<String> {
self.tool_index.keys().cloned().collect()
}
/// Tool definition for a given prefixed name (cloned).
pub async fn get_tool_def(&self, prefixed_name: &str) -> Option<McpToolDef> {
let (server_idx, original_name) = self.tool_index.get(prefixed_name)?;
let inner = self.servers[*server_idx].inner.lock().await;
inner
.tools
.iter()
.find(|t| &t.name == original_name)
.cloned()
}
/// Execute a tool by prefixed name.
pub async fn call_tool(
&self,
prefixed_name: &str,
arguments: serde_json::Value,
) -> Result<String> {
let (server_idx, original_name) = self
.tool_index
.get(prefixed_name)
.ok_or_else(|| anyhow!("unknown MCP tool `{prefixed_name}`"))?;
let result = self.servers[*server_idx]
.call_tool(original_name, arguments)
.await?;
serde_json::to_string_pretty(&result)
.with_context(|| format!("failed to serialize result of MCP tool `{prefixed_name}`"))
}
pub fn is_empty(&self) -> bool {
self.servers.is_empty()
}
pub fn server_count(&self) -> usize {
self.servers.len()
}
pub fn tool_count(&self) -> usize {
self.tool_index.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::McpTransport;
#[test]
fn tool_name_prefix_format() {
let prefixed = format!("{}__{}", "filesystem", "read_file");
assert_eq!(prefixed, "filesystem__read_file");
}
#[tokio::test]
async fn connect_nonexistent_command_fails_cleanly() {
// A command that doesn't exist should fail at spawn, not panic.
let config = McpServerConfig {
name: "nonexistent".to_string(),
command: "/usr/bin/this_binary_does_not_exist_zeroclaw_test".to_string(),
args: vec![],
env: HashMap::default(),
tool_timeout_secs: None,
transport: McpTransport::Stdio,
url: None,
headers: HashMap::default(),
};
let result = McpServer::connect(config).await;
assert!(result.is_err());
let msg = result.err().unwrap().to_string();
assert!(msg.contains("failed to create transport"), "got: {msg}");
}
#[tokio::test]
async fn connect_all_nonfatal_on_single_failure() {
// If one server config is bad, connect_all should succeed (with 0 servers).
let configs = vec![McpServerConfig {
name: "bad".to_string(),
command: "/usr/bin/does_not_exist_zc_test".to_string(),
args: vec![],
env: HashMap::default(),
tool_timeout_secs: None,
transport: McpTransport::Stdio,
url: None,
headers: HashMap::default(),
}];
let registry = McpRegistry::connect_all(&configs)
.await
.expect("connect_all should not fail");
assert!(registry.is_empty());
assert_eq!(registry.tool_count(), 0);
}
#[test]
fn http_transport_requires_url() {
let config = McpServerConfig {
name: "test".into(),
transport: McpTransport::Http,
..Default::default()
};
let result = create_transport(&config);
assert!(result.is_err());
}
#[test]
fn sse_transport_requires_url() {
let config = McpServerConfig {
name: "test".into(),
transport: McpTransport::Sse,
..Default::default()
};
let result = create_transport(&config);
assert!(result.is_err());
}
}
+130
View File
@@ -0,0 +1,130 @@
//! MCP (Model Context Protocol) JSON-RPC 2.0 protocol types.
//! Protocol version: 2024-11-05
//! Adapted from ops-mcp-server/src/protocol.rs for client use.
//! Both Serialize and Deserialize are derived — the client both sends (Serialize)
//! and receives (Deserialize) JSON-RPC messages.
use serde::{Deserialize, Serialize};
pub const JSONRPC_VERSION: &str = "2.0";
pub const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
// Standard JSON-RPC 2.0 error codes
#[allow(dead_code)]
pub const PARSE_ERROR: i32 = -32700;
#[allow(dead_code)]
pub const INVALID_REQUEST: i32 = -32600;
#[allow(dead_code)]
pub const METHOD_NOT_FOUND: i32 = -32601;
#[allow(dead_code)]
pub const INVALID_PARAMS: i32 = -32602;
pub const INTERNAL_ERROR: i32 = -32603;
/// Outbound JSON-RPC request (client -> MCP server).
/// Used for both method calls (with id) and notifications (id = None).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest {
pub jsonrpc: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<serde_json::Value>,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<serde_json::Value>,
}
impl JsonRpcRequest {
/// Create a method call request with a numeric id.
pub fn new(id: u64, method: impl Into<String>, params: serde_json::Value) -> Self {
Self {
jsonrpc: JSONRPC_VERSION.to_string(),
id: Some(serde_json::Value::Number(id.into())),
method: method.into(),
params: Some(params),
}
}
/// Create a notification — no id, no response expected from server.
pub fn notification(method: impl Into<String>, params: serde_json::Value) -> Self {
Self {
jsonrpc: JSONRPC_VERSION.to_string(),
id: None,
method: method.into(),
params: Some(params),
}
}
}
/// Inbound JSON-RPC response (MCP server -> client).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse {
pub jsonrpc: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
/// JSON-RPC error object embedded in a response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
/// A tool advertised by an MCP server (from `tools/list` response).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolDef {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "inputSchema")]
pub input_schema: serde_json::Value,
}
/// Expected shape of the `tools/list` result payload.
#[derive(Debug, Deserialize)]
pub struct McpToolsListResult {
pub tools: Vec<McpToolDef>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_serializes_with_id() {
let req = JsonRpcRequest::new(1, "tools/list", serde_json::json!({}));
let s = serde_json::to_string(&req).unwrap();
assert!(s.contains("\"id\":1"));
assert!(s.contains("\"method\":\"tools/list\""));
assert!(s.contains("\"jsonrpc\":\"2.0\""));
}
#[test]
fn notification_omits_id() {
let notif =
JsonRpcRequest::notification("notifications/initialized", serde_json::json!({}));
let s = serde_json::to_string(&notif).unwrap();
assert!(!s.contains("\"id\""));
}
#[test]
fn response_deserializes() {
let json = r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#;
let resp: JsonRpcResponse = serde_json::from_str(json).unwrap();
assert!(resp.result.is_some());
assert!(resp.error.is_none());
}
#[test]
fn tool_def_deserializes_input_schema() {
let json = r#"{"name":"read_file","description":"Read a file","inputSchema":{"type":"object","properties":{"path":{"type":"string"}}}}"#;
let def: McpToolDef = serde_json::from_str(json).unwrap();
assert_eq!(def.name, "read_file");
assert!(def.input_schema.is_object());
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Wraps a discovered MCP tool as a zeroclaw [`Tool`] so it is dispatched
//! through the existing tool registry and agent loop without modification.
use std::sync::Arc;
use async_trait::async_trait;
use crate::tools::mcp_client::McpRegistry;
use crate::tools::mcp_protocol::McpToolDef;
use crate::tools::traits::{Tool, ToolResult};
/// A zeroclaw [`Tool`] backed by an MCP server tool.
///
/// The `prefixed_name` (e.g. `filesystem__read_file`) is what the agent loop
/// sees. The registry knows how to route it to the correct server.
pub struct McpToolWrapper {
/// Prefixed name: `<server_name>__<tool_name>`.
prefixed_name: String,
/// Description extracted from the MCP tool definition. Stored as an owned
/// String so that `description()` can return `&str` with self's lifetime.
description: String,
/// JSON schema for the tool's input parameters.
input_schema: serde_json::Value,
/// Shared registry — used to dispatch actual tool calls.
registry: Arc<McpRegistry>,
}
impl McpToolWrapper {
pub fn new(prefixed_name: String, def: McpToolDef, registry: Arc<McpRegistry>) -> Self {
let description = def.description.unwrap_or_else(|| "MCP tool".to_string());
Self {
prefixed_name,
description,
input_schema: def.input_schema,
registry,
}
}
}
#[async_trait]
impl Tool for McpToolWrapper {
fn name(&self) -> &str {
&self.prefixed_name
}
fn description(&self) -> &str {
&self.description
}
fn parameters_schema(&self) -> serde_json::Value {
self.input_schema.clone()
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
match self.registry.call_tool(&self.prefixed_name, args).await {
Ok(output) => Ok(ToolResult {
success: true,
output,
error: None,
}),
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(e.to_string()),
}),
}
}
}
+868
View File
@@ -0,0 +1,868 @@
//! MCP transport abstraction — supports stdio, SSE, and HTTP transports.
use std::borrow::Cow;
use anyhow::{anyhow, bail, Context, Result};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::{oneshot, Mutex, Notify};
use tokio::time::{timeout, Duration};
use tokio_stream::StreamExt;
use crate::config::schema::{McpServerConfig, McpTransport};
use crate::tools::mcp_protocol::{JsonRpcError, JsonRpcRequest, JsonRpcResponse, INTERNAL_ERROR};
/// Maximum bytes for a single JSON-RPC response.
const MAX_LINE_BYTES: usize = 4 * 1024 * 1024; // 4 MB
/// Timeout for init/list operations.
const RECV_TIMEOUT_SECS: u64 = 30;
// ── Transport Trait ──────────────────────────────────────────────────────
/// Abstract transport for MCP communication.
#[async_trait::async_trait]
pub trait McpTransportConn: Send + Sync {
/// Send a JSON-RPC request and receive the response.
async fn send_and_recv(&mut self, request: &JsonRpcRequest) -> Result<JsonRpcResponse>;
/// Close the connection.
async fn close(&mut self) -> Result<()>;
}
// ── Stdio Transport ──────────────────────────────────────────────────────
/// Stdio-based transport (spawn local process).
pub struct StdioTransport {
_child: Child,
stdin: tokio::process::ChildStdin,
stdout_lines: tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
}
impl StdioTransport {
pub fn new(config: &McpServerConfig) -> Result<Self> {
let mut child = Command::new(&config.command)
.args(&config.args)
.envs(&config.env)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.kill_on_drop(true)
.spawn()
.with_context(|| format!("failed to spawn MCP server `{}`", config.name))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow!("no stdin on MCP server `{}`", config.name))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("no stdout on MCP server `{}`", config.name))?;
let stdout_lines = BufReader::new(stdout).lines();
Ok(Self {
_child: child,
stdin,
stdout_lines,
})
}
async fn send_raw(&mut self, line: &str) -> Result<()> {
self.stdin
.write_all(line.as_bytes())
.await
.context("failed to write to MCP server stdin")?;
self.stdin
.write_all(b"\n")
.await
.context("failed to write newline to MCP server stdin")?;
self.stdin.flush().await.context("failed to flush stdin")?;
Ok(())
}
async fn recv_raw(&mut self) -> Result<String> {
let line = self
.stdout_lines
.next_line()
.await?
.ok_or_else(|| anyhow!("MCP server closed stdout"))?;
if line.len() > MAX_LINE_BYTES {
bail!("MCP response too large: {} bytes", line.len());
}
Ok(line)
}
}
#[async_trait::async_trait]
impl McpTransportConn for StdioTransport {
async fn send_and_recv(&mut self, request: &JsonRpcRequest) -> Result<JsonRpcResponse> {
let line = serde_json::to_string(request)?;
self.send_raw(&line).await?;
if request.id.is_none() {
return Ok(JsonRpcResponse {
jsonrpc: crate::tools::mcp_protocol::JSONRPC_VERSION.to_string(),
id: None,
result: None,
error: None,
});
}
let resp_line = timeout(Duration::from_secs(RECV_TIMEOUT_SECS), self.recv_raw())
.await
.context("timeout waiting for MCP response")??;
let resp: JsonRpcResponse = serde_json::from_str(&resp_line)
.with_context(|| format!("invalid JSON-RPC response: {}", resp_line))?;
Ok(resp)
}
async fn close(&mut self) -> Result<()> {
let _ = self.stdin.shutdown().await;
Ok(())
}
}
// ── HTTP Transport ───────────────────────────────────────────────────────
/// HTTP-based transport (POST requests).
pub struct HttpTransport {
url: String,
client: reqwest::Client,
headers: std::collections::HashMap<String, String>,
}
impl HttpTransport {
pub fn new(config: &McpServerConfig) -> Result<Self> {
let url = config
.url
.as_ref()
.ok_or_else(|| anyhow!("URL required for HTTP transport"))?
.clone();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.context("failed to build HTTP client")?;
Ok(Self {
url,
client,
headers: config.headers.clone(),
})
}
}
#[async_trait::async_trait]
impl McpTransportConn for HttpTransport {
async fn send_and_recv(&mut self, request: &JsonRpcRequest) -> Result<JsonRpcResponse> {
let body = serde_json::to_string(request)?;
let mut req = self.client.post(&self.url).body(body);
for (key, value) in &self.headers {
req = req.header(key, value);
}
let resp = req
.send()
.await
.context("HTTP request to MCP server failed")?;
if !resp.status().is_success() {
bail!("MCP server returned HTTP {}", resp.status());
}
if request.id.is_none() {
return Ok(JsonRpcResponse {
jsonrpc: crate::tools::mcp_protocol::JSONRPC_VERSION.to_string(),
id: None,
result: None,
error: None,
});
}
let resp_text = resp.text().await.context("failed to read HTTP response")?;
let mcp_resp: JsonRpcResponse = serde_json::from_str(&resp_text)
.with_context(|| format!("invalid JSON-RPC response: {}", resp_text))?;
Ok(mcp_resp)
}
async fn close(&mut self) -> Result<()> {
Ok(())
}
}
// ── SSE Transport ─────────────────────────────────────────────────────────
/// SSE-based transport (HTTP POST for requests, SSE for responses).
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum SseStreamState {
Unknown,
Connected,
Unsupported,
}
pub struct SseTransport {
sse_url: String,
server_name: String,
client: reqwest::Client,
headers: std::collections::HashMap<String, String>,
stream_state: SseStreamState,
shared: std::sync::Arc<Mutex<SseSharedState>>,
notify: std::sync::Arc<Notify>,
shutdown_tx: Option<oneshot::Sender<()>>,
reader_task: Option<tokio::task::JoinHandle<()>>,
}
impl SseTransport {
pub fn new(config: &McpServerConfig) -> Result<Self> {
let sse_url = config
.url
.as_ref()
.ok_or_else(|| anyhow!("URL required for SSE transport"))?
.clone();
let client = reqwest::Client::builder()
.build()
.context("failed to build HTTP client")?;
Ok(Self {
sse_url,
server_name: config.name.clone(),
client,
headers: config.headers.clone(),
stream_state: SseStreamState::Unknown,
shared: std::sync::Arc::new(Mutex::new(SseSharedState::default())),
notify: std::sync::Arc::new(Notify::new()),
shutdown_tx: None,
reader_task: None,
})
}
async fn ensure_connected(&mut self) -> Result<()> {
if self.stream_state == SseStreamState::Unsupported {
return Ok(());
}
if let Some(task) = &self.reader_task {
if !task.is_finished() {
self.stream_state = SseStreamState::Connected;
return Ok(());
}
}
let mut req = self
.client
.get(&self.sse_url)
.header("Accept", "text/event-stream")
.header("Cache-Control", "no-cache");
for (key, value) in &self.headers {
req = req.header(key, value);
}
let resp = req.send().await.context("SSE GET to MCP server failed")?;
if resp.status() == reqwest::StatusCode::NOT_FOUND
|| resp.status() == reqwest::StatusCode::METHOD_NOT_ALLOWED
{
self.stream_state = SseStreamState::Unsupported;
return Ok(());
}
if !resp.status().is_success() {
return Err(anyhow!("MCP server returned HTTP {}", resp.status()));
}
let is_event_stream = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("text/event-stream"));
if !is_event_stream {
self.stream_state = SseStreamState::Unsupported;
return Ok(());
}
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
self.shutdown_tx = Some(shutdown_tx);
let shared = self.shared.clone();
let notify = self.notify.clone();
let sse_url = self.sse_url.clone();
let server_name = self.server_name.clone();
self.reader_task = Some(tokio::spawn(async move {
let stream = resp
.bytes_stream()
.map(|item| item.map_err(std::io::Error::other));
let reader = tokio_util::io::StreamReader::new(stream);
let mut lines = BufReader::new(reader).lines();
let mut cur_event: Option<String> = None;
let mut cur_id: Option<String> = None;
let mut cur_data: Vec<String> = Vec::new();
loop {
tokio::select! {
_ = &mut shutdown_rx => {
break;
}
line = lines.next_line() => {
let Ok(line_opt) = line else { break; };
let Some(mut line) = line_opt else { break; };
if line.ends_with('\r') {
line.pop();
}
if line.is_empty() {
if cur_event.is_none() && cur_id.is_none() && cur_data.is_empty() {
continue;
}
let event = cur_event.take();
let data = cur_data.join("\n");
cur_data.clear();
let id = cur_id.take();
handle_sse_event(
&server_name,
&sse_url,
&shared,
&notify,
event.as_deref(),
id.as_deref(),
data,
)
.await;
continue;
}
if line.starts_with(':') {
continue;
}
if let Some(rest) = line.strip_prefix("event:") {
cur_event = Some(rest.trim().to_string());
continue;
}
if let Some(rest) = line.strip_prefix("data:") {
let rest = rest.strip_prefix(' ').unwrap_or(rest);
cur_data.push(rest.to_string());
continue;
}
if let Some(rest) = line.strip_prefix("id:") {
cur_id = Some(rest.trim().to_string());
}
}
}
}
let pending = {
let mut guard = shared.lock().await;
std::mem::take(&mut guard.pending)
};
for (_, tx) in pending {
let _ = tx.send(JsonRpcResponse {
jsonrpc: crate::tools::mcp_protocol::JSONRPC_VERSION.to_string(),
id: None,
result: None,
error: Some(JsonRpcError {
code: INTERNAL_ERROR,
message: "SSE connection closed".to_string(),
data: None,
}),
});
}
}));
self.stream_state = SseStreamState::Connected;
Ok(())
}
async fn get_message_url(&self) -> Result<(String, bool)> {
let guard = self.shared.lock().await;
if let Some(url) = &guard.message_url {
return Ok((url.clone(), guard.message_url_from_endpoint));
}
drop(guard);
let derived = derive_message_url(&self.sse_url, "messages")
.or_else(|| derive_message_url(&self.sse_url, "message"))
.ok_or_else(|| anyhow!("invalid SSE URL"))?;
let mut guard = self.shared.lock().await;
if guard.message_url.is_none() {
guard.message_url = Some(derived.clone());
guard.message_url_from_endpoint = false;
}
Ok((derived, false))
}
}
#[derive(Default)]
struct SseSharedState {
message_url: Option<String>,
message_url_from_endpoint: bool,
pending: std::collections::HashMap<u64, oneshot::Sender<JsonRpcResponse>>,
}
fn derive_message_url(sse_url: &str, message_path: &str) -> Option<String> {
let url = reqwest::Url::parse(sse_url).ok()?;
let mut segments: Vec<&str> = url.path_segments()?.collect();
if segments.is_empty() {
return None;
}
if segments.last().copied() == Some("sse") {
segments.pop();
segments.push(message_path);
let mut new_url = url.clone();
new_url.set_path(&format!("/{}", segments.join("/")));
return Some(new_url.to_string());
}
let mut new_url = url.clone();
let mut path = url.path().trim_end_matches('/').to_string();
path.push('/');
path.push_str(message_path);
new_url.set_path(&path);
Some(new_url.to_string())
}
async fn handle_sse_event(
server_name: &str,
sse_url: &str,
shared: &std::sync::Arc<Mutex<SseSharedState>>,
notify: &std::sync::Arc<Notify>,
event: Option<&str>,
_id: Option<&str>,
data: String,
) {
let event = event.unwrap_or("message");
let trimmed = data.trim();
if trimmed.is_empty() {
return;
}
if event.eq_ignore_ascii_case("endpoint") || event.eq_ignore_ascii_case("mcp-endpoint") {
if let Some(url) = parse_endpoint_from_data(sse_url, trimmed) {
let mut guard = shared.lock().await;
guard.message_url = Some(url);
guard.message_url_from_endpoint = true;
drop(guard);
notify.notify_waiters();
}
return;
}
if !event.eq_ignore_ascii_case("message") {
return;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
return;
};
let Ok(resp) = serde_json::from_value::<JsonRpcResponse>(value.clone()) else {
let _ = serde_json::from_value::<JsonRpcRequest>(value);
return;
};
let Some(id_val) = resp.id.clone() else {
return;
};
let id = match id_val.as_u64() {
Some(v) => v,
None => return,
};
let tx = {
let mut guard = shared.lock().await;
guard.pending.remove(&id)
};
if let Some(tx) = tx {
let _ = tx.send(resp);
} else {
tracing::debug!(
"MCP SSE `{}` received response for unknown id {}",
server_name,
id
);
}
}
fn parse_endpoint_from_data(sse_url: &str, data: &str) -> Option<String> {
if data.starts_with('{') {
let v: serde_json::Value = serde_json::from_str(data).ok()?;
let endpoint = v.get("endpoint")?.as_str()?;
return parse_endpoint_from_data(sse_url, endpoint);
}
if data.starts_with("http://") || data.starts_with("https://") {
return Some(data.to_string());
}
let base = reqwest::Url::parse(sse_url).ok()?;
base.join(data).ok().map(|u| u.to_string())
}
fn extract_json_from_sse_text(resp_text: &str) -> Cow<'_, str> {
let text = resp_text.trim_start_matches('\u{feff}');
let mut current_data_lines: Vec<&str> = Vec::new();
let mut last_event_data_lines: Vec<&str> = Vec::new();
for raw_line in text.lines() {
let line = raw_line.trim_end_matches('\r').trim_start();
if line.is_empty() {
if !current_data_lines.is_empty() {
last_event_data_lines = std::mem::take(&mut current_data_lines);
}
continue;
}
if line.starts_with(':') {
continue;
}
if let Some(rest) = line.strip_prefix("data:") {
let rest = rest.strip_prefix(' ').unwrap_or(rest);
current_data_lines.push(rest);
}
}
if !current_data_lines.is_empty() {
last_event_data_lines = current_data_lines;
}
if last_event_data_lines.is_empty() {
return Cow::Borrowed(text.trim());
}
if last_event_data_lines.len() == 1 {
return Cow::Borrowed(last_event_data_lines[0].trim());
}
let joined = last_event_data_lines.join("\n");
Cow::Owned(joined.trim().to_string())
}
async fn read_first_jsonrpc_from_sse_response(
resp: reqwest::Response,
) -> Result<Option<JsonRpcResponse>> {
let stream = resp
.bytes_stream()
.map(|item| item.map_err(std::io::Error::other));
let reader = tokio_util::io::StreamReader::new(stream);
let mut lines = BufReader::new(reader).lines();
let mut cur_event: Option<String> = None;
let mut cur_data: Vec<String> = Vec::new();
while let Ok(line_opt) = lines.next_line().await {
let Some(mut line) = line_opt else { break };
if line.ends_with('\r') {
line.pop();
}
if line.is_empty() {
if cur_event.is_none() && cur_data.is_empty() {
continue;
}
let event = cur_event.take();
let data = cur_data.join("\n");
cur_data.clear();
let event = event.unwrap_or_else(|| "message".to_string());
if event.eq_ignore_ascii_case("endpoint") || event.eq_ignore_ascii_case("mcp-endpoint")
{
continue;
}
if !event.eq_ignore_ascii_case("message") {
continue;
}
let trimmed = data.trim();
if trimmed.is_empty() {
continue;
}
let json_str = extract_json_from_sse_text(trimmed);
if let Ok(resp) = serde_json::from_str::<JsonRpcResponse>(json_str.as_ref()) {
return Ok(Some(resp));
}
continue;
}
if line.starts_with(':') {
continue;
}
if let Some(rest) = line.strip_prefix("event:") {
cur_event = Some(rest.trim().to_string());
continue;
}
if let Some(rest) = line.strip_prefix("data:") {
let rest = rest.strip_prefix(' ').unwrap_or(rest);
cur_data.push(rest.to_string());
}
}
Ok(None)
}
#[async_trait::async_trait]
impl McpTransportConn for SseTransport {
async fn send_and_recv(&mut self, request: &JsonRpcRequest) -> Result<JsonRpcResponse> {
self.ensure_connected().await?;
let id = request.id.as_ref().and_then(|v| v.as_u64());
let body = serde_json::to_string(request)?;
let (mut message_url, mut from_endpoint) = self.get_message_url().await?;
if self.stream_state == SseStreamState::Connected && !from_endpoint {
for _ in 0..3 {
{
let guard = self.shared.lock().await;
if guard.message_url_from_endpoint {
if let Some(url) = &guard.message_url {
message_url = url.clone();
from_endpoint = true;
break;
}
}
}
let _ = timeout(Duration::from_millis(300), self.notify.notified()).await;
}
}
let primary_url = if from_endpoint {
message_url.clone()
} else {
self.sse_url.clone()
};
let secondary_url = if message_url == self.sse_url {
None
} else if primary_url == message_url {
Some(self.sse_url.clone())
} else {
Some(message_url.clone())
};
let has_secondary = secondary_url.is_some();
let mut rx = None;
if let Some(id) = id {
if self.stream_state == SseStreamState::Connected {
let (tx, ch) = oneshot::channel();
{
let mut guard = self.shared.lock().await;
guard.pending.insert(id, tx);
}
rx = Some((id, ch));
}
}
let mut got_direct = None;
let mut last_status = None;
for (i, url) in std::iter::once(primary_url)
.chain(secondary_url.into_iter())
.enumerate()
{
let mut req = self
.client
.post(&url)
.timeout(Duration::from_secs(120))
.body(body.clone())
.header("Content-Type", "application/json");
for (key, value) in &self.headers {
req = req.header(key, value);
}
if !self
.headers
.keys()
.any(|k| k.eq_ignore_ascii_case("Accept"))
{
req = req.header("Accept", "application/json, text/event-stream");
}
let resp = req.send().await.context("SSE POST to MCP server failed")?;
let status = resp.status();
last_status = Some(status);
if (status == reqwest::StatusCode::NOT_FOUND
|| status == reqwest::StatusCode::METHOD_NOT_ALLOWED)
&& i == 0
{
continue;
}
if !status.is_success() {
break;
}
if request.id.is_none() {
got_direct = Some(JsonRpcResponse {
jsonrpc: crate::tools::mcp_protocol::JSONRPC_VERSION.to_string(),
id: None,
result: None,
error: None,
});
break;
}
let is_sse = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("text/event-stream"));
if is_sse {
if i == 0 && has_secondary {
match timeout(
Duration::from_secs(3),
read_first_jsonrpc_from_sse_response(resp),
)
.await
{
Ok(res) => {
if let Some(resp) = res? {
got_direct = Some(resp);
}
break;
}
Err(_) => continue,
}
}
if let Some(resp) = read_first_jsonrpc_from_sse_response(resp).await? {
got_direct = Some(resp);
}
break;
}
let text = if i == 0 && has_secondary {
match timeout(Duration::from_secs(3), resp.text()).await {
Ok(Ok(t)) => t,
Ok(Err(_)) => String::new(),
Err(_) => continue,
}
} else {
resp.text().await.unwrap_or_default()
};
let trimmed = text.trim();
if !trimmed.is_empty() {
let json_str = if trimmed.contains("\ndata:") || trimmed.starts_with("data:") {
extract_json_from_sse_text(trimmed)
} else {
Cow::Borrowed(trimmed)
};
if let Ok(mcp_resp) = serde_json::from_str::<JsonRpcResponse>(json_str.as_ref()) {
got_direct = Some(mcp_resp);
}
}
break;
}
if let Some((id, _)) = rx.as_ref() {
if got_direct.is_some() {
let mut guard = self.shared.lock().await;
guard.pending.remove(id);
} else if let Some(status) = last_status {
if !status.is_success() {
let mut guard = self.shared.lock().await;
guard.pending.remove(id);
}
}
}
if let Some(resp) = got_direct {
return Ok(resp);
}
if let Some(status) = last_status {
if !status.is_success() {
bail!("MCP server returned HTTP {}", status);
}
} else {
bail!("MCP request not sent");
}
let Some((_id, rx)) = rx else {
bail!("MCP server returned no response");
};
rx.await.map_err(|_| anyhow!("SSE response channel closed"))
}
async fn close(&mut self) -> Result<()> {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(task) = self.reader_task.take() {
task.abort();
}
Ok(())
}
}
// ── Factory ──────────────────────────────────────────────────────────────
/// Create a transport based on config.
pub fn create_transport(config: &McpServerConfig) -> Result<Box<dyn McpTransportConn>> {
match config.transport {
McpTransport::Stdio => Ok(Box::new(StdioTransport::new(config)?)),
McpTransport::Http => Ok(Box::new(HttpTransport::new(config)?)),
McpTransport::Sse => Ok(Box::new(SseTransport::new(config)?)),
}
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transport_default_is_stdio() {
let config = McpServerConfig::default();
assert_eq!(config.transport, McpTransport::Stdio);
}
#[test]
fn test_http_transport_requires_url() {
let config = McpServerConfig {
name: "test".into(),
transport: McpTransport::Http,
..Default::default()
};
assert!(HttpTransport::new(&config).is_err());
}
#[test]
fn test_sse_transport_requires_url() {
let config = McpServerConfig {
name: "test".into(),
transport: McpTransport::Sse,
..Default::default()
};
assert!(SseTransport::new(&config).is_err());
}
#[test]
fn test_extract_json_from_sse_data_no_space() {
let input = "data:{\"jsonrpc\":\"2.0\",\"result\":{}}\n\n";
let extracted = extract_json_from_sse_text(input);
let _: JsonRpcResponse = serde_json::from_str(extracted.as_ref()).unwrap();
}
#[test]
fn test_extract_json_from_sse_with_event_and_id() {
let input = "id: 1\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"result\":{}}\n\n";
let extracted = extract_json_from_sse_text(input);
let _: JsonRpcResponse = serde_json::from_str(extracted.as_ref()).unwrap();
}
#[test]
fn test_extract_json_from_sse_multiline_data() {
let input = "event: message\ndata: {\ndata: \"jsonrpc\": \"2.0\",\ndata: \"result\": {}\ndata: }\n\n";
let extracted = extract_json_from_sse_text(input);
let _: JsonRpcResponse = serde_json::from_str(extracted.as_ref()).unwrap();
}
#[test]
fn test_extract_json_from_sse_skips_bom_and_leading_whitespace() {
let input = "\u{feff}\n\n data: {\"jsonrpc\":\"2.0\",\"result\":{}}\n\n";
let extracted = extract_json_from_sse_text(input);
let _: JsonRpcResponse = serde_json::from_str(extracted.as_ref()).unwrap();
}
#[test]
fn test_extract_json_from_sse_uses_last_event_with_data() {
let input =
": keep-alive\n\nid: 1\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"result\":{}}\n\n";
let extracted = extract_json_from_sse_text(input);
let _: JsonRpcResponse = serde_json::from_str(extracted.as_ref()).unwrap();
}
}
+5
View File
@@ -40,6 +40,10 @@ pub mod hardware_memory_map;
pub mod hardware_memory_read;
pub mod http_request;
pub mod image_info;
pub mod mcp_client;
pub mod mcp_protocol;
pub mod mcp_tool;
pub mod mcp_transport;
pub mod memory_forget;
pub mod memory_recall;
pub mod memory_store;
@@ -340,6 +344,7 @@ pub fn all_tools_with_runtime(
.map(std::path::PathBuf::from),
secrets_encrypt: root_config.secrets.encrypt,
reasoning_enabled: root_config.runtime.reasoning_enabled,
provider_timeout_secs: Some(root_config.provider_timeout_secs),
},
)
.with_parent_tools(parent_tools)
+1
View File
@@ -151,6 +151,7 @@ async fn openai_codex_second_vision_support() -> Result<()> {
zeroclaw_dir: None,
secrets_encrypt: false,
reasoning_enabled: None,
provider_timeout_secs: None,
};
let provider = zeroclaw::providers::create_provider_with_options("openai-codex", None, &opts)?;
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<link rel="icon" type="image/png" href="/_app/logo.png" />
<title>ZeroClaw</title>
</head>
<body>
+1 -3
View File
@@ -5,9 +5,7 @@
"license": "(MIT OR Apache-2.0)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"build": "tsc -b && vite build"
},
"dependencies": {
"lucide-react": "^0.468.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+31 -11
View File
@@ -47,11 +47,23 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
};
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="bg-gray-900 rounded-xl p-8 w-full max-w-md border border-gray-800">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-white mb-2">ZeroClaw</h1>
<p className="text-gray-400">Enter the pairing code from your terminal</p>
<div className="min-h-screen flex items-center justify-center" style={{ background: 'radial-gradient(ellipse at center, #0a0a20 0%, #050510 70%)' }}>
{/* Ambient glow */}
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] rounded-full opacity-20 pointer-events-none" style={{ background: 'radial-gradient(circle, #0080ff 0%, transparent 70%)' }} />
<div className="relative glass-card p-8 w-full max-w-md animate-fade-in-scale">
{/* Top glow accent */}
<div className="absolute -top-px left-1/4 right-1/4 h-px" style={{ background: 'linear-gradient(90deg, transparent, #0080ff, transparent)' }} />
<div className="text-center mb-8">
<img
src="/_app/logo.png"
alt="ZeroClaw"
className="h-20 w-20 rounded-2xl object-cover mx-auto mb-4 animate-float"
style={{ boxShadow: '0 0 30px rgba(0,128,255,0.3)' }}
/>
<h1 className="text-2xl font-bold text-gradient-blue mb-2">ZeroClaw</h1>
<p className="text-[#556080] text-sm">Enter the pairing code from your terminal</p>
</div>
<form onSubmit={handleSubmit}>
<input
@@ -59,19 +71,24 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6-digit code"
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-center text-2xl tracking-widest focus:outline-none focus:border-blue-500 mb-4"
className="input-electric w-full px-4 py-4 text-center text-2xl tracking-[0.3em] font-medium mb-4"
maxLength={6}
autoFocus
/>
{error && (
<p className="text-red-400 text-sm mb-4 text-center">{error}</p>
<p className="text-[#ff4466] text-sm mb-4 text-center animate-fade-in">{error}</p>
)}
<button
type="submit"
disabled={loading || code.length < 6}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg font-medium transition-colors"
className="btn-electric w-full py-3.5 text-sm font-semibold tracking-wide"
>
{loading ? 'Pairing...' : 'Pair'}
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Pairing...
</span>
) : 'Pair'}
</button>
</form>
</div>
@@ -99,8 +116,11 @@ function AppContent() {
if (loading) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<p className="text-gray-400">Connecting...</p>
<div className="min-h-screen flex items-center justify-center" style={{ background: 'radial-gradient(ellipse at center, #0a0a20 0%, #050510 70%)' }}>
<div className="flex flex-col items-center gap-4 animate-fade-in">
<div className="h-10 w-10 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" />
<p className="text-[#556080] text-sm">Connecting...</p>
</div>
</div>
);
}
+6 -6
View File
@@ -30,17 +30,17 @@ export default function Header() {
};
return (
<header className="h-14 bg-gray-800 border-b border-gray-700 flex items-center justify-between px-6">
<header className="h-14 flex items-center justify-between px-6 border-b border-[#1a1a3e]/40 animate-fade-in" style={{ background: 'linear-gradient(90deg, rgba(8,8,24,0.9), rgba(5,5,16,0.9))', backdropFilter: 'blur(12px)' }}>
{/* Page title */}
<h1 className="text-lg font-semibold text-white">{pageTitle}</h1>
<h1 className="text-lg font-semibold text-white tracking-tight">{pageTitle}</h1>
{/* Right-side controls */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
{/* Language switcher */}
<button
type="button"
onClick={toggleLanguage}
className="px-3 py-1 rounded-md text-sm font-medium border border-gray-600 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
className="px-3 py-1 rounded-lg text-xs font-semibold border border-[#1a1a3e] text-[#8892a8] hover:text-white hover:border-[#0080ff40] hover:bg-[#0080ff10] transition-all duration-300"
>
{locale === 'en' ? 'EN' : 'TR'}
</button>
@@ -49,9 +49,9 @@ export default function Header() {
<button
type="button"
onClick={logout}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm text-gray-300 hover:bg-gray-700 hover:text-white transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-[#8892a8] hover:text-[#ff4466] hover:bg-[#ff446610] transition-all duration-300"
>
<LogOut className="h-4 w-4" />
<LogOut className="h-3.5 w-3.5" />
<span>{t('auth.logout')}</span>
</button>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ import Header from '@/components/layout/Header';
export default function Layout() {
return (
<div className="min-h-screen bg-gray-950 text-white">
<div className="min-h-screen text-white" style={{ background: 'linear-gradient(135deg, #050510 0%, #080818 50%, #050510 100%)' }}>
{/* Fixed sidebar */}
<Sidebar />
+33 -12
View File
@@ -28,38 +28,59 @@ const navItems = [
export default function Sidebar() {
return (
<aside className="fixed top-0 left-0 h-screen w-60 bg-gray-900 flex flex-col border-r border-gray-800">
<aside className="fixed top-0 left-0 h-screen w-60 flex flex-col" style={{ background: 'linear-gradient(180deg, #080818 0%, #050510 100%)' }}>
{/* Glow line on right edge */}
<div className="sidebar-glow-line" />
{/* Logo / Title */}
<div className="flex items-center gap-2 px-5 py-5 border-b border-gray-800">
<div className="h-8 w-8 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold text-sm">
ZC
</div>
<span className="text-lg font-semibold text-white tracking-wide">
<div className="flex items-center gap-3 px-4 py-4 border-b border-[#1a1a3e]/50">
<img
src="/_app/logo.png"
alt="ZeroClaw"
className="h-10 w-10 rounded-xl object-cover animate-pulse-glow"
/>
<span className="text-lg font-bold text-gradient-blue tracking-wide">
ZeroClaw
</span>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
{navItems.map(({ to, icon: Icon, labelKey }) => (
{navItems.map(({ to, icon: Icon, labelKey }, idx) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
[
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
'flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-300 animate-slide-in-left group',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white',
? 'text-white shadow-[0_0_15px_rgba(0,128,255,0.2)]'
: 'text-[#556080] hover:text-white hover:bg-[#0080ff08]',
].join(' ')
}
style={({ isActive }) => ({
animationDelay: `${idx * 40}ms`,
...(isActive ? { background: 'linear-gradient(135deg, rgba(0,128,255,0.15), rgba(0,128,255,0.05))' } : {}),
})}
>
<Icon className="h-5 w-5 flex-shrink-0" />
<span>{t(labelKey)}</span>
{({ isActive }) => (
<>
<Icon className={`h-5 w-5 flex-shrink-0 transition-colors duration-300 ${isActive ? 'text-[#0080ff]' : 'group-hover:text-[#0080ff80]'}`} />
<span>{t(labelKey)}</span>
{isActive && (
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-[#0080ff] glow-dot" />
)}
</>
)}
</NavLink>
))}
</nav>
{/* Footer */}
<div className="px-5 py-4 border-t border-[#1a1a3e]/50">
<p className="text-[10px] text-[#334060] tracking-wider uppercase">ZeroClaw Runtime</p>
</div>
</aside>
);
}
+270 -30
View File
@@ -1,33 +1,37 @@
@import "tailwindcss";
/*
* ZeroClaw Dark Theme
* Dark-mode by default with gray cards and blue/green accents.
* ZeroClaw Electric Blue Theme
* Dark-mode with electric blue accents, glassmorphism, and animations.
*/
@theme {
--color-bg-primary: #0a0a0f;
--color-bg-secondary: #12121a;
--color-bg-card: #1a1a2e;
--color-bg-card-hover: #22223a;
--color-bg-input: #14141f;
--color-bg-primary: #050510;
--color-bg-secondary: #0a0a1a;
--color-bg-card: #0d0d20;
--color-bg-card-hover: #141430;
--color-bg-input: #0a0a18;
--color-border-default: #2a2a3e;
--color-border-subtle: #1e1e30;
--color-border-default: #1a1a3e;
--color-border-subtle: #12122a;
--color-accent-blue: #3b82f6;
--color-accent-blue-hover: #2563eb;
--color-accent-green: #10b981;
--color-accent-green-hover: #059669;
--color-accent-blue: #0080ff;
--color-accent-blue-hover: #0066cc;
--color-accent-cyan: #00d4ff;
--color-accent-green: #00e68a;
--color-accent-green-hover: #00cc7a;
--color-text-primary: #e2e8f0;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--color-text-primary: #e8edf5;
--color-text-secondary: #8892a8;
--color-text-muted: #556080;
--color-status-success: #10b981;
--color-status-warning: #f59e0b;
--color-status-error: #ef4444;
--color-status-info: #3b82f6;
--color-status-success: #00e68a;
--color-status-warning: #ffaa00;
--color-status-error: #ff4466;
--color-status-info: #0080ff;
--color-glow-blue: #0080ff40;
--color-glow-cyan: #00d4ff30;
}
/* Base styles */
@@ -54,32 +58,35 @@ body {
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border-default);
border-radius: 4px;
background: #1a1a3e;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
background: #0080ff60;
}
/* Card utility */
.card {
background-color: var(--color-bg-card);
border: 1px solid var(--color-border-default);
border-radius: 0.75rem;
background: linear-gradient(135deg, rgba(13, 13, 32, 0.8), rgba(10, 10, 26, 0.6));
border: 1px solid rgba(0, 128, 255, 0.1);
border-radius: 1rem;
backdrop-filter: blur(12px);
transition: all 0.3s ease;
}
.card:hover {
background-color: var(--color-bg-card-hover);
border-color: rgba(0, 128, 255, 0.25);
box-shadow: 0 0 20px rgba(0, 128, 255, 0.08);
}
/* Focus ring utility */
@@ -87,3 +94,236 @@ body {
outline: 2px solid var(--color-accent-blue);
outline-offset: 2px;
}
/* ========== ANIMATIONS ========== */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes slideInLeft {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes slideInUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 8px rgba(0, 128, 255, 0.3); }
50% { box-shadow: 0 0 20px rgba(0, 128, 255, 0.6); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-4px); }
}
@keyframes borderGlow {
0%, 100% { border-color: rgba(0, 128, 255, 0.15); }
50% { border-color: rgba(0, 128, 255, 0.35); }
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* Animation utility classes */
.animate-fade-in {
animation: fadeIn 0.4s ease-out both;
}
.animate-fade-in-scale {
animation: fadeInScale 0.3s ease-out both;
}
.animate-slide-in-left {
animation: slideInLeft 0.4s ease-out both;
}
.animate-slide-in-right {
animation: slideInRight 0.4s ease-out both;
}
.animate-slide-in-up {
animation: slideInUp 0.4s ease-out both;
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
.animate-border-glow {
animation: borderGlow 3s ease-in-out infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
/* Stagger delays for grid children */
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 60ms; }
.stagger-children > *:nth-child(3) { animation-delay: 120ms; }
.stagger-children > *:nth-child(4) { animation-delay: 180ms; }
.stagger-children > *:nth-child(5) { animation-delay: 240ms; }
.stagger-children > *:nth-child(6) { animation-delay: 300ms; }
.stagger-children > *:nth-child(7) { animation-delay: 360ms; }
.stagger-children > *:nth-child(8) { animation-delay: 420ms; }
.stagger-children > *:nth-child(9) { animation-delay: 480ms; }
.stagger-children > *:nth-child(10) { animation-delay: 540ms; }
/* Glass card */
.glass-card {
background: linear-gradient(135deg, rgba(13, 13, 32, 0.7), rgba(5, 5, 16, 0.5));
border: 1px solid rgba(0, 128, 255, 0.12);
border-radius: 1rem;
backdrop-filter: blur(16px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-card:hover {
border-color: rgba(0, 128, 255, 0.3);
box-shadow: 0 4px 30px rgba(0, 128, 255, 0.1), 0 0 0 1px rgba(0, 128, 255, 0.05);
transform: translateY(-1px);
}
/* Electric button */
.btn-electric {
background: linear-gradient(135deg, #0080ff, #0066cc);
color: white;
border: none;
border-radius: 0.75rem;
font-weight: 500;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.btn-electric:hover:not(:disabled) {
background: linear-gradient(135deg, #0090ff, #0070dd);
box-shadow: 0 0 20px rgba(0, 128, 255, 0.4);
transform: translateY(-1px);
}
.btn-electric:active:not(:disabled) {
transform: translateY(0);
}
.btn-electric:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Gradient text */
.text-gradient-blue {
background: linear-gradient(135deg, #0080ff, #00d4ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Glow dot */
.glow-dot {
box-shadow: 0 0 6px currentColor;
}
/* Electric input */
.input-electric {
background: rgba(10, 10, 26, 0.8);
border: 1px solid rgba(0, 128, 255, 0.15);
border-radius: 0.75rem;
color: var(--color-text-primary);
transition: all 0.3s ease;
}
.input-electric:focus {
outline: none;
border-color: rgba(0, 128, 255, 0.5);
box-shadow: 0 0 0 3px rgba(0, 128, 255, 0.15), 0 0 20px rgba(0, 128, 255, 0.1);
}
.input-electric::placeholder {
color: var(--color-text-muted);
}
/* Progress bar animation */
.progress-bar-animated {
position: relative;
overflow: hidden;
}
.progress-bar-animated::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
/* Table styling */
.table-electric {
width: 100%;
}
.table-electric thead tr {
border-bottom: 1px solid rgba(0, 128, 255, 0.1);
}
.table-electric thead th {
color: var(--color-text-muted);
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.75rem 1rem;
}
.table-electric tbody tr {
border-bottom: 1px solid rgba(26, 26, 62, 0.5);
transition: all 0.2s ease;
}
.table-electric tbody tr:hover {
background: rgba(0, 128, 255, 0.04);
}
/* Modal backdrop */
.modal-backdrop {
background: rgba(5, 5, 16, 0.8);
backdrop-filter: blur(8px);
}
/* Sidebar glow line */
.sidebar-glow-line {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 1px;
background: linear-gradient(180deg, transparent, rgba(0, 128, 255, 0.3), transparent);
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Generate a UUID v4 string.
*
* Uses `crypto.randomUUID()` when available (modern browsers, secure contexts)
* and falls back to a manual implementation backed by `crypto.getRandomValues()`
* for older browsers (e.g. Safari < 15.4, some Electron/Raspberry-Pi builds).
*
* Closes #3303, #3261.
*/
export function generateUUID(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback: RFC 4122 version 4 UUID via getRandomValues
// crypto must exist if we reached here (only randomUUID is missing)
const c = globalThis.crypto;
const bytes = new Uint8Array(16);
c.getRandomValues(bytes);
// Set version (4) and variant (10xx) bits per RFC 4122
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
+2 -1
View File
@@ -1,5 +1,6 @@
import type { WsMessage } from '../types/api';
import { getToken } from './auth';
import { generateUUID } from './uuid';
export type WsMessageHandler = (msg: WsMessage) => void;
export type WsOpenHandler = () => void;
@@ -26,7 +27,7 @@ const SESSION_STORAGE_KEY = 'zeroclaw_session_id';
function getOrCreateSessionId(): string {
let id = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!id) {
id = crypto.randomUUID();
id = generateUUID();
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
}
return id;
+53 -40
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { Send, Bot, User, AlertCircle, Copy, Check } from 'lucide-react';
import type { WsMessage } from '@/types/api';
import { WebSocketClient } from '@/lib/ws';
import { generateUUID } from '@/lib/uuid';
interface ChatMessage {
id: string;
@@ -53,7 +54,7 @@ export default function AgentChat() {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
id: generateUUID(),
role: 'agent',
content,
timestamp: new Date(),
@@ -69,7 +70,7 @@ export default function AgentChat() {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
id: generateUUID(),
role: 'agent',
content: `[Tool Call] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
timestamp: new Date(),
@@ -81,7 +82,7 @@ export default function AgentChat() {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
id: generateUUID(),
role: 'agent',
content: `[Tool Result] ${msg.output ?? ''}`,
timestamp: new Date(),
@@ -93,7 +94,7 @@ export default function AgentChat() {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
id: generateUUID(),
role: 'agent',
content: `[Error] ${msg.message ?? 'Unknown error'}`,
timestamp: new Date(),
@@ -124,7 +125,7 @@ export default function AgentChat() {
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
id: generateUUID(),
role: 'user',
content: trimmed,
timestamp: new Date(),
@@ -170,7 +171,7 @@ export default function AgentChat() {
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
{/* Connection status bar */}
{error && (
<div className="px-4 py-2 bg-red-900/30 border-b border-red-700 flex items-center gap-2 text-sm text-red-300">
<div className="px-4 py-2 bg-[#ff446615] border-b border-[#ff446630] flex items-center gap-2 text-sm text-[#ff6680] animate-fade-in">
<AlertCircle className="h-4 w-4 flex-shrink-0" />
{error}
</div>
@@ -179,45 +180,58 @@ export default function AgentChat() {
{/* Messages area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Bot className="h-12 w-12 mb-3 text-gray-600" />
<p className="text-lg font-medium">ZeroClaw Agent</p>
<p className="text-sm mt-1">Send a message to start the conversation</p>
<div className="flex flex-col items-center justify-center h-full text-[#334060] animate-fade-in">
<div className="h-16 w-16 rounded-2xl flex items-center justify-center mb-4 animate-float" style={{ background: 'linear-gradient(135deg, #0080ff15, #0080ff08)' }}>
<Bot className="h-8 w-8 text-[#0080ff]" />
</div>
<p className="text-lg font-semibold text-white mb-1">ZeroClaw Agent</p>
<p className="text-sm text-[#556080]">Send a message to start the conversation</p>
</div>
)}
{messages.map((msg) => (
{messages.map((msg, idx) => (
<div
key={msg.id}
className={`group flex items-start gap-3 ${
msg.role === 'user' ? 'flex-row-reverse' : ''
msg.role === 'user' ? 'flex-row-reverse animate-slide-in-right' : 'animate-slide-in-left'
}`}
style={{ animationDelay: `${Math.min(idx * 30, 200)}ms` }}
>
<div
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
className={`flex-shrink-0 w-8 h-8 rounded-xl flex items-center justify-center ${
msg.role === 'user'
? 'bg-blue-600'
: 'bg-gray-700'
? ''
: ''
}`}
style={{
background: msg.role === 'user'
? 'linear-gradient(135deg, #0080ff, #0060cc)'
: 'linear-gradient(135deg, #1a1a3e, #12122a)'
}}
>
{msg.role === 'user' ? (
<User className="h-4 w-4 text-white" />
) : (
<Bot className="h-4 w-4 text-white" />
<Bot className="h-4 w-4 text-[#0080ff]" />
)}
</div>
<div className="relative max-w-[75%]">
<div
className={`rounded-xl px-4 py-3 ${
className={`rounded-2xl px-4 py-3 ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-100 border border-gray-700'
? 'text-white'
: 'text-[#e8edf5] border border-[#1a1a3e]'
}`}
style={{
background: msg.role === 'user'
? 'linear-gradient(135deg, #0080ff, #0066cc)'
: 'linear-gradient(135deg, rgba(13,13,32,0.8), rgba(10,10,26,0.6))'
}}
>
<p className="text-sm whitespace-pre-wrap break-words">{msg.content}</p>
<p
className={`text-xs mt-1 ${
msg.role === 'user' ? 'text-blue-200' : 'text-gray-500'
className={`text-[10px] mt-1.5 ${
msg.role === 'user' ? 'text-white/50' : 'text-[#334060]'
}`}
>
{msg.timestamp.toLocaleTimeString()}
@@ -226,12 +240,12 @@ export default function AgentChat() {
<button
onClick={() => handleCopy(msg.id, msg.content)}
aria-label="Copy message"
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-white"
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-all duration-300 p-1.5 rounded-lg bg-[#0a0a18] border border-[#1a1a3e] text-[#556080] hover:text-white hover:border-[#0080ff40]"
>
{copiedId === msg.id ? (
<Check className="h-3.5 w-3.5 text-green-400" />
<Check className="h-3 w-3 text-[#00e68a]" />
) : (
<Copy className="h-3.5 w-3.5" />
<Copy className="h-3 w-3" />
)}
</button>
</div>
@@ -239,17 +253,16 @@ export default function AgentChat() {
))}
{typing && (
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center">
<Bot className="h-4 w-4 text-white" />
<div className="flex items-start gap-3 animate-fade-in">
<div className="flex-shrink-0 w-8 h-8 rounded-xl flex items-center justify-center" style={{ background: 'linear-gradient(135deg, #1a1a3e, #12122a)' }}>
<Bot className="h-4 w-4 text-[#0080ff]" />
</div>
<div className="bg-gray-800 border border-gray-700 rounded-xl px-4 py-3">
<div className="flex items-center gap-1">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
<div className="rounded-2xl px-4 py-3 border border-[#1a1a3e]" style={{ background: 'linear-gradient(135deg, rgba(13,13,32,0.8), rgba(10,10,26,0.6))' }}>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-[#0080ff] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1.5 h-1.5 bg-[#0080ff] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1.5 h-1.5 bg-[#0080ff] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<p className="text-xs text-gray-500 mt-1">Typing...</p>
</div>
</div>
)}
@@ -258,9 +271,9 @@ export default function AgentChat() {
</div>
{/* Input area */}
<div className="border-t border-gray-800 bg-gray-900 p-4">
<div className="border-t border-[#1a1a3e]/40 p-4" style={{ background: 'linear-gradient(180deg, rgba(8,8,24,0.9), rgba(5,5,16,0.95))' }}>
<div className="flex items-end gap-3 max-w-4xl mx-auto">
<div className="flex-1 relative">
<div className="flex-1">
<textarea
ref={inputRef}
rows={1}
@@ -269,25 +282,25 @@ export default function AgentChat() {
onKeyDown={handleKeyDown}
placeholder={connected ? 'Type a message...' : 'Connecting...'}
disabled={!connected}
className="w-full bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 resize-none overflow-y-auto"
className="input-electric w-full px-4 py-3 text-sm resize-none overflow-y-auto disabled:opacity-40"
style={{ minHeight: '44px', maxHeight: '200px' }}
/>
</div>
<button
onClick={handleSend}
disabled={!connected || !input.trim()}
className="flex-shrink-0 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-xl p-3 transition-colors"
className="btn-electric flex-shrink-0 p-3 rounded-xl"
>
<Send className="h-5 w-5" />
</button>
</div>
<div className="flex items-center justify-center mt-2 gap-2">
<span
className={`inline-block h-2 w-2 rounded-full ${
connected ? 'bg-green-500' : 'bg-red-500'
className={`inline-block h-1.5 w-1.5 rounded-full glow-dot ${
connected ? 'text-[#00e68a] bg-[#00e68a]' : 'text-[#ff4466] bg-[#ff4466]'
}`}
/>
<span className="text-xs text-gray-500">
<span className="text-[10px] text-[#334060]">
{connected ? 'Connected' : 'Disconnected'}
</span>
</div>
+21 -22
View File
@@ -18,7 +18,6 @@ export default function Config() {
useEffect(() => {
getConfig()
.then((data) => {
// The API may return either a raw string or a JSON string
setConfig(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
})
.catch((err) => setError(err.message))
@@ -49,23 +48,23 @@ export default function Config() {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" />
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Configuration</h2>
<Settings className="h-5 w-5 text-[#0080ff]" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">Configuration</h2>
</div>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
>
<Save className="h-4 w-4" />
{saving ? 'Saving...' : 'Save'}
@@ -73,13 +72,13 @@ export default function Config() {
</div>
{/* Sensitive fields note */}
<div className="flex items-start gap-3 bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-4">
<ShieldAlert className="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" />
<div className="flex items-start gap-3 rounded-xl p-4 border border-[#ffaa0020]" style={{ background: 'rgba(255,170,0,0.05)' }}>
<ShieldAlert className="h-5 w-5 text-[#ffaa00] flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-yellow-300 font-medium">
<p className="text-sm text-[#ffaa00] font-medium">
Sensitive fields are masked
</p>
<p className="text-sm text-yellow-400/70 mt-0.5">
<p className="text-sm text-[#ffaa0080] mt-0.5">
API keys, tokens, and passwords are hidden for security. To update a
masked field, replace the entire masked value with your new value.
</p>
@@ -88,27 +87,27 @@ export default function Config() {
{/* Success message */}
{success && (
<div className="flex items-center gap-2 bg-green-900/30 border border-green-700 rounded-lg p-3">
<CheckCircle className="h-4 w-4 text-green-400 flex-shrink-0" />
<span className="text-sm text-green-300">{success}</span>
<div className="flex items-center gap-2 rounded-xl p-3 border border-[#00e68a30] animate-fade-in" style={{ background: 'rgba(0,230,138,0.06)' }}>
<CheckCircle className="h-4 w-4 text-[#00e68a] flex-shrink-0" />
<span className="text-sm text-[#00e68a]">{success}</span>
</div>
)}
{/* Error message */}
{error && (
<div className="flex items-center gap-2 bg-red-900/30 border border-red-700 rounded-lg p-3">
<AlertTriangle className="h-4 w-4 text-red-400 flex-shrink-0" />
<span className="text-sm text-red-300">{error}</span>
<div className="flex items-center gap-2 rounded-xl p-3 border border-[#ff446630] animate-fade-in" style={{ background: 'rgba(255,68,102,0.06)' }}>
<AlertTriangle className="h-4 w-4 text-[#ff4466] flex-shrink-0" />
<span className="text-sm text-[#ff6680]">{error}</span>
</div>
)}
{/* Config Editor */}
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-800/50">
<span className="text-xs text-gray-400 font-medium uppercase tracking-wider">
<div className="glass-card overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[#1a1a3e]" style={{ background: 'rgba(0,128,255,0.03)' }}>
<span className="text-[10px] text-[#334060] font-semibold uppercase tracking-wider">
TOML Configuration
</span>
<span className="text-xs text-gray-500">
<span className="text-[10px] text-[#334060]">
{config.split('\n').length} lines
</span>
</div>
@@ -116,8 +115,8 @@ export default function Config() {
value={config}
onChange={(e) => setConfig(e.target.value)}
spellCheck={false}
className="w-full min-h-[500px] bg-gray-950 text-gray-200 font-mono text-sm p-4 resize-y focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
style={{ tabSize: 4 }}
className="w-full min-h-[500px] text-[#8892a8] font-mono text-sm p-4 resize-y focus:outline-none focus:ring-2 focus:ring-[#0080ff40] focus:ring-inset"
style={{ background: 'rgba(5,5,16,0.8)', tabSize: 4 }}
/>
</div>
</div>
+51 -107
View File
@@ -26,8 +26,8 @@ export default function Cost() {
if (error) {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
<div className="p-6 animate-fade-in">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680]">
Failed to load cost data: {error}
</div>
</div>
@@ -37,7 +37,7 @@ export default function Cost() {
if (loading || !cost) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" />
</div>
);
}
@@ -45,120 +45,67 @@ export default function Cost() {
const models = Object.values(cost.by_model);
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 animate-fade-in">
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-blue-600/20 rounded-lg">
<DollarSign className="h-5 w-5 text-blue-400" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
{[
{ icon: DollarSign, color: '#0080ff', bg: '#0080ff15', label: 'Session Cost', value: formatUSD(cost.session_cost_usd) },
{ icon: TrendingUp, color: '#00e68a', bg: '#00e68a15', label: 'Daily Cost', value: formatUSD(cost.daily_cost_usd) },
{ icon: Layers, color: '#a855f7', bg: '#a855f715', label: 'Monthly Cost', value: formatUSD(cost.monthly_cost_usd) },
{ icon: Hash, color: '#ff8800', bg: '#ff880015', label: 'Total Requests', value: cost.request_count.toLocaleString() },
].map(({ icon: Icon, color, bg, label, value }) => (
<div key={label} className="glass-card p-5 animate-slide-in-up">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-xl" style={{ background: bg }}>
<Icon className="h-5 w-5" style={{ color }} />
</div>
<span className="text-xs text-[#556080] uppercase tracking-wider font-medium">{label}</span>
</div>
<span className="text-sm text-gray-400">Session Cost</span>
<p className="text-2xl font-bold text-white font-mono">{value}</p>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.session_cost_usd)}
</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-green-600/20 rounded-lg">
<TrendingUp className="h-5 w-5 text-green-400" />
</div>
<span className="text-sm text-gray-400">Daily Cost</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.daily_cost_usd)}
</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-purple-600/20 rounded-lg">
<Layers className="h-5 w-5 text-purple-400" />
</div>
<span className="text-sm text-gray-400">Monthly Cost</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.monthly_cost_usd)}
</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-orange-600/20 rounded-lg">
<Hash className="h-5 w-5 text-orange-400" />
</div>
<span className="text-sm text-gray-400">Total Requests</span>
</div>
<p className="text-2xl font-bold text-white">
{cost.request_count.toLocaleString()}
</p>
</div>
))}
</div>
{/* Token Statistics */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h3 className="text-base font-semibold text-white mb-4">
<div className="glass-card p-5 animate-slide-in-up" style={{ animationDelay: '200ms' }}>
<h3 className="text-sm font-semibold text-white mb-4 uppercase tracking-wider">
Token Statistics
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Total Tokens</p>
<p className="text-xl font-bold text-white mt-1">
{cost.total_tokens.toLocaleString()}
</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Avg Tokens / Request</p>
<p className="text-xl font-bold text-white mt-1">
{cost.request_count > 0
? Math.round(cost.total_tokens / cost.request_count).toLocaleString()
: '0'}
</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Cost per 1K Tokens</p>
<p className="text-xl font-bold text-white mt-1">
{cost.total_tokens > 0
? formatUSD((cost.monthly_cost_usd / cost.total_tokens) * 1000)
: '$0.0000'}
</p>
</div>
{[
{ label: 'Total Tokens', value: cost.total_tokens.toLocaleString() },
{ label: 'Avg Tokens / Request', value: cost.request_count > 0 ? Math.round(cost.total_tokens / cost.request_count).toLocaleString() : '0' },
{ label: 'Cost per 1K Tokens', value: cost.total_tokens > 0 ? formatUSD((cost.monthly_cost_usd / cost.total_tokens) * 1000) : '$0.0000' },
].map(({ label, value }) => (
<div key={label} className="rounded-xl p-4" style={{ background: 'rgba(0,128,255,0.04)', border: '1px solid rgba(0,128,255,0.08)' }}>
<p className="text-xs text-[#556080] uppercase tracking-wider">{label}</p>
<p className="text-xl font-bold text-white mt-1 font-mono">{value}</p>
</div>
))}
</div>
</div>
{/* Model Breakdown Table */}
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="px-5 py-4 border-b border-gray-800">
<h3 className="text-base font-semibold text-white">
<div className="glass-card overflow-hidden animate-slide-in-up" style={{ animationDelay: '300ms' }}>
<div className="px-5 py-4 border-b border-[#1a1a3e]">
<h3 className="text-sm font-semibold text-white uppercase tracking-wider">
Model Breakdown
</h3>
</div>
{models.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<div className="p-8 text-center text-[#334060]">
No model data available.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="table-electric">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-5 py-3 text-gray-400 font-medium">
Model
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Cost
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Tokens
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Requests
</th>
<th className="text-left px-5 py-3 text-gray-400 font-medium">
Share
</th>
<tr>
<th className="text-left">Model</th>
<th className="text-right">Cost</th>
<th className="text-right">Tokens</th>
<th className="text-right">Requests</th>
<th className="text-left">Share</th>
</tr>
</thead>
<tbody>
@@ -170,31 +117,28 @@ export default function Cost() {
? (m.cost_usd / cost.monthly_cost_usd) * 100
: 0;
return (
<tr
key={m.model}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<td className="px-5 py-3 text-white font-medium">
<tr key={m.model}>
<td className="px-5 py-3 text-white font-medium text-sm">
{m.model}
</td>
<td className="px-5 py-3 text-gray-300 text-right font-mono">
<td className="px-5 py-3 text-[#8892a8] text-right font-mono text-sm">
{formatUSD(m.cost_usd)}
</td>
<td className="px-5 py-3 text-gray-300 text-right">
<td className="px-5 py-3 text-[#8892a8] text-right text-sm">
{m.total_tokens.toLocaleString()}
</td>
<td className="px-5 py-3 text-gray-300 text-right">
<td className="px-5 py-3 text-[#8892a8] text-right text-sm">
{m.request_count.toLocaleString()}
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-gray-800 rounded-full overflow-hidden">
<div className="w-20 h-1.5 bg-[#0a0a18] rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full"
style={{ width: `${Math.max(share, 2)}%` }}
className="h-full rounded-full progress-bar-animated transition-all duration-700"
style={{ width: `${Math.max(share, 2)}%`, background: '#0080ff' }}
/>
</div>
<span className="text-xs text-gray-400 w-10 text-right">
<span className="text-xs text-[#556080] w-10 text-right font-mono">
{share.toFixed(1)}%
</span>
</div>
+52 -68
View File
@@ -84,19 +84,19 @@ export default function Cron() {
switch (status.toLowerCase()) {
case 'ok':
case 'success':
return <CheckCircle className="h-4 w-4 text-green-400" />;
return <CheckCircle className="h-4 w-4 text-[#00e68a]" />;
case 'error':
case 'failed':
return <XCircle className="h-4 w-4 text-red-400" />;
return <XCircle className="h-4 w-4 text-[#ff4466]" />;
default:
return <AlertCircle className="h-4 w-4 text-yellow-400" />;
return <AlertCircle className="h-4 w-4 text-[#ffaa00]" />;
}
};
if (error) {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
<div className="p-6 animate-fade-in">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680]">
Failed to load cron jobs: {error}
</div>
</div>
@@ -106,24 +106,24 @@ export default function Cron() {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" />
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
<Clock className="h-5 w-5 text-[#0080ff]" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
Scheduled Tasks ({jobs.length})
</h2>
</div>
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
>
<Plus className="h-4 w-4" />
Add Job
@@ -132,8 +132,8 @@ export default function Cron() {
{/* Add Job Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md mx-4">
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">
<div className="glass-card p-6 w-full max-w-md mx-4 animate-fade-in-scale">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Add Cron Job</h3>
<button
@@ -141,21 +141,21 @@ export default function Cron() {
setShowForm(false);
setFormError(null);
}}
className="text-gray-400 hover:text-white transition-colors"
className="text-[#556080] hover:text-white transition-colors duration-300"
>
<X className="h-5 w-5" />
</button>
</div>
{formError && (
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-700 p-3 text-sm text-red-300">
<div className="mb-4 rounded-xl bg-[#ff446615] border border-[#ff446630] p-3 text-sm text-[#ff6680] animate-fade-in">
{formError}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
<label className="block text-xs font-semibold text-[#8892a8] mb-1.5 uppercase tracking-wider">
Name (optional)
</label>
<input
@@ -163,31 +163,31 @@ export default function Cron() {
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. Daily cleanup"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="input-electric w-full px-3 py-2.5 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Schedule <span className="text-red-400">*</span>
<label className="block text-xs font-semibold text-[#8892a8] mb-1.5 uppercase tracking-wider">
Schedule <span className="text-[#ff4466]">*</span>
</label>
<input
type="text"
value={formSchedule}
onChange={(e) => setFormSchedule(e.target.value)}
placeholder="e.g. 0 0 * * * (cron expression)"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="input-electric w-full px-3 py-2.5 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Command <span className="text-red-400">*</span>
<label className="block text-xs font-semibold text-[#8892a8] mb-1.5 uppercase tracking-wider">
Command <span className="text-[#ff4466]">*</span>
</label>
<input
type="text"
value={formCommand}
onChange={(e) => setFormCommand(e.target.value)}
placeholder="e.g. cleanup --older-than 7d"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="input-electric w-full px-3 py-2.5 text-sm"
/>
</div>
</div>
@@ -198,14 +198,14 @@ export default function Cron() {
setShowForm(false);
setFormError(null);
}}
className="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
className="px-4 py-2 text-sm font-medium text-[#8892a8] hover:text-white border border-[#1a1a3e] rounded-xl hover:bg-[#0080ff08] transition-all duration-300"
>
Cancel
</button>
<button
onClick={handleAdd}
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
className="btn-electric px-4 py-2 text-sm font-medium"
>
{submitting ? 'Adding...' : 'Add Job'}
</button>
@@ -216,88 +216,72 @@ export default function Cron() {
{/* Jobs Table */}
{jobs.length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Clock className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No scheduled tasks configured.</p>
<div className="glass-card p-8 text-center">
<Clock className="h-10 w-10 text-[#1a1a3e] mx-auto mb-3" />
<p className="text-[#556080]">No scheduled tasks configured.</p>
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-x-auto">
<table className="w-full text-sm">
<div className="glass-card overflow-x-auto">
<table className="table-electric">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
ID
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Name
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Command
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Next Run
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Last Status
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Enabled
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
Actions
</th>
<tr>
<th className="text-left">ID</th>
<th className="text-left">Name</th>
<th className="text-left">Command</th>
<th className="text-left">Next Run</th>
<th className="text-left">Last Status</th>
<th className="text-left">Enabled</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr
key={job.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<td className="px-4 py-3 text-gray-400 font-mono text-xs">
<tr key={job.id}>
<td className="px-4 py-3 text-[#556080] font-mono text-xs">
{job.id.slice(0, 8)}
</td>
<td className="px-4 py-3 text-white font-medium">
<td className="px-4 py-3 text-white font-medium text-sm">
{job.name ?? '-'}
</td>
<td className="px-4 py-3 text-gray-300 font-mono text-xs max-w-[200px] truncate">
<td className="px-4 py-3 text-[#8892a8] font-mono text-xs max-w-[200px] truncate">
{job.command}
</td>
<td className="px-4 py-3 text-gray-400 text-xs">
<td className="px-4 py-3 text-[#556080] text-xs">
{formatDate(job.next_run)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
{statusIcon(job.last_status)}
<span className="text-gray-300 text-xs capitalize">
<span className="text-[#8892a8] text-xs capitalize">
{job.last_status ?? '-'}
</span>
</div>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${
job.enabled
? 'bg-green-900/40 text-green-400 border border-green-700/50'
: 'bg-gray-800 text-gray-500 border border-gray-700'
? 'text-[#00e68a] border-[#00e68a30]'
: 'text-[#334060] border-[#1a1a3e]'
}`}
style={{ background: job.enabled ? 'rgba(0,230,138,0.06)' : 'rgba(26,26,62,0.3)' }}
>
{job.enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td className="px-4 py-3 text-right">
{confirmDelete === job.id ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-red-400">Delete?</span>
<div className="flex items-center justify-end gap-2 animate-fade-in">
<span className="text-xs text-[#ff4466]">Delete?</span>
<button
onClick={() => handleDelete(job.id)}
className="text-red-400 hover:text-red-300 text-xs font-medium"
className="text-[#ff4466] hover:text-[#ff6680] text-xs font-medium"
>
Yes
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-gray-400 hover:text-white text-xs font-medium"
className="text-[#556080] hover:text-white text-xs font-medium"
>
No
</button>
@@ -305,7 +289,7 @@ export default function Cron() {
) : (
<button
onClick={() => setConfirmDelete(job.id)}
className="text-gray-400 hover:text-red-400 transition-colors"
className="text-[#334060] hover:text-[#ff4466] transition-all duration-300"
>
<Trash2 className="h-4 w-4" />
</button>
+66 -100
View File
@@ -28,13 +28,13 @@ function healthColor(status: string): string {
switch (status.toLowerCase()) {
case 'ok':
case 'healthy':
return 'bg-green-500';
return 'bg-[#00e68a]';
case 'warn':
case 'warning':
case 'degraded':
return 'bg-yellow-500';
return 'bg-[#ffaa00]';
default:
return 'bg-red-500';
return 'bg-[#ff4466]';
}
}
@@ -42,13 +42,13 @@ function healthBorder(status: string): string {
switch (status.toLowerCase()) {
case 'ok':
case 'healthy':
return 'border-green-500/30';
return 'border-[#00e68a30]';
case 'warn':
case 'warning':
case 'degraded':
return 'border-yellow-500/30';
return 'border-[#ffaa0030]';
default:
return 'border-red-500/30';
return 'border-[#ff446630]';
}
}
@@ -68,8 +68,8 @@ export default function Dashboard() {
if (error) {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
<div className="p-6 animate-fade-in">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680]">
Failed to load dashboard: {error}
</div>
</div>
@@ -79,7 +79,7 @@ export default function Dashboard() {
if (!status || !cost) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" />
</div>
);
}
@@ -87,124 +87,89 @@ export default function Dashboard() {
const maxCost = Math.max(cost.session_cost_usd, cost.daily_cost_usd, cost.monthly_cost_usd, 0.001);
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 animate-fade-in">
{/* Status Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-blue-600/20 rounded-lg">
<Cpu className="h-5 w-5 text-blue-400" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children">
{[
{ icon: Cpu, color: '#0080ff', bg: '#0080ff15', label: 'Provider / Model', value: status.provider ?? 'Unknown', sub: status.model },
{ icon: Clock, color: '#00e68a', bg: '#00e68a15', label: 'Uptime', value: formatUptime(status.uptime_seconds), sub: 'Since last restart' },
{ icon: Globe, color: '#a855f7', bg: '#a855f715', label: 'Gateway Port', value: `:${status.gateway_port}`, sub: `Locale: ${status.locale}` },
{ icon: Database, color: '#ff8800', bg: '#ff880015', label: 'Memory Backend', value: status.memory_backend, sub: `Paired: ${status.paired ? 'Yes' : 'No'}` },
].map(({ icon: Icon, color, bg, label, value, sub }) => (
<div key={label} className="glass-card p-5 animate-slide-in-up">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 rounded-xl" style={{ background: bg }}>
<Icon className="h-5 w-5" style={{ color }} />
</div>
<span className="text-xs text-[#556080] uppercase tracking-wider font-medium">{label}</span>
</div>
<span className="text-sm text-gray-400">Provider / Model</span>
<p className="text-lg font-semibold text-white truncate capitalize">{value}</p>
<p className="text-sm text-[#556080] truncate">{sub}</p>
</div>
<p className="text-lg font-semibold text-white truncate">
{status.provider ?? 'Unknown'}
</p>
<p className="text-sm text-gray-400 truncate">{status.model}</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-green-600/20 rounded-lg">
<Clock className="h-5 w-5 text-green-400" />
</div>
<span className="text-sm text-gray-400">Uptime</span>
</div>
<p className="text-lg font-semibold text-white">
{formatUptime(status.uptime_seconds)}
</p>
<p className="text-sm text-gray-400">Since last restart</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-purple-600/20 rounded-lg">
<Globe className="h-5 w-5 text-purple-400" />
</div>
<span className="text-sm text-gray-400">Gateway Port</span>
</div>
<p className="text-lg font-semibold text-white">
:{status.gateway_port}
</p>
<p className="text-sm text-gray-400">Locale: {status.locale}</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-orange-600/20 rounded-lg">
<Database className="h-5 w-5 text-orange-400" />
</div>
<span className="text-sm text-gray-400">Memory Backend</span>
</div>
<p className="text-lg font-semibold text-white capitalize">
{status.memory_backend}
</p>
<p className="text-sm text-gray-400">
Paired: {status.paired ? 'Yes' : 'No'}
</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 stagger-children">
{/* Cost Widget */}
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<DollarSign className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Cost Overview</h2>
<div className="glass-card p-5 animate-slide-in-up">
<div className="flex items-center gap-2 mb-5">
<DollarSign className="h-5 w-5 text-[#0080ff]" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">Cost Overview</h2>
</div>
<div className="space-y-4">
{[
{ label: 'Session', value: cost.session_cost_usd, color: 'bg-blue-500' },
{ label: 'Daily', value: cost.daily_cost_usd, color: 'bg-green-500' },
{ label: 'Monthly', value: cost.monthly_cost_usd, color: 'bg-purple-500' },
{ label: 'Session', value: cost.session_cost_usd, color: '#0080ff' },
{ label: 'Daily', value: cost.daily_cost_usd, color: '#00e68a' },
{ label: 'Monthly', value: cost.monthly_cost_usd, color: '#a855f7' },
].map(({ label, value, color }) => (
<div key={label}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">{label}</span>
<span className="text-white font-medium">{formatUSD(value)}</span>
<div className="flex justify-between text-sm mb-1.5">
<span className="text-[#556080]">{label}</span>
<span className="text-white font-medium font-mono">{formatUSD(value)}</span>
</div>
<div className="w-full h-2 bg-gray-800 rounded-full overflow-hidden">
<div className="w-full h-1.5 bg-[#0a0a18] rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${color}`}
style={{ width: `${Math.max((value / maxCost) * 100, 2)}%` }}
className="h-full rounded-full progress-bar-animated transition-all duration-700 ease-out"
style={{ width: `${Math.max((value / maxCost) * 100, 2)}%`, background: color }}
/>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-gray-800 flex justify-between text-sm">
<span className="text-gray-400">Total Tokens</span>
<span className="text-white">{cost.total_tokens.toLocaleString()}</span>
<div className="mt-5 pt-4 border-t border-[#1a1a3e]/50 flex justify-between text-sm">
<span className="text-[#556080]">Total Tokens</span>
<span className="text-white font-mono">{cost.total_tokens.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-gray-400">Requests</span>
<span className="text-white">{cost.request_count.toLocaleString()}</span>
<span className="text-[#556080]">Requests</span>
<span className="text-white font-mono">{cost.request_count.toLocaleString()}</span>
</div>
</div>
{/* Active Channels */}
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<Radio className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Active Channels</h2>
<div className="glass-card p-5 animate-slide-in-up">
<div className="flex items-center gap-2 mb-5">
<Radio className="h-5 w-5 text-[#0080ff]" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">Active Channels</h2>
</div>
<div className="space-y-2">
{Object.entries(status.channels).length === 0 ? (
<p className="text-sm text-gray-500">No channels configured</p>
<p className="text-sm text-[#334060]">No channels configured</p>
) : (
Object.entries(status.channels).map(([name, active]) => (
<div
key={name}
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-800/50"
className="flex items-center justify-between py-2.5 px-3 rounded-xl transition-all duration-300 hover:bg-[#0080ff08]"
style={{ background: 'rgba(10, 10, 26, 0.5)' }}
>
<span className="text-sm text-white capitalize">{name}</span>
<span className="text-sm text-white capitalize font-medium">{name}</span>
<div className="flex items-center gap-2">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${
active ? 'bg-green-500' : 'bg-gray-500'
className={`inline-block h-2 w-2 rounded-full glow-dot ${
active ? 'text-[#00e68a] bg-[#00e68a]' : 'text-[#334060] bg-[#334060]'
}`}
/>
<span className="text-xs text-gray-400">
<span className="text-xs text-[#556080]">
{active ? 'Active' : 'Inactive'}
</span>
</div>
@@ -215,29 +180,30 @@ export default function Dashboard() {
</div>
{/* Health Grid */}
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<Activity className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Component Health</h2>
<div className="glass-card p-5 animate-slide-in-up">
<div className="flex items-center gap-2 mb-5">
<Activity className="h-5 w-5 text-[#0080ff]" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">Component Health</h2>
</div>
<div className="grid grid-cols-2 gap-3">
{Object.entries(status.health.components).length === 0 ? (
<p className="text-sm text-gray-500 col-span-2">No components reporting</p>
<p className="text-sm text-[#334060] col-span-2">No components reporting</p>
) : (
Object.entries(status.health.components).map(([name, comp]) => (
<div
key={name}
className={`rounded-lg p-3 border ${healthBorder(comp.status)} bg-gray-800/50`}
className={`rounded-xl p-3 border ${healthBorder(comp.status)} transition-all duration-300 hover:scale-[1.02]`}
style={{ background: 'rgba(10, 10, 26, 0.5)' }}
>
<div className="flex items-center gap-2 mb-1">
<span className={`inline-block h-2 w-2 rounded-full ${healthColor(comp.status)}`} />
<span className={`inline-block h-2 w-2 rounded-full ${healthColor(comp.status)} glow-dot`} />
<span className="text-sm font-medium text-white capitalize truncate">
{name}
</span>
</div>
<p className="text-xs text-gray-400 capitalize">{comp.status}</p>
<p className="text-xs text-[#556080] capitalize">{comp.status}</p>
{comp.restart_count > 0 && (
<p className="text-xs text-yellow-400 mt-1">
<p className="text-xs text-[#ffaa00] mt-1">
Restarts: {comp.restart_count}
</p>
)}
+43 -44
View File
@@ -13,33 +13,33 @@ import { runDoctor } from '@/lib/api';
function severityIcon(severity: DiagResult['severity']) {
switch (severity) {
case 'ok':
return <CheckCircle className="h-4 w-4 text-green-400 flex-shrink-0" />;
return <CheckCircle className="h-4 w-4 text-[#00e68a] flex-shrink-0" />;
case 'warn':
return <AlertTriangle className="h-4 w-4 text-yellow-400 flex-shrink-0" />;
return <AlertTriangle className="h-4 w-4 text-[#ffaa00] flex-shrink-0" />;
case 'error':
return <XCircle className="h-4 w-4 text-red-400 flex-shrink-0" />;
return <XCircle className="h-4 w-4 text-[#ff4466] flex-shrink-0" />;
}
}
function severityBorder(severity: DiagResult['severity']): string {
switch (severity) {
case 'ok':
return 'border-green-700/40';
return 'border-[#00e68a20]';
case 'warn':
return 'border-yellow-700/40';
return 'border-[#ffaa0020]';
case 'error':
return 'border-red-700/40';
return 'border-[#ff446620]';
}
}
function severityBg(severity: DiagResult['severity']): string {
switch (severity) {
case 'ok':
return 'bg-green-900/10';
return 'rgba(0,230,138,0.04)';
case 'warn':
return 'bg-yellow-900/10';
return 'rgba(255,170,0,0.04)';
case 'error':
return 'bg-red-900/10';
return 'rgba(255,68,102,0.04)';
}
}
@@ -62,12 +62,10 @@ export default function Doctor() {
}
};
// Compute summary counts
const okCount = results?.filter((r) => r.severity === 'ok').length ?? 0;
const warnCount = results?.filter((r) => r.severity === 'warn').length ?? 0;
const errorCount = results?.filter((r) => r.severity === 'error').length ?? 0;
// Group by category
const grouped =
results?.reduce<Record<string, DiagResult[]>>((acc, item) => {
const key = item.category;
@@ -77,17 +75,17 @@ export default function Doctor() {
}, {}) ?? {};
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Stethoscope className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Diagnostics</h2>
<Stethoscope className="h-5 w-5 text-[#0080ff]" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">Diagnostics</h2>
</div>
<button
onClick={handleRun}
disabled={loading}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
>
{loading ? (
<>
@@ -105,17 +103,17 @@ export default function Doctor() {
{/* Error */}
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680] animate-fade-in">
{error}
</div>
)}
{/* Loading spinner */}
{loading && (
<div className="flex flex-col items-center justify-center py-16">
<Loader2 className="h-10 w-10 text-blue-500 animate-spin mb-4" />
<p className="text-gray-400">Running diagnostics...</p>
<p className="text-sm text-gray-500 mt-1">
<div className="flex flex-col items-center justify-center py-16 animate-fade-in">
<div className="h-12 w-12 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin mb-4" />
<p className="text-[#8892a8]">Running diagnostics...</p>
<p className="text-sm text-[#334060] mt-1">
This may take a few seconds.
</p>
</div>
@@ -125,29 +123,29 @@ export default function Doctor() {
{results && !loading && (
<>
{/* Summary Bar */}
<div className="flex items-center gap-4 bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="glass-card flex items-center gap-4 p-4 animate-slide-in-up">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<CheckCircle className="h-5 w-5 text-[#00e68a]" />
<span className="text-sm text-white font-medium">
{okCount} <span className="text-gray-400 font-normal">ok</span>
{okCount} <span className="text-[#556080] font-normal">ok</span>
</span>
</div>
<div className="w-px h-5 bg-gray-700" />
<div className="w-px h-5 bg-[#1a1a3e]" />
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-400" />
<AlertTriangle className="h-5 w-5 text-[#ffaa00]" />
<span className="text-sm text-white font-medium">
{warnCount}{' '}
<span className="text-gray-400 font-normal">
<span className="text-[#556080] font-normal">
warning{warnCount !== 1 ? 's' : ''}
</span>
</span>
</div>
<div className="w-px h-5 bg-gray-700" />
<div className="w-px h-5 bg-[#1a1a3e]" />
<div className="flex items-center gap-2">
<XCircle className="h-5 w-5 text-red-400" />
<XCircle className="h-5 w-5 text-[#ff4466]" />
<span className="text-sm text-white font-medium">
{errorCount}{' '}
<span className="text-gray-400 font-normal">
<span className="text-[#556080] font-normal">
error{errorCount !== 1 ? 's' : ''}
</span>
</span>
@@ -156,15 +154,15 @@ export default function Doctor() {
{/* Overall indicator */}
<div className="ml-auto">
{errorCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-red-900/40 text-red-400 border border-red-700/50">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-semibold border text-[#ff4466] border-[#ff446630]" style={{ background: 'rgba(255,68,102,0.06)' }}>
Issues Found
</span>
) : warnCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-yellow-900/40 text-yellow-400 border border-yellow-700/50">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-semibold border text-[#ffaa00] border-[#ffaa0030]" style={{ background: 'rgba(255,170,0,0.06)' }}>
Warnings
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-green-900/40 text-green-400 border border-green-700/50">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-semibold border text-[#00e68a] border-[#00e68a30]" style={{ background: 'rgba(0,230,138,0.06)' }}>
All Clear
</span>
)}
@@ -174,23 +172,22 @@ export default function Doctor() {
{/* Grouped Results */}
{Object.entries(grouped)
.sort(([a], [b]) => a.localeCompare(b))
.map(([category, items]) => (
<div key={category}>
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3 capitalize">
.map(([category, items], catIdx) => (
<div key={category} className="animate-slide-in-up" style={{ animationDelay: `${(catIdx + 1) * 100}ms` }}>
<h3 className="text-[10px] font-semibold text-[#334060] uppercase tracking-wider mb-3 capitalize">
{category}
</h3>
<div className="space-y-2">
<div className="space-y-2 stagger-children">
{items.map((result, idx) => (
<div
key={`${category}-${idx}`}
className={`flex items-start gap-3 rounded-lg border p-3 ${severityBorder(
result.severity,
)} ${severityBg(result.severity)}`}
className={`flex items-start gap-3 rounded-xl border p-3 transition-all duration-300 hover:translate-x-1 ${severityBorder(result.severity)} animate-slide-in-left`}
style={{ background: severityBg(result.severity) }}
>
{severityIcon(result.severity)}
<div className="min-w-0">
<p className="text-sm text-white">{result.message}</p>
<p className="text-xs text-gray-500 mt-0.5 capitalize">
<p className="text-[10px] text-[#334060] mt-0.5 capitalize uppercase tracking-wider">
{result.severity}
</p>
</div>
@@ -204,10 +201,12 @@ export default function Doctor() {
{/* Empty state */}
{!results && !loading && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
<Stethoscope className="h-12 w-12 text-gray-600 mb-4" />
<p className="text-lg font-medium">System Diagnostics</p>
<p className="text-sm mt-1">
<div className="flex flex-col items-center justify-center py-16 text-[#334060] animate-fade-in">
<div className="h-16 w-16 rounded-2xl flex items-center justify-center mb-4 animate-float" style={{ background: 'linear-gradient(135deg, #0080ff15, #0080ff08)' }}>
<Stethoscope className="h-8 w-8 text-[#0080ff]" />
</div>
<p className="text-lg font-semibold text-white mb-1">System Diagnostics</p>
<p className="text-sm text-[#556080]">
Click "Run Diagnostics" to check your ZeroClaw installation.
</p>
</div>
+25 -20
View File
@@ -9,19 +9,22 @@ function statusBadge(status: Integration['status']) {
return {
icon: Check,
label: 'Active',
classes: 'bg-green-900/40 text-green-400 border-green-700/50',
classes: 'text-[#00e68a] border-[#00e68a30]',
bg: 'rgba(0,230,138,0.06)',
};
case 'Available':
return {
icon: Zap,
label: 'Available',
classes: 'bg-blue-900/40 text-blue-400 border-blue-700/50',
classes: 'text-[#0080ff] border-[#0080ff30]',
bg: 'rgba(0,128,255,0.06)',
};
case 'ComingSoon':
return {
icon: Clock,
label: 'Coming Soon',
classes: 'bg-gray-800 text-gray-400 border-gray-700',
classes: 'text-[#556080] border-[#1a1a3e]',
bg: 'rgba(26,26,62,0.3)',
};
}
}
@@ -59,8 +62,8 @@ export default function Integrations() {
if (error) {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
<div className="p-6 animate-fade-in">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680]">
Failed to load integrations: {error}
</div>
</div>
@@ -70,17 +73,17 @@ export default function Integrations() {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" />
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center gap-2">
<Puzzle className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
<Puzzle className="h-5 w-5 text-[#0080ff]" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
Integrations ({integrations.length})
</h2>
</div>
@@ -91,11 +94,12 @@ export default function Integrations() {
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors capitalize ${
className={`px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all duration-300 capitalize ${
activeCategory === cat
? 'bg-blue-600 text-white'
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:bg-gray-800 hover:text-white'
? 'text-white shadow-[0_0_15px_rgba(0,128,255,0.2)]'
: 'text-[#556080] border border-[#1a1a3e] hover:text-white hover:border-[#0080ff40]'
}`}
style={activeCategory === cat ? { background: 'linear-gradient(135deg, #0080ff, #0066cc)' } : {}}
>
{cat}
</button>
@@ -104,38 +108,39 @@ export default function Integrations() {
{/* Grouped Integration Cards */}
{Object.keys(grouped).length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Puzzle className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No integrations found.</p>
<div className="glass-card p-8 text-center">
<Puzzle className="h-10 w-10 text-[#1a1a3e] mx-auto mb-3" />
<p className="text-[#556080]">No integrations found.</p>
</div>
) : (
Object.entries(grouped)
.sort(([a], [b]) => a.localeCompare(b))
.map(([category, items]) => (
<div key={category}>
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3 capitalize">
<h3 className="text-[10px] font-semibold text-[#334060] uppercase tracking-wider mb-3 capitalize">
{category}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
{items.map((integration) => {
const badge = statusBadge(integration.status);
const BadgeIcon = badge.icon;
return (
<div
key={integration.name}
className="bg-gray-900 rounded-xl border border-gray-800 p-5 hover:border-gray-700 transition-colors"
className="glass-card p-5 animate-slide-in-up"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h4 className="text-sm font-semibold text-white truncate">
{integration.name}
</h4>
<p className="text-sm text-gray-400 mt-1 line-clamp-2">
<p className="text-sm text-[#556080] mt-1 line-clamp-2">
{integration.description}
</p>
</div>
<span
className={`flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${badge.classes}`}
className={`flex-shrink-0 inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-semibold border ${badge.classes}`}
style={{ background: badge.bg }}
>
<BadgeIcon className="h-3 w-3" />
{badge.label}
+38 -35
View File
@@ -14,24 +14,24 @@ function formatTimestamp(ts?: string): string {
return new Date(ts).toLocaleTimeString();
}
function eventTypeBadgeColor(type: string): string {
function eventTypeBadgeColor(type: string): { classes: string; bg: string } {
switch (type.toLowerCase()) {
case 'error':
return 'bg-red-900/50 text-red-400 border-red-700/50';
return { classes: 'text-[#ff4466] border-[#ff446630]', bg: 'rgba(255,68,102,0.06)' };
case 'warn':
case 'warning':
return 'bg-yellow-900/50 text-yellow-400 border-yellow-700/50';
return { classes: 'text-[#ffaa00] border-[#ffaa0030]', bg: 'rgba(255,170,0,0.06)' };
case 'tool_call':
case 'tool_result':
return 'bg-purple-900/50 text-purple-400 border-purple-700/50';
return { classes: 'text-[#a855f7] border-[#a855f730]', bg: 'rgba(168,85,247,0.06)' };
case 'message':
case 'chat':
return 'bg-blue-900/50 text-blue-400 border-blue-700/50';
return { classes: 'text-[#0080ff] border-[#0080ff30]', bg: 'rgba(0,128,255,0.06)' };
case 'health':
case 'status':
return 'bg-green-900/50 text-green-400 border-green-700/50';
return { classes: 'text-[#00e68a] border-[#00e68a30]', bg: 'rgba(0,230,138,0.06)' };
default:
return 'bg-gray-800 text-gray-400 border-gray-700';
return { classes: 'text-[#556080] border-[#1a1a3e]', bg: 'rgba(26,26,62,0.3)' };
}
}
@@ -76,7 +76,6 @@ export default function Logs() {
event,
};
setEntries((prev) => {
// Cap at 500 entries for performance
const next = [...prev, entry];
return next.length > 500 ? next.slice(-500) : next;
});
@@ -112,7 +111,6 @@ export default function Logs() {
setAutoScroll(true);
};
// Collect all event types for filter checkboxes
const allTypes = Array.from(new Set(entries.map((e) => e.event.type))).sort();
const toggleTypeFilter = (type: string) => {
@@ -135,21 +133,21 @@ export default function Logs() {
return (
<div className="flex flex-col h-[calc(100vh-3.5rem)]">
{/* Toolbar */}
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-800 bg-gray-900">
<div className="flex items-center justify-between px-6 py-3 border-b border-[#1a1a3e]/40 animate-fade-in" style={{ background: 'linear-gradient(90deg, rgba(8,8,24,0.9), rgba(5,5,16,0.9))' }}>
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Live Logs</h2>
<Activity className="h-5 w-5 text-[#0080ff]" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">Live Logs</h2>
<div className="flex items-center gap-2 ml-2">
<span
className={`inline-block h-2 w-2 rounded-full ${
connected ? 'bg-green-500' : 'bg-red-500'
className={`inline-block h-1.5 w-1.5 rounded-full glow-dot ${
connected ? 'text-[#00e68a] bg-[#00e68a]' : 'text-[#ff4466] bg-[#ff4466]'
}`}
/>
<span className="text-xs text-gray-500">
<span className="text-[10px] text-[#334060]">
{connected ? 'Connected' : 'Disconnected'}
</span>
</div>
<span className="text-xs text-gray-500 ml-2">
<span className="text-[10px] text-[#334060] ml-2 font-mono">
{filteredEntries.length} events
</span>
</div>
@@ -158,11 +156,16 @@ export default function Logs() {
{/* Pause/Resume */}
<button
onClick={() => setPaused(!paused)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all duration-300 ${
paused
? 'bg-green-600 hover:bg-green-700 text-white'
: 'bg-yellow-600 hover:bg-yellow-700 text-white'
? 'text-white shadow-[0_0_15px_rgba(0,230,138,0.2)]'
: 'text-white shadow-[0_0_15px_rgba(255,170,0,0.2)]'
}`}
style={{
background: paused
? 'linear-gradient(135deg, #00e68a, #00cc7a)'
: 'linear-gradient(135deg, #ffaa00, #ee9900)'
}}
>
{paused ? (
<>
@@ -179,7 +182,7 @@ export default function Logs() {
{!autoScroll && (
<button
onClick={jumpToBottom}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors"
className="btn-electric flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold"
>
<ArrowDown className="h-3.5 w-3.5" />
Jump to bottom
@@ -190,9 +193,9 @@ export default function Logs() {
{/* Event type filters */}
{allTypes.length > 0 && (
<div className="flex items-center gap-2 px-6 py-2 border-b border-gray-800 bg-gray-900/80 overflow-x-auto">
<Filter className="h-4 w-4 text-gray-500 flex-shrink-0" />
<span className="text-xs text-gray-500 flex-shrink-0">Filter:</span>
<div className="flex items-center gap-2 px-6 py-2 border-b border-[#1a1a3e]/30 overflow-x-auto" style={{ background: 'rgba(5,5,16,0.6)' }}>
<Filter className="h-3.5 w-3.5 text-[#334060] flex-shrink-0" />
<span className="text-[10px] text-[#334060] flex-shrink-0 uppercase tracking-wider">Filter:</span>
{allTypes.map((type) => (
<label
key={type}
@@ -202,15 +205,15 @@ export default function Logs() {
type="checkbox"
checked={typeFilters.has(type)}
onChange={() => toggleTypeFilter(type)}
className="rounded bg-gray-800 border-gray-600 text-blue-500 focus:ring-blue-500 focus:ring-offset-0 h-3.5 w-3.5"
className="rounded bg-[#0a0a18] border-[#1a1a3e] text-[#0080ff] focus:ring-[#0080ff] focus:ring-offset-0 h-3 w-3"
/>
<span className="text-xs text-gray-400 capitalize">{type}</span>
<span className="text-[10px] text-[#556080] capitalize">{type}</span>
</label>
))}
{typeFilters.size > 0 && (
<button
onClick={() => setTypeFilters(new Set())}
className="text-xs text-blue-400 hover:text-blue-300 flex-shrink-0 ml-1"
className="text-[10px] text-[#0080ff] hover:text-[#00d4ff] flex-shrink-0 ml-1 transition-colors"
>
Clear
</button>
@@ -222,11 +225,11 @@ export default function Logs() {
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-4 space-y-2"
className="flex-1 overflow-y-auto p-4 space-y-1.5"
>
{filteredEntries.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Activity className="h-10 w-10 text-gray-600 mb-3" />
<div className="flex flex-col items-center justify-center h-full text-[#334060] animate-fade-in">
<Activity className="h-10 w-10 text-[#1a1a3e] mb-3" />
<p className="text-sm">
{paused
? 'Log streaming is paused.'
@@ -236,6 +239,7 @@ export default function Logs() {
) : (
filteredEntries.map((entry) => {
const { event } = entry;
const badge = eventTypeBadgeColor(event.type);
const detail =
event.message ??
event.content ??
@@ -251,20 +255,19 @@ export default function Logs() {
return (
<div
key={entry.id}
className="bg-gray-900 border border-gray-800 rounded-lg p-3 hover:border-gray-700 transition-colors"
className="glass-card rounded-lg p-3 hover:border-[#0080ff20] transition-all duration-200"
>
<div className="flex items-start gap-3">
<span className="text-xs text-gray-500 font-mono whitespace-nowrap mt-0.5">
<span className="text-[10px] text-[#334060] font-mono whitespace-nowrap mt-0.5">
{formatTimestamp(event.timestamp)}
</span>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border capitalize flex-shrink-0 ${eventTypeBadgeColor(
event.type,
)}`}
className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border capitalize flex-shrink-0 ${badge.classes}`}
style={{ background: badge.bg }}
>
{event.type}
</span>
<p className="text-sm text-gray-300 break-all min-w-0">
<p className="text-sm text-[#8892a8] break-all min-w-0">
{typeof detail === 'string' ? detail : JSON.stringify(detail)}
</p>
</div>
+47 -60
View File
@@ -96,8 +96,8 @@ export default function Memory() {
if (error && entries.length === 0) {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
<div className="p-6 animate-fade-in">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680]">
Failed to load memory: {error}
</div>
</div>
@@ -105,18 +105,18 @@ export default function Memory() {
}
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Brain className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
<Brain className="h-5 w-5 text-[#0080ff]" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
Memory ({entries.length})
</h2>
</div>
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
className="btn-electric flex items-center gap-2 text-sm px-4 py-2"
>
<Plus className="h-4 w-4" />
Add Memory
@@ -126,22 +126,22 @@ export default function Memory() {
{/* Search and Filter */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#334060]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search memory entries..."
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="input-electric w-full pl-10 pr-4 py-2.5 text-sm"
/>
</div>
<div className="relative">
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Filter className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#334060]" />
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-8 py-2.5 text-sm text-white appearance-none focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
className="input-electric pl-10 pr-8 py-2.5 text-sm appearance-none cursor-pointer"
>
<option value="">All Categories</option>
{categories.map((cat) => (
@@ -153,7 +153,7 @@ export default function Memory() {
</div>
<button
onClick={handleSearch}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
className="btn-electric px-4 py-2.5 text-sm"
>
Search
</button>
@@ -161,15 +161,15 @@ export default function Memory() {
{/* Error banner (non-fatal) */}
{error && (
<div className="rounded-lg bg-red-900/30 border border-red-700 p-3 text-sm text-red-300">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-3 text-sm text-[#ff6680] animate-fade-in">
{error}
</div>
)}
{/* Add Memory Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md mx-4">
<div className="fixed inset-0 modal-backdrop flex items-center justify-center z-50">
<div className="glass-card p-6 w-full max-w-md mx-4 animate-fade-in-scale">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Add Memory</h3>
<button
@@ -177,45 +177,45 @@ export default function Memory() {
setShowForm(false);
setFormError(null);
}}
className="text-gray-400 hover:text-white transition-colors"
className="text-[#556080] hover:text-white transition-colors duration-300"
>
<X className="h-5 w-5" />
</button>
</div>
{formError && (
<div className="mb-4 rounded-lg bg-red-900/30 border border-red-700 p-3 text-sm text-red-300">
<div className="mb-4 rounded-xl bg-[#ff446615] border border-[#ff446630] p-3 text-sm text-[#ff6680] animate-fade-in">
{formError}
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Key <span className="text-red-400">*</span>
<label className="block text-xs font-semibold text-[#8892a8] mb-1.5 uppercase tracking-wider">
Key <span className="text-[#ff4466]">*</span>
</label>
<input
type="text"
value={formKey}
onChange={(e) => setFormKey(e.target.value)}
placeholder="e.g. user_preferences"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="input-electric w-full px-3 py-2.5 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Content <span className="text-red-400">*</span>
<label className="block text-xs font-semibold text-[#8892a8] mb-1.5 uppercase tracking-wider">
Content <span className="text-[#ff4466]">*</span>
</label>
<textarea
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
placeholder="Memory content..."
rows={4}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
className="input-electric w-full px-3 py-2.5 text-sm resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
<label className="block text-xs font-semibold text-[#8892a8] mb-1.5 uppercase tracking-wider">
Category (optional)
</label>
<input
@@ -223,7 +223,7 @@ export default function Memory() {
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g. preferences, context, facts"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="input-electric w-full px-3 py-2.5 text-sm"
/>
</div>
</div>
@@ -234,14 +234,14 @@ export default function Memory() {
setShowForm(false);
setFormError(null);
}}
className="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
className="px-4 py-2 text-sm font-medium text-[#8892a8] hover:text-white border border-[#1a1a3e] rounded-xl hover:bg-[#0080ff08] transition-all duration-300"
>
Cancel
</button>
<button
onClick={handleAdd}
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
className="btn-electric px-4 py-2 text-sm font-medium"
>
{submitting ? 'Saving...' : 'Save'}
</button>
@@ -253,70 +253,57 @@ export default function Memory() {
{/* Memory Table */}
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" />
</div>
) : entries.length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Brain className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No memory entries found.</p>
<div className="glass-card p-8 text-center">
<Brain className="h-10 w-10 text-[#1a1a3e] mx-auto mb-3" />
<p className="text-[#556080]">No memory entries found.</p>
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-x-auto">
<table className="w-full text-sm">
<div className="glass-card overflow-x-auto">
<table className="table-electric">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Key
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Content
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Category
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Timestamp
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
Actions
</th>
<tr>
<th className="text-left">Key</th>
<th className="text-left">Content</th>
<th className="text-left">Category</th>
<th className="text-left">Timestamp</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr
key={entry.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<tr key={entry.id}>
<td className="px-4 py-3 text-white font-medium font-mono text-xs">
{entry.key}
</td>
<td className="px-4 py-3 text-gray-300 max-w-[300px]">
<td className="px-4 py-3 text-[#8892a8] max-w-[300px] text-sm">
<span title={entry.content}>
{truncate(entry.content, 80)}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-800 text-gray-300 capitalize">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold capitalize border border-[#1a1a3e] text-[#8892a8]" style={{ background: 'rgba(0,128,255,0.06)' }}>
{entry.category}
</span>
</td>
<td className="px-4 py-3 text-gray-400 text-xs whitespace-nowrap">
<td className="px-4 py-3 text-[#556080] text-xs whitespace-nowrap">
{formatDate(entry.timestamp)}
</td>
<td className="px-4 py-3 text-right">
{confirmDelete === entry.key ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-red-400">Delete?</span>
<div className="flex items-center justify-end gap-2 animate-fade-in">
<span className="text-xs text-[#ff4466]">Delete?</span>
<button
onClick={() => handleDelete(entry.key)}
className="text-red-400 hover:text-red-300 text-xs font-medium"
className="text-[#ff4466] hover:text-[#ff6680] text-xs font-medium"
>
Yes
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-gray-400 hover:text-white text-xs font-medium"
className="text-[#556080] hover:text-white text-xs font-medium"
>
No
</button>
@@ -324,7 +311,7 @@ export default function Memory() {
) : (
<button
onClick={() => setConfirmDelete(entry.key)}
className="text-gray-400 hover:text-red-400 transition-colors"
className="text-[#334060] hover:text-[#ff4466] transition-all duration-300"
>
<Trash2 className="h-4 w-4" />
</button>
+34 -45
View File
@@ -42,8 +42,8 @@ export default function Tools() {
if (error) {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
<div className="p-6 animate-fade-in">
<div className="rounded-xl bg-[#ff446615] border border-[#ff446630] p-4 text-[#ff6680]">
Failed to load tools: {error}
</div>
</div>
@@ -53,75 +53,75 @@ export default function Tools() {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
<div className="h-8 w-8 border-2 border-[#0080ff30] border-t-[#0080ff] rounded-full animate-spin" />
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-6 animate-fade-in">
{/* Search */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#334060]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tools..."
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="input-electric w-full pl-10 pr-4 py-2.5 text-sm"
/>
</div>
{/* Agent Tools Grid */}
<div>
<div className="flex items-center gap-2 mb-4">
<Wrench className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
<Wrench className="h-5 w-5 text-[#0080ff]" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
Agent Tools ({filtered.length})
</h2>
</div>
{filtered.length === 0 ? (
<p className="text-sm text-gray-500">No tools match your search.</p>
<p className="text-sm text-[#334060]">No tools match your search.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
{filtered.map((tool) => {
const isExpanded = expandedTool === tool.name;
return (
<div
key={tool.name}
className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden"
className="glass-card overflow-hidden animate-slide-in-up"
>
<button
onClick={() =>
setExpandedTool(isExpanded ? null : tool.name)
}
className="w-full text-left p-4 hover:bg-gray-800/50 transition-colors"
className="w-full text-left p-4 hover:bg-[#0080ff08] transition-all duration-300"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Package className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" />
<Package className="h-4 w-4 text-[#0080ff] flex-shrink-0 mt-0.5" />
<h3 className="text-sm font-semibold text-white truncate">
{tool.name}
</h3>
</div>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-400 flex-shrink-0" />
<ChevronDown className="h-4 w-4 text-[#0080ff] flex-shrink-0 transition-transform" />
) : (
<ChevronRight className="h-4 w-4 text-gray-400 flex-shrink-0" />
<ChevronRight className="h-4 w-4 text-[#334060] flex-shrink-0 transition-transform" />
)}
</div>
<p className="text-sm text-gray-400 mt-2 line-clamp-2">
<p className="text-sm text-[#556080] mt-2 line-clamp-2">
{tool.description}
</p>
</button>
{isExpanded && tool.parameters && (
<div className="border-t border-gray-800 p-4">
<p className="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wider">
<div className="border-t border-[#1a1a3e] p-4 animate-fade-in">
<p className="text-[10px] text-[#334060] mb-2 font-semibold uppercase tracking-wider">
Parameter Schema
</p>
<pre className="text-xs text-gray-300 bg-gray-950 rounded-lg p-3 overflow-x-auto max-h-64 overflow-y-auto">
<pre className="text-xs text-[#8892a8] rounded-xl p-3 overflow-x-auto max-h-64 overflow-y-auto" style={{ background: 'rgba(5,5,16,0.8)' }}>
{JSON.stringify(tool.parameters, null, 2)}
</pre>
</div>
@@ -135,49 +135,38 @@ export default function Tools() {
{/* CLI Tools Section */}
{filteredCli.length > 0 && (
<div>
<div className="animate-slide-in-up" style={{ animationDelay: '200ms' }}>
<div className="flex items-center gap-2 mb-4">
<Terminal className="h-5 w-5 text-green-400" />
<h2 className="text-base font-semibold text-white">
<Terminal className="h-5 w-5 text-[#00e68a]" />
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
CLI Tools ({filteredCli.length})
</h2>
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<div className="glass-card overflow-hidden">
<table className="table-electric">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Name
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Path
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Version
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Category
</th>
<tr>
<th className="text-left">Name</th>
<th className="text-left">Path</th>
<th className="text-left">Version</th>
<th className="text-left">Category</th>
</tr>
</thead>
<tbody>
{filteredCli.map((tool) => (
<tr
key={tool.name}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<td className="px-4 py-3 text-white font-medium">
<tr key={tool.name}>
<td className="px-4 py-3 text-white font-medium text-sm">
{tool.name}
</td>
<td className="px-4 py-3 text-gray-400 font-mono text-xs truncate max-w-[200px]">
<td className="px-4 py-3 text-[#556080] font-mono text-xs truncate max-w-[200px]">
{tool.path}
</td>
<td className="px-4 py-3 text-gray-400">
<td className="px-4 py-3 text-[#556080] text-sm">
{tool.version ?? '-'}
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-800 text-gray-300 capitalize">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold capitalize border border-[#1a1a3e] text-[#8892a8]" style={{ background: 'rgba(0,128,255,0.06)' }}>
{tool.category}
</span>
</td>
+2 -20
View File
@@ -3,6 +3,8 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
// Build-only config. The web dashboard is served by the Rust gateway
// via rust-embed. Run `npm run build` then `cargo build` to update.
export default defineConfig({
base: "/_app/",
plugins: [react(), tailwindcss()],
@@ -14,24 +16,4 @@ export default defineConfig({
build: {
outDir: "dist",
},
server: {
proxy: {
"/health": {
target: "http://localhost:5555",
changeOrigin: true,
},
"/pair": {
target: "http://localhost:5555",
changeOrigin: true,
},
"/api": {
target: "http://localhost:5555",
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:5555",
ws: true,
},
},
},
});