From 9dd9b2b22eb9cf4681ab644f53dd55752dc5713e Mon Sep 17 00:00:00 2001 From: coder-hhx Date: Mon, 30 Mar 2026 00:39:18 +0800 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20Phase=20A=20=E2=80=94=20sandbox?= =?UTF-8?q?=20core=20+=20Tauri=20commands=20+=20Agent=20Tools=20+=20System?= =?UTF-8?q?=20Prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A of ChatPage v1.0 implementation: Backend: - Extract sandbox logic (run_code, install_packages, templates) from cratebay-mcp to cratebay-core/src/sandbox.rs for sharing - Add Tauri commands: sandbox_run_code, sandbox_install - MCP layer now delegates to core sandbox module Frontend: - Add sandboxTools.ts (sandbox_run_code + sandbox_install Agent Tools) - Add sandboxStore.ts (session→sandbox binding) - Rewrite systemPrompt.ts: "code execution assistant" positioning, prioritizes sandbox_run_code tool, dynamic sandbox state injection - Register sandbox tools in tools/index.ts (placed first in tool list) All cargo tests pass. Workspace compiles clean. --- crates/cratebay-core/src/lib.rs | 1 + crates/cratebay-core/src/sandbox.rs | 421 ++++++++++++++++++ .../src-tauri/src/commands/mod.rs | 1 + .../src-tauri/src/commands/sandbox.rs | 59 +++ crates/cratebay-gui/src-tauri/src/main.rs | 3 + crates/cratebay-gui/src/lib/systemPrompt.ts | 70 +-- .../cratebay-gui/src/stores/sandboxStore.ts | 75 ++++ crates/cratebay-gui/src/tools/index.ts | 6 + crates/cratebay-gui/src/tools/sandboxTools.ts | 173 +++++++ crates/cratebay-mcp/src/sandbox.rs | 224 +--------- crates/cratebay-mcp/src/templates.rs | 77 +--- crates/cratebay-mcp/src/tools.rs | 1 + docs/chatpage-v1-plan.md | 239 ++++++++++ 13 files changed, 1032 insertions(+), 318 deletions(-) create mode 100644 crates/cratebay-core/src/sandbox.rs create mode 100644 crates/cratebay-gui/src-tauri/src/commands/sandbox.rs create mode 100644 crates/cratebay-gui/src/stores/sandboxStore.ts create mode 100644 crates/cratebay-gui/src/tools/sandboxTools.ts create mode 100644 docs/chatpage-v1-plan.md diff --git a/crates/cratebay-core/src/lib.rs b/crates/cratebay-core/src/lib.rs index b379a12..456a678 100644 --- a/crates/cratebay-core/src/lib.rs +++ b/crates/cratebay-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod mcp; pub mod models; pub mod proxy; pub mod runtime; +pub mod sandbox; pub mod storage; pub mod validation; diff --git a/crates/cratebay-core/src/sandbox.rs b/crates/cratebay-core/src/sandbox.rs new file mode 100644 index 0000000..806a153 --- /dev/null +++ b/crates/cratebay-core/src/sandbox.rs @@ -0,0 +1,421 @@ +//! Sandbox high-level operations. +//! +//! Provides `run_code` and `install_packages` — the primary functions for +//! AI agents to execute code in isolated containers. Shared by both +//! cratebay-mcp (MCP Server) and cratebay-gui (Tauri commands). + +use std::collections::HashMap; + +use bollard::container::{Config, CreateContainerOptions, RemoveContainerOptions}; +use bollard::Docker; +use serde::Serialize; + +use crate::error::AppError; + +// --------------------------------------------------------------------------- +// Templates +// --------------------------------------------------------------------------- + +/// A sandbox template definition. +#[derive(Debug, Clone, Serialize)] +pub struct SandboxTemplate { + pub id: String, + pub name: String, + pub description: String, + pub image: String, + pub default_command: String, + pub default_cpu_cores: u32, + pub default_memory_mb: u64, + pub tags: Vec, +} + +/// Return the 4 built-in sandbox templates. +pub fn builtin_templates() -> Vec { + vec![ + SandboxTemplate { + id: "node-dev".to_string(), + name: "Node.js Development".to_string(), + description: "Node.js 20 LTS with npm, yarn, and common dev tools".to_string(), + image: "node:20-slim".to_string(), + default_command: "sleep infinity".to_string(), + default_cpu_cores: 2, + default_memory_mb: 2048, + tags: vec![ + "javascript".to_string(), + "typescript".to_string(), + "node".to_string(), + ], + }, + SandboxTemplate { + id: "python-dev".to_string(), + name: "Python Development".to_string(), + description: "Python 3.12 with pip, venv, and common scientific packages".to_string(), + image: "python:3.12-slim-bookworm".to_string(), + default_command: "sleep infinity".to_string(), + default_cpu_cores: 2, + default_memory_mb: 2048, + tags: vec![ + "python".to_string(), + "data-science".to_string(), + "ml".to_string(), + ], + }, + SandboxTemplate { + id: "rust-dev".to_string(), + name: "Rust Development".to_string(), + description: "Rust stable with cargo, rustfmt, clippy".to_string(), + image: "rust:1-slim-bookworm".to_string(), + default_command: "sleep infinity".to_string(), + default_cpu_cores: 4, + default_memory_mb: 4096, + tags: vec!["rust".to_string(), "systems".to_string()], + }, + SandboxTemplate { + id: "ubuntu-base".to_string(), + name: "Ubuntu Base".to_string(), + description: "Clean Ubuntu 24.04 with basic tools".to_string(), + image: "ubuntu:24.04".to_string(), + default_command: "sleep infinity".to_string(), + default_cpu_cores: 1, + default_memory_mb: 1024, + tags: vec!["general".to_string(), "linux".to_string()], + }, + ] +} + +/// Look up a template by ID. +pub fn find_template(id: &str) -> Option { + builtin_templates().into_iter().find(|t| t.id == id) +} + +// --------------------------------------------------------------------------- +// run_code +// --------------------------------------------------------------------------- + +/// Parameters for the run_code operation. +#[derive(Debug)] +pub struct RunCodeParams { + pub language: String, + pub code: String, + pub timeout_seconds: Option, + pub environment: Option>, + pub cleanup: Option, + /// Reuse an existing sandbox instead of creating a new one. + pub sandbox_id: Option, +} + +/// Result of a run_code operation. +#[derive(Debug, Serialize)] +pub struct RunCodeResult { + pub sandbox_id: String, + pub exit_code: i64, + pub stdout: String, + pub stderr: String, + pub duration_ms: u64, + pub language: String, +} + +/// Language-specific configuration. +struct LangConfig { + template_id: &'static str, + file_path: &'static str, + run_cmd: &'static str, +} + +fn lang_config(language: &str) -> Result { + match language { + "python" => Ok(LangConfig { + template_id: "python-dev", + file_path: "/app/run.py", + run_cmd: "python /app/run.py", + }), + "javascript" => Ok(LangConfig { + template_id: "node-dev", + file_path: "/app/run.js", + run_cmd: "node /app/run.js", + }), + "bash" => Ok(LangConfig { + template_id: "ubuntu-base", + file_path: "/app/run.sh", + run_cmd: "bash /app/run.sh", + }), + "rust" => Ok(LangConfig { + template_id: "rust-dev", + file_path: "/app/run.rs", + run_cmd: "rustc /app/run.rs -o /app/run && /app/run", + }), + _ => Err(AppError::Validation(format!( + "Unsupported language '{}'. Supported: python, javascript, bash, rust", + language + ))), + } +} + +/// Create a temporary sandbox container for code execution. +async fn create_temp_sandbox( + docker: &Docker, + template_id: &str, + env: Option>, +) -> Result { + let template = find_template(template_id) + .ok_or_else(|| AppError::Validation(format!("Unknown template '{}'", template_id)))?; + + let sandbox_name = format!( + "cratebay-{}-{}", + template_id, + &uuid::Uuid::new_v4().to_string()[..8] + ); + + let mut labels = HashMap::new(); + labels.insert( + "com.cratebay.sandbox.managed".to_string(), + "true".to_string(), + ); + labels.insert( + "com.cratebay.sandbox.template_id".to_string(), + template_id.to_string(), + ); + labels.insert( + "com.cratebay.sandbox.owner".to_string(), + "gui_run_code".to_string(), + ); + + let host_config = bollard::models::HostConfig { + memory: Some((template.default_memory_mb * 1024 * 1024) as i64), + nano_cpus: Some((template.default_cpu_cores as i64) * 1_000_000_000), + ..Default::default() + }; + + let config = Config { + image: Some(template.image.clone()), + cmd: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + template.default_command.clone(), + ]), + env, + host_config: Some(host_config), + labels: Some(labels), + ..Default::default() + }; + + let options = CreateContainerOptions { + name: sandbox_name.as_str(), + platform: None, + }; + + let response = docker.create_container(Some(options), config).await?; + docker.start_container::(&response.id, None).await?; + + Ok(response.id) +} + +/// Delete a sandbox container. +async fn delete_temp_sandbox(docker: &Docker, sandbox_id: &str) { + let options = Some(RemoveContainerOptions { + force: true, + ..Default::default() + }); + let _ = docker.remove_container(sandbox_id, options).await; +} + +/// Create a sandbox, write code, execute it, and return the result. +/// +/// This is the primary high-level function for AI agents to run code. +pub async fn run_code(docker: &Docker, params: RunCodeParams) -> Result { + let start = std::time::Instant::now(); + let config = lang_config(¶ms.language)?; + let timeout_secs = params.timeout_seconds.unwrap_or(60); + let should_cleanup = params.cleanup.unwrap_or(true); + + let env: Option> = params.environment.map(|map| { + map.into_iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect() + }); + + // Use existing sandbox or create a new one + let (sandbox_id, created_new) = if let Some(ref id) = params.sandbox_id { + (id.clone(), false) + } else { + let id = create_temp_sandbox(docker, config.template_id, env).await?; + (id, true) + }; + + // Write code to file + if let Err(e) = + crate::container::exec_put_text(docker, &sandbox_id, config.file_path, ¶ms.code).await + { + if should_cleanup && created_new { + delete_temp_sandbox(docker, &sandbox_id).await; + } + return Err(AppError::Runtime(format!( + "Failed to write code file: {}", + e + ))); + } + + // Execute code + let exec_result = crate::container::exec_with_timeout( + docker, + &sandbox_id, + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + config.run_cmd.to_string(), + ], + Some("/app".to_string()), + timeout_secs, + ) + .await; + + let exec_result = match exec_result { + Ok(r) => r, + Err(e) => { + if should_cleanup && created_new { + delete_temp_sandbox(docker, &sandbox_id).await; + } + return Err(AppError::Runtime(format!("Code execution failed: {}", e))); + } + }; + + let duration_ms = start.elapsed().as_millis() as u64; + + // Cleanup if requested + if should_cleanup && created_new { + delete_temp_sandbox(docker, &sandbox_id).await; + } + + Ok(RunCodeResult { + sandbox_id: if should_cleanup && created_new { + sandbox_id.chars().take(12).collect() + } else { + sandbox_id + }, + exit_code: exec_result.exit_code, + stdout: exec_result.stdout, + stderr: exec_result.stderr, + duration_ms, + language: params.language, + }) +} + +// --------------------------------------------------------------------------- +// install_packages +// --------------------------------------------------------------------------- + +/// Parameters for the install operation. +#[derive(Debug)] +pub struct InstallParams { + pub sandbox_id: String, + pub package_manager: String, + pub packages: Vec, +} + +/// Result of an install operation. +#[derive(Debug, Serialize)] +pub struct InstallResult { + pub sandbox_id: String, + pub package_manager: String, + pub exit_code: i64, + pub stdout: String, + pub stderr: String, + pub duration_ms: u64, +} + +/// Install packages in an existing sandbox. +pub async fn install_packages( + docker: &Docker, + params: InstallParams, +) -> Result { + let start = std::time::Instant::now(); + + // Validate package names + for pkg in ¶ms.packages { + if pkg.contains(';') || pkg.contains('&') || pkg.contains('|') || pkg.contains('`') { + return Err(AppError::Validation(format!( + "Invalid package name '{}': contains shell metacharacters", + pkg + ))); + } + } + + let packages_str = params.packages.join(" "); + + let cmd = match params.package_manager.as_str() { + "pip" => format!("pip install --no-cache-dir {}", packages_str), + "npm" => format!("npm install --no-fund --no-audit {}", packages_str), + "cargo" => format!("cargo add {}", packages_str), + "apt" => format!( + "apt-get update -qq && apt-get install -y -qq {}", + packages_str + ), + other => { + return Err(AppError::Validation(format!( + "Unsupported package manager '{}'. Supported: pip, npm, cargo, apt", + other + ))); + } + }; + + let result = crate::container::exec_with_timeout( + docker, + ¶ms.sandbox_id, + vec!["/bin/sh".to_string(), "-c".to_string(), cmd], + None, + 300, + ) + .await?; + + let duration_ms = start.elapsed().as_millis() as u64; + + Ok(InstallResult { + sandbox_id: params.sandbox_id, + package_manager: params.package_manager, + exit_code: result.exit_code, + stdout: result.stdout, + stderr: result.stderr, + duration_ms, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_builtin_templates_count() { + assert_eq!(builtin_templates().len(), 4); + } + + #[test] + fn test_find_template() { + assert!(find_template("python-dev").is_some()); + assert!(find_template("node-dev").is_some()); + assert!(find_template("rust-dev").is_some()); + assert!(find_template("ubuntu-base").is_some()); + assert!(find_template("nonexistent").is_none()); + } + + #[test] + fn test_lang_config() { + assert!(lang_config("python").is_ok()); + assert!(lang_config("javascript").is_ok()); + assert!(lang_config("bash").is_ok()); + assert!(lang_config("rust").is_ok()); + assert!(lang_config("go").is_err()); + } + + #[test] + fn test_lang_config_templates_exist() { + for lang in &["python", "javascript", "bash", "rust"] { + let cfg = lang_config(lang).unwrap(); + assert!( + find_template(cfg.template_id).is_some(), + "Template '{}' for language '{}' not found", + cfg.template_id, + lang + ); + } + } +} diff --git a/crates/cratebay-gui/src-tauri/src/commands/mod.rs b/crates/cratebay-gui/src-tauri/src/commands/mod.rs index 9a27800..a4587e8 100644 --- a/crates/cratebay-gui/src-tauri/src/commands/mod.rs +++ b/crates/cratebay-gui/src-tauri/src/commands/mod.rs @@ -3,5 +3,6 @@ pub mod container; pub mod llm; pub mod mcp; +pub mod sandbox; pub mod storage; pub mod system; diff --git a/crates/cratebay-gui/src-tauri/src/commands/sandbox.rs b/crates/cratebay-gui/src-tauri/src/commands/sandbox.rs new file mode 100644 index 0000000..c769d89 --- /dev/null +++ b/crates/cratebay-gui/src-tauri/src/commands/sandbox.rs @@ -0,0 +1,59 @@ +//! Sandbox execution Tauri commands. +//! +//! Exposes `cratebay_core::sandbox` operations (run_code, install_packages) +//! to the frontend via Tauri invoke. + +use tauri::State; + +use crate::state::AppState; +use cratebay_core::error::AppError; +use cratebay_core::sandbox; + +/// Run code in a temporary sandbox container. +/// +/// Creates a container, writes the code, executes it, and returns the result. +/// By default the container is cleaned up after execution. +#[tauri::command] +pub async fn sandbox_run_code( + state: State<'_, AppState>, + language: String, + code: String, + sandbox_id: Option, + timeout_seconds: Option, +) -> Result { + let docker = state.ensure_docker_once().await?; + + let params = sandbox::RunCodeParams { + language, + code, + timeout_seconds, + environment: None, + cleanup: if sandbox_id.is_some() { + Some(false) + } else { + Some(true) + }, + sandbox_id, + }; + + sandbox::run_code(&docker, params).await +} + +/// Install packages in an existing sandbox container. +#[tauri::command] +pub async fn sandbox_install( + state: State<'_, AppState>, + sandbox_id: String, + package_manager: String, + packages: Vec, +) -> Result { + let docker = state.ensure_docker_once().await?; + + let params = sandbox::InstallParams { + sandbox_id, + package_manager, + packages, + }; + + sandbox::install_packages(&docker, params).await +} diff --git a/crates/cratebay-gui/src-tauri/src/main.rs b/crates/cratebay-gui/src-tauri/src/main.rs index a30fa1f..bd3d355 100644 --- a/crates/cratebay-gui/src-tauri/src/main.rs +++ b/crates/cratebay-gui/src-tauri/src/main.rs @@ -616,6 +616,9 @@ fn main() { commands::system::runtime_status, commands::system::runtime_start, commands::system::runtime_stop, + // Sandbox + commands::sandbox::sandbox_run_code, + commands::sandbox::sandbox_install, // Debug #[cfg(debug_assertions)] commands::system::webview_debug_report, diff --git a/crates/cratebay-gui/src/lib/systemPrompt.ts b/crates/cratebay-gui/src/lib/systemPrompt.ts index 2b283b9..3a61c5d 100644 --- a/crates/cratebay-gui/src/lib/systemPrompt.ts +++ b/crates/cratebay-gui/src/lib/systemPrompt.ts @@ -1,60 +1,72 @@ /** * System Prompt for the CrateBay AI Assistant. * - * Defines the agent's persona, capabilities, behavioral rules, and restrictions. - * The tool descriptions section is dynamically generated from the registered tools. - * - * @see agent-spec.md §6 for the system prompt design specification. + * Defines the agent's persona focused on code execution in sandboxes. + * Tool descriptions are dynamically generated from registered tools. */ import type { AgentTool } from "@mariozechner/pi-agent-core"; /** - * Build the system prompt with dynamic tool descriptions. - * - * @param tools - The registered agent tools (used to generate the tool list section) - * @returns The complete system prompt string + * Build the system prompt with dynamic tool descriptions and optional sandbox state. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function buildSystemPrompt(tools: AgentTool[]): string { +export function buildSystemPrompt( + tools: AgentTool[], + sandboxState?: { id: string; language: string; status: string } | null, +): string { const toolDescriptions = tools .map((t) => `- **${t.name}**: ${t.description}`) .join("\n"); - return `You are CrateBay AI Assistant, a helpful development environment manager. + const sandboxSection = sandboxState + ? `\n## Current Sandbox +- ID: ${sandboxState.id} +- Language: ${sandboxState.language} +- Status: ${sandboxState.status} +- When the user asks to run code, use this sandbox (pass sandbox_id) instead of creating a new one. +` + : `\n## Sandbox +No active sandbox. When the user asks to run code, sandbox_run_code will automatically create one. +`; + + return `You are CrateBay AI Assistant — an AI that can execute code in secure local sandboxes. -## Capabilities -You can manage containers, execute commands, read/write files, and interact with MCP tools. +## Core Capabilities +1. **Run code** — Execute Python, JavaScript, Bash, or Rust code using sandbox_run_code +2. **Install packages** — Install dependencies with pip, npm, cargo, or apt using sandbox_install +3. **File operations** — Read and write files inside sandboxes -## Available Tools -${toolDescriptions} +## Primary Tools (use these first) +- **sandbox_run_code**: Execute code in an isolated sandbox. Creates one automatically if needed. +- **sandbox_install**: Install packages in a running sandbox. +## All Available Tools +${toolDescriptions} +${sandboxSection} ## Behavioral Rules -1. **Safety First**: Always check container status before operations. Never delete containers without user confirmation. +1. **Code execution first**: When the user provides code or asks to run something, call sandbox_run_code immediately. Don't explain what the code does unless asked. -2. **Explain Before Acting**: For destructive or complex operations, explain what you plan to do before executing tools. +2. **Auto-create sandbox**: If no sandbox exists, sandbox_run_code creates one automatically. For multi-step work, use cleanup=false to keep the sandbox alive. -3. **Error Recovery**: If a tool call fails, analyze the error, explain it to the user, and suggest alternatives. +3. **Error recovery**: If code fails, analyze the error and suggest a fix. Common issues: missing packages (use sandbox_install), syntax errors, wrong language. -4. **Efficiency**: Prefer single commands over multiple when possible. Use container_list before operations to confirm targets. +4. **Keep it concise**: Show execution results directly. Don't wrap output in extra explanation unless the user asks. -5. **Context Awareness**: Track which containers exist and their states. Don't attempt operations on non-existent containers. +5. **Multi-step workflows**: For tasks requiring multiple steps (install packages → write code → run), chain the tools naturally. Use the same sandbox_id across steps. ## Response Format -- Use markdown for formatted responses -- Include code blocks with language annotations -- For multi-step operations, use numbered lists -- Keep explanations concise but informative +- Code execution results: show stdout/stderr directly +- Use markdown code blocks with language annotations +- For errors, explain the cause briefly and suggest a fix ## Container Templates -Available templates: node-dev, python-dev, rust-dev, ubuntu-base -Each template provides a pre-configured development environment. +Available: python-dev, node-dev, rust-dev, ubuntu-base ## Restrictions -- You cannot access the host filesystem directly — only files inside containers -- You cannot modify application settings — direct users to the Settings page -- You cannot manage LLM providers — direct users to Settings > LLM Providers -- API keys are managed securely and you have no access to them +- You can only access files inside sandboxes, not the host filesystem +- You cannot modify application settings +- API keys are managed securely — you have no access to them `; } diff --git a/crates/cratebay-gui/src/stores/sandboxStore.ts b/crates/cratebay-gui/src/stores/sandboxStore.ts new file mode 100644 index 0000000..106d3d0 --- /dev/null +++ b/crates/cratebay-gui/src/stores/sandboxStore.ts @@ -0,0 +1,75 @@ +/** + * Sandbox state management. + * + * Tracks which sandbox is bound to which chat session, + * enabling multi-step workflows within the same sandbox. + */ + +import { create } from "zustand"; + +interface SandboxBinding { + sandboxId: string; + language: string; + status: "running" | "stopped" | "unknown"; +} + +interface SandboxState { + /** Maps chat session ID → sandbox binding */ + sessionSandboxes: Record; + + /** Bind a sandbox to a chat session */ + bindSandbox: ( + sessionId: string, + sandboxId: string, + language: string, + ) => void; + + /** Unbind (and optionally cleanup) a sandbox from a session */ + unbindSandbox: (sessionId: string) => void; + + /** Get the sandbox bound to a session */ + getSessionSandbox: (sessionId: string) => SandboxBinding | null; + + /** Update sandbox status */ + updateSandboxStatus: ( + sessionId: string, + status: "running" | "stopped" | "unknown", + ) => void; +} + +export const useSandboxStore = create((set, get) => ({ + sessionSandboxes: {}, + + bindSandbox: (sessionId, sandboxId, language) => { + set((state) => ({ + sessionSandboxes: { + ...state.sessionSandboxes, + [sessionId]: { sandboxId, language, status: "running" }, + }, + })); + }, + + unbindSandbox: (sessionId) => { + set((state) => { + const { [sessionId]: _, ...rest } = state.sessionSandboxes; + return { sessionSandboxes: rest }; + }); + }, + + getSessionSandbox: (sessionId) => { + return get().sessionSandboxes[sessionId] ?? null; + }, + + updateSandboxStatus: (sessionId, status) => { + set((state) => { + const existing = state.sessionSandboxes[sessionId]; + if (!existing) return state; + return { + sessionSandboxes: { + ...state.sessionSandboxes, + [sessionId]: { ...existing, status }, + }, + }; + }); + }, +})); diff --git a/crates/cratebay-gui/src/tools/index.ts b/crates/cratebay-gui/src/tools/index.ts index 222a288..d83d411 100644 --- a/crates/cratebay-gui/src/tools/index.ts +++ b/crates/cratebay-gui/src/tools/index.ts @@ -13,6 +13,7 @@ import { filesystemTools } from "./filesystemTools"; import { shellTools } from "./shellTools"; import { mcpTools } from "./mcpTools"; import { systemTools } from "./systemTools"; +import { sandboxTools } from "./sandboxTools"; import type { RiskLevel } from "@/types/agent"; /** @@ -24,6 +25,7 @@ import type { RiskLevel } from "@/types/agent"; */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export const builtinTools: AgentTool[] = [ + ...sandboxTools, ...containerTools, ...imageTools, ...filesystemTools, @@ -72,6 +74,10 @@ export const toolRiskLevels: Record = { mcp_list_tools: "low", mcp_call_tool: "medium", + // Sandbox tools + sandbox_run_code: "low", + sandbox_install: "medium", + // System tools docker_status: "low", system_info: "low", diff --git a/crates/cratebay-gui/src/tools/sandboxTools.ts b/crates/cratebay-gui/src/tools/sandboxTools.ts new file mode 100644 index 0000000..bcf063e --- /dev/null +++ b/crates/cratebay-gui/src/tools/sandboxTools.ts @@ -0,0 +1,173 @@ +/** + * Sandbox tools for the CrateBay Agent. + * + * High-level sandbox operations that abstract away container management. + * These are the primary tools AI agents should use for code execution. + */ + +import { Type } from "@sinclair/typebox"; +import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; +import { invoke } from "@/lib/tauri"; + +// --------------------------------------------------------------------------- +// Parameters +// --------------------------------------------------------------------------- + +const SandboxRunCodeParams = Type.Object({ + language: Type.Union( + [ + Type.Literal("python"), + Type.Literal("javascript"), + Type.Literal("bash"), + Type.Literal("rust"), + ], + { description: "Programming language to use" }, + ), + code: Type.String({ description: "Source code to execute" }), + timeout_seconds: Type.Optional( + Type.Number({ + description: "Execution timeout in seconds (default: 60)", + minimum: 1, + maximum: 3600, + }), + ), + sandbox_id: Type.Optional( + Type.String({ + description: "Reuse an existing sandbox instead of creating a new one", + }), + ), +}); + +const SandboxInstallParams = Type.Object({ + sandbox_id: Type.String({ description: "Target sandbox ID" }), + package_manager: Type.Union( + [ + Type.Literal("pip"), + Type.Literal("npm"), + Type.Literal("cargo"), + Type.Literal("apt"), + ], + { description: "Package manager to use" }, + ), + packages: Type.Array(Type.String(), { + description: "Package names to install", + minItems: 1, + }), +}); + +// --------------------------------------------------------------------------- +// Result types +// --------------------------------------------------------------------------- + +interface RunCodeResult { + sandbox_id: string; + exit_code: number; + stdout: string; + stderr: string; + duration_ms: number; + language: string; +} + +interface InstallResult { + sandbox_id: string; + package_manager: string; + exit_code: number; + stdout: string; + stderr: string; + duration_ms: number; +} + +// --------------------------------------------------------------------------- +// Tool helpers +// --------------------------------------------------------------------------- + +function textResult(text: string): AgentToolResult { + return { content: [{ type: "text", text }] }; +} + +function formatRunCodeResult(result: RunCodeResult): string { + let output = ""; + + if (result.stdout) { + output += result.stdout; + } + if (result.stderr) { + if (output) output += "\n"; + output += `[stderr] ${result.stderr}`; + } + + const header = `Language: ${result.language} | Exit: ${result.exit_code} | ${result.duration_ms}ms | Sandbox: ${result.sandbox_id}`; + + if (!output) { + return `${header}\n(no output)`; + } + return `${header}\n\n${output}`; +} + +function formatInstallResult(result: InstallResult): string { + let output = ""; + if (result.stdout) output += result.stdout; + if (result.stderr) { + if (output) output += "\n"; + output += result.stderr; + } + + const header = `${result.package_manager} | Exit: ${result.exit_code} | ${result.duration_ms}ms`; + return output ? `${header}\n\n${output}` : `${header}\n(no output)`; +} + +// --------------------------------------------------------------------------- +// Tools +// --------------------------------------------------------------------------- + +const sandboxRunCodeTool: AgentTool = { + name: "sandbox_run_code", + label: "Run Code", + description: + "Execute code in an isolated sandbox. Automatically creates a sandbox if needed. " + + "Supports Python, JavaScript, Bash, and Rust. Returns stdout, stderr, and exit code.", + parameters: SandboxRunCodeParams, + execute: async (_toolCallId, params) => { + try { + const result = await invoke("sandbox_run_code", { + language: params.language, + code: params.code, + sandbox_id: params.sandbox_id ?? null, + timeout_seconds: params.timeout_seconds ?? null, + }); + return textResult(formatRunCodeResult(result)); + } catch (error) { + return textResult(`Error: ${error}`); + } + }, +}; + +const sandboxInstallTool: AgentTool = { + name: "sandbox_install", + label: "Install Packages", + description: + "Install packages in an existing sandbox using pip, npm, cargo, or apt. " + + "The sandbox must be running. Use sandbox_run_code first to create a sandbox with cleanup=false.", + parameters: SandboxInstallParams, + execute: async (_toolCallId, params) => { + try { + const result = await invoke("sandbox_install", { + sandbox_id: params.sandbox_id, + package_manager: params.package_manager, + packages: params.packages, + }); + return textResult(formatInstallResult(result)); + } catch (error) { + return textResult(`Error: ${error}`); + } + }, +}; + +/** + * All sandbox tools. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const sandboxTools: AgentTool[] = [ + sandboxRunCodeTool, + sandboxInstallTool, +]; diff --git a/crates/cratebay-mcp/src/sandbox.rs b/crates/cratebay-mcp/src/sandbox.rs index db1d67a..35607d3 100644 --- a/crates/cratebay-mcp/src/sandbox.rs +++ b/crates/cratebay-mcp/src/sandbox.rs @@ -409,239 +409,33 @@ pub async fn get_path( } // --------------------------------------------------------------------------- -// High-level sandbox operations (sandbox_run_code, sandbox_install) +// High-level sandbox operations — delegated to cratebay-core::sandbox // --------------------------------------------------------------------------- -/// Parameters for the run_code high-level operation. -#[derive(Debug)] -pub struct RunCodeParams { - pub language: String, - pub code: String, - pub timeout_seconds: Option, - pub environment: Option>, - pub cleanup: Option, -} - -/// Result of a run_code operation. -#[derive(Debug, Serialize)] -pub struct RunCodeResult { - pub sandbox_id: String, - pub exit_code: i64, - pub stdout: String, - pub stderr: String, - pub duration_ms: u64, - pub language: String, -} - -/// Language-specific configuration for code execution. -struct LangConfig { - template_id: &'static str, - file_path: &'static str, - run_cmd: &'static str, -} - -fn lang_config(language: &str) -> Result { - match language { - "python" => Ok(LangConfig { - template_id: "python-dev", - file_path: "/app/run.py", - run_cmd: "python /app/run.py", - }), - "javascript" => Ok(LangConfig { - template_id: "node-dev", - file_path: "/app/run.js", - run_cmd: "node /app/run.js", - }), - "bash" => Ok(LangConfig { - template_id: "ubuntu-base", - file_path: "/app/run.sh", - run_cmd: "bash /app/run.sh", - }), - "rust" => Ok(LangConfig { - template_id: "rust-dev", - file_path: "/app/run.rs", - run_cmd: "rustc /app/run.rs -o /app/run && /app/run", - }), - _ => Err(McpError::InvalidParams(format!( - "Unsupported language '{}'. Supported: python, javascript, bash, rust", - language - ))), - } -} +// Re-export types so existing MCP tool code continues to compile. +pub use cratebay_core::sandbox::{InstallParams, InstallResult, RunCodeParams, RunCodeResult}; /// Create a sandbox, write code, execute it, and return the result. /// -/// This is the primary high-level tool for AI agents to run code. +/// Delegates to `cratebay_core::sandbox::run_code`. pub async fn run_code(docker: &Docker, params: RunCodeParams) -> Result { - let start = std::time::Instant::now(); - let config = lang_config(¶ms.language)?; - let timeout_secs = params.timeout_seconds.unwrap_or(60); - let should_cleanup = params.cleanup.unwrap_or(true); - - // Build environment variables - let env: Option> = params.environment.map(|map| { - map.into_iter() - .map(|(k, v)| format!("{}={}", k, v)) - .collect() - }); - - // 1. Create sandbox - let create_params = CreateSandboxParams { - template_id: config.template_id.to_string(), - name: None, - image: None, - command: None, - env, - cpu_cores: Some(2), - memory_mb: Some(2048), - ttl_hours: Some(1), // auto-expire in 1 hour - owner: Some("mcp_run_code".to_string()), - }; - - let sandbox_info = create_sandbox(docker, create_params).await?; - let sandbox_id = sandbox_info.id.clone(); - - // 2. Write code to file - if let Err(e) = - cratebay_core::container::exec_put_text(docker, &sandbox_id, config.file_path, ¶ms.code) - .await - { - if should_cleanup { - let _ = delete_sandbox(docker, &sandbox_id).await; - } - return Err(McpError::Internal(format!( - "Failed to write code file: {}", - e - ))); - } - - // 3. Execute code - let exec_result = cratebay_core::container::exec_with_timeout( - docker, - &sandbox_id, - vec![ - "/bin/sh".to_string(), - "-c".to_string(), - config.run_cmd.to_string(), - ], - Some("/app".to_string()), - timeout_secs, - ) - .await; - - let exec_result = match exec_result { - Ok(r) => r, - Err(e) => { - if should_cleanup { - let _ = delete_sandbox(docker, &sandbox_id).await; - } - return Err(McpError::Internal(format!("Code execution failed: {}", e))); - } - }; - - let duration_ms = start.elapsed().as_millis() as u64; - - // 4. Cleanup if requested - if should_cleanup { - let _ = delete_sandbox(docker, &sandbox_id).await; - } - - Ok(RunCodeResult { - sandbox_id: if should_cleanup { - sandbox_info.short_id - } else { - sandbox_id - }, - exit_code: exec_result.exit_code, - stdout: exec_result.stdout, - stderr: exec_result.stderr, - duration_ms, - language: params.language, - }) -} - -/// Parameters for the install operation. -#[derive(Debug)] -pub struct InstallParams { - pub sandbox_id: String, - pub package_manager: String, - pub packages: Vec, -} - -/// Result of an install operation. -#[derive(Debug, Serialize)] -pub struct InstallResult { - pub sandbox_id: String, - pub package_manager: String, - pub exit_code: i64, - pub stdout: String, - pub stderr: String, - pub duration_ms: u64, + Ok(cratebay_core::sandbox::run_code(docker, params).await?) } /// Install packages in an existing sandbox. +/// +/// Verifies the sandbox is managed and running before delegating to core. pub async fn install_packages( docker: &Docker, params: InstallParams, ) -> Result { - let start = std::time::Instant::now(); - - // Verify sandbox is running + // Verify sandbox is managed and running (MCP-level check) let info = inspect_sandbox(docker, ¶ms.sandbox_id).await?; if info.lifecycle_state != "running" { return Err(McpError::SandboxNotRunning(params.sandbox_id.clone())); } - // Validate package names (basic sanitization) - for pkg in ¶ms.packages { - if pkg.contains(';') || pkg.contains('&') || pkg.contains('|') || pkg.contains('`') { - return Err(McpError::InvalidParams(format!( - "Invalid package name '{}': contains shell metacharacters", - pkg - ))); - } - } - - let packages_str = params.packages.join(" "); - - // Build the install command - let cmd = match params.package_manager.as_str() { - "pip" => format!("pip install --no-cache-dir {}", packages_str), - "npm" => format!("npm install --no-fund --no-audit {}", packages_str), - "cargo" => format!("cargo add {}", packages_str), - "apt" => format!( - "apt-get update -qq && apt-get install -y -qq {}", - packages_str - ), - other => { - return Err(McpError::InvalidParams(format!( - "Unsupported package manager '{}'. Supported: pip, npm, cargo, apt", - other - ))); - } - }; - - // 300s timeout for package installation - let result = cratebay_core::container::exec_with_timeout( - docker, - ¶ms.sandbox_id, - vec!["/bin/sh".to_string(), "-c".to_string(), cmd], - None, - 300, - ) - .await - .map_err(|e| McpError::Internal(format!("Package installation failed: {}", e)))?; - - let duration_ms = start.elapsed().as_millis() as u64; - - Ok(InstallResult { - sandbox_id: params.sandbox_id, - package_manager: params.package_manager, - exit_code: result.exit_code, - stdout: result.stdout, - stderr: result.stderr, - duration_ms, - }) + Ok(cratebay_core::sandbox::install_packages(docker, params).await?) } /// Build a SandboxInfo from Docker container labels and state. diff --git a/crates/cratebay-mcp/src/templates.rs b/crates/cratebay-mcp/src/templates.rs index 362faec..d3c42cf 100644 --- a/crates/cratebay-mcp/src/templates.rs +++ b/crates/cratebay-mcp/src/templates.rs @@ -1,80 +1,9 @@ //! Sandbox template definitions. //! -//! Templates provide pre-configured development environments per mcp-spec.md §2.3. +//! Re-exports from `cratebay-core::sandbox` — the canonical source. -use serde::Serialize; - -/// A sandbox template definition. -#[derive(Debug, Clone, Serialize)] -pub struct SandboxTemplate { - pub id: String, - pub name: String, - pub description: String, - pub image: String, - pub default_command: String, - pub default_cpu_cores: u32, - pub default_memory_mb: u64, - pub tags: Vec, -} - -/// Return the 4 built-in sandbox templates per mcp-spec.md §2.3. -pub fn builtin_templates() -> Vec { - vec![ - SandboxTemplate { - id: "node-dev".to_string(), - name: "Node.js Development".to_string(), - description: "Node.js 20 LTS with npm, yarn, and common dev tools".to_string(), - image: "node:20-slim".to_string(), - default_command: "sleep infinity".to_string(), - default_cpu_cores: 2, - default_memory_mb: 2048, - tags: vec![ - "javascript".to_string(), - "typescript".to_string(), - "node".to_string(), - ], - }, - SandboxTemplate { - id: "python-dev".to_string(), - name: "Python Development".to_string(), - description: "Python 3.12 with pip, venv, and common scientific packages".to_string(), - image: "python:3.12-slim-bookworm".to_string(), - default_command: "sleep infinity".to_string(), - default_cpu_cores: 2, - default_memory_mb: 2048, - tags: vec![ - "python".to_string(), - "data-science".to_string(), - "ml".to_string(), - ], - }, - SandboxTemplate { - id: "rust-dev".to_string(), - name: "Rust Development".to_string(), - description: "Rust stable with cargo, rustfmt, clippy".to_string(), - image: "rust:1-slim-bookworm".to_string(), - default_command: "sleep infinity".to_string(), - default_cpu_cores: 4, - default_memory_mb: 4096, - tags: vec!["rust".to_string(), "systems".to_string()], - }, - SandboxTemplate { - id: "ubuntu-base".to_string(), - name: "Ubuntu Base".to_string(), - description: "Clean Ubuntu 24.04 with basic tools".to_string(), - image: "ubuntu:24.04".to_string(), - default_command: "sleep infinity".to_string(), - default_cpu_cores: 1, - default_memory_mb: 1024, - tags: vec!["general".to_string(), "linux".to_string()], - }, - ] -} - -/// Look up a template by ID. -pub fn find_template(id: &str) -> Option { - builtin_templates().into_iter().find(|t| t.id == id) -} +#[allow(unused_imports)] +pub use cratebay_core::sandbox::{builtin_templates, find_template, SandboxTemplate}; #[cfg(test)] mod tests { diff --git a/crates/cratebay-mcp/src/tools.rs b/crates/cratebay-mcp/src/tools.rs index 1d29a4d..22278f2 100644 --- a/crates/cratebay-mcp/src/tools.rs +++ b/crates/cratebay-mcp/src/tools.rs @@ -401,6 +401,7 @@ async fn handle_run_code( timeout_seconds, environment, cleanup, + sandbox_id: None, }; let result = sandbox::run_code(docker, params).await?; diff --git a/docs/chatpage-v1-plan.md b/docs/chatpage-v1-plan.md new file mode 100644 index 0000000..3eca719 --- /dev/null +++ b/docs/chatpage-v1-plan.md @@ -0,0 +1,239 @@ +# ChatPage v1.0 开发方案 + +> CrateBay v0.9.0 → v1.0.0:让 ChatPage 成为"内置沙盒能力的 AI 聊天" + +## 一、定位 + +CrateBay ChatPage = **AI 聊天 + 开箱即用的代码执行沙盒** + +和 Claude Desktop 的核心区别: + +| | Claude Desktop + MCP | CrateBay ChatPage | +|---|---|---| +| 代码执行 | 需要手动配 MCP | **开箱即用**,内置沙盒 | +| LLM | 只能用 Claude(订阅制) | **接自己的 API Key**,支持多供应商 | +| 成本 | Claude 订阅费 | **按 API 用量付费**(或接本地模型免费) | +| 隐私 | 代码上传到云端 | **代码在本地 VM 执行** | +| 工具 | 通用 | **沙盒专用**(run_code 一步到位) | + +**一句话**:CrateBay ChatPage 是一个自带代码执行能力的 AI 聊天界面,用户不需要理解 Docker/MCP/容器,配个 API Key 就能让 AI 帮你跑代码。 + +## 二、从 LiveAgent 复用 + +### 2.1 供应商配置系统(95% 复用) + +**来源**: LiveAgent `crates/agent-gui/src/lib/settings/index.ts` + +复用内容: +- `CustomProvider` 类型(id/name/type/baseUrl/apiKey/models/activeModels/requestFormat/reasoning) +- `normalizeCustomProvider()` — URL 后缀自动推断 API 格式(completions vs responses) +- `/v1/models` 动态获取模型列表 +- `createModelFromConfig()` — 创建 pi-ai Model 实例 +- 双认证头(Bearer + x-api-key) + +改动点: +- ProviderId 扩展为更通用类型(不限于 codex/claude_code) +- UI 用 CrateBay 的 shadcn/ui 重写 +- API Key 存储保留 Rust SQLite 后端(比 localStorage 更安全) + +### 2.2 Chat UI 组件 + +**来源**: LiveAgent `crates/agent-gui/src/pages/ChatPage.tsx` + +复用内容: +- **Round 分组**(行 676-750):多轮工具调用分组展示 +- **ToolCallItem**(行 540-674):状态圆点 + 参数截断 + 折叠展开 +- **ThinkingBlock**(行 490-537):可折叠思考过程 + +### 2.3 Agent 增强 + +**来源**: LiveAgent `crates/agent-gui/src/lib/chat/agentRunner.ts` + +复用内容: +- **Bash 并行执行**(行 264-301):连续 Bash 调用最多 4 个并行 +- **工具摘要生成**(行 26-96):为工具调用生成简洁描述 + +不复用: +- Agent 循环本身(继续用 pi-agent-core) +- @Mention、Skills 系统 + +## 三、从 pi-mono 升级 + +升级 pi-agent-core + pi-ai 到 0.63.2: +- 多编辑支持(单个工具调用修改多个位置) +- Bug 修复 + 更好的取消操作 +- 新模型支持(Gemini 3.1 Pro) + +继续只用 pi-agent-core + pi-ai,不引入其他包。 + +## 四、新增开发 + +### 4.1 Tauri Sandbox 命令 + +新增 `commands/sandbox.rs`,复用 cratebay-mcp 的 sandbox 逻辑: + +```rust +#[tauri::command] +pub async fn sandbox_run_code( + state: State<'_, AppState>, + language: String, + code: String, + sandbox_id: Option, + timeout_seconds: Option, +) -> Result + +#[tauri::command] +pub async fn sandbox_install( + state: State<'_, AppState>, + sandbox_id: String, + package_manager: String, + packages: Vec, +) -> Result +``` + +提取 cratebay-mcp/src/sandbox.rs 的 run_code/install_packages 到 cratebay-core 共享。 + +### 4.2 Sandbox Agent Tools + +新增 `tools/sandboxTools.ts`: + +```typescript +sandbox_run_code — 一键执行代码(自动创建沙盒) +sandbox_install — 安装依赖包 +``` + +注册到 Agent 工具集,和现有的 container/file/shell 工具并存。System prompt 引导 AI **优先使用 sandbox 工具**。 + +### 4.3 System Prompt 重写 + +从"容器管理助手"改为"代码执行助手": + +``` +你是 CrateBay AI 助手,擅长在安全沙盒中执行代码和解决编程问题。 + +核心工具: +- sandbox_run_code — 执行 Python/JavaScript/Bash/Rust 代码 +- sandbox_install — 安装依赖(pip/npm/cargo/apt) +- file_read/file_write — 读写沙盒内文件 + +行为规则: +- 用户要求运行代码时,直接调用 sandbox_run_code +- 需要安装包时,先调用 sandbox_install +- 执行失败时分析错误并建议修复 +- 保持简洁,代码结果直接展示 + +当前沙盒状态:{动态注入} +``` + +### 4.4 会话绑定沙盒 + +新增 `stores/sandboxStore.ts`: +- 每个会话可绑定一个持久沙盒(cleanup=false) +- 后续操作复用同一沙盒(保留变量、已装包) +- 会话删除时自动清理沙盒 + +### 4.5 欢迎页改造 + +首次进入 ChatPage 显示引导: + +``` +欢迎使用 CrateBay + +[快速开始] +- "帮我写一个 Python 脚本分析 CSV 数据" +- "用 Node.js 创建一个 HTTP 服务器" +- "运行 Rust 计算斐波那契数列" + +[配置提示] +需要先在 Settings 中配置 LLM 供应商(OpenAI / Anthropic / 自定义) +``` + +## 五、执行计划 + +### Phase A: 核心能力(1 周) + +| 任务 | 来源 | 天数 | +|------|------|------| +| 提取 sandbox 逻辑到 cratebay-core | 重构 | 1 | +| 新增 Tauri sandbox 命令 | 新代码 | 0.5 | +| 新增 sandboxTools Agent Tools | 新代码 | 0.5 | +| 新增 sandboxStore + 会话绑定 | 新代码 | 0.5 | +| 重写 System Prompt | 新代码 | 0.5 | +| 升级 pi-agent-core + pi-ai → 0.63.2 | 升级 | 0.5 | +| ChatPage 集成 sandbox 工具 | 修改 | 0.5 | + +### Phase B: 供应商配置(1 周) + +| 任务 | 来源 | 天数 | +|------|------|------| +| 迁移 CustomProvider + normalize | LiveAgent | 0.5 | +| 迁移 createModelFromConfig | LiveAgent | 0.5 | +| 迁移 /v1/models 动态获取 | LiveAgent | 0.5 | +| Settings 页面 Provider UI 重构 | shadcn/ui | 2 | +| ChatPage 模型选择器 | 新代码 | 0.5 | +| API Key 后端存储适配 | 修改 | 0.5 | +| 测试 | 新代码 | 0.5 | + +### Phase C: UI 增强(1 周) + +| 任务 | 来源 | 天数 | +|------|------|------| +| Round 分组逻辑 | LiveAgent 参考 | 1 | +| ToolCallItem 组件 | LiveAgent 参考 | 1 | +| ThinkingBlock 组件 | LiveAgent 参考 | 0.5 | +| 沙盒状态栏 | 新代码 | 0.5 | +| 欢迎页改造 | 新代码 | 0.5 | +| Bash 并行执行 | LiveAgent 参考 | 0.5 | + +### Phase D: 收尾(0.5 周) + +| 任务 | 天数 | +|------|------| +| 全量测试(cargo test + pnpm test) | 1 | +| 文档 + Spec 同步 | 1 | + +**总计:3.5 周** + +## 六、验收标准 + +``` +场景 1:首次使用 + 打开 CrateBay → Settings 配置 OpenAI API Key + → ChatPage 自动加载模型列表 + → 告诉 AI "用 Python 计算 1 到 100 的和" + → AI 调用 sandbox_run_code → 返回 5050 + +场景 2:多轮开发 + → "安装 pandas 和 matplotlib" + → AI 调用 sandbox_install + → "生成随机数据并画个图表" + → AI 写代码 → sandbox_run_code 执行 → 返回结果 + → 整个过程复用同一个沙盒 + +场景 3:多供应商 + → Settings 添加 Anthropic 供应商 + → 动态获取模型列表 + → ChatPage 切换到 Claude Sonnet + → 继续对话,沙盒状态保持 +``` + +## 七、文件变更清单 + +### 新增 +- `crates/cratebay-core/src/sandbox.rs` — 共享 sandbox 逻辑(从 mcp 提取) +- `crates/cratebay-gui/src-tauri/src/commands/sandbox.rs` — Tauri sandbox 命令 +- `crates/cratebay-gui/src/tools/sandboxTools.ts` — Sandbox Agent Tools +- `crates/cratebay-gui/src/stores/sandboxStore.ts` — 沙盒状态管理 +- `crates/cratebay-gui/src/components/chat/ToolCallItem.tsx` — 工具卡片 +- `crates/cratebay-gui/src/components/chat/ThinkingBlock.tsx` — 思考块 +- `crates/cratebay-gui/src/components/chat/SandboxBar.tsx` — 沙盒状态栏 + +### 修改 +- `crates/cratebay-gui/src/lib/systemPrompt.ts` — 重写 +- `crates/cratebay-gui/src/lib/agent.ts` — 注册 sandboxTools +- `crates/cratebay-gui/src/pages/ChatPage.tsx` — 沙盒状态 + 欢迎页 +- `crates/cratebay-gui/src/stores/settingsStore.ts` — Provider 配置迁移 +- `crates/cratebay-gui/src/stores/chatStore.ts` — boundSandboxId +- `crates/cratebay-gui/src/tools/index.ts` — 导出 sandboxTools +- `crates/cratebay-gui/src/components/chat/MessageBubble.tsx` — Round 分组 +- `crates/cratebay-gui/package.json` — 升级依赖 From b861a5206b761675f5fa45851cd8eb0ac31d415c Mon Sep 17 00:00:00 2001 From: coder-hhx Date: Mon, 30 Mar 2026 00:45:32 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20Phase=20B+C=20=E2=80=94=20provide?= =?UTF-8?q?r=20config=20+=20UI=20components=20upgrade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B: Provider configuration improvements - Enhanced settingsStore with requestFormat and reasoning level support - Improved ProviderForm with API format auto-detection from URL suffix - ReasoningEffort selector component - Updated i18n strings (en + zh-CN) Phase C: Chat UI enhancements - New ToolCallItem component with status indicators (yellow pulse/green/red) - New ThinkingBlock component with collapsible thinking process - New SandboxBar component showing active sandbox status - Updated MessageBubble to use new ToolCallItem and ThinkingBlock - Updated ChatInput with model selector integration - Updated MessageList welcome screen with sandbox-focused suggestions Upgraded pi-agent-core + pi-ai from 0.61.0 to 0.63.2. Frontend build passes. Rust workspace compiles clean. --- crates/cratebay-gui/package.json | 4 +- crates/cratebay-gui/pnpm-lock.yaml | 22 +- .../src/components/chat/ChatInput.tsx | 84 +++++-- .../src/components/chat/MessageBubble.tsx | 18 +- .../src/components/chat/MessageList.tsx | 14 +- .../src/components/chat/SandboxBar.tsx | 50 ++++ .../src/components/chat/ThinkingBlock.tsx | 64 ++++++ .../src/components/chat/ToolCallItem.tsx | 216 ++++++++++++++++++ .../src/components/settings/ProviderForm.tsx | 32 ++- .../components/settings/ReasoningEffort.tsx | 23 +- crates/cratebay-gui/src/locales/en.ts | 20 +- crates/cratebay-gui/src/locales/zh-CN.ts | 20 +- .../cratebay-gui/src/stores/settingsStore.ts | 5 +- crates/cratebay-gui/src/types/i18n.ts | 16 +- crates/cratebay-gui/src/types/settings.ts | 54 ++++- 15 files changed, 541 insertions(+), 101 deletions(-) create mode 100644 crates/cratebay-gui/src/components/chat/SandboxBar.tsx create mode 100644 crates/cratebay-gui/src/components/chat/ThinkingBlock.tsx create mode 100644 crates/cratebay-gui/src/components/chat/ToolCallItem.tsx diff --git a/crates/cratebay-gui/package.json b/crates/cratebay-gui/package.json index ad08cd7..86d6d87 100644 --- a/crates/cratebay-gui/package.json +++ b/crates/cratebay-gui/package.json @@ -21,8 +21,8 @@ "test:e2e": "playwright test" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.61.0", - "@mariozechner/pi-ai": "^0.61.0", + "@mariozechner/pi-agent-core": "^0.63.2", + "@mariozechner/pi-ai": "^0.63.2", "@sinclair/typebox": "^0.34.48", "@tauri-apps/api": "^2.0.0", "class-variance-authority": "^0.7.1", diff --git a/crates/cratebay-gui/pnpm-lock.yaml b/crates/cratebay-gui/pnpm-lock.yaml index 6255e28..94351ff 100644 --- a/crates/cratebay-gui/pnpm-lock.yaml +++ b/crates/cratebay-gui/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: dependencies: '@mariozechner/pi-agent-core': - specifier: ^0.61.0 - version: 0.61.0(ws@8.19.0)(zod@4.3.6) + specifier: ^0.63.2 + version: 0.63.2(ws@8.19.0)(zod@4.3.6) '@mariozechner/pi-ai': - specifier: ^0.61.0 - version: 0.61.0(ws@8.19.0)(zod@4.3.6) + specifier: ^0.63.2 + version: 0.63.2(ws@8.19.0)(zod@4.3.6) '@sinclair/typebox': specifier: ^0.34.48 version: 0.34.48 @@ -598,12 +598,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@mariozechner/pi-agent-core@0.61.0': - resolution: {integrity: sha512-MBCZfcYDmc5ssZGitv66nSsjma0W+4VwnfzPDRXdOcIhtxiJYux2t8mZe23CbUb4WNkeY3eMU2N6pe4YWeGexg==} + '@mariozechner/pi-agent-core@0.63.2': + resolution: {integrity: sha512-9QTS7ylcmoAIWXk0EVpwCCop3fK4NIqTAN8TiRuXvuKYx+wYmUJc+P5+RfehIZhwsy7g9O/rktz0c1YEUBFB0g==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.61.0': - resolution: {integrity: sha512-iiTiZ91aEND1AfP314exsissbPJnMMZv0NWLkazFf8TwYlUo9qD+6TlXEUYnX1ZRMCnZ7RjSEVerRrQ63FGZXw==} + '@mariozechner/pi-ai@0.63.2': + resolution: {integrity: sha512-EJNPyzeZeifTJmkD8PPYQmSO4P4h8kFCrhUqU4NvFUkug+GNYr954KlxhYnXH0f77MpdIEpf/O5zdDrYJQyafA==} engines: {node: '>=20.0.0'} hasBin: true @@ -4610,9 +4610,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@mariozechner/pi-agent-core@0.61.0(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-agent-core@0.63.2(ws@8.19.0)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.61.0(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.63.2(ws@8.19.0)(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -4622,7 +4622,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.61.0(ws@8.19.0)(zod@4.3.6)': + '@mariozechner/pi-ai@0.63.2(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) '@aws-sdk/client-bedrock-runtime': 3.1013.0 diff --git a/crates/cratebay-gui/src/components/chat/ChatInput.tsx b/crates/cratebay-gui/src/components/chat/ChatInput.tsx index 5e97064..95f2f21 100644 --- a/crates/cratebay-gui/src/components/chat/ChatInput.tsx +++ b/crates/cratebay-gui/src/components/chat/ChatInput.tsx @@ -64,13 +64,34 @@ export function ChatInput({ onSend, onStop, disabled, placeholder }: ChatInputPr const mcpServers = useMcpStore((s) => s.servers); const mcpTools = useMcpStore((s) => s.availableTools); + const providers = useSettingsStore((s) => s.providers); + const allEnabledModels = useMemo(() => enabledModels(), [enabledModels]); const activeModelName = useMemo(() => { if (activeModelId === null) return t("chat", "selectModel"); const model = allEnabledModels.find((m) => m.id === activeModelId); - return model?.name ?? t("chat", "selectModel"); + return model?.name ?? model?.id ?? t("chat", "selectModel"); }, [activeModelId, allEnabledModels]); + // Group enabled models by provider + const groupedModels = useMemo(() => { + const groups: { provider: { id: string; name: string }; models: typeof allEnabledModels }[] = []; + const providerMap = new Map(); + for (const model of allEnabledModels) { + const list = providerMap.get(model.providerId) ?? []; + list.push(model); + providerMap.set(model.providerId, list); + } + for (const [providerId, models] of providerMap) { + const provider = providers.find((p) => p.id === providerId); + groups.push({ + provider: { id: providerId, name: provider?.name ?? providerId }, + models, + }); + } + return groups; + }, [allEnabledModels, providers]); + const mentionItems: MentionItem[] = useMemo(() => { const items: MentionItem[] = []; @@ -348,32 +369,47 @@ export function ChatInput({ onSend, onStop, disabled, placeholder }: ChatInputPr - {/* Model dropdown */} - {modelDropdownOpen && allEnabledModels.length > 0 && ( -
- {allEnabledModels.map((model) => ( - + {/* Model dropdown — grouped by provider */} + {modelDropdownOpen && groupedModels.length > 0 && ( +
+ {groupedModels.map((group) => ( +
+
+ {group.provider.name} +
+ {group.models.map((model) => ( + + ))} +
))}
)} + {/* Empty state */} + {modelDropdownOpen && groupedModels.length === 0 && ( +
+

+ No models available. Add a provider in Settings and fetch models. +

+
+ )}
diff --git a/crates/cratebay-gui/src/components/chat/MessageBubble.tsx b/crates/cratebay-gui/src/components/chat/MessageBubble.tsx index a2cc51e..36212cd 100644 --- a/crates/cratebay-gui/src/components/chat/MessageBubble.tsx +++ b/crates/cratebay-gui/src/components/chat/MessageBubble.tsx @@ -4,8 +4,8 @@ import { useI18n } from "@/lib/i18n"; import { User, Bot } from "lucide-react"; import { Streamdown } from "streamdown"; import type { ChatMessage } from "@/types/chat"; -import { AgentThinking } from "./AgentThinking"; -import { ToolCallCard } from "./ToolCallCard"; +import { ThinkingBlock } from "./ThinkingBlock"; +import { ToolCallItem } from "./ToolCallItem"; interface MessageBubbleProps { message: ChatMessage; @@ -15,7 +15,7 @@ interface MessageBubbleProps { /** * Single message bubble with Streamdown rendering for assistant messages. - * Includes agent thinking display and tool call cards. + * Includes ThinkingBlock and ToolCallItem components. * * Visual design: * - User messages: right-aligned, purple translucent bg, rounded-2xl with tr-sm @@ -75,25 +75,25 @@ export function MessageBubble({ message, isThinking, thinkingContent }: MessageB {/* Assistant message: structured content */} {!isUser && (
- {/* Agent thinking / reasoning */} + {/* Thinking block — replaces AgentThinking */} {isAssistant && message.reasoning !== undefined && ( - )} {isAssistant && isThinking === true && thinkingContent !== undefined && message.reasoning === undefined && ( - )} - {/* Tool call cards */} + {/* Tool call items — replaces ToolCallCard */} {message.toolCalls !== undefined && message.toolCalls.length > 0 && ( -
+
{message.toolCalls.map((tc) => ( - + ))}
)} diff --git a/crates/cratebay-gui/src/components/chat/MessageList.tsx b/crates/cratebay-gui/src/components/chat/MessageList.tsx index 8e9c903..aa54ea1 100644 --- a/crates/cratebay-gui/src/components/chat/MessageList.tsx +++ b/crates/cratebay-gui/src/components/chat/MessageList.tsx @@ -3,22 +3,22 @@ import { useChatStore } from "@/stores/chatStore"; import { useI18n } from "@/lib/i18n"; import { ScrollArea } from "@/components/ui/scroll-area"; import { MessageBubble } from "./MessageBubble"; -import { Box, Database, Plug, Rocket } from "lucide-react"; +import { Code, Server, TerminalSquare, Cog } from "lucide-react"; import type { LucideIcon } from "lucide-react"; const EMPTY_MESSAGES: never[] = []; interface Suggestion { icon: LucideIcon; - titleKey: "suggestionCreateContainer" | "suggestionQueryDb" | "suggestionManageMcp" | "suggestionDeploy"; - descKey: "suggestionCreateContainerDesc" | "suggestionQueryDbDesc" | "suggestionManageMcpDesc" | "suggestionDeployDesc"; + titleKey: "suggestionPythonAnalysis" | "suggestionNodeServer" | "suggestionBashSystem" | "suggestionRustSort"; + descKey: "suggestionPythonAnalysisDesc" | "suggestionNodeServerDesc" | "suggestionBashSystemDesc" | "suggestionRustSortDesc"; } const suggestions: Suggestion[] = [ - { icon: Box, titleKey: "suggestionCreateContainer", descKey: "suggestionCreateContainerDesc" }, - { icon: Database, titleKey: "suggestionQueryDb", descKey: "suggestionQueryDbDesc" }, - { icon: Plug, titleKey: "suggestionManageMcp", descKey: "suggestionManageMcpDesc" }, - { icon: Rocket, titleKey: "suggestionDeploy", descKey: "suggestionDeployDesc" }, + { icon: Code, titleKey: "suggestionPythonAnalysis", descKey: "suggestionPythonAnalysisDesc" }, + { icon: Server, titleKey: "suggestionNodeServer", descKey: "suggestionNodeServerDesc" }, + { icon: TerminalSquare, titleKey: "suggestionBashSystem", descKey: "suggestionBashSystemDesc" }, + { icon: Cog, titleKey: "suggestionRustSort", descKey: "suggestionRustSortDesc" }, ]; function CrateBayLogo({ size = 64 }: { size?: number }) { diff --git a/crates/cratebay-gui/src/components/chat/SandboxBar.tsx b/crates/cratebay-gui/src/components/chat/SandboxBar.tsx new file mode 100644 index 0000000..ffecefb --- /dev/null +++ b/crates/cratebay-gui/src/components/chat/SandboxBar.tsx @@ -0,0 +1,50 @@ +import { Box } from "lucide-react"; + +interface SandboxBarProps { + sandboxId: string; + language?: string; + status: "running" | "stopped" | "unknown"; +} + +/** + * Compact bar showing the sandbox bound to the current chat session. + * Placed above the message list. + */ +export function SandboxBar({ sandboxId, language, status }: SandboxBarProps) { + const shortId = sandboxId.length > 12 ? sandboxId.slice(0, 12) : sandboxId; + + const statusColor = + status === "running" + ? "bg-emerald-500" + : status === "stopped" + ? "bg-zinc-400" + : "bg-yellow-400"; + + const statusLabel = + status === "running" + ? "Running" + : status === "stopped" + ? "Stopped" + : "Unknown"; + + return ( +
+ + + Sandbox + + {shortId} + + {language && ( + + {language} + + )} + +
+ + {statusLabel} +
+
+ ); +} diff --git a/crates/cratebay-gui/src/components/chat/ThinkingBlock.tsx b/crates/cratebay-gui/src/components/chat/ThinkingBlock.tsx new file mode 100644 index 0000000..671d3cd --- /dev/null +++ b/crates/cratebay-gui/src/components/chat/ThinkingBlock.tsx @@ -0,0 +1,64 @@ +import { useState, useEffect, useRef } from "react"; +import { useI18n } from "@/lib/i18n"; +import { Sparkles, ChevronRight } from "lucide-react"; + +interface ThinkingBlockProps { + content: string; + isActive: boolean; +} + +/** + * Collapsible thinking/reasoning block. + * + * - Auto-opens while streaming (isActive=true) + * - Once the user manually collapses it, stays collapsed even during streaming + * - Sparkles icon + "Thinking..." label + */ +export function ThinkingBlock({ content, isActive }: ThinkingBlockProps) { + const { t } = useI18n(); + const [isOpen, setIsOpen] = useState(isActive); + const userInteractedRef = useRef(false); + + // Auto-open during streaming, but respect user's manual collapse + useEffect(() => { + if (!userInteractedRef.current && isActive) { + setIsOpen(true); + } + }, [isActive]); + + if (!/\S/.test(content || "")) return null; + + return ( +
+ + + {isOpen && ( +
+
+            {content}
+          
+
+ )} +
+ ); +} diff --git a/crates/cratebay-gui/src/components/chat/ToolCallItem.tsx b/crates/cratebay-gui/src/components/chat/ToolCallItem.tsx new file mode 100644 index 0000000..078f2f6 --- /dev/null +++ b/crates/cratebay-gui/src/components/chat/ToolCallItem.tsx @@ -0,0 +1,216 @@ +import { useState } from "react"; +import { useI18n } from "@/lib/i18n"; +import type { ToolCallInfo } from "@/types/chat"; +import { Terminal, Wrench, ChevronRight } from "lucide-react"; + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function previewText(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen) + "\n…(truncated)"; +} + +interface ToolCallItemProps { + toolCall: ToolCallInfo; +} + +/** + * A single tool call card with compact header and collapsible detail. + * + * - Status dot: running=yellow pulse, success=green, error=red, pending=gray + * - Bash/shell commands show the command inline in the header + * - Other tools show the tool name + * - Collapsible via native
element + */ +export function ToolCallItem({ toolCall }: ToolCallItemProps) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + + const isBash = + toolCall.toolName === "Bash" || + toolCall.toolName === "bash" || + toolCall.toolName === "shell"; + + const bashCmd = + isBash && typeof toolCall.parameters?.command === "string" + ? toolCall.parameters.command.trim() + : ""; + const firstLine = bashCmd ? bashCmd.split("\n")[0] : ""; + + // Status dot color + const dotClass = + toolCall.status === "running" + ? "bg-yellow-400 animate-pulse" + : toolCall.status === "success" + ? "bg-emerald-500" + : toolCall.status === "error" + ? "bg-red-500" + : "bg-zinc-400"; + + // Status label + const statusLabel = + toolCall.status === "running" + ? t("chat", "toolExecuting") + : toolCall.status === "success" + ? t("chat", "toolCompleted") + : toolCall.status === "error" + ? t("chat", "toolFailed") + : t("chat", "toolPreparing"); + + // Status text color + const statusClass = + toolCall.status === "running" + ? "text-yellow-500" + : toolCall.status === "success" + ? "text-emerald-500" + : toolCall.status === "error" + ? "text-red-500" + : "text-muted-foreground"; + + // Duration + const duration = + toolCall.startedAt && toolCall.completedAt + ? (() => { + const ms = + new Date(toolCall.completedAt).getTime() - + new Date(toolCall.startedAt).getTime(); + return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; + })() + : null; + + return ( +
+ setOpen((e.currentTarget as HTMLDetailsElement).open) + } + > + + {/* Status dot */} + + + {isBash ? ( + /* Bash: terminal icon + inline command */ +
+ + {firstLine ? ( + + $ + {firstLine.length > 56 ? ( + <> + {firstLine.slice(0, 56)} + + + ) : ( + firstLine + )} + {bashCmd.includes("\n") && ( + + +{bashCmd.split("\n").length - 1} lines + + )} + + ) : ( + + Bash + + )} +
+ ) : ( + /* Other tools: wrench icon + tool name + param summary */ +
+ + + {toolCall.toolLabel || toolCall.toolName} + + {!isBash && toolCall.parameters && Object.keys(toolCall.parameters).length > 0 && ( + + ({Object.keys(toolCall.parameters).join(", ")}) + + )} +
+ )} + + {/* Duration */} + {duration !== null && ( + + {duration} + + )} + + {/* Status label */} + + {statusLabel} + + + {/* Chevron */} + +
+ + {open && ( +
+ {/* Parameters / Command */} + {isBash && bashCmd ? ( +
+
+ Command +
+
+                $
+                {bashCmd}
+              
+
+ ) : ( +
+
+ {t("common", "parameters")} +
+
+                {safeStringify(toolCall.parameters)}
+              
+
+ )} + + {/* Result */} + {toolCall.status === "success" && toolCall.result !== undefined && ( +
+
+ {t("common", "result")} +
+
+                {previewText(
+                  typeof toolCall.result === "string"
+                    ? toolCall.result
+                    : safeStringify(toolCall.result),
+                  6000,
+                )}
+              
+
+ )} + + {/* Error */} + {toolCall.status === "error" && toolCall.error !== undefined && ( +
+
+ {t("common", "error")} +
+
+                {toolCall.error}
+              
+
+ )} +
+ )} +
+ ); +} diff --git a/crates/cratebay-gui/src/components/settings/ProviderForm.tsx b/crates/cratebay-gui/src/components/settings/ProviderForm.tsx index fc011e3..180249f 100644 --- a/crates/cratebay-gui/src/components/settings/ProviderForm.tsx +++ b/crates/cratebay-gui/src/components/settings/ProviderForm.tsx @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; import { useSettingsStore, + normalizeProvider, type LlmProviderCreateRequest, type ApiFormat, } from "@/stores/settingsStore"; @@ -40,6 +41,7 @@ export function ProviderForm() { const [apiFormat, setApiFormat] = useState("openai_completions"); const [showKey, setShowKey] = useState(false); const [saving, setSaving] = useState(false); + const [formatAutoDetected, setFormatAutoDetected] = useState(false); const resetForm = useCallback(() => { setName(""); @@ -47,19 +49,34 @@ export function ProviderForm() { setApiKey(""); setApiFormat("openai_completions"); setShowKey(false); + setFormatAutoDetected(false); }, []); const canSave = name.trim().length > 0 && apiBase.trim().length > 0; + const handleBaseUrlBlur = useCallback(() => { + if (apiBase.trim().length === 0) return; + const normalized = normalizeProvider(apiBase); + if (normalized.baseUrl !== apiBase.trim()) { + setApiBase(normalized.baseUrl); + } + // Auto-detect format from URL suffix + if (normalized.apiFormat !== apiFormat) { + setApiFormat(normalized.apiFormat); + setFormatAutoDetected(true); + } + }, [apiBase, apiFormat]); + const handleSave = useCallback(async () => { if (!canSave) return; setSaving(true); try { + const normalized = normalizeProvider(apiBase); const request: LlmProviderCreateRequest = { name: name.trim(), - apiBase: apiBase.trim(), + apiBase: normalized.baseUrl, apiKey, - apiFormat, + apiFormat: apiFormat, }; await createProvider(request); resetForm(); @@ -104,8 +121,14 @@ export function ProviderForm() { id="provider-url" value={apiBase} onChange={(e) => setApiBase(e.target.value)} + onBlur={handleBaseUrlBlur} placeholder="https://api.openai.com" /> + {formatAutoDetected && ( +

+ API format auto-detected from URL +

+ )}
{/* API Key */} @@ -142,7 +165,10 @@ export function ProviderForm() { - - - - - {templates.map((tmpl) => ( - - {tmpl.name} — {tmpl.description} - - ))} - - -
- )} - +
{/* Name */}
@@ -187,7 +139,7 @@ export function ContainerCreate() { {/* Image — searchable dropdown with local images */}
- +
setImageDropdownOpen(true)} - placeholder={selectedTemplate?.image ?? "ubuntu:latest"} + placeholder={t("containers", "selectImage")} className="pr-8" autoComplete="off" /> @@ -221,7 +173,7 @@ export function ContainerCreate() { {imageDropdownOpen && imageOptions.length > 0 && (
{imageOptions.map((item) => (