From 74031fc0a62a069baa5e0c17fad617a5324c4801 Mon Sep 17 00:00:00 2001 From: GCWing Date: Sat, 21 Mar 2026 17:39:26 +0800 Subject: [PATCH] feat(web,core): nursery profile, remove IDE/linter tools, i18n - Remove IdeControl and Linter tools from the agentic stack, registry, and chat tool cards. - Replace PersonaView with a nursery flow: gallery, assistant and hatch-template config pages, plus shared store and token estimate helpers. - Add a dedicated Mermaid editor scene and refine mermaid panel, preview, and SVG interaction. - Update chat input, model selector, flexible panel, mini-apps, my-agent nav, and related locales. - Externalize AgentsScene and template-config copy; use English-only comments in profile config pages. --- CONTRIBUTING.md | 18 + CONTRIBUTING_CN.md | 18 + README.md | 10 + README.zh-CN.md | 10 + .../core/src/agentic/agents/agentic_mode.rs | 3 +- .../core/src/agentic/agents/claw_mode.rs | 1 - .../core/src/agentic/agents/cowork_mode.rs | 1 - .../core/src/agentic/agents/debug_mode.rs | 2 - .../src/agentic/execution/execution_engine.rs | 1 - .../tools/implementations/ide_control_tool.rs | 435 ---- .../tools/implementations/linter_tool.rs | 570 ------ .../src/agentic/tools/implementations/mod.rs | 4 - src/crates/core/src/agentic/tools/registry.rs | 10 +- .../core/src/service/lsp/workspace_manager.rs | 2 +- .../src/app/components/NavPanel/NavPanel.scss | 127 ++ .../components/PersistentFooterActions.tsx | 114 +- .../src/app/components/SceneBar/types.ts | 1 + .../components/panels/base/FlexiblePanel.scss | 18 + .../components/panels/base/FlexiblePanel.tsx | 4 + src/web-ui/src/app/scenes/SceneViewport.tsx | 3 + .../src/app/scenes/agents/AgentsScene.tsx | 86 +- .../scenes/mermaid/MermaidEditorScene.scss | 144 ++ .../app/scenes/mermaid/MermaidEditorScene.tsx | 201 ++ .../miniapps/components/MiniAppCard.scss | 22 +- .../miniapps/components/MiniAppCard.tsx | 12 +- .../src/app/scenes/my-agent/MyAgentNav.scss | 14 +- .../app/scenes/my-agent/identityDocument.ts | 27 +- .../src/app/scenes/my-agent/myAgentConfig.ts | 10 +- .../src/app/scenes/profile/ProfileScene.tsx | 18 +- .../src/app/scenes/profile/nurseryStore.ts | 19 + .../scenes/profile/views/AssistantCard.tsx | 77 + .../profile/views/AssistantConfigPage.tsx | 550 +++++ .../scenes/profile/views/NurseryGallery.tsx | 193 ++ .../app/scenes/profile/views/NurseryView.scss | 1735 ++++++++++++++++ .../app/scenes/profile/views/NurseryView.tsx | 22 + .../app/scenes/profile/views/PersonaView.scss | 1790 ----------------- .../app/scenes/profile/views/PersonaView.tsx | 1471 -------------- .../profile/views/TemplateConfigPage.tsx | 683 +++++++ .../src/app/scenes/profile/views/index.ts | 10 +- .../scenes/profile/views/useTokenEstimate.ts | 58 + src/web-ui/src/app/scenes/registry.ts | 10 + .../component-library/components/registry.tsx | 89 - .../src/flow_chat/components/ChatInput.scss | 623 ++++-- .../src/flow_chat/components/ChatInput.tsx | 412 +++- .../components/FileMentionPicker.scss | 52 +- .../flow_chat/components/ModelSelector.scss | 27 + .../tool-cards/IdeControlToolCard.scss | 40 - .../tool-cards/IdeControlToolCard.tsx | 182 -- .../flow_chat/tool-cards/LinterToolCard.scss | 68 - .../flow_chat/tool-cards/LinterToolCard.tsx | 252 --- src/web-ui/src/flow_chat/tool-cards/index.ts | 27 - src/web-ui/src/locales/en-US/common.json | 10 +- src/web-ui/src/locales/en-US/flow-chat.json | 14 +- .../src/locales/en-US/scenes/agents.json | 10 +- .../src/locales/en-US/scenes/profile.json | 57 + src/web-ui/src/locales/zh-CN/common.json | 12 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 14 +- .../src/locales/zh-CN/scenes/agents.json | 12 +- .../src/locales/zh-CN/scenes/profile.json | 57 + src/web-ui/src/shared/types/global-state.ts | 2 + .../components/MermaidEditor.tsx | 2 + .../components/MermaidPanel.scss | 9 +- .../components/MermaidPanel.tsx | 6 +- .../components/MermaidPreview.tsx | 4 + .../mermaid-editor/hooks/useSvgInteraction.ts | 15 +- .../src/tools/mermaid-editor/types/index.ts | 5 + 66 files changed, 5118 insertions(+), 5387 deletions(-) delete mode 100644 src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs delete mode 100644 src/crates/core/src/agentic/tools/implementations/linter_tool.rs create mode 100644 src/web-ui/src/app/scenes/mermaid/MermaidEditorScene.scss create mode 100644 src/web-ui/src/app/scenes/mermaid/MermaidEditorScene.tsx create mode 100644 src/web-ui/src/app/scenes/profile/nurseryStore.ts create mode 100644 src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx create mode 100644 src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx create mode 100644 src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx create mode 100644 src/web-ui/src/app/scenes/profile/views/NurseryView.scss create mode 100644 src/web-ui/src/app/scenes/profile/views/NurseryView.tsx delete mode 100644 src/web-ui/src/app/scenes/profile/views/PersonaView.scss delete mode 100644 src/web-ui/src/app/scenes/profile/views/PersonaView.tsx create mode 100644 src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx create mode 100644 src/web-ui/src/app/scenes/profile/views/useTokenEstimate.ts delete mode 100644 src/web-ui/src/flow_chat/tool-cards/IdeControlToolCard.scss delete mode 100644 src/web-ui/src/flow_chat/tool-cards/IdeControlToolCard.tsx delete mode 100644 src/web-ui/src/flow_chat/tool-cards/LinterToolCard.scss delete mode 100644 src/web-ui/src/flow_chat/tool-cards/LinterToolCard.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66c79946..58a7b833 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,24 @@ Be respectful, kind, and constructive. We welcome contributors of all background - Rust toolchain (install via [rustup](https://rustup.rs/)) - [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for desktop development +#### Windows: OpenSSL Setup + +The desktop app includes SSH remote support, which requires OpenSSL. On Windows, you need to provide pre-built OpenSSL binaries and set environment variables before building. + +1. Download the [FireDaemon OpenSSL 3.5.5 LTS ZIP (x86+x64+ARM64)](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip) +2. Extract to a directory, e.g. `C:\Users\\openssl` +3. Set the following **user environment variables** (persist across terminal sessions): + +```powershell +[System.Environment]::SetEnvironmentVariable("OPENSSL_DIR", "C:\Users\\openssl\x64", "User") +[System.Environment]::SetEnvironmentVariable("OPENSSL_NO_VENDOR", "1", "User") +[System.Environment]::SetEnvironmentVariable("OPENSSL_STATIC", "1", "User") +``` + +4. Restart your terminal (or IDE) for the variables to take effect. + +> **Alternative**: Install [Strawberry Perl](https://strawberryperl.com/) to let Cargo build OpenSSL from source automatically — no environment variables needed. + ### Install dependencies ```bash diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index 2afd1f97..d3f04986 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -17,6 +17,24 @@ - Rust toolchain(通过 rustup 安装) - 桌面端开发需准备 Tauri 依赖 +#### Windows:OpenSSL 配置 + +桌面端包含 SSH 远程功能,该功能依赖 OpenSSL。在 Windows 上需要提供预编译的 OpenSSL 并设置环境变量,才能正常编译。 + +1. 下载 [FireDaemon OpenSSL 3.5.5 LTS ZIP(x86+x64+ARM64)](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip) +2. 解压到任意目录,例如 `C:\Users\<用户名>\openssl` +3. 在 PowerShell 中执行以下命令,将环境变量**永久写入用户环境**: + +```powershell +[System.Environment]::SetEnvironmentVariable("OPENSSL_DIR", "C:\Users\<用户名>\openssl\x64", "User") +[System.Environment]::SetEnvironmentVariable("OPENSSL_NO_VENDOR", "1", "User") +[System.Environment]::SetEnvironmentVariable("OPENSSL_STATIC", "1", "User") +``` + +4. 重启终端(或 IDE)使环境变量生效。 + +> **备选方案**:安装 [Strawberry Perl](https://strawberryperl.com/),Cargo 会自动从源码编译 OpenSSL,无需配置环境变量。 + ### 安装依赖 ```bash diff --git a/README.md b/README.md index aca2a2a8..a6d3166a 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,16 @@ Make sure you have the following prerequisites installed: - Rust toolchain (install via [rustup](https://rustup.rs/)) - [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for desktop development +**Windows only**: The desktop app includes SSH remote support which requires OpenSSL. Before building, download the [FireDaemon OpenSSL 3.5.5 LTS ZIP](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip), extract it, and set these environment variables in PowerShell: + +```powershell +[System.Environment]::SetEnvironmentVariable("OPENSSL_DIR", "C:\path\to\openssl\x64", "User") +[System.Environment]::SetEnvironmentVariable("OPENSSL_NO_VENDOR", "1", "User") +[System.Environment]::SetEnvironmentVariable("OPENSSL_STATIC", "1", "User") +``` + +Then restart your terminal before running the build commands. + ```bash # Install dependencies pnpm install diff --git a/README.zh-CN.md b/README.zh-CN.md index 62f89f28..8837ffb8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -114,6 +114,16 @@ Mini Apps 从对话中涌现,Skills 在社区里更新,Agent 在协作中进 - [Rust 工具链](https://rustup.rs/) - [Tauri 前置依赖](https://v2.tauri.app/start/prerequisites/)(桌面端开发需要) +**Windows 特别说明**:桌面端包含 SSH 远程功能,依赖 OpenSSL。构建前请下载 [FireDaemon OpenSSL 3.5.5 LTS ZIP](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip),解压后在 PowerShell 中执行: + +```powershell +[System.Environment]::SetEnvironmentVariable("OPENSSL_DIR", "C:\解压路径\openssl\x64", "User") +[System.Environment]::SetEnvironmentVariable("OPENSSL_NO_VENDOR", "1", "User") +[System.Environment]::SetEnvironmentVariable("OPENSSL_STATIC", "1", "User") +``` + +执行后重启终端再运行构建命令。 + ```bash # 安装依赖 pnpm install diff --git a/src/crates/core/src/agentic/agents/agentic_mode.rs b/src/crates/core/src/agentic/agents/agentic_mode.rs index da296fe6..3f1d1832 100644 --- a/src/crates/core/src/agentic/agents/agentic_mode.rs +++ b/src/crates/core/src/agentic/agents/agentic_mode.rs @@ -20,9 +20,8 @@ impl AgenticMode { "Glob".to_string(), "WebSearch".to_string(), "TodoWrite".to_string(), - "IdeControl".to_string(), "MermaidInteractive".to_string(), - "ReadLints".to_string(), + "view_image".to_string(), "Skill".to_string(), "AskUserQuestion".to_string(), "Git".to_string(), diff --git a/src/crates/core/src/agentic/agents/claw_mode.rs b/src/crates/core/src/agentic/agents/claw_mode.rs index 8ca14129..6195d02f 100644 --- a/src/crates/core/src/agentic/agents/claw_mode.rs +++ b/src/crates/core/src/agentic/agents/claw_mode.rs @@ -19,7 +19,6 @@ impl ClawMode { "Grep".to_string(), "Glob".to_string(), "WebSearch".to_string(), - "IdeControl".to_string(), "MermaidInteractive".to_string(), "Skill".to_string(), "Git".to_string(), diff --git a/src/crates/core/src/agentic/agents/cowork_mode.rs b/src/crates/core/src/agentic/agents/cowork_mode.rs index 932ca41e..b0580b1b 100644 --- a/src/crates/core/src/agentic/agents/cowork_mode.rs +++ b/src/crates/core/src/agentic/agents/cowork_mode.rs @@ -28,7 +28,6 @@ impl CoworkMode { "Delete".to_string(), // Utilities "GetFileDiff".to_string(), - "ReadLints".to_string(), "Git".to_string(), "Bash".to_string(), "TerminalControl".to_string(), diff --git a/src/crates/core/src/agentic/agents/debug_mode.rs b/src/crates/core/src/agentic/agents/debug_mode.rs index 9c6d81da..b9e9a1c9 100644 --- a/src/crates/core/src/agentic/agents/debug_mode.rs +++ b/src/crates/core/src/agentic/agents/debug_mode.rs @@ -356,10 +356,8 @@ Below is a snapshot of the current workspace's file structure. "Glob".to_string(), "WebSearch".to_string(), "TodoWrite".to_string(), - "IdeControl".to_string(), "MermaidInteractive".to_string(), "Log".to_string(), - "ReadLints".to_string(), "TerminalControl".to_string(), ] } diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 81bfb2c2..a9104e84 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -1135,7 +1135,6 @@ impl ExecutionEngine { "Skill", "Log", "MermaidInteractive", - "IdeControl", ]; let num_tools = ordering.len(); ordering diff --git a/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs deleted file mode 100644 index 3e9b1da9..00000000 --- a/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs +++ /dev/null @@ -1,435 +0,0 @@ -//! IDE control tool - allows Agent to control IDE UI operations -//! -//! Provides IDE control capabilities such as panel opening, navigation, layout adjustment - -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; -use crate::infrastructure::events::event_system::{get_global_event_system, BackendEvent}; -use crate::util::errors::BitFunResult; -use async_trait::async_trait; -use chrono::Utc; -use log::debug; -use serde_json::{json, Value}; - -/// IDE control tool -pub struct IdeControlTool; - -impl IdeControlTool { - pub fn new() -> Self { - Self - } - - /// Check if operation requires user confirmation - fn needs_confirmation(&self, action: &str, _target: &Value) -> bool { - // Some sensitive operations may require confirmation, currently all IDE control operations are safe - matches!(action, "close_all_tabs" | "reset_layout") - } - - /// Map action to operation - fn map_action_to_operation(&self, action: &str) -> String { - match action { - "open_panel" => "open_panel", - "close_panel" => "close_panel", - "toggle_panel" => "toggle_panel", - "navigate_to" => "navigate", - "set_layout" => "layout", - "manage_tab" => "tab", - "focus_view" => "focus", - "expand_section" => "expand", - _ => "unknown", - } - .to_string() - } - - /// Build target information - fn build_target_info(&self, target: &Value) -> Value { - let panel_type = target - .get("panel_type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - - let panel_config = target.get("panel_config").cloned().unwrap_or(json!({})); - - json!({ - "type": panel_type, - "id": format!("{}_{}", panel_type, Utc::now().timestamp_millis()), - "config": panel_config - }) - } - - /// Validate if panel type is valid - fn is_valid_panel_type(&self, panel_type: &str) -> bool { - matches!( - panel_type, - "git-settings" - | "git-diff" - | "config-center" - | "planner" - | "files" - | "code-editor" - | "markdown-editor" - | "ai-session" - | "mermaid-editor" - ) - } - - /// Generate user-friendly operation description - fn generate_action_description(&self, action: &str, target: &Value) -> String { - let panel_type = target - .get("panel_type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - - match action { - "open_panel" => format!("Opening {} panel", panel_type), - "close_panel" => format!("Closing {} panel", panel_type), - "toggle_panel" => format!("Toggling {} panel", panel_type), - "navigate_to" => "Navigating to location".to_string(), - "set_layout" => "Adjusting layout".to_string(), - "manage_tab" => "Managing tab".to_string(), - "focus_view" => "Focusing view".to_string(), - _ => format!("Executing {} action", action), - } - } -} - -#[async_trait] -impl Tool for IdeControlTool { - fn name(&self) -> &str { - "IdeControl" - } - - async fn description(&self) -> BitFunResult { - Ok(r#"Use the IdeControl tool to interact with the IDE user interface. You can: - -1. Open panels to show specific views: - - Git settings: {"action": "open_panel", "target": {"panel_type": "git-settings"}} - - Settings (specific section): {"action": "open_panel", "target": {"panel_type": "config-center", "panel_config": {"section": "models"}}} - - Planner: {"action": "open_panel", "target": {"panel_type": "planner"}} - -2. Close or toggle panels when needed - -3. Navigate to specific files or code locations - -Always use this tool when you need to show the user specific IDE panels or navigate the interface."#.to_string()) - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": [ - "open_panel", - "close_panel", - "toggle_panel", - "navigate_to", - "set_layout", - "manage_tab", - "focus_view", - "expand_section" - ], - "description": "The IDE control action to perform" - }, - "target": { - "type": "object", - "properties": { - "panel_type": { - "type": "string", - "enum": [ - "git-settings", - "git-diff", - "config-center", - "planner", - "files", - "code-editor", - "markdown-editor", - "ai-session", - "mermaid-editor" - ], - "description": "Type of panel to control" - }, - "panel_config": { - "type": "object", - "properties": { - "section": { - "type": "string", - "description": "Specific section to open (e.g., 'models' for config-center)" - }, - "tab_id": { - "type": "string", - "description": "Specific tab identifier" - }, - "session_id": { - "type": "string", - "description": "Session ID" - }, - "file_path": { - "type": "string", - "description": "File path (for file-related panels)" - }, - "data": { - "type": "object", - "description": "Additional custom data" - } - }, - "description": "Configuration for the panel" - } - }, - "required": ["panel_type"], - "description": "Target information for the action" - }, - "position": { - "type": "string", - "enum": ["left", "right", "bottom", "center"], - "description": "Position where the panel should appear" - }, - "auto_focus": { - "type": "boolean", - "description": "Whether to automatically focus the panel", - "default": true - }, - "mode": { - "type": "string", - "enum": ["agent", "project"], - "description": "Operation mode", - "default": "agent" - }, - "options": { - "type": "object", - "properties": { - "replace_existing": { - "type": "boolean", - "description": "Whether to replace existing tab", - "default": false - }, - "check_duplicate": { - "type": "boolean", - "description": "Whether to check for duplicate tabs", - "default": true - }, - "expand_panel": { - "type": "boolean", - "description": "Whether to expand the panel", - "default": true - } - }, - "description": "Additional operation options" - } - }, - "required": ["action", "target"] - }) - } - - fn user_facing_name(&self) -> String { - "IDE Control".to_string() - } - - async fn is_enabled(&self) -> bool { - true - } - - fn is_readonly(&self) -> bool { - true - } - - fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { - true - } - - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - - async fn validate_input( - &self, - input: &Value, - _context: Option<&ToolUseContext>, - ) -> ValidationResult { - // Validate action field - let action = match input.get("action").and_then(|v| v.as_str()) { - Some(a) => a, - None => { - return ValidationResult { - result: false, - message: Some("Missing required field: action".to_string()), - error_code: Some(400), - meta: None, - }; - } - }; - - // Validate target field - let target = match input.get("target") { - Some(t) => t, - None => { - return ValidationResult { - result: false, - message: Some("Missing required field: target".to_string()), - error_code: Some(400), - meta: None, - }; - } - }; - - // Validate panel_type - if let Some(panel_type) = target.get("panel_type").and_then(|v| v.as_str()) { - if !self.is_valid_panel_type(panel_type) { - return ValidationResult { - result: false, - message: Some(format!("Invalid panel_type: {}", panel_type)), - error_code: Some(400), - meta: None, - }; - } - } else if matches!(action, "open_panel" | "close_panel" | "toggle_panel") { - return ValidationResult { - result: false, - message: Some("Missing required field: target.panel_type".to_string()), - error_code: Some(400), - meta: None, - }; - } - - ValidationResult { - result: true, - message: None, - error_code: None, - meta: None, - } - } - - fn render_result_for_assistant(&self, output: &Value) -> String { - if let Some(success) = output.get("success").and_then(|v| v.as_bool()) { - if success { - if let Some(message) = output.get("message").and_then(|v| v.as_str()) { - return message.to_string(); - } - return "IDE operation completed successfully".to_string(); - } - } - - if let Some(error) = output.get("error").and_then(|v| v.as_str()) { - return format!("IDE operation failed: {}", error); - } - - "IDE operation result unknown".to_string() - } - - fn render_tool_use_message( - &self, - input: &Value, - _options: &crate::agentic::tools::framework::ToolRenderOptions, - ) -> String { - let action = input - .get("action") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - - let target = input.get("target").cloned().unwrap_or(json!({})); - - self.generate_action_description(action, &target) - } - - async fn call_impl( - &self, - input: &Value, - context: &ToolUseContext, - ) -> BitFunResult> { - let action = input - .get("action") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing action field"))?; - - let target = input - .get("target") - .ok_or_else(|| anyhow::anyhow!("Missing target field"))?; - - // Check if user confirmation is needed - if self.needs_confirmation(action, target) { - return Ok(vec![ToolResult::Progress { - content: json!({ - "message": "This operation requires user confirmation", - "action": action, - "target": target - }), - normalized_messages: None, - tools: None, - }]); - } - - // Generate unique request ID for tracking execution results - let request_id = uuid::Uuid::new_v4().to_string(); - - // Build standardized event - let operation = self.map_action_to_operation(action); - let target_info = self.build_target_info(target); - - let event = BackendEvent::Custom { - event_name: "ide-control-event".to_string(), - payload: json!({ - "operation": operation, - "target": target_info, - "position": input.get("position").and_then(|v| v.as_str()).unwrap_or("right"), - "options": { - "auto_focus": input.get("auto_focus").and_then(|v| v.as_bool()).unwrap_or(true), - "replace_existing": input.get("options") - .and_then(|o| o.get("replace_existing")) - .and_then(|v| v.as_bool()) - .unwrap_or(false), - "check_duplicate": input.get("options") - .and_then(|o| o.get("check_duplicate")) - .and_then(|v| v.as_bool()) - .unwrap_or(true), - "expand_panel": input.get("options") - .and_then(|o| o.get("expand_panel")) - .and_then(|v| v.as_bool()) - .unwrap_or(true), - "mode": input.get("mode").and_then(|v| v.as_str()).unwrap_or("agent") - }, - "metadata": { - "source": "agent_tool", - "timestamp": Utc::now().timestamp_millis(), - "session_id": context.session_id.clone().unwrap_or_default(), - "request_id": request_id.clone() - } - }), - }; - - // Send event to frontend - debug!( - "IdeControl tool sending IDE control event, operation: {}, target_type: {}", - operation, - target_info - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - ); - - let event_system = get_global_event_system(); - event_system.emit(event).await?; - - // Generate operation description - let description = self.generate_action_description(action, target); - - // Return execution result (includes request_id for subsequent tracking) - Ok(vec![ToolResult::Result { - data: json!({ - "success": true, - "message": format!("{} - Command sent to IDE", description), - "request_id": request_id, - "action": action, - "target": target_info, - "details": { - "operation_type": operation, - "panel_type": target_info.get("type"), - "timestamp": Utc::now().to_rfc3339() - } - }), - result_for_assistant: Some(format!( - "{} successfully. The IDE has been updated and the panel should now be visible to the user.", - description - )) - }]) - } -} diff --git a/src/crates/core/src/agentic/tools/implementations/linter_tool.rs b/src/crates/core/src/agentic/tools/implementations/linter_tool.rs deleted file mode 100644 index d7b7b7d0..00000000 --- a/src/crates/core/src/agentic/tools/implementations/linter_tool.rs +++ /dev/null @@ -1,570 +0,0 @@ -//! ReadLints tool - get LSP diagnostic information -//! -//! Responsibilities: -//! - Get LSP diagnostic information for files or directories (errors, warnings, hints) -//! - Support filtering by severity -//! - Provide friendly output format - -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; -use crate::service::lsp::get_workspace_manager; -use crate::util::errors::{BitFunError, BitFunResult}; - -/// ReadLints tool -pub struct ReadLintsTool; - -impl ReadLintsTool { - pub fn new() -> Self { - Self - } -} - -/// Diagnostic severity filter -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "lowercase")] -enum SeverityFilter { - Error, - Warning, - Info, - Hint, - All, -} - -impl Default for SeverityFilter { - fn default() -> Self { - SeverityFilter::All - } -} - -/// ReadLints input parameters -#[derive(Debug, Deserialize)] -struct ReadLintsInput { - /// File or directory path - path: String, - - /// Severity filter - #[serde(default)] - severity: SeverityFilter, - - /// Maximum number of diagnostics to return per file - #[serde(default = "default_max_results")] - max_results: usize, -} - -fn default_max_results() -> usize { - 50 -} - -/// Diagnostic output structure -#[derive(Debug, Serialize)] -struct ReadLintsOutput { - /// Path type - path_type: String, - - /// Queried path - path: String, - - /// Diagnostic results (organized by file) - diagnostics: HashMap, - - /// Summary information - summary: DiagnosticSummary, - - /// Warning messages - warnings: Vec, -} - -/// Diagnostic information for a single file -#[derive(Debug, Serialize)] -struct FileDiagnostics { - /// File relative path - file_path: String, - - /// Language type - language: Option, - - /// LSP server status - lsp_status: String, - - /// Diagnostic list - items: Vec, - - /// Statistics - error_count: usize, - warning_count: usize, - info_count: usize, - hint_count: usize, -} - -/// Diagnostic item -#[derive(Debug, Clone, Serialize)] -struct Diagnostic { - /// Severity: 1=Error, 2=Warning, 3=Info, 4=Hint - severity: u8, - - /// Severity text - severity_text: String, - - /// Line number (starting from 1) - line: u32, - - /// Column number (starting from 1) - column: u32, - - /// Diagnostic message - message: String, - - /// Error code - code: Option, - - /// Source - source: Option, -} - -/// Diagnostic summary -#[derive(Debug, Serialize)] -struct DiagnosticSummary { - total_files: usize, - files_with_issues: usize, - total_diagnostics: usize, - error_count: usize, - warning_count: usize, - info_count: usize, - hint_count: usize, -} - -#[async_trait] -impl Tool for ReadLintsTool { - fn name(&self) -> &str { - "ReadLints" - } - - async fn description(&self) -> BitFunResult { - Ok( - r#"Read linter errors and warnings from LSP diagnostics for files or directories. - -IMPORTANT PREREQUISITES: -- This tool ONLY works after the LSP server has started and analyzed the file/directory -- Files are automatically synced to LSP when modified by tools (FileWrite, FileEdit, etc.) -- There is a ~500ms delay after file modifications to ensure LSP analysis is complete - -Usage Guidelines: -- Use this tool to understand code quality issues, errors, and warnings AFTER editing code -- Specify a file path OR a directory path (not both) -- For directories, returns diagnostics for all analyzed files within -- Results include: severity, line number, message, and error code - -When to use: -- After writing or editing code to check for errors -- Before suggesting fixes to understand existing issues -- During code review to identify quality problems -- To help debug compilation or runtime errors - -When NOT to use: -- Immediately after file operations (wait for the tool to return first) -- For files that haven't been opened or analyzed by LSP yet -- For languages without LSP support - -Example usage: -1. AI calls FileWrite to create file.rs -2. AI waits for FileWrite to complete -3. AI calls ReadLints("file.rs") to check for errors -4. AI sees "error: cannot find value `x`" and fixes it - -Severity levels: -- "error": Only show errors (compilation failures) -- "warning": Show warnings (potential issues) -- "info": Show informational messages -- "hint": Show hints and suggestions -- "all" (default): Show everything"# - .to_string(), - ) - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path or directory path to get diagnostics for. Can be relative or absolute." - }, - "severity": { - "type": "string", - "enum": ["error", "warning", "info", "hint", "all"], - "description": "Filter diagnostics by severity level. Default is 'all'." - }, - "max_results": { - "type": "integer", - "description": "Maximum number of diagnostics to return per file. Default is 50." - } - }, - "required": ["path"] - }) - } - - fn is_readonly(&self) -> bool { - true - } - - fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { - true - } - - fn needs_permissions(&self, _input: Option<&Value>) -> bool { - false - } - - async fn call_impl( - &self, - input: &Value, - context: &ToolUseContext, - ) -> BitFunResult> { - // 1. Parse input - let params: ReadLintsInput = serde_json::from_value(input.clone()) - .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; - - // 2. Get workspace path - let workspace = context.workspace_root().ok_or_else(|| { - BitFunError::tool("Workspace not set. Please open a workspace first.".to_string()) - })?; - - // 3. Parse path (supports relative and absolute paths) - let path = if Path::new(¶ms.path).is_absolute() { - PathBuf::from(¶ms.path) - } else { - workspace.join(¶ms.path) - }; - - if !path.exists() { - return Err(BitFunError::tool(format!( - "Path does not exist: {}", - path.display() - ))); - } - - // 4. Wait for file listener to complete synchronization (give LSP time to analyze) - // This delay ensures LspFileSync debounce window (300ms) + analysis time - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - // 5. Get workspace LSP manager - let workspace_manager = get_workspace_manager(workspace.to_path_buf()) - .await - .map_err(|e| { - BitFunError::tool(format!( - "LSP manager not found for workspace: {}. Error: {}", - workspace.display(), - e - )) - })?; - - // 6. Get diagnostics based on path type - let result = if path.is_file() { - self.get_file_diagnostics(&path, &workspace, &workspace_manager, ¶ms) - .await? - } else if path.is_dir() { - self.get_directory_diagnostics(&path, &workspace, &workspace_manager, ¶ms) - .await? - } else { - return Err(BitFunError::tool(format!( - "Path is neither a file nor directory: {}", - path.display() - ))); - }; - - // 7. Return result - let result_json = serde_json::to_value(&result) - .map_err(|e| BitFunError::tool(format!("Failed to serialize result: {}", e)))?; - - Ok(vec![ToolResult::Result { - data: result_json, - result_for_assistant: Some(self.format_summary(&result)), - }]) - } -} - -impl ReadLintsTool { - /// Get diagnostics for a single file - async fn get_file_diagnostics( - &self, - path: &Path, - workspace: &Path, - manager: &std::sync::Arc, - params: &ReadLintsInput, - ) -> BitFunResult { - let uri = format!("file://{}", path.display()); - let relative_path = path - .strip_prefix(workspace) - .unwrap_or(path) - .display() - .to_string(); - - // Detect language - let language = self.detect_language(path); - - // Get diagnostic information (prefer cache, since just synced) - let raw_diagnostics = manager.get_diagnostics(&uri).await.unwrap_or_default(); - - // Parse and filter diagnostics - let diagnostics = - self.parse_diagnostics(&raw_diagnostics, ¶ms.severity, params.max_results); - - // Statistics - let (error_count, warning_count, info_count, hint_count) = - Self::count_by_severity(&diagnostics); - - // Check LSP status - let lsp_status = if let Some(lang) = &language { - let state = manager.get_server_state(lang).await; - format!("{:?}", state.status) - } else { - "unknown".to_string() - }; - - let file_diag = FileDiagnostics { - file_path: relative_path.clone(), - language, - lsp_status, - items: diagnostics.clone(), - error_count, - warning_count, - info_count, - hint_count, - }; - - let mut diagnostics_map = HashMap::new(); - let has_issues = !diagnostics.is_empty(); - if has_issues { - diagnostics_map.insert(relative_path, file_diag); - } - - let summary = DiagnosticSummary { - total_files: 1, - files_with_issues: if has_issues { 1 } else { 0 }, - total_diagnostics: diagnostics.len(), - error_count, - warning_count, - info_count, - hint_count, - }; - - Ok(ReadLintsOutput { - path_type: "file".to_string(), - path: params.path.clone(), - diagnostics: diagnostics_map, - summary, - warnings: vec![], - }) - } - - /// Get directory diagnostics (recursive) - async fn get_directory_diagnostics( - &self, - _path: &Path, - _workspace: &Path, - _manager: &std::sync::Arc, - params: &ReadLintsInput, - ) -> BitFunResult { - // TODO: Implement recursive directory retrieval - // Current simplified implementation: prompt user to specify a specific file - Ok(ReadLintsOutput { - path_type: "directory".to_string(), - path: params.path.clone(), - diagnostics: HashMap::new(), - summary: DiagnosticSummary { - total_files: 0, - files_with_issues: 0, - total_diagnostics: 0, - error_count: 0, - warning_count: 0, - info_count: 0, - hint_count: 0, - }, - warnings: vec![ - "Directory diagnostics not yet implemented. Please specify a file path instead." - .to_string(), - ], - }) - } - - /// Parse LSP diagnostic information - fn parse_diagnostics( - &self, - raw: &[serde_json::Value], - filter: &SeverityFilter, - max_results: usize, - ) -> Vec { - let mut diagnostics = Vec::new(); - - for diag in raw.iter().take(max_results) { - // Parse severity - let severity = diag.get("severity").and_then(|s| s.as_u64()).unwrap_or(3) as u8; - - // Filter - if !self.matches_filter(severity, filter) { - continue; - } - - let severity_text = match severity { - 1 => "Error", - 2 => "Warning", - 3 => "Info", - 4 => "Hint", - _ => "Unknown", - } - .to_string(); - - // Parse position - let range = diag.get("range"); - let start = range.and_then(|r| r.get("start")); - let line = start - .and_then(|s| s.get("line")) - .and_then(|l| l.as_u64()) - .unwrap_or(0) as u32 - + 1; // LSP starts from 0, we start from 1 - let column = start - .and_then(|s| s.get("character")) - .and_then(|c| c.as_u64()) - .unwrap_or(0) as u32 - + 1; - - // Parse message - let message = diag - .get("message") - .and_then(|m| m.as_str()) - .unwrap_or("(no message)") - .to_string(); - - // Parse code and source - let code = diag - .get("code") - .and_then(|c| c.as_str().or_else(|| c.as_i64().map(|_| ""))) - .map(|s| s.to_string()); - - let source = diag - .get("source") - .and_then(|s| s.as_str()) - .map(|s| s.to_string()); - - diagnostics.push(Diagnostic { - severity, - severity_text, - line, - column, - message, - code, - source, - }); - } - - diagnostics - } - - /// Check if diagnostic matches filter - fn matches_filter(&self, severity: u8, filter: &SeverityFilter) -> bool { - match filter { - SeverityFilter::Error => severity == 1, - SeverityFilter::Warning => severity == 2, - SeverityFilter::Info => severity == 3, - SeverityFilter::Hint => severity == 4, - SeverityFilter::All => true, - } - } - - /// Count by severity - fn count_by_severity(diagnostics: &[Diagnostic]) -> (usize, usize, usize, usize) { - let mut error = 0; - let mut warning = 0; - let mut info = 0; - let mut hint = 0; - - for diag in diagnostics { - match diag.severity { - 1 => error += 1, - 2 => warning += 1, - 3 => info += 1, - 4 => hint += 1, - _ => {} - } - } - - (error, warning, info, hint) - } - - /// Detect file language - fn detect_language(&self, path: &Path) -> Option { - path.extension() - .and_then(|e| e.to_str()) - .and_then(|ext| match ext { - "rs" => Some("rust"), - "ts" => Some("typescript"), - "tsx" => Some("typescriptreact"), - "js" => Some("javascript"), - "jsx" => Some("javascriptreact"), - "py" => Some("python"), - "go" => Some("go"), - "java" => Some("java"), - "c" => Some("c"), - "cpp" | "cc" | "cxx" => Some("cpp"), - "h" | "hpp" => Some("cpp"), - _ => None, - }) - .map(|s| s.to_string()) - } - - /// Format summary information - fn format_summary(&self, output: &ReadLintsOutput) -> String { - let summary = &output.summary; - - if summary.total_diagnostics == 0 { - return format!("No issues found in {}", output.path); - } - - let mut parts = vec![format!( - "Found {} issue(s) in {}", - summary.total_diagnostics, output.path - )]; - - if summary.error_count > 0 { - parts.push(format!(" {} error(s)", summary.error_count)); - } - if summary.warning_count > 0 { - parts.push(format!(" {} warning(s)", summary.warning_count)); - } - if summary.info_count > 0 { - parts.push(format!(" {} info", summary.info_count)); - } - if summary.hint_count > 0 { - parts.push(format!(" {} hint(s)", summary.hint_count)); - } - - // Add summary of first few errors - if let Some((_, file_diag)) = output.diagnostics.iter().next() { - parts.push("\nTop issues:".to_string()); - for (i, diag) in file_diag.items.iter().take(3).enumerate() { - parts.push(format!( - " {}. [{}] Line {}: {}", - i + 1, - diag.severity_text, - diag.line, - if diag.message.chars().count() > 80 { - format!("{}...", diag.message.chars().take(80).collect::()) - } else { - diag.message.clone() - } - )); - } - - if file_diag.items.len() > 3 { - parts.push(format!(" ... and {} more", file_diag.items.len() - 3)); - } - } - - parts.join("\n") - } -} diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index f59409aa..c6e702d2 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -13,8 +13,6 @@ pub mod get_file_diff_tool; pub mod git_tool; pub mod glob_tool; pub mod grep_tool; -pub mod ide_control_tool; -pub mod linter_tool; pub mod log_tool; pub mod ls_tool; pub mod mermaid_interactive_tool; @@ -44,8 +42,6 @@ pub use get_file_diff_tool::GetFileDiffTool; pub use git_tool::GitTool; pub use glob_tool::GlobTool; pub use grep_tool::GrepTool; -pub use ide_control_tool::IdeControlTool; -pub use linter_tool::ReadLintsTool; pub use log_tool::LogTool; pub use ls_tool::LSTool; pub use mermaid_interactive_tool::MermaidInteractiveTool; diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 9b4caa49..8c5f2617 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -113,9 +113,6 @@ impl ToolRegistry { self.register_tool(Arc::new(WebSearchTool::new())); self.register_tool(Arc::new(WebFetchTool::new())); - // IDE control tool - self.register_tool(Arc::new(IdeControlTool::new())); - // Mermaid interactive chart tool self.register_tool(Arc::new(MermaidInteractiveTool::new())); @@ -125,11 +122,8 @@ impl ToolRegistry { // Log tool self.register_tool(Arc::new(LogTool::new())); - // Linter tool (LSP diagnosis) - self.register_tool(Arc::new(ReadLintsTool::new())); - - // Image analysis / viewing tool (deprecated - use native multimodal support) - // self.register_tool(Arc::new(ViewImageTool::new())); + // Image analysis / viewing tool + self.register_tool(Arc::new(ViewImageTool::new())); // Git version control tool self.register_tool(Arc::new(GitTool::new())); diff --git a/src/crates/core/src/service/lsp/workspace_manager.rs b/src/crates/core/src/service/lsp/workspace_manager.rs index 11f244d2..981e1e2c 100644 --- a/src/crates/core/src/service/lsp/workspace_manager.rs +++ b/src/crates/core/src/service/lsp/workspace_manager.rs @@ -1366,7 +1366,7 @@ impl WorkspaceLspManager { lsp.get_document_symbols(&server_language, uri).await } - /// Gets diagnostics for a file (used by the `ReadLints` tool). + /// Gets diagnostics for a file (e.g. for UI or other callers). /// Returns cached diagnostics without triggering new LSP requests. pub async fn get_diagnostics(&self, uri: &str) -> Result> { let lsp = self.lsp_manager.read().await; diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 56d572b2..5e4867db 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1349,6 +1349,133 @@ $_section-header-height: 24px; position: relative; } +// ────────────────────────────────────────────── +// Multimodal tools picker (Browser + Mermaid) +// ────────────────────────────────────────────── + +@keyframes bitfun-multimodal-menu-in { + from { + opacity: 0; + transform: translateY(5px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.bitfun-nav-panel__footer-multimodal-wrap { + position: relative; + display: flex; + align-items: center; +} + +.bitfun-nav-panel__footer-multimodal-menu { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + min-width: 140px; + padding: $size-gap-1; + background: var(--color-bg-elevated, #1e1e22); + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08)); + border-radius: $size-radius-base; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28), 0 2px 8px rgba(0, 0, 0, 0.16); + z-index: 9999; + transform-origin: bottom center; + animation: bitfun-multimodal-menu-in 0.14s $easing-decelerate forwards; + +} + +.bitfun-nav-panel__footer-multimodal-item { + display: flex; + align-items: center; + gap: $size-gap-2; + width: 100%; + padding: 0 $size-gap-2; + height: 30px; + border: none; + border-radius: $size-radius-sm; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + font-size: $font-size-sm; + font-weight: 400; + text-align: left; + white-space: nowrap; + transition: color $motion-fast $easing-standard, + background $motion-fast $easing-standard; + + &:hover { + color: var(--color-text-primary); + background: var(--element-bg-soft); + + .bitfun-nav-panel__footer-multimodal-item-icon { + opacity: 1; + } + } + + &:active { + background: var(--element-bg-medium); + } + + &:focus-visible { + outline: 2px solid var(--color-accent-500); + outline-offset: -1px; + } + + &.is-active { + color: var(--color-text-primary); + background: var(--element-bg-soft); + + .bitfun-nav-panel__footer-multimodal-item-icon { + color: var(--color-primary); + opacity: 1; + } + } +} + +.bitfun-nav-panel__footer-multimodal-item-icon { + flex-shrink: 0; + opacity: 0.65; + transition: opacity $motion-fast $easing-standard, + color $motion-fast $easing-standard; +} + +.bitfun-nav-panel__footer-multimodal-item-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bitfun-nav-panel__footer-multimodal-item-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--color-primary); + flex-shrink: 0; + margin-left: auto; +} + +// Active dot badge on main Layers button +.bitfun-nav-panel__footer-multimodal-dot { + position: absolute; + bottom: 5px; + right: 5px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--color-primary); + pointer-events: none; +} + +// Hover-open subtle glow on the main button +.bitfun-nav-panel__footer-btn--icon.is-hover-open { + color: var(--color-text-primary); + background: var(--element-bg-soft); +} + .bitfun-nav-panel__footer-backdrop { position: fixed; inset: 0; diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index 0b594a9e..4a5e5cdf 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -1,5 +1,5 @@ -import React, { useState, useCallback } from 'react'; -import { Settings, Info, MoreVertical, PictureInPicture2, SquareTerminal, Smartphone, Globe } from 'lucide-react'; +import React, { useState, useCallback, useRef } from 'react'; +import { Settings, Info, MoreVertical, PictureInPicture2, SquareTerminal, Smartphone, Globe, Network, Layers } from 'lucide-react'; import { Tooltip, Modal } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { useSceneManager } from '../../../hooks/useSceneManager'; @@ -17,6 +17,7 @@ import { setRemoteConnectDisclaimerAgreed, RemoteConnectDisclaimerContent, } from '../../RemoteConnectDialog/RemoteConnectDisclaimer'; +import { MERMAID_INTERACTIVE_EXAMPLE } from '@/flow_chat/constants/mermaidExamples'; const PersistentFooterActions: React.FC = () => { const { t } = useI18n('common'); @@ -32,12 +33,18 @@ const PersistentFooterActions: React.FC = () => { const activeTab = s.primaryGroup.tabs.find((t) => t.id === s.primaryGroup.activeTabId); return activeTab?.content.type === 'browser'; }); + const isMermaidPanelActiveInCanvas = useCanvasStore((s) => { + const activeTab = s.primaryGroup.tabs.find((t) => t.id === s.primaryGroup.activeTabId); + return activeTab?.content.type === 'mermaid-editor'; + }); const { enableToolbarMode } = useToolbarModeContext(); const { hasWorkspace } = useCurrentWorkspace(); const { warning } = useNotification(); const [menuOpen, setMenuOpen] = useState(false); const [menuClosing, setMenuClosing] = useState(false); + const [multimodalOpen, setMultimodalOpen] = useState(false); + const multimodalHoverTimerRef = useRef | null>(null); const [showAbout, setShowAbout] = useState(false); const [showRemoteConnect, setShowRemoteConnect] = useState(false); const [showRemoteDisclaimer, setShowRemoteDisclaimer] = useState(false); @@ -89,6 +96,37 @@ const PersistentFooterActions: React.FC = () => { } }, [activeTabId, openScene, t]); + const handleOpenMermaidEditor = useCallback(() => { + const title = t('scenes.mermaidEditor'); + const detail = { + type: 'mermaid-editor' as const, + title, + data: { ...MERMAID_INTERACTIVE_EXAMPLE, title }, + metadata: { + duplicateCheckKey: 'mermaid-dual-mode-demo', + }, + checkDuplicate: true, + duplicateCheckKey: 'mermaid-dual-mode-demo', + replaceExisting: false, + }; + + if (activeTabId === 'session') { + window.dispatchEvent(new CustomEvent('agent-create-tab', { detail })); + } else { + openScene('mermaid'); + } + }, [activeTabId, openScene, t]); + + const handleMultimodalEnter = useCallback(() => { + if (multimodalHoverTimerRef.current) clearTimeout(multimodalHoverTimerRef.current); + multimodalHoverTimerRef.current = setTimeout(() => setMultimodalOpen(true), 100); + }, []); + + const handleMultimodalLeave = useCallback(() => { + if (multimodalHoverTimerRef.current) clearTimeout(multimodalHoverTimerRef.current); + multimodalHoverTimerRef.current = setTimeout(() => setMultimodalOpen(false), 180); + }, []); + const handleShowAbout = () => { closeMenu(); setShowAbout(true); @@ -212,17 +250,67 @@ const PersistentFooterActions: React.FC = () => { - - - +
+ {(() => { + const isBrowserActive = activeTabId === 'browser' || (activeTabId === 'session' && isBrowserPanelActiveInCanvas); + const isMermaidActive = activeTabId === 'mermaid' || (activeTabId === 'session' && isMermaidPanelActiveInCanvas); + const isAnyActive = isBrowserActive || isMermaidActive; + return ( + <> + + + + + {multimodalOpen && ( +
+ + + +
+ )} + + ); + })()} +
diff --git a/src/web-ui/src/app/components/SceneBar/types.ts b/src/web-ui/src/app/components/SceneBar/types.ts index 0a507292..1588b8a0 100644 --- a/src/web-ui/src/app/components/SceneBar/types.ts +++ b/src/web-ui/src/app/components/SceneBar/types.ts @@ -17,6 +17,7 @@ export type SceneTabId = | 'skills' | 'miniapps' | 'browser' + | 'mermaid' | 'my-agent' | 'shell' | `miniapp:${string}`; diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.scss b/src/web-ui/src/app/components/panels/base/FlexiblePanel.scss index c85104cd..c8f48928 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.scss +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.scss @@ -106,6 +106,7 @@ min-height: 0; } + .bitfun-flexible-panel__markdown-editor { flex: 1; display: flex; @@ -581,6 +582,23 @@ } } +// ==================== Mermaid Panel Container ==================== + +.bitfun-flexible-panel__mermaid-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + + .mermaid-panel, + .mermaid-editor { + flex: 1 1 0; + min-height: 0; + height: auto; + } +} + // ==================== Terminal Panel Container ==================== .bitfun-flexible-panel__terminal-container { diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index 270300bb..bc0e2964 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -298,6 +298,7 @@ const FlexiblePanel: React.FC = memo(({ if (mermaidData.mode || mermaidData.interactive_config || mermaidData.mermaid_code) { return ( +
{t('flexiblePanel.loading.mermaidPanel')}
}> = memo(({ /> +
); } else { if (!mermaidEditorProps) return null; return ( +
{t('flexiblePanel.loading.mermaidEditor')}
}> = memo(({ /> + ); } diff --git a/src/web-ui/src/app/scenes/SceneViewport.tsx b/src/web-ui/src/app/scenes/SceneViewport.tsx index a35b2245..61c2749f 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.tsx +++ b/src/web-ui/src/app/scenes/SceneViewport.tsx @@ -24,6 +24,7 @@ const AgentsScene = lazy(() => import('./agents/AgentsScene')); const SkillsScene = lazy(() => import('./skills/SkillsScene')); const MiniAppGalleryScene = lazy(() => import('./miniapps/MiniAppGalleryScene')); const BrowserScene = lazy(() => import('./browser/BrowserScene')); +const MermaidEditorScene = lazy(() => import('./mermaid/MermaidEditorScene')); const MyAgentScene = lazy(() => import('./my-agent/MyAgentScene')); const ShellScene = lazy(() => import('./shell/ShellScene')); const WelcomeScene = lazy(() => import('./welcome/WelcomeScene')); @@ -91,6 +92,8 @@ function renderScene(id: SceneTabId, workspacePath?: string, isEntering?: boolea return ; case 'browser': return ; + case 'mermaid': + return ; case 'my-agent': return ; case 'shell': diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index 43da6cf1..74718f63 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -45,13 +45,9 @@ import './AgentsScene.scss'; const EXAMPLE_TEAM_IDS = new Set(MOCK_AGENT_TEAMS.map((team) => team.id)); -const CORE_AGENT_IDS = new Set(['Claw', 'agentic', 'Cowork']); +const HIDDEN_AGENT_IDS = new Set(['Claw']); -const CORE_AGENT_META: Record = { - Claw: { role: '个人助理', accentColor: '#f59e0b', accentBg: 'rgba(245,158,11,0.10)' }, - agentic: { role: '编码专业智能体', accentColor: '#6366f1', accentBg: 'rgba(99,102,241,0.10)' }, - Cowork: { role: '办公智能体', accentColor: '#14b8a6', accentBg: 'rgba(20,184,166,0.10)' }, -}; +const CORE_AGENT_IDS = new Set(['agentic', 'Cowork']); const AgentTeamEditorView: React.FC = () => { const { t } = useTranslation('scenes/agents'); @@ -128,6 +124,19 @@ const AgentsHomeView: React.FC = () => { t, }); + const coreAgentMeta = useMemo((): Record => ({ + agentic: { + role: t('coreAgentsZone.modes.agentic.role'), + accentColor: '#6366f1', + accentBg: 'rgba(99,102,241,0.10)', + }, + Cowork: { + role: t('coreAgentsZone.modes.cowork.role'), + accentColor: '#14b8a6', + accentBg: 'rgba(20,184,166,0.10)', + }, + }), [t]); + const filteredTeams = useMemo(() => agentTeams.filter((team) => { if (!searchQuery) return true; const query = searchQuery.toLowerCase(); @@ -136,11 +145,16 @@ const AgentsHomeView: React.FC = () => { const coreAgents = useMemo(() => allAgents.filter((agent) => CORE_AGENT_IDS.has(agent.id)), [allAgents]); + const visibleAgents = useMemo( + () => filteredAgents.filter((agent) => !HIDDEN_AGENT_IDS.has(agent.id) && !CORE_AGENT_IDS.has(agent.id)), + [filteredAgents], + ); + const handleCreateTeam = useCallback(() => { const id = `agent-team-${Date.now()}`; addAgentTeam({ id, - name: t('teamsZone.newTeamName', '新团队'), + name: t('teamsZone.newTeamName'), icon: 'users', description: '', strategy: 'collaborative', @@ -154,14 +168,14 @@ const AgentsHomeView: React.FC = () => { }, []); const levelFilters = [ - { key: 'builtin', label: t('filters.builtin', '内置'), count: counts.builtin }, - { key: 'user', label: t('filters.user', '用户'), count: counts.user }, - { key: 'project', label: t('filters.project', '项目'), count: counts.project }, + { key: 'builtin', label: t('filters.builtin'), count: counts.builtin }, + { key: 'user', label: t('filters.user'), count: counts.user }, + { key: 'project', label: t('filters.project'), count: counts.project }, ] as const; const typeFilters = [ - { key: 'mode', label: t('filters.mode', 'Agent'), count: counts.mode }, - { key: 'subagent', label: t('filters.subagent', 'Sub-Agent'), count: counts.subagent }, + { key: 'mode', label: t('filters.mode'), count: counts.mode }, + { key: 'subagent', label: t('filters.subagent'), count: counts.subagent }, ] as const; const renderSkeletons = (prefix: string) => ( @@ -317,7 +331,7 @@ const AgentsHomeView: React.FC = () => { key={agent.id} agent={agent} index={index} - meta={CORE_AGENT_META[agent.id] ?? { role: agent.name, accentColor: '#6366f1', accentBg: 'rgba(99,102,241,0.10)' }} + meta={coreAgentMeta[agent.id] ?? { role: agent.name, accentColor: '#6366f1', accentBg: 'rgba(99,102,241,0.10)' }} skillCount={agent.agentKind === 'mode' ? (getModeConfig(agent.id)?.available_skills?.length ?? 0) : 0} onOpenDetails={openAgentDetails} /> @@ -335,7 +349,7 @@ const AgentsHomeView: React.FC = () => {
- {t('filters.source', '来源')} + {t('filters.source')} {levelFilters.map(({ key, label, count }) => (
- {t('filters.kind', '类型')} + {t('filters.kind')} {typeFilters.map(({ key, label, count }) => ( - {filteredAgents.length} + {visibleAgents.length} )} > {loading ? renderSkeletons('agent') : null} - {!loading && filteredAgents.length === 0 ? ( + {!loading && visibleAgents.length === 0 ? ( } message={allAgents.length === 0 ? t('agentsZone.empty.noAgents') : t('agentsZone.empty.noMatch')} /> ) : null} - {!loading && filteredAgents.length > 0 ? ( + {!loading && visibleAgents.length > 0 ? ( - {filteredAgents.map((agent, index) => ( + {visibleAgents.map((agent, index) => ( { {selectedAgent.agentKind === 'mode' ? : } {getAgentBadge(t, selectedAgent.agentKind, selectedAgent.subagentSource).label} - {!selectedAgent.enabled ? {t('agentCard.badges.disabled', '已禁用')} : null} + {!selectedAgent.enabled ? {t('agentCard.badges.disabled')} : null} {selectedAgent.model ? {selectedAgent.model} : null} ) : null} description={selectedAgent?.description} meta={selectedAgent ? ( <> - {t('agentCard.meta.tools', '{{count}} 个工具', { count: selectedAgent.toolCount ?? selectedAgentTools.length })} + {t('agentCard.meta.tools', { count: selectedAgent.toolCount ?? selectedAgentTools.length })} {selectedAgent.agentKind === 'mode' ? ( - {t('agentCard.meta.skills', '{{count}} 个 Skills', { count: selectedAgentSkills.length })} + {t('agentCard.meta.skills', { count: selectedAgentSkills.length })} ) : null} ) : null} @@ -519,7 +533,7 @@ const AgentsHomeView: React.FC = () => {
- {t('agentsOverview.tools', '工具')} + {t('agentsOverview.tools')} {selectedAgent.agentKind === 'mode' ? `${(toolsEditing ? (pendingTools ?? selectedAgentTools) : selectedAgentTools).length}/${availableTools.length}` @@ -533,7 +547,7 @@ const AgentsHomeView: React.FC = () => { { await handleResetTools(selectedAgent.id); setToolsEditing(false); @@ -550,7 +564,7 @@ const AgentsHomeView: React.FC = () => { setPendingTools(null); }} > - {t('agentsOverview.toolsCancel', '取消')} + {t('agentsOverview.toolsCancel')} ) : ( @@ -591,7 +605,7 @@ const AgentsHomeView: React.FC = () => { setToolsEditing(true); }} > - {t('agentsOverview.toolsEdit', '管理工具')} + {t('agentsOverview.toolsEdit')} )}
@@ -649,7 +663,7 @@ const AgentsHomeView: React.FC = () => {
- {t('agentsOverview.skills', 'Skills')} + {t('agentsOverview.skills')} {`${(skillsEditing ? (pendingSkills ?? selectedAgentSkills) : selectedAgentSkills).length}/${availableSkills.length}`} @@ -665,7 +679,7 @@ const AgentsHomeView: React.FC = () => { setPendingSkills(null); }} > - {t('agentsOverview.skillsCancel', '取消')} + {t('agentsOverview.skillsCancel')} ) : ( @@ -706,7 +720,7 @@ const AgentsHomeView: React.FC = () => { setSkillsEditing(true); }} > - {t('agentsOverview.skillsEdit', '管理 Skills')} + {t('agentsOverview.skillsEdit')} )}
@@ -750,7 +764,7 @@ const AgentsHomeView: React.FC = () => {
{selectedAgentSkillItems.length === 0 ? ( - {t('agentsOverview.noSkills', '未启用任何 Skill')} + {t('agentsOverview.noSkills')} ) : ( selectedAgentSkillItems.map((skill) => ( @@ -778,7 +792,7 @@ const AgentsHomeView: React.FC = () => { title={selectedTeam?.name ?? ''} badges={selectedTeam ? ( <> - {EXAMPLE_TEAM_IDS.has(selectedTeam.id) ? {t('teamCard.badges.example', '示例')} : null} + {EXAMPLE_TEAM_IDS.has(selectedTeam.id) ? {t('teamCard.badges.example')} : null} {selectedTeam.strategy === 'collaborative' ? t('composer.strategy.collaborative') @@ -787,7 +801,7 @@ const AgentsHomeView: React.FC = () => { : t('composer.strategy.free')} {selectedTeam.shareContext ? ( - {t('teamCard.badges.sharedContext', '共享上下文')} + {t('teamCard.badges.sharedContext')} ) : null} ) : null} @@ -808,7 +822,7 @@ const AgentsHomeView: React.FC = () => { > {selectedAgentTeamMembers.length > 0 ? (
-
{t('teamCard.sections.members', '成员')}
+
{t('teamCard.sections.members')}
{selectedAgentTeamMembers.map((agent) => { const member = selectedTeam?.members.find((item) => item.agentId === agent.id); @@ -834,7 +848,7 @@ const AgentsHomeView: React.FC = () => { {selectedTeamTopCaps.length > 0 ? (
-
{t('teamCard.sections.capabilities', '能力')}
+
{t('teamCard.sections.capabilities')}
{selectedTeamTopCaps.map((cap) => ( + import('@/tools/mermaid-editor/components').then((m) => ({ default: m.MermaidPanel })) +); +const MermaidErrorBoundary = lazy(() => + import('@/tools/mermaid-editor/components').then((m) => ({ default: m.MermaidErrorBoundary })) +); + +interface FileTab { + id: string; + filePath: string; + title: string; + jumpToLine?: number; +} + +const MIN_TOP_PX = 160; +const MIN_BOTTOM_PX = 120; +const DEFAULT_TOP_RATIO = 0.55; + +const MermaidEditorScene: React.FC = () => { + const { t } = useI18n('common'); + const { t: tComponents } = useI18n('components'); + const { workspacePath } = useCurrentWorkspace(); + + const panelData = useMemo( + (): MermaidPanelData => + ({ + ...MERMAID_INTERACTIVE_EXAMPLE, + title: t('scenes.mermaidEditor'), + session_id: 'mermaid-scene-standalone', + mode: 'interactive', + }) as MermaidPanelData, + [t] + ); + + const [fileTabs, setFileTabs] = useState([]); + const [activeFileTabId, setActiveFileTabId] = useState(null); + + const activeFileTab = fileTabs.find((tab) => tab.id === activeFileTabId) ?? null; + const hasFiles = fileTabs.length > 0; + + // ── resizer ───────────────────────────────────────────────────────────────── + const containerRef = useRef(null); + const [topRatio, setTopRatio] = useState(DEFAULT_TOP_RATIO); + const isDraggingRef = useRef(false); + + const handleResizerMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isDraggingRef.current = true; + document.body.style.cursor = 'row-resize'; + document.body.style.userSelect = 'none'; + + const onMouseMove = (ev: MouseEvent) => { + if (!containerRef.current || !isDraggingRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const totalH = rect.height; + const relY = ev.clientY - rect.top; + const clampedRatio = Math.min( + Math.max(relY / totalH, MIN_TOP_PX / totalH), + (totalH - MIN_BOTTOM_PX) / totalH + ); + setTopRatio(clampedRatio); + }; + const onMouseUp = () => { + isDraggingRef.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, []); + + // Update jumpToLine when re-navigating to the same file at a different line. + const handleFileNavigateWithLineUpdate = useCallback( + (filePath: string, line: number, _metadata: NodeMetadata) => { + const id = filePath; + setFileTabs((prev) => { + const existing = prev.find((t) => t.id === id); + if (existing) { + return prev.map((t) => (t.id === id ? { ...t, jumpToLine: line } : t)); + } + const title = filePath.split(/[/\\]/).pop() ?? filePath; + return [...prev, { id, filePath, title, jumpToLine: line }]; + }); + setActiveFileTabId(id); + }, + [] + ); + + const handleCloseFileTab = useCallback( + (tabId: string, e: React.MouseEvent) => { + e.stopPropagation(); + setFileTabs((prev) => { + const idx = prev.findIndex((t) => t.id === tabId); + const next = prev.filter((t) => t.id !== tabId); + if (activeFileTabId === tabId) { + const nextTab = next[Math.min(idx, next.length - 1)]; + setActiveFileTabId(nextTab?.id ?? null); + } + return next; + }); + }, + [activeFileTabId] + ); + + // Collapse bottom pane when all tabs closed. + useEffect(() => { + if (fileTabs.length === 0) setActiveFileTabId(null); + }, [fileTabs]); + + return ( +
+ {/* ── Top: Mermaid editor ────────────────────────────────────────── */} +
+ + {tComponents('flexiblePanel.loading.mermaidPanel')} +
+ } + > + + + + +
+ + {/* ── Resizer ────────────────────────────────────────────────────── */} + {hasFiles && ( +
+ )} + + {/* ── Bottom: file tab pane ──────────────────────────────────────── */} + {hasFiles && ( +
+ {/* Tab bar */} +
+ {fileTabs.map((tab) => ( + + ))} +
+ + {/* File content */} +
+ {activeFileTab && ( + + )} +
+
+ )} +
+ ); +}; + +export default MermaidEditorScene; diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.scss b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.scss index 541d1c97..7a5512cc 100644 --- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.scss +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.scss @@ -54,17 +54,26 @@ // Running state - green border removed, relies on run-dot indicator } - // ── Header with icon ── + // ── Header with icon and title ── &__header { display: flex; - align-items: flex-start; - justify-content: space-between; + align-items: center; + gap: $size-gap-2; padding: $size-gap-3; padding-bottom: 0; position: relative; z-index: 1; } + &__title-group { + display: flex; + align-items: baseline; + gap: $size-gap-2; + flex: 1; + min-width: 0; + overflow: hidden; + } + &__icon-area { display: flex; align-items: center; @@ -107,13 +116,6 @@ z-index: 1; } - &__row { - display: flex; - align-items: center; - justify-content: space-between; - gap: $size-gap-2; - } - &__name { font-size: 1.2em; font-weight: 900; diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.tsx b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.tsx index 88bfc64a..421b3d03 100644 --- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.tsx +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.tsx @@ -64,22 +64,22 @@ const MiniAppCard: React.FC = ({ onKeyDown={(e) => e.key === 'Enter' && handleOpenDetails()} aria-label={app.name} > - {/* Header with icon */} + {/* Header with icon and title */}
{renderMiniAppIcon(app.icon || 'box', 20)}
+
+ {app.name} + v{app.version} +
{isRunning && }
- {/* Body: name + description + tags */} + {/* Body: description + tags */}
-
- {app.name} - v{app.version} -
{app.description ? (
{app.description} diff --git a/src/web-ui/src/app/scenes/my-agent/MyAgentNav.scss b/src/web-ui/src/app/scenes/my-agent/MyAgentNav.scss index d1acfe1a..228c78a2 100644 --- a/src/web-ui/src/app/scenes/my-agent/MyAgentNav.scss +++ b/src/web-ui/src/app/scenes/my-agent/MyAgentNav.scss @@ -96,20 +96,8 @@ &.is-active { color: var(--color-text-primary); - background: color-mix(in srgb, var(--color-primary) 10%, transparent); + background: var(--element-bg-soft); font-weight: 500; - - &::before { - content: ''; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 2px; - height: 16px; - background: var(--color-primary); - border-radius: 0 2px 2px 0; - } } &:focus-visible { diff --git a/src/web-ui/src/app/scenes/my-agent/identityDocument.ts b/src/web-ui/src/app/scenes/my-agent/identityDocument.ts index fd09dc29..254e9325 100644 --- a/src/web-ui/src/app/scenes/my-agent/identityDocument.ts +++ b/src/web-ui/src/app/scenes/my-agent/identityDocument.ts @@ -6,6 +6,10 @@ export interface IdentityDocument { vibe: string; emoji: string; body: string; + /** Override for primary model slot. Empty string = inherit from template. */ + modelPrimary?: string; + /** Override for fast model slot. Empty string = inherit from template. */ + modelFast?: string; } export const EMPTY_IDENTITY_DOCUMENT: IdentityDocument = { @@ -14,6 +18,8 @@ export const EMPTY_IDENTITY_DOCUMENT: IdentityDocument = { vibe: '', emoji: '', body: '', + modelPrimary: '', + modelFast: '', }; const FRONTMATTER_FIELDS: Array> = [ @@ -21,6 +27,8 @@ const FRONTMATTER_FIELDS: Array> = [ 'creature', 'vibe', 'emoji', + 'modelPrimary', + 'modelFast', ]; function normalizeLineEndings(content: string): string { @@ -59,6 +67,8 @@ export function parseIdentityDocument(content: string): IdentityDocument { vibe: normalizeShortField(parsed.vibe), emoji: normalizeShortField(parsed.emoji), body: body.replace(/^\n+/, '').trimEnd(), + modelPrimary: normalizeShortField(parsed.modelPrimary), + modelFast: normalizeShortField(parsed.modelFast), }; } @@ -69,12 +79,21 @@ export function serializeIdentityDocument(document: IdentityDocument): string { vibe: normalizeShortField(document.vibe), emoji: normalizeShortField(document.emoji), body: normalizeLineEndings(document.body || '').replace(/^\n+/, '').trimEnd(), + modelPrimary: normalizeShortField(document.modelPrimary ?? ''), + modelFast: normalizeShortField(document.modelFast ?? ''), }; - const frontmatter = FRONTMATTER_FIELDS.map((field) => { - const value = normalized[field]; - return value ? `${field}: ${serializeScalar(value)}` : `${field}:`; - }).join('\n'); + const optionalFields = new Set>(['modelPrimary', 'modelFast']); + const frontmatter = FRONTMATTER_FIELDS + .filter((field) => { + if (optionalFields.has(field)) return !!normalized[field]; + return true; + }) + .map((field) => { + const value = normalized[field]; + return value ? `${field}: ${serializeScalar(value)}` : `${field}:`; + }) + .join('\n'); return `---\n${frontmatter}\n---\n\n${normalized.body}`.trimEnd() + '\n'; } diff --git a/src/web-ui/src/app/scenes/my-agent/myAgentConfig.ts b/src/web-ui/src/app/scenes/my-agent/myAgentConfig.ts index f99872f2..046b4a82 100644 --- a/src/web-ui/src/app/scenes/my-agent/myAgentConfig.ts +++ b/src/web-ui/src/app/scenes/my-agent/myAgentConfig.ts @@ -16,17 +16,17 @@ export interface MyAgentNavCategory { export const MY_AGENT_NAV_CATEGORIES: MyAgentNavCategory[] = [ { - id: 'identity', - nameKey: 'nav.myAgent.categories.identity', + id: 'agents', + nameKey: 'nav.myAgent.categories.agents', items: [ { id: 'profile', panelTab: 'profile', labelKey: 'nav.items.persona' }, + { id: 'agents', panelTab: 'agents', labelKey: 'nav.items.agents' }, ], }, { - id: 'collaboration', - nameKey: 'nav.myAgent.categories.collaboration', + id: 'extensions', + nameKey: 'nav.myAgent.categories.extensions', items: [ - { id: 'agents', panelTab: 'agents', labelKey: 'nav.items.agents' }, { id: 'skills', panelTab: 'skills', labelKey: 'nav.items.skills' }, ], }, diff --git a/src/web-ui/src/app/scenes/profile/ProfileScene.tsx b/src/web-ui/src/app/scenes/profile/ProfileScene.tsx index 4d91d8f7..c6f7af67 100644 --- a/src/web-ui/src/app/scenes/profile/ProfileScene.tsx +++ b/src/web-ui/src/app/scenes/profile/ProfileScene.tsx @@ -1,20 +1,16 @@ import React from 'react'; -import { PersonaView } from './views'; +import { NurseryView } from './views'; import './ProfileScene.scss'; interface ProfileSceneProps { + /** Legacy prop – preserved for compatibility; nursery manages its own navigation */ workspacePath?: string; } -const ProfileScene: React.FC = ({ workspacePath }) => { - const normalizedWorkspacePath = workspacePath ?? ''; - - return ( -
- -
- ); -}; +const ProfileScene: React.FC = () => ( +
+ +
+); export default ProfileScene; - diff --git a/src/web-ui/src/app/scenes/profile/nurseryStore.ts b/src/web-ui/src/app/scenes/profile/nurseryStore.ts new file mode 100644 index 00000000..464350b7 --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/nurseryStore.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand'; + +export type NurseryPage = 'gallery' | 'template' | 'assistant'; + +interface NurseryStoreState { + page: NurseryPage; + activeWorkspaceId: string | null; + openGallery: () => void; + openTemplate: () => void; + openAssistant: (workspaceId: string) => void; +} + +export const useNurseryStore = create((set) => ({ + page: 'gallery', + activeWorkspaceId: null, + openGallery: () => set({ page: 'gallery', activeWorkspaceId: null }), + openTemplate: () => set({ page: 'template', activeWorkspaceId: null }), + openAssistant: (workspaceId) => set({ page: 'assistant', activeWorkspaceId: workspaceId }), +})); diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx new file mode 100644 index 00000000..2d1809c1 --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/AssistantCard.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/component-library'; +import type { WorkspaceInfo } from '@/shared/types'; +import { getCardGradient } from '@/shared/utils/cardGradients'; + +interface AssistantCardProps { + workspace: WorkspaceInfo; + onClick: () => void; + style?: React.CSSProperties; +} + +const AssistantCard: React.FC = ({ workspace, onClick, style }) => { + const { t } = useTranslation('scenes/profile'); + const identity = workspace.identity; + + const name = identity?.name?.trim() || workspace.name || t('nursery.card.unnamed'); + const emoji = identity?.emoji?.trim() || '🤖'; + const creature = identity?.creature?.trim() || ''; + const vibe = identity?.vibe?.trim() || ''; + const modelPrimary = identity?.modelPrimary?.trim() || ''; + const modelFast = identity?.modelFast?.trim() || ''; + + const gradient = getCardGradient(workspace.id || name); + + return ( + + ); +}; + +export default AssistantCard; diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx new file mode 100644 index 00000000..08ab8e63 --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/AssistantConfigPage.tsx @@ -0,0 +1,550 @@ +import React, { + useCallback, useEffect, useMemo, useRef, useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ArrowLeft, RefreshCw, Zap, Star, Wrench, Puzzle, ListChecks, Smile, Radar, +} from 'lucide-react'; +import { + ConfirmDialog, Input, Select, Switch, type SelectOption, +} from '@/component-library'; +import { Tabs, TabPane } from '@/component-library'; +import { AIRulesAPI, RuleLevel, type AIRule } from '@/infrastructure/api/service-api/AIRulesAPI'; +import { getAllMemories, type AIMemory } from '@/infrastructure/api/aiMemoryApi'; +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import type { AIModelConfig, ModeConfigItem, SkillInfo } from '@/infrastructure/config/types'; +import { notificationService } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { useAgentIdentityDocument } from '@/app/scenes/my-agent/useAgentIdentityDocument'; +import { MEditor } from '@/tools/editor/meditor'; +import { PersonaRadar } from './PersonaRadar'; +import { useNurseryStore } from '../nurseryStore'; +import { useTokenEstimate, formatTokenCount } from './useTokenEstimate'; + +const log = createLogger('AssistantConfigPage'); + +interface ToolInfo { name: string; description: string; is_readonly: boolean; } + +const MODEL_SLOTS = ['primary', 'fast'] as const; +type ModelSlot = typeof MODEL_SLOTS[number]; + +const DEFAULT_AGENT_NAME = 'BitFun Agent'; + +// ── Radar dim computation (same formula as original PersonaView L894-902) ────── +function computeRadarDims( + rules: AIRule[], + memories: AIMemory[], + agenticConfig: ModeConfigItem | null, + skills: SkillInfo[], + t: (k: string) => string, +) { + const skillEn = skills.filter((s) => s.enabled); + const memEn = memories.filter((m) => m.enabled).length; + const rulesEn = rules.filter((r) => r.enabled); + const avgImp = memEn > 0 + ? memories.filter((m) => m.enabled).reduce((s, m) => s + m.importance, 0) / memEn + : 0; + const enabledTools = agenticConfig?.available_tools?.length ?? 0; + + return [ + { label: t('radar.dims.creativity'), value: Math.min(10, skillEn.length * 0.9) }, + { label: t('radar.dims.rigor'), value: Math.min(10, rulesEn.length * 1.5) }, + { label: t('radar.dims.autonomy'), value: agenticConfig?.enabled + ? Math.min(10, 4 + enabledTools * 0.25) + : Math.min(10, enabledTools * 0.3) }, + { label: t('radar.dims.memory'), value: Math.min(10, memEn * 0.7 + avgImp * 0.3) }, + { label: t('radar.dims.expression'), value: Math.min(10, skillEn.length * 0.8 + skillEn.length * 0.4) }, + { label: t('radar.dims.adaptability'), value: Math.min(10, skillEn.length * 1.2) }, + ]; +} + +const AssistantConfigPage: React.FC = () => { + const { t } = useTranslation('scenes/profile'); + const { openGallery, activeWorkspaceId } = useNurseryStore(); + const { assistantWorkspacesList } = useWorkspaceContext(); + + const workspace = useMemo( + () => assistantWorkspacesList.find((w) => w.id === activeWorkspaceId) ?? null, + [assistantWorkspacesList, activeWorkspaceId], + ); + const workspacePath = workspace?.rootPath ?? ''; + + const { + document: identityDocument, + updateField: updateIdentityField, + resetPersonaFiles, + loading: identityLoading, + } = useAgentIdentityDocument(workspacePath); + + // ── Identity edit state ──────────────────────────────────────────────────── + const [editingField, setEditingField] = useState<'name' | 'emoji' | 'creature' | 'vibe' | null>(null); + const [editValue, setEditValue] = useState(''); + const nameInputRef = useRef(null); + const metaInputRef = useRef(null); + const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); + + // ── Capability state ─────────────────────────────────────────────────────── + const [models, setModels] = useState([]); + const [, setFuncAgentModels] = useState>({}); + const [agenticConfig, setAgenticConfig] = useState(null); + const [availableTools, setAvailableTools] = useState([]); + const [toolsLoading, setToolsLoading] = useState>({}); + + // ── Memory state ─────────────────────────────────────────────────────────── + const [rules, setRules] = useState([]); + const [memories, setMemories] = useState([]); + const [skills, setSkills] = useState([]); + const [capsLoaded, setCapsLoaded] = useState(false); + const [memLoaded, setMemLoaded] = useState(false); + + // ── Active tab ───────────────────────────────────────────────────────────── + const [activeTab, setActiveTab] = useState('identity'); + + // ── Body edit debounce ───────────────────────────────────────────────────── + const bodyTimerRef = useRef | null>(null); + + const handleBodyChange = useCallback((newBody: string) => { + if (bodyTimerRef.current) clearTimeout(bodyTimerRef.current); + bodyTimerRef.current = setTimeout(() => updateIdentityField('body', newBody), 600); + }, [updateIdentityField]); + + // Load models/tools/skills on first visit to personality or ability tab + useEffect(() => { + if ((activeTab === 'personality' || activeTab === 'ability') && !capsLoaded) { + (async () => { + try { + const { invoke } = await import('@tauri-apps/api/core'); + const [allModels, funcModels, modeConf, tools, sks] = await Promise.all([ + (configManager.getConfig('ai.models')).catch(() => [] as AIModelConfig[]), + (configManager.getConfig>('ai.func_agent_models')).catch(() => ({} as Record)), + configAPI.getModeConfig('agentic').catch(() => null as ModeConfigItem | null), + invoke('get_all_tools_info').catch(() => [] as ToolInfo[]), + configAPI.getSkillConfigs({ workspacePath: workspacePath || undefined }).catch(() => [] as SkillInfo[]), + ]); + setModels(allModels ?? []); + setFuncAgentModels(funcModels ?? {}); + setAgenticConfig(modeConf); + setAvailableTools(tools); + setSkills(sks); + setCapsLoaded(true); + } catch (e) { log.error('caps load', e); } + })(); + } + }, [activeTab, capsLoaded, workspacePath]); + + // Load rules and memories on first visit to personality or memory tab + useEffect(() => { + if ((activeTab === 'personality' || activeTab === 'memory') && !memLoaded) { + (async () => { + try { + const [u, p, m] = await Promise.all([ + AIRulesAPI.getRules(RuleLevel.User), + AIRulesAPI.getRules(RuleLevel.Project, workspacePath || undefined), + getAllMemories(), + ]); + setRules([...u, ...p]); + setMemories(m); + setMemLoaded(true); + } catch (e) { log.error('memory/rules load', e); } + })(); + } + }, [activeTab, memLoaded, workspacePath]); + + // ── Identity edit helpers ────────────────────────────────────────────────── + const startEdit = useCallback((field: 'name' | 'emoji' | 'creature' | 'vibe') => { + setEditingField(field); + setEditValue(field === 'name' ? identityDocument.name : identityDocument[field as keyof typeof identityDocument] as string); + setTimeout(() => { + (field === 'name' ? nameInputRef : metaInputRef).current?.focus(); + }, 10); + }, [identityDocument]); + + const commitEdit = useCallback(() => { + if (!editingField) return; + updateIdentityField(editingField, editValue.trim()); + setEditingField(null); + }, [editingField, editValue, updateIdentityField]); + + const onEditKey = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') commitEdit(); + if (e.key === 'Escape') setEditingField(null); + }, [commitEdit]); + + // ── Model helpers ────────────────────────────────────────────────────────── + const INHERIT_VALUE = '__inherit__'; + + const buildModelOptions = useCallback((slot: ModelSlot): SelectOption[] => { + const inheritLabel = slot === 'primary' ? t('nursery.assistant.inheritPrimary') : t('nursery.assistant.inheritFast'); + const modelOptions: SelectOption[] = models + .filter((m) => m.enabled && !!m.id) + .map((m) => ({ value: m.id!, label: m.name, group: t('modelGroups.models') })); + return [ + { value: INHERIT_VALUE, label: inheritLabel, group: t('nursery.assistant.inheritGroup') }, + ...modelOptions, + ]; + }, [models, t]); + + const getModelValue = useCallback((slot: ModelSlot): string => { + const override = slot === 'primary' ? identityDocument.modelPrimary : identityDocument.modelFast; + return override || INHERIT_VALUE; + }, [identityDocument]); + + const handleModelChange = useCallback(async (slot: ModelSlot, raw: string | number | (string | number)[]) => { + if (Array.isArray(raw)) return; + const val = String(raw) === INHERIT_VALUE ? '' : String(raw); + updateIdentityField(slot === 'primary' ? 'modelPrimary' : 'modelFast', val); + }, [updateIdentityField]); + + // ── Tool helpers ─────────────────────────────────────────────────────────── + const handleToolToggle = useCallback(async (toolName: string) => { + if (!agenticConfig) return; + setToolsLoading((p) => ({ ...p, [toolName]: true })); + const current = agenticConfig.available_tools ?? []; + const isOn = current.includes(toolName); + const newTools = isOn ? current.filter((n) => n !== toolName) : [...current, toolName]; + const newConf = { ...agenticConfig, available_tools: newTools }; + setAgenticConfig(newConf); + try { + await configAPI.setModeConfig('agentic', newConf); + const { globalEventBus } = await import('@/infrastructure/event-bus'); + globalEventBus.emit('mode:config:updated'); + } catch (e) { + log.error('tool toggle', e); + notificationService.error(t('notifications.toggleFailed')); + setAgenticConfig(agenticConfig); + } finally { + setToolsLoading((p) => ({ ...p, [toolName]: false })); + } + }, [agenticConfig, t]); + + // ── Radar ────────────────────────────────────────────────────────────────── + const radarDims = useMemo( + () => computeRadarDims(rules, memories, agenticConfig, skills, t), + [rules, memories, agenticConfig, skills, t], + ); + + // ── Token estimate ───────────────────────────────────────────────────────── + const enabledToolCount = agenticConfig?.available_tools?.length ?? 0; + const enabledRulesCount = rules.filter((r) => r.enabled).length; + const enabledMemCount = memories.filter((m) => m.enabled).length; + const tokenBreakdown = useTokenEstimate( + identityDocument.body, + enabledToolCount, + enabledRulesCount, + enabledMemCount, + ); + + const identityName = identityDocument.name || DEFAULT_AGENT_NAME; + + const metaItems = useMemo(() => [ + { key: 'emoji' as const, label: t('identity.emoji'), value: identityDocument.emoji, placeholder: t('identity.emojiPlaceholder') }, + { key: 'creature' as const, label: t('identity.creature'), value: identityDocument.creature, placeholder: t('identity.creaturePlaceholderShort') }, + { key: 'vibe' as const, label: t('identity.vibe'), value: identityDocument.vibe, placeholder: t('identity.vibePlaceholderShort') }, + ] as const, [identityDocument.emoji, identityDocument.creature, identityDocument.vibe, t]); + + return ( +
+
+ +

+ {identityDocument.emoji && {identityDocument.emoji} } + {identityName} +

+ {identityDocument.creature && ( + {identityDocument.creature} + )} + +
+ +
+ + {/* Identity tab */} + }> +
+ {identityLoading ? ( +
+ ) : ( + <> + {/* Name row */} +
+ {editingField === 'name' ? ( + setEditValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={onEditKey} + className="nursery-identity__name-input" + /> + ) : ( +

startEdit('name')} + title={t('hero.editNameTitle')} + > + {identityName} +

+ )} +
+ + {/* Meta pills */} +
+ {metaItems.map((item) => ( +
+ {item.label} + {editingField === item.key ? ( + setEditValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={onEditKey} + size="small" + /> + ) : ( + startEdit(item.key)} + > + {item.value || item.placeholder} + + )} +
+ ))} +
+ + {/* Body editor */} +
+ +
+ + )} +
+
+ + {/* Personality tab */} + }> +
+ +
+ {radarDims.map((d) => ( +
+ {d.label} +
+
+
+ {d.value.toFixed(1)} +
+ ))} +
+

{t('radar.subtitle')}

+
+ + + {/* Ability tab */} + }> +
+ {/* Model overrides */} +
+
+ + {t('cards.model')} +
+
+ {MODEL_SLOTS.map((slot) => { + const Icon = slot === 'primary' ? Star : Zap; + return ( +
+
+ + + {t(`modelSlots.${slot}.label`)} + +
+ -
- ); -}; -const PersonaView: React.FC<{ workspacePath: string }> = ({ workspacePath }) => { - const { t } = useTranslation('scenes/profile'); - const { - document: identityDocument, - updateField: updateIdentityField, - resetPersonaFiles, - } = useAgentIdentityDocument(workspacePath); - const [editingField, setEditingField] = useState< - 'name' | 'emoji' | 'creature' | 'vibe' | null - >(null); - const [editValue, setEditValue] = useState(''); - const nameInputRef = useRef(null); - const metaInputRef = useRef(null); - - const [models, setModels] = useState([]); - const [funcAgentModels, setFuncAgentModels] = useState>({}); - const [rules, setRules] = useState([]); - const [memories, setMemories] = useState([]); - const [availableTools, setAvailableTools] = useState([]); - const [agenticConfig, setAgenticConfig] = useState(null); - const [mcpServers, setMcpServers] = useState([]); - const [skills, setSkills] = useState([]); - const [aiExp, setAiExp] = useState>({ - enable_visual_mode: false, - enable_session_title_generation: true, - enable_welcome_panel_ai_analysis: true, - }); - - // loading maps (optimistic toggle) - const [rulesLoading, setRulesLoading] = useState>({}); - const [memoriesLoading, setMemoriesLoading] = useState>({}); - const [toolsLoading, setToolsLoading] = useState>({}); - const [skillsLoading, setSkillsLoading] = useState>({}); - - const [rulesExpanded, setRulesExpanded] = useState(false); - const [memoriesExpanded, setMemoriesExpanded] = useState(false); - const [skillsExpanded, setSkillsExpanded] = useState(false); - const [toolsExpanded, setToolsExpanded] = useState(false); - const [toolQuery, setToolQuery] = useState(''); - - const [activeZone, setActiveZone] = useState<'brain' | 'capabilities' | 'interaction'>('brain'); - const [railExpanded, setRailExpanded] = useState(false); - - const [radarOpen, setRadarOpen] = useState(false); - const [radarClosing, setRadarClosing] = useState(false); - const closingTimer = useRef | null>(null); - - // home ↔ detail view transition - const [detailMode, setDetailMode] = useState(false); - const [isResetIdentityDialogOpen, setIsResetIdentityDialogOpen] = useState(false); - - // section refs for radar-click scroll navigation - const rulesRef = useRef(null); - const memoryRef = useRef(null); - const toolsRef = useRef(null); - const skillsRef = useRef(null); - const prefsRef = useRef(null); - - // detail section ref (kept for internal scroll-to section) - const detailRef = useRef(null); - - // panel refs for wheel drag mechanics - const brainPanelRef = useRef(null); - const capabilitiesPanelRef = useRef(null); - const interactionPanelRef = useRef(null); - const dragAccumRef = useRef(0); - const dragTimerRef = useRef | null>(null); - const isSwitchingRef = useRef(false); - // tab-rail dot refs for drag animation - const tabDotsRef = useRef<(HTMLSpanElement | null)[]>([]); - - useEffect(() => { - (async () => { - try { - const [u, p, m] = await Promise.all([ - AIRulesAPI.getRules(RuleLevel.User), - AIRulesAPI.getRules(RuleLevel.Project, workspacePath || undefined), - getAllMemories(), - ]); - setRules([...u, ...p]); - setMemories(m); - } catch (e) { log.error('rules/memory', e); } - })(); - }, [workspacePath]); - - const loadCaps = useCallback(async () => { - try { - const { invoke } = await import('@tauri-apps/api/core'); - const [tools, mcps, sks, modeConf, allModels, funcModels, exp] = await Promise.all([ - invoke('get_all_tools_info').catch(() => [] as ToolInfo[]), - MCPAPI.getServers().catch(() => [] as MCPServerInfo[]), - configAPI.getSkillConfigs({ - workspacePath: workspacePath || undefined, - }).catch(() => [] as SkillInfo[]), - configAPI.getModeConfig('agentic').catch(() => null as ModeConfigItem | null), - (configManager.getConfig('ai.models') as Promise).catch(() => [] as AIModelConfig[]), - (configManager.getConfig>('ai.func_agent_models') as Promise>).catch(() => ({} as Record)), - configAPI.getConfig('app.ai_experience').catch(() => null) as Promise, - ]); - setAvailableTools(tools); - setMcpServers(mcps); - setSkills(sks); - setAgenticConfig(modeConf); - setModels(allModels ?? []); - setFuncAgentModels(funcModels ?? {}); - if (exp) setAiExp(exp); - } catch (e) { log.error('capabilities', e); } - }, [workspacePath]); - useEffect(() => { loadCaps(); }, [loadCaps]); - - const identityName = identityDocument.name || DEFAULT_AGENT_NAME; - const identityBodyFallback = t('defaultDesc', { - defaultValue: '用 IDENTITY.md 的正文补充你的设定、风格、边界和偏好。', - }); - const identityMetaItems = useMemo( - () => [ - { - key: 'emoji' as const, - label: t('identity.emoji'), - value: identityDocument.emoji, - placeholder: t('identity.emojiPlaceholder'), - }, - { - key: 'creature' as const, - label: t('identity.creature'), - value: identityDocument.creature, - placeholder: t('identity.creaturePlaceholderShort'), - }, - { - key: 'vibe' as const, - label: t('identity.vibe'), - value: identityDocument.vibe, - placeholder: t('identity.vibePlaceholderShort'), - }, - ] as const, - [identityDocument.creature, identityDocument.emoji, identityDocument.vibe, t] - ); - - const startEdit = (field: 'name' | 'emoji' | 'creature' | 'vibe') => { - setEditingField(field); - const nextValue = - field === 'name' - ? identityDocument.name - : identityDocument[field]; - - setEditValue(nextValue); - setTimeout(() => { - if (field === 'name') { - nameInputRef.current?.focus(); - return; - } - - metaInputRef.current?.focus(); - }, 10); - }; - const commitEdit = useCallback(() => { - if (!editingField) return; - if (editingField === 'name') { - updateIdentityField('name', editValue.trim()); - } else { - updateIdentityField(editingField, editValue.trim()); - } - setEditingField(null); - }, [editValue, editingField, updateIdentityField]); - const onEditKey = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') commitEdit(); - if (e.key === 'Escape') setEditingField(null); - }; - - const bodyUpdateTimerRef = useRef | null>(null); - const handleBodyChange = useCallback((newBody: string) => { - if (bodyUpdateTimerRef.current) clearTimeout(bodyUpdateTimerRef.current); - bodyUpdateTimerRef.current = setTimeout(() => { - updateIdentityField('body', newBody); - }, 600); - }, [updateIdentityField]); - - const handleConfirmResetIdentity = useCallback(async () => { - setEditingField(null); - setEditValue(''); - - try { - await resetPersonaFiles(); - notificationService.success(t('identity.resetSuccess')); - } catch (error) { - notificationService.error( - error instanceof Error ? error.message : t('identity.resetFailed') - ); - } - }, [resetPersonaFiles, t]); - - const openRadar = useCallback(() => setRadarOpen(true), []); - const closeRadar = useCallback(() => { - setRadarClosing(true); - closingTimer.current = setTimeout(() => { setRadarOpen(false); setRadarClosing(false); }, 220); - }, []); - useEffect(() => { - if (!radarOpen) return; - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') closeRadar(); }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [radarOpen, closeRadar]); - useEffect(() => () => { if (closingTimer.current) clearTimeout(closingTimer.current); }, []); - - const ZONE_TABS = useMemo(() => [ - { id: 'brain' as const, Icon: Brain, label: t('sections.brain'), shortLabel: t('nav.brain', { defaultValue: '大脑' }) }, - { id: 'capabilities' as const, Icon: Zap, label: t('sections.capabilities'), shortLabel: t('nav.capabilities', { defaultValue: '能力' }) }, - { id: 'interaction' as const, Icon: Sliders, label: t('sections.interaction'), shortLabel: t('nav.interaction', { defaultValue: '交互' }) }, - ], [t]); - - const dimToZone = useMemo>(() => ({ - [t('radar.dims.rigor')]: 'brain', - [t('radar.dims.memory')]: 'brain', - [t('radar.dims.autonomy')]: 'capabilities', - [t('radar.dims.adaptability')]: 'capabilities', - [t('radar.dims.creativity')]: 'interaction', - [t('radar.dims.expression')]: 'interaction', - }), [t]); - - const handleRadarDimClick = useCallback((label: string) => { - const zone = dimToZone[label]; - const refMap: Record> = { - [t('radar.dims.rigor')]: rulesRef, - [t('radar.dims.memory')]: memoryRef, - [t('radar.dims.autonomy')]: toolsRef, - [t('radar.dims.adaptability')]: skillsRef, - [t('radar.dims.creativity')]: prefsRef, - [t('radar.dims.expression')]: prefsRef, - }; - if (zone) setActiveZone(zone); - // delay to let panel become visible before scrollIntoView - setTimeout(() => { - const target = refMap[label]; - if (target?.current) { - target.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); - target.current.classList.add('is-pulse'); - setTimeout(() => target.current?.classList.remove('is-pulse'), 900); - } - }, 60); - if (radarOpen) closeRadar(); - }, [dimToZone, radarOpen, closeRadar, t]); - - const handleModelChange = useCallback(async (key: string, id: string) => { - try { - const cur = await (configManager.getConfig>('ai.func_agent_models') as Promise | null>).catch(() => null) ?? {}; - const upd = { ...cur, [key]: id }; - await configManager.setConfig('ai.func_agent_models', upd); - setFuncAgentModels(upd); - notificationService.success(t('notifications.modelUpdated'), { duration: 1500 }); - } catch (e) { log.error('model update', e); notificationService.error(t('notifications.updateFailed')); } - }, [t]); - - const toggleRule = useCallback(async (rule: AIRule) => { - const key = `${rule.level}-${rule.name}`; - const newEnabled = !rule.enabled; - setRulesLoading(p => ({ ...p, [key]: true })); - setRules(p => p.map(r => r.name === rule.name && r.level === rule.level ? { ...r, enabled: newEnabled } : r)); - try { - await AIRulesAPI.updateRule( - rule.level === RuleLevel.User ? RuleLevel.User : RuleLevel.Project, - rule.name, - { enabled: newEnabled }, - rule.level === RuleLevel.Project ? workspacePath || undefined : undefined, - ); - } catch (e) { - log.error('rule toggle', e); - setRules(p => p.map(r => r.name === rule.name && r.level === rule.level ? { ...r, enabled: rule.enabled } : r)); - notificationService.error(t('notifications.toggleFailed')); - } finally { setRulesLoading(p => { const n = { ...p }; delete n[key]; return n; }); } - }, [t, workspacePath]); - - const toggleMem = useCallback(async (mem: AIMemory) => { - setMemoriesLoading(p => ({ ...p, [mem.id]: true })); - setMemories(p => p.map(m => m.id === mem.id ? { ...m, enabled: !m.enabled } : m)); - try { await toggleMemory(mem.id); } - catch (e) { - log.error('memory toggle', e); - setMemories(p => p.map(m => m.id === mem.id ? { ...m, enabled: mem.enabled } : m)); - notificationService.error(t('notifications.toggleFailed')); - } finally { setMemoriesLoading(p => { const n = { ...p }; delete n[mem.id]; return n; }); } - }, [t]); - - const toggleTool = useCallback(async (name: string) => { - if (!agenticConfig) return; - setToolsLoading(p => ({ ...p, [name]: true })); - const tools = agenticConfig.available_tools ?? []; - const newTools = tools.includes(name) ? tools.filter(t => t !== name) : [...tools, name]; - const newCfg = { ...agenticConfig, available_tools: newTools }; - setAgenticConfig(newCfg); - try { - await configAPI.setModeConfig('agentic', newCfg); - const { globalEventBus } = await import('@/infrastructure/event-bus'); - globalEventBus.emit('mode:config:updated'); - } catch (e) { - log.error('tool toggle', e); - setAgenticConfig(agenticConfig); - notificationService.error(t('notifications.toggleFailed')); - } finally { setToolsLoading(p => { const n = { ...p }; delete n[name]; return n; }); } - }, [agenticConfig, t]); - - const selectAllTools = useCallback(async () => { - if (!agenticConfig) return; - const c = { ...agenticConfig, available_tools: availableTools.map(t => t.name) }; - setAgenticConfig(c); - try { await configAPI.setModeConfig('agentic', c); } catch { setAgenticConfig(agenticConfig); } - }, [agenticConfig, availableTools]); - - const clearAllTools = useCallback(async () => { - if (!agenticConfig) return; - const c = { ...agenticConfig, available_tools: [] }; - setAgenticConfig(c); - try { await configAPI.setModeConfig('agentic', c); } catch { setAgenticConfig(agenticConfig); } - }, [agenticConfig]); - - const resetTools = useCallback(async () => { - if (!window.confirm(t('notifications.resetConfirm'))) return; - try { await configAPI.resetModeConfig('agentic'); await loadCaps(); notificationService.success(t('notifications.resetSuccess')); } - catch { notificationService.error(t('notifications.resetFailed')); } - }, [loadCaps, t]); - - const toggleSkill = useCallback(async (sk: SkillInfo) => { - const newEnabled = !sk.enabled; - setSkillsLoading(p => ({ ...p, [sk.name]: true })); - setSkills(p => p.map(s => s.name === sk.name ? { ...s, enabled: newEnabled } : s)); - try { - await configAPI.setSkillEnabled({ - skillName: sk.name, - enabled: newEnabled, - workspacePath: workspacePath || undefined, - }); - } - catch (e) { - log.error('skill toggle', e); - setSkills(p => p.map(s => s.name === sk.name ? { ...s, enabled: sk.enabled } : s)); - notificationService.error(t('notifications.toggleFailed')); - } finally { setSkillsLoading(p => { const n = { ...p }; delete n[sk.name]; return n; }); } - }, [t, workspacePath]); - - const togglePref = useCallback(async (key: keyof AIExperienceConfig) => { - const cur = aiExp[key] as boolean; - setAiExp(p => ({ ...p, [key]: !cur })); - try { await configAPI.setConfig(`app.ai_experience.${key}`, !cur); } - catch { setAiExp(p => ({ ...p, [key]: cur })); } - }, [aiExp]); - - const openSkillsScene = useCallback(() => { - window.dispatchEvent(new CustomEvent('scene:open', { detail: { sceneId: 'skills' } })); - }, []); - - const goToDetail = useCallback((zone?: ZoneId) => { - if (zone) setActiveZone(zone); - setDetailMode(true); - }, []); - - const goToHome = useCallback(() => { - setDetailMode(false); - }, []); - - const scrollToZone = useCallback((zone: ZoneId) => { - goToDetail(zone); - }, [goToDetail]); - - // Helper: get panel DOM element by zone id - const getPanel = useCallback((id: ZoneId) => { - if (id === 'brain') return brainPanelRef.current; - if (id === 'capabilities') return capabilitiesPanelRef.current; - return interactionPanelRef.current; - }, []); - - // Tab click — simple crossfade - const handleTabClick = useCallback((id: ZoneId) => { - if (id === activeZone || isSwitchingRef.current) return; - isSwitchingRef.current = true; - const cur = getPanel(activeZone); - if (cur) { - cur.style.transition = 'opacity 0.14s ease'; - cur.style.opacity = '0'; - } - setTimeout(() => { - if (cur) { cur.style.transition = ''; cur.style.opacity = ''; } - setActiveZone(id); - isSwitchingRef.current = false; - }, 140); - }, [activeZone, getPanel]); - - // Wheel — elastic resistance + ghost preview + slide switch + dot merge animation - const handleWheel = useCallback((e: React.WheelEvent) => { - if (isSwitchingRef.current) return; - - const curPanel = getPanel(activeZone); - if (!curPanel) return; - - const goDown = e.deltaY > 0; - const atBottom = curPanel.scrollTop + curPanel.clientHeight >= curPanel.scrollHeight - 2; - const atTop = curPanel.scrollTop <= 0; - if (goDown && !atBottom) return; - if (!goDown && !atTop) return; - - const dir = goDown ? 1 : -1; - const idx = ZONE_ORDER.indexOf(activeZone); - const nextId = ZONE_ORDER[idx + dir] as ZoneId | undefined; - if (!nextId) return; - - if (dragAccumRef.current !== 0 && Math.sign(e.deltaY) !== Math.sign(dragAccumRef.current)) { - dragAccumRef.current = e.deltaY; - } else { - dragAccumRef.current += e.deltaY; - } - const accum = dragAccumRef.current; - const progress = Math.min(Math.abs(accum) / DRAG_THRESHOLD, 1); - - // ── panel visual feedback ────────────────────────── - const displace = elasticDisplace(accum); - const gOpacity = ghostOpacity(accum); - const nextPanel = getPanel(nextId); - - curPanel.style.transform = `translateY(${displace}px)`; - curPanel.style.opacity = String(1 - gOpacity * 0.25); - - if (nextPanel) { - if (gOpacity > 0) { - const t = Math.min(Math.abs(accum) / DRAG_THRESHOLD, 1); - const ghostOffset = dir * 28 * (1 - (t - 0.5) / 0.5); - nextPanel.style.display = 'flex'; - nextPanel.style.flexDirection = 'column'; - nextPanel.style.position = 'absolute'; - nextPanel.style.inset = '0'; - nextPanel.style.overflowY = 'hidden'; - nextPanel.style.pointerEvents = 'none'; - nextPanel.style.zIndex = '0'; - nextPanel.style.transform = `translateY(${ghostOffset}px)`; - nextPanel.style.opacity = String(gOpacity); - } else { - nextPanel.style.display = ''; - nextPanel.style.position = ''; - nextPanel.style.transform = ''; - nextPanel.style.opacity = ''; - } - } - - // ── dot merge animation ──────────────────────────── - const curDot = tabDotsRef.current[idx]; - const nextDot = tabDotsRef.current[idx + dir]; - - if (curDot) { - // active dot stretches into a pill toward the next dot - const stretchH = 8 + progress * 18; // 8px → 26px - const pillMove = dir * (stretchH - 8) / 2; // keep one edge anchored - curDot.style.transition = 'none'; - curDot.style.height = `${stretchH}px`; - curDot.style.borderRadius = progress > 0.08 ? '3px' : '50%'; - curDot.style.transform = `translateY(${pillMove}px)`; - } - if (nextDot) { - // next dot grows and brightens with accent color - const nextScale = 1 + progress * 0.65; - nextDot.style.transition = 'none'; - nextDot.style.transform = `scale(${nextScale})`; - nextDot.style.background = 'var(--color-accent-500)'; - nextDot.style.opacity = String(0.25 + progress * 0.75); - } - - // ── threshold → execute switch ───────────────────── - if (Math.abs(accum) >= DRAG_THRESHOLD) { - if (dragTimerRef.current) clearTimeout(dragTimerRef.current); - isSwitchingRef.current = true; - dragAccumRef.current = 0; - - curPanel.style.transition = 'transform 0.22s cubic-bezier(0.4,0,1,0.6), opacity 0.22s ease'; - curPanel.style.transform = `translateY(${dir * -44}px)`; - curPanel.style.opacity = '0'; - - if (nextPanel) { - nextPanel.style.transition = ''; - nextPanel.style.position = 'absolute'; - nextPanel.style.inset = '0'; - nextPanel.style.display = 'flex'; - nextPanel.style.flexDirection = 'column'; - nextPanel.style.overflowY = 'auto'; - nextPanel.style.zIndex = '1'; - nextPanel.style.pointerEvents = 'none'; - nextPanel.style.transform = `translateY(${dir * 34}px)`; - nextPanel.style.opacity = '0.28'; - requestAnimationFrame(() => requestAnimationFrame(() => { - if (nextPanel) { - nextPanel.style.transition = 'transform 0.28s cubic-bezier(0.2,0,0.2,1), opacity 0.28s ease'; - nextPanel.style.transform = ''; - nextPanel.style.opacity = ''; - } - })); - } - - // commit state — clear all inline styles so CSS class takes over - setTimeout(() => { - curPanel.style.cssText = ''; - if (nextPanel) nextPanel.style.cssText = ''; - if (curDot) curDot.style.cssText = ''; - if (nextDot) nextDot.style.cssText = ''; - setActiveZone(nextId); - isSwitchingRef.current = false; - }, 295); - return; - } - - // ── spring-back timer ────────────────────────────── - if (dragTimerRef.current) clearTimeout(dragTimerRef.current); - dragTimerRef.current = setTimeout(() => { - dragAccumRef.current = 0; - dragTimerRef.current = null; - - // panel spring back with overshoot - curPanel.style.transition = 'transform 0.36s cubic-bezier(0.34,1.56,0.64,1), opacity 0.28s ease'; - curPanel.style.transform = ''; - curPanel.style.opacity = ''; - setTimeout(() => { curPanel.style.transition = ''; }, 360); - - if (nextPanel && parseFloat(nextPanel.style.opacity || '0') > 0) { - nextPanel.style.transition = 'opacity 0.2s ease, transform 0.2s ease'; - nextPanel.style.opacity = '0'; - setTimeout(() => { if (nextPanel) nextPanel.style.cssText = ''; }, 200); - } - - // dot spring back — current dot un-stretches with overshoot - if (curDot) { - curDot.style.transition = 'height 0.36s cubic-bezier(0.34,1.56,0.64,1), transform 0.36s cubic-bezier(0.34,1.56,0.64,1), border-radius 0.2s ease'; - curDot.style.height = ''; - curDot.style.borderRadius = ''; - curDot.style.transform = ''; - setTimeout(() => { if (curDot) curDot.style.transition = ''; }, 360); - } - if (nextDot) { - nextDot.style.transition = 'transform 0.22s ease, opacity 0.22s ease, background 0.22s ease'; - nextDot.style.transform = ''; - nextDot.style.opacity = ''; - nextDot.style.background = ''; - setTimeout(() => { if (nextDot) nextDot.style.transition = ''; }, 220); - } - }, 160); - }, [activeZone, getPanel]); - - const sortRules = useMemo(() => - [...rules].sort((a, b) => a.enabled === b.enabled ? 0 : a.enabled ? -1 : 1), [rules]); - const sortMem = useMemo(() => - [...memories].sort((a, b) => a.enabled !== b.enabled ? (a.enabled ? -1 : 1) : b.importance - a.importance), - [memories]); - const sortTools = useMemo(() => { - const en = agenticConfig?.available_tools ?? []; - return [...availableTools].sort((a, b) => { - const ao = en.includes(a.name), bo = en.includes(b.name); - return ao !== bo ? (ao ? -1 : 1) : a.name.localeCompare(b.name); - }); - }, [availableTools, agenticConfig]); - const sortSkills = useMemo(() => - [...skills].sort((a, b) => a.enabled !== b.enabled ? (a.enabled ? -1 : 1) : a.name.localeCompare(b.name)), - [skills]); - const userRulesList = useMemo( - () => sortRules.filter(rule => rule.level === RuleLevel.User), - [sortRules], - ); - const projectRulesList = useMemo( - () => sortRules.filter(rule => rule.level === RuleLevel.Project), - [sortRules], - ); - const filteredTools = useMemo(() => { - const query = toolQuery.trim().toLowerCase(); - if (!query) return sortTools; - return sortTools.filter(tool => - tool.name.toLowerCase().includes(query) - || tool.description.toLowerCase().includes(query), - ); - }, [sortTools, toolQuery]); - const visibleTools = useMemo( - () => (toolsExpanded ? filteredTools : filteredTools.slice(0, TOOL_LIST_LIMIT)), - [filteredTools, toolsExpanded], - ); - const visibleSkills = useMemo( - () => (skillsExpanded ? sortSkills : sortSkills.slice(0, SKILL_GRID_LIMIT)), - [sortSkills, skillsExpanded], - ); - - const enabledRules = useMemo(() => rules.filter(r => r.enabled).length, [rules]); - const userRules = useMemo(() => rules.filter(r => r.level === RuleLevel.User).length, [rules]); - const projRules = useMemo(() => rules.filter(r => r.level === RuleLevel.Project).length, [rules]); - const enabledMems = useMemo(() => memories.filter(m => m.enabled).length, [memories]); - const enabledTools = useMemo(() => agenticConfig?.available_tools?.length ?? 0, [agenticConfig]); - const enabledSkls = useMemo(() => skills.filter(s => s.enabled).length, [skills]); - const healthyMcp = useMemo(() => mcpServers.filter(s => s.status === 'Healthy' || s.status === 'Connected').length, [mcpServers]); - - const skillEn = useMemo(() => skills.filter(s => s.enabled), [skills]); - const memEn = useMemo(() => memories.filter(m => m.enabled).length, [memories]); - const rulesEn = useMemo(() => rules.filter(r => r.enabled), [rules]); - const avgImp = useMemo(() => memEn > 0 ? memories.filter(m => m.enabled).reduce((s, m) => s + m.importance, 0) / memEn : 0, [memories, memEn]); - const radarDims = useMemo(() => [ - { label: t('radar.dims.creativity'), value: Math.min(10, skillEn.length * 0.9 + mcpServers.length * 0.35) }, - { label: t('radar.dims.rigor'), value: Math.min(10, rulesEn.length * 1.5) }, - { label: t('radar.dims.autonomy'), value: agenticConfig?.enabled - ? Math.min(10, 4 + (agenticConfig.available_tools?.length ?? 0) * 0.25 + mcpServers.length * 0.5) - : Math.min(10, enabledTools * 0.3 + healthyMcp * 0.8) }, - { label: t('radar.dims.memory'), value: Math.min(10, memEn * 0.7 + avgImp * 0.3) }, - { label: t('radar.dims.expression'), value: Math.min(10, skillEn.length * 0.8 + enabledSkls * 0.4) }, - { label: t('radar.dims.adaptability'), value: Math.min(10, skillEn.length * 1.2 + mcpServers.length * 0.8) }, - ], [skillEn, rulesEn, agenticConfig, mcpServers, enabledTools, healthyMcp, memEn, avgImp, enabledSkls, t]); - - // model slot current IDs (with fallbacks) - const slotIds: Record = useMemo(() => ({ - primary: funcAgentModels['primary'] ?? 'primary', - fast: funcAgentModels['fast'] ?? 'fast', - compression: funcAgentModels['compression'] ?? 'fast', - image: funcAgentModels['image'] ?? '', - voice: funcAgentModels['voice'] ?? '', - retrieval: funcAgentModels['retrieval'] ?? '', - }), [funcAgentModels]); - - // Tool KPI text - const toolKpi = useMemo(() => { - if (mcpServers.length > 0) { - return t('kpi.toolStatsMcp', { - enabled: enabledTools, - total: availableTools.length, - mcpHealthy: healthyMcp, - mcpTotal: mcpServers.length, - }); - } - return t('kpi.toolStats', { enabled: enabledTools, total: availableTools.length }); - }, [t, enabledTools, availableTools.length, healthyMcp, mcpServers.length]); - - // Preference items — computed inside render to use t() - const prefItems = useMemo(() => [ - { - key: 'enable_visual_mode' as keyof AIExperienceConfig, - label: t('prefs.visualMode'), - desc: t('prefs.visualModeDesc'), - }, - { - key: 'enable_session_title_generation' as keyof AIExperienceConfig, - label: t('prefs.sessionTitle'), - desc: t('prefs.sessionTitleDesc'), - }, - { - key: 'enable_welcome_panel_ai_analysis' as keyof AIExperienceConfig, - label: t('prefs.welcomeAnalysis'), - desc: t('prefs.welcomeAnalysisDesc'), - }, - ], [t]); - - const HOME_ZONES = useMemo(() => [ - { id: 'brain' as ZoneId, Icon: Brain, label: t('sections.brain'), desc: t('home.brainDesc', { defaultValue: '模型 · 规则 · 记忆' }) }, - { id: 'capabilities' as ZoneId, Icon: Zap, label: t('sections.capabilities'), desc: t('home.capabilitiesDesc', { defaultValue: '工具 · 技能 · MCP' }) }, - { id: 'interaction' as ZoneId, Icon: Sliders, label: t('sections.interaction'), desc: t('home.interactionDesc', { defaultValue: '模板 · 偏好' }) }, - ], [t]); - - return ( -
- - {/* ══════════ Home / 首页 ══════════════════════════════ */} -
- - {/* Left — Full-body panda */} -
-
- {t('hero.avatarAlt', - -
-
- - {/* Right — Identity + body row + CTA */} -
- - {/* Name row */} -
- {editingField === 'name' ? ( - setEditValue(e.target.value)} - onBlur={commitEdit} - onKeyDown={onEditKey} - inputSize="small" - /> - ) : ( - <> -

startEdit('name')} - title={t('hero.editNameTitle')} - > - {identityName} - -

- - - - - )} -
- -
- {identityMetaItems.map((item) => { - const isEditingMeta = editingField === item.key; - const displayValue = item.value || item.placeholder; - return ( - !isEditingMeta && startEdit(item.key)} - > - {isEditingMeta ? ( - setEditValue(event.target.value)} - onBlur={commitEdit} - onKeyDown={onEditKey} - placeholder={item.placeholder} - inputSize="small" - /> - ) : ( - <> - {item.key !== 'emoji' && {item.label}} - {displayValue} - - )} - - ); - })} -
- - {/* Description — IR Markdown editor */} -
-
- -
-
- - {/* Hint + inline CTA */} -

- {t('home.hint', { defaultValue: '选择章节装配你的大熊猫,或' })} - -

- - {/* Category chips */} -
- {HOME_ZONES.map(({ id, Icon, label, desc }) => ( - - ))} -
-
-
- - {/* ══════════ Detail / 章节 ═════════════════════════════ */} -
- - {/* ── Persistent header ────────────────────────────── */} -
-
-
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goToHome(); } }} - > - {t('hero.avatarAlt', - -
-
-
-

- {identityName} -

-
-
- {identityMetaItems.map((item) => { - const displayValue = item.value || item.placeholder; - return ( - - {item.key !== 'emoji' && {item.label}} - {displayValue} - - ); - })} -
-
-
-
- -
-
- - {/* ── Content: zone viewport + tab rail ───────────── */} -
-
- - {/* Brain */} -
-
-
-
- {t('cards.model')} - -
-
-
- {(['primary', 'fast'] as ModelSlotKey[]).map(key => ( - handleModelChange(key, id)} - /> - ))} -
-
-
- {(['compression', 'image', 'voice', 'retrieval'] as ModelSlotKey[]).map(key => ( - handleModelChange(key, id)} - /> - ))} -
-
-
-
-
- {t('cards.rules')} - - {t('kpi.rules', { user: userRules, project: projRules, enabled: enabledRules })} - - -
- {sortRules.length === 0 && {t('empty.rules')}} - {sortRules.length > 0 && ( - <> - {[ - { label: 'User', items: userRulesList }, - { label: 'Project', items: projectRulesList }, - ].map(group => { - const groupItems = rulesExpanded ? group.items : group.items.slice(0, CHIP_LIMIT); - if (group.items.length === 0) return null; - return ( -
- {group.label} -
- {groupItems.map(rule => ( - toggleRule(rule)} - accentColor="#60a5fa" - loading={rulesLoading[`${rule.level}-${rule.name}`]} - /> - ))} -
-
- ); - })} - {rules.length > CHIP_LIMIT && ( - - )} - - )} -
-
-
- {t('cards.memory')} - {t('kpi.memory', { count: enabledMems })} - -
-
- {(memories.length > CHIP_LIMIT && !memoriesExpanded ? sortMem.slice(0, CHIP_LIMIT) : sortMem).map(m => ( -
- toggleMem(m)} - accentColor="#c9944d" - loading={memoriesLoading[m.id]} - tooltip={m.title} - /> -
- ))} - {sortMem.length === 0 && {t('empty.memory')}} - {memories.length > CHIP_LIMIT && ( - - )} -
-
-
- - {/* Capabilities */} -
-
-
-
- {t('cards.toolsMcp')} - {toolKpi} -
- - - -
-
- {availableTools.length > 15 && ( - - )} -
- {visibleTools.map(tool => ( - toggleTool(tool.name)} - /> - ))} -
- {filteredTools.length === 0 && ( - - {toolQuery.trim() - ? t('profile.toolSearchEmpty', { defaultValue: '没有匹配的工具' }) - : t('empty.tools')} - - )} - {filteredTools.length > TOOL_LIST_LIMIT && ( - - )} - {mcpServers.length > 0 && ( -
- MCP - {mcpServers.map(srv => { - const ok = srv.status === 'Healthy' || srv.status === 'Connected'; - return ( - - - {srv.name} - - ); - })} - -
- )} -
-
-
- {t('cards.skills')} - {t('kpi.skills', { count: enabledSkls })} - -
-
- {visibleSkills.map(sk => ( - toggleSkill(sk)} - onOpen={openSkillsScene} - /> - ))} -
- {sortSkills.length === 0 && {t('empty.skills')}} - {skills.length > SKILL_GRID_LIMIT && ( - - )} -
-
- - {/* Interaction */} -
-
-
-
- {t('cards.preferences')} -
-
- {prefItems.map(({ key, label, desc }) => ( - togglePref(key)} - /> - ))} -
-
-
- -
- - {/* ── Tab Rail ─────────────────────────────────── */} - {/* nav stays 28 px wide in layout; list floats as overlay */} - -
- -
{/* ── /detail ── */} - - {radarOpen && createPortal( -
-
e.stopPropagation()}> -
-
-

{t('radar.title')}

-

{t('radar.subtitle')}

-
- -
-
- -
-
- {radarDims.map(d => ( -
- {d.label} -
-
-
- {d.value.toFixed(1)} -
- ))} -
-
-
, - document.body, - )} - - setIsResetIdentityDialogOpen(false)} - onConfirm={() => { void handleConfirmResetIdentity(); }} - title={t('identity.resetConfirmTitle')} - message={t('identity.resetConfirmMessage')} - type="warning" - confirmDanger - confirmText={t('identity.resetConfirmAction')} - cancelText={t('identity.resetCancel')} - /> -
- ); -}; - -export default PersonaView; diff --git a/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx b/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx new file mode 100644 index 00000000..1c578b14 --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx @@ -0,0 +1,683 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ArrowLeft, + ChevronDown, + Cpu, + Plug2, + Puzzle, + RefreshCw, + Star, + Wrench, + Zap, +} from 'lucide-react'; +import { Select, Switch, type SelectOption } from '@/component-library'; +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import type { AIModelConfig, ModeConfigItem, SkillInfo } from '@/infrastructure/config/types'; +import { MCPAPI, type MCPServerInfo } from '@/infrastructure/api/service-api/MCPAPI'; +import { notificationService } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { useNurseryStore } from '../nurseryStore'; +import { formatTokenCount } from './useTokenEstimate'; + +const log = createLogger('TemplateConfigPage'); + +interface ToolInfo { name: string; description: string; is_readonly: boolean; } + +const MODEL_SLOTS = ['primary', 'fast'] as const; +type ModelSlot = typeof MODEL_SLOTS[number]; + +// MCP tools are registered as "mcp_{server_id}_{tool_name}" (single underscores) +function isMcpTool(name: string): boolean { + return name.startsWith('mcp_'); +} + +// Extract server id: "mcp_github_create_issue" → "github" +function getMcpServerName(toolName: string): string { + return toolName.split('_')[1] ?? toolName; +} + +// Short display name: "mcp_github_create_issue" → "create_issue" +function getMcpShortName(toolName: string): string { + const parts = toolName.split('_'); + return parts.slice(2).join('_') || toolName; +} + +type CtxSegKey = 'systemPrompt' | 'toolInjection' | 'rules' | 'memories'; + +const CTX_SEGMENT_ORDER: readonly CtxSegKey[] = ['systemPrompt', 'toolInjection', 'rules', 'memories']; + +const CTX_SEGMENT_COLORS: Record = { + systemPrompt: '#34d399', + toolInjection: '#60a5fa', + rules: '#a78bfa', + memories: '#f472b6', +}; + +const CTX_LABEL_I18N_KEY: Record = { + systemPrompt: 'nursery.template.tokenSystemPrompt', + toolInjection: 'nursery.template.tokenToolInjection', + rules: 'nursery.template.tokenRules', + memories: 'nursery.template.tokenMemories', +}; + +function fmtPct(val: number, total: number): string { + if (total === 0) return '0%'; + return `${Math.round((val / total) * 100)}%`; +} + +// ── Claw agent token estimates (based on actual prompt files) ───────────── +// claw_mode.md ≈ 838 tok + persona files (BOOTSTRAP/SOUL/USER/IDENTITY) ≈ 600 tok +const CLAW_SYS_TOKENS = 1438; +const TOKENS_PER_TOOL = 45; // matches backend estimation +const TOKENS_PER_RULE = 80; +const TOKENS_PER_MEMORY = 60; +const CTX_WINDOW = 128_000; + +interface MockBreakdown { + systemPrompt: number; + toolInjection: number; + rules: number; + memories: number; + total: number; +} + +function buildMockBreakdown( + toolCount: number, + rulesCount: number, + memoriesCount: number, +): MockBreakdown { + const systemPrompt = CLAW_SYS_TOKENS; + const toolInjection = toolCount * TOKENS_PER_TOOL; + const rules = rulesCount * TOKENS_PER_RULE; + const memories = memoriesCount * TOKENS_PER_MEMORY; + return { systemPrompt, toolInjection, rules, memories, total: systemPrompt + toolInjection + rules + memories }; +} + +const TemplateConfigPage: React.FC = () => { + const { t } = useTranslation('scenes/profile'); + const { openGallery } = useNurseryStore(); + + const [models, setModels] = useState([]); + const [funcAgentModels, setFuncAgentModels] = useState>({}); + const [agenticConfig, setAgenticConfig] = useState(null); + const [availableTools, setAvailableTools] = useState([]); + const [mcpServers, setMcpServers] = useState([]); + const [skills, setSkills] = useState([]); + const [toolsLoading, setToolsLoading] = useState>({}); + const [skillsLoading, setSkillsLoading] = useState>({}); + const [loading, setLoading] = useState(true); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + + const enabledToolCount = useMemo( + () => agenticConfig?.available_tools?.length ?? 0, + [agenticConfig], + ); + + // Whether a skill is enabled in this template: + // - If available_skills is undefined, fall back to the skill's global enabled state + // - Otherwise check if the skill name appears in the list + const isSkillEnabled = useCallback( + (skillName: string): boolean => { + if (agenticConfig?.available_skills == null) { + return skills.find((s) => s.name === skillName)?.enabled ?? true; + } + return agenticConfig.available_skills.includes(skillName); + }, + [agenticConfig, skills], + ); + + const enabledSkillCount = useMemo( + () => skills.filter((s) => isSkillEnabled(s.name)).length, + [skills, isSkillEnabled], + ); + + const tokenBreakdown = useMemo( + () => buildMockBreakdown(enabledToolCount, 0, 0), + [enabledToolCount], + ); + + const ctxSegments = useMemo( + () => CTX_SEGMENT_ORDER.map((key) => ({ + key, + color: CTX_SEGMENT_COLORS[key], + label: t(CTX_LABEL_I18N_KEY[key]), + })), + [t], + ); + + // Split tools into built-in vs MCP + const builtinTools = useMemo( + () => availableTools.filter((t) => !isMcpTool(t.name)), + [availableTools], + ); + + // MCP tools grouped by server id + const mcpToolsByServer = useMemo(() => { + const map = new Map(); + for (const tool of availableTools) { + if (!isMcpTool(tool.name)) continue; + const server = getMcpServerName(tool.name); + if (!map.has(server)) map.set(server, []); + map.get(server)!.push(tool); + } + return map; + }, [availableTools]); + + // All known MCP server ids — union of detected tool servers + registered servers + const mcpServerIds = useMemo(() => { + const fromTools = new Set(mcpToolsByServer.keys()); + const fromRegistry = new Set(mcpServers.map((s) => s.id)); + return new Set([...fromTools, ...fromRegistry]); + }, [mcpToolsByServer, mcpServers]); + + useEffect(() => { + (async () => { + setLoading(true); + try { + const { invoke } = await import('@tauri-apps/api/core'); + const [allModels, funcModels, modeConf, tools, skillList, servers] = await Promise.all([ + configManager.getConfig('ai.models').catch(() => [] as AIModelConfig[]), + configManager.getConfig>('ai.func_agent_models').catch(() => ({} as Record)), + configAPI.getModeConfig('agentic').catch(() => null as ModeConfigItem | null), + invoke('get_all_tools_info').catch(() => [] as ToolInfo[]), + configAPI.getSkillConfigs({}).catch(() => [] as SkillInfo[]), + MCPAPI.getServers().catch(() => [] as MCPServerInfo[]), + ]); + setModels(allModels ?? []); + setFuncAgentModels(funcModels ?? {}); + setAgenticConfig(modeConf); + setAvailableTools(tools); + setSkills(skillList ?? []); + setMcpServers(servers ?? []); + } catch (e) { + log.error('Failed to load template config', e); + } finally { + setLoading(false); + } + })(); + }, []); + + const buildModelOptions = useCallback((slot: ModelSlot): SelectOption[] => { + const presets: SelectOption[] = [ + { value: 'preset:primary', label: t('slotDefault.primary'), group: t('modelGroups.presets') }, + { value: 'preset:fast', label: t('slotDefault.fast'), group: t('modelGroups.presets') }, + ]; + const modelOptions: SelectOption[] = models + .filter((m) => m.enabled && !!m.id) + .map((m) => ({ value: `model:${m.id}`, label: m.name, group: t('modelGroups.models') })); + if (slot === 'fast') return [...presets, ...modelOptions]; + return [presets[0], ...modelOptions]; + }, [models, t]); + + const getSelectedValue = useCallback((slot: ModelSlot): string => { + const id = funcAgentModels[slot] ?? ''; + if (!id) return ''; + return ['primary', 'fast'].includes(id) ? `preset:${id}` : `model:${id}`; + }, [funcAgentModels]); + + const getSelectedLabel = useCallback((slot: ModelSlot): string => { + const val = getSelectedValue(slot); + if (!val) return slot === 'primary' ? t('slotDefault.primary') : t('slotDefault.fast'); + const opts = buildModelOptions(slot); + return opts.find((o) => o.value === val)?.label + ?? (slot === 'primary' ? t('slotDefault.primary') : t('slotDefault.fast')); + }, [getSelectedValue, buildModelOptions, t]); + + const handleModelChange = useCallback(async ( + slot: ModelSlot, + raw: string | number | (string | number)[], + ) => { + if (Array.isArray(raw)) return; + const rawStr = String(raw); + const newId = rawStr.startsWith('preset:') ? rawStr.replace('preset:', '') : rawStr.replace('model:', ''); + const updated = { ...funcAgentModels, [slot]: newId }; + setFuncAgentModels(updated); + try { + await configManager.setConfig('ai.func_agent_models', updated); + notificationService.success(t('notifications.modelUpdated')); + } catch (e) { + log.error('Failed to update model', e); + notificationService.error(t('notifications.updateFailed')); + } + }, [funcAgentModels, t]); + + const handleToolToggle = useCallback(async (toolName: string) => { + if (!agenticConfig) return; + setToolsLoading((prev) => ({ ...prev, [toolName]: true })); + const current = agenticConfig.available_tools ?? []; + const isEnabled = current.includes(toolName); + const newTools = isEnabled ? current.filter((n) => n !== toolName) : [...current, toolName]; + const newConfig = { ...agenticConfig, available_tools: newTools }; + setAgenticConfig(newConfig); + try { + await configAPI.setModeConfig('agentic', newConfig); + const { globalEventBus } = await import('@/infrastructure/event-bus'); + globalEventBus.emit('mode:config:updated'); + } catch (e) { + log.error('Failed to toggle tool', e); + notificationService.error(t('notifications.toggleFailed')); + setAgenticConfig(agenticConfig); + } finally { + setToolsLoading((prev) => ({ ...prev, [toolName]: false })); + } + }, [agenticConfig, t]); + + const handleResetTools = useCallback(async () => { + try { + await configAPI.resetModeConfig('agentic'); + const modeConf = await configAPI.getModeConfig('agentic'); + setAgenticConfig(modeConf); + const { globalEventBus } = await import('@/infrastructure/event-bus'); + globalEventBus.emit('mode:config:updated'); + notificationService.success(t('notifications.resetSuccess')); + } catch (e) { + log.error('Failed to reset tools', e); + notificationService.error(t('notifications.resetFailed')); + } + }, [t]); + + const handleGroupToggleAll = useCallback(async (toolNames: string[]) => { + if (!agenticConfig) return; + const current = agenticConfig.available_tools ?? []; + const allEnabled = toolNames.every((n) => current.includes(n)); + const newTools = allEnabled + ? current.filter((n) => !toolNames.includes(n)) + : [...new Set([...current, ...toolNames])]; + const newConfig = { ...agenticConfig, available_tools: newTools }; + setAgenticConfig(newConfig); + try { + await configAPI.setModeConfig('agentic', newConfig); + const { globalEventBus } = await import('@/infrastructure/event-bus'); + globalEventBus.emit('mode:config:updated'); + } catch (e) { + log.error('Failed to toggle group', e); + notificationService.error(t('notifications.toggleFailed')); + setAgenticConfig(agenticConfig); + } + }, [agenticConfig, t]); + + const handleSkillToggle = useCallback(async (skillName: string) => { + if (!agenticConfig) return; + setSkillsLoading((prev) => ({ ...prev, [skillName]: true })); + // Initialise from global state when available_skills is not yet set + const current = + agenticConfig.available_skills ?? + skills.filter((s) => s.enabled).map((s) => s.name); + const isEnabled = current.includes(skillName); + const next = isEnabled + ? current.filter((n) => n !== skillName) + : [...current, skillName]; + const newConfig = { ...agenticConfig, available_skills: next }; + setAgenticConfig(newConfig); + try { + await configAPI.setModeConfig('agentic', newConfig); + const { globalEventBus } = await import('@/infrastructure/event-bus'); + globalEventBus.emit('mode:config:updated'); + } catch (e) { + log.error('Failed to toggle skill', e); + notificationService.error(t('notifications.toggleFailed')); + setAgenticConfig(agenticConfig); + } finally { + setSkillsLoading((prev) => ({ ...prev, [skillName]: false })); + } + }, [agenticConfig, skills, t]); + + const toggleCollapse = useCallback((id: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }, []); + + // Context breakdown: each segment = part / total (composition of consumed tokens) + const ctxTotal = tokenBreakdown.total; + + const segmentWidths = useMemo(() => { + if (ctxTotal === 0) return CTX_SEGMENT_ORDER.map(() => 0); + return CTX_SEGMENT_ORDER.map((key) => { + const val = tokenBreakdown[key]; + return typeof val === 'number' ? (val / ctxTotal) * 100 : 0; + }); + }, [tokenBreakdown, ctxTotal]); + + const primaryLabel = getSelectedLabel('primary'); + const fastLabel = getSelectedLabel('fast'); + + // ── Render helpers ─────────────────────────────────────────────────────── + + const renderToolGrid = (tools: ToolInfo[], isMcp: boolean) => ( +
+ {tools.map((tool) => { + const enabled = agenticConfig?.available_tools?.includes(tool.name) ?? false; + const displayName = isMcp ? getMcpShortName(tool.name) : tool.name; + return ( +
+
+ {isMcp ? : } +
+
+ {displayName} + {tool.description} +
+ handleToolToggle(tool.name)} + aria-label={tool.name} + /> +
+ ); + })} +
+ ); + + const renderGroupHeader = ( + id: string, + label: string, + toolNames: string[], + isMcp: boolean, + serverStatus?: string, + ) => { + const groupEnabled = toolNames.filter( + (n) => agenticConfig?.available_tools?.includes(n), + ).length; + const isCollapsed = collapsedGroups.has(id); + const allOn = toolNames.length > 0 && groupEnabled === toolNames.length; + + return ( +
+ {toolNames.length > 0 && ( + + )} + {isMcp + ? + : + } + {label} + {serverStatus && ( + + {serverStatus} + + )} + + {toolNames.length > 0 ? `${groupEnabled}/${toolNames.length}` : t('nursery.template.groupCountEmpty')} + + {toolNames.length > 0 && ( + handleGroupToggleAll(toolNames)} + aria-label={`Toggle all in ${label}`} + /> + )} +
+ ); + }; + + return ( +
+
+ +

{t('nursery.template.title')}

+
+ +
+ {loading ? ( +
+ +
+ ) : ( + <> + {/* ── Hero panel ──────────────────────────────────────── */} +
+ {/* Left: identity + stat chips */} +
+
+ {t('nursery.template.tag')} +

{t('nursery.template.title')}

+

{t('nursery.template.subtitle')}

+
+
+ + + {primaryLabel} + + + + {fastLabel} + + + + {t('nursery.template.stats.tools', { count: enabledToolCount })} + + {enabledSkillCount > 0 && ( + + + {t('nursery.template.stats.skills', { count: enabledSkillCount })} + + )} +
+
+ + {/* Divider */} +
+ + {/* Right: model config (one row) + context visualization */} +
+ {/* Model row: primary + fast selectors (horizontal) */} +
+
+ + {t('modelSlots.primary.label')} +
+ handleModelChange('fast', v)} + placeholder={t('slotDefault.fast')} + /> +
+
+
+ + {/* Context visualization — total + each part's share of total */} +
+ {t('nursery.template.tokenTitle')} + + {formatTokenCount(ctxTotal)} +  tok  + + {fmtPct(ctxTotal, CTX_WINDOW)} of {formatTokenCount(CTX_WINDOW)} + + +
+ +
+ {ctxTotal === 0 ? ( +
+ ) : ctxSegments.map(({ key, color, label }, i) => ( + segmentWidths[i] > 0 && ( +
+ ) + ))} +
+ +
+ {ctxSegments.map(({ key, color, label }) => { + const val = tokenBreakdown[key as keyof typeof tokenBreakdown]; + const num = typeof val === 'number' ? val : 0; + return ( +
+ + {label} + {formatTokenCount(num)} + {fmtPct(num, ctxTotal)} +
+ ); + })} +
+
+
+ + {/* ── Built-in tools ───────────────────────────────────── */} +
+
+ + {t('nursery.template.builtinToolsSection')} + + {builtinTools.filter((t) => agenticConfig?.available_tools?.includes(t.name)).length} + /{builtinTools.length} + + +
+ + {builtinTools.length === 0 ? ( +

{t('empty.tools')}

+ ) : ( +
+ {renderGroupHeader('__builtin__', t('nursery.template.builtinToolsSection'), builtinTools.map((tool) => tool.name), false)} + {!collapsedGroups.has('__builtin__') && renderToolGrid(builtinTools, false)} +
+ )} +
+ + {/* ── MCP tools ────────────────────────────────────────── */} +
+
+ + {t('nursery.template.mcpToolsSection')} + + {[...mcpToolsByServer.values()].flat() + .filter((t) => agenticConfig?.available_tools?.includes(t.name)).length} + /{[...mcpToolsByServer.values()].flat().length} + +
+ + {mcpServerIds.size === 0 ? ( +
+ + {t('nursery.template.mcpEmptyTitle')} + {t('nursery.template.mcpEmptyHint')} +
+ ) : ( +
+ {[...mcpServerIds].map((serverId) => { + const serverTools = mcpToolsByServer.get(serverId) ?? []; + const serverInfo = mcpServers.find((s) => s.id === serverId); + const status = serverInfo?.status ?? (serverTools.length > 0 ? 'Connected' : 'Unknown'); + const groupId = `mcp_${serverId}`; + + return ( +
+ {renderGroupHeader( + groupId, + serverInfo?.name ?? serverId, + serverTools.map((t) => t.name), + true, + status, + )} + {!collapsedGroups.has(groupId) && serverTools.length > 0 + && renderToolGrid(serverTools, true)} + {!collapsedGroups.has(groupId) && serverTools.length === 0 && ( +

{t('nursery.template.mcpServerNoTools')}

+ )} +
+ ); + })} +
+ )} +
+ + {/* ── Skills ───────────────────────────────────────────── */} +
+
+ + {t('cards.skills')} + + {enabledSkillCount}/{skills.length} + +
+ + {skills.length === 0 ? ( +

{t('empty.skills')}

+ ) : ( +
+ {skills.map((skill) => { + const on = isSkillEnabled(skill.name); + return ( +
+
+
+ {skill.name} + {skill.level} +
+ {skill.description} +
+ handleSkillToggle(skill.name)} + disabled={skillsLoading[skill.name]} + size="small" + /> +
+ ); + })} +
+ )} +
+ + )} +
+
+ ); +}; + +export default TemplateConfigPage; diff --git a/src/web-ui/src/app/scenes/profile/views/index.ts b/src/web-ui/src/app/scenes/profile/views/index.ts index 77ae59d4..9c98da77 100644 --- a/src/web-ui/src/app/scenes/profile/views/index.ts +++ b/src/web-ui/src/app/scenes/profile/views/index.ts @@ -1,2 +1,8 @@ -export { default as PersonaView } from './PersonaView'; - +export { default as NurseryView } from './NurseryView'; +export { default as NurseryGallery } from './NurseryGallery'; +export { default as AssistantCard } from './AssistantCard'; +export { default as TemplateConfigPage } from './TemplateConfigPage'; +export { default as AssistantConfigPage } from './AssistantConfigPage'; +export { PersonaRadar } from './PersonaRadar'; +export { useTokenEstimate, estimateTokens, formatTokenCount } from './useTokenEstimate'; +export type { TokenBreakdown } from './useTokenEstimate'; diff --git a/src/web-ui/src/app/scenes/profile/views/useTokenEstimate.ts b/src/web-ui/src/app/scenes/profile/views/useTokenEstimate.ts new file mode 100644 index 00000000..30e0c4e8 --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/useTokenEstimate.ts @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; + +export interface TokenBreakdown { + systemPrompt: number; + toolInjection: number; + rules: number; + memories: number; + total: number; + contextWindowSize: number; + percentage: string; +} + +const CONTEXT_WINDOW_SIZE = 128_000; +const TOKENS_PER_TOOL = 45; +const TOKENS_PER_RULE = 80; +const TOKENS_PER_MEMORY = 60; +const CHARS_PER_TOKEN = 3; + +export function estimateTokens( + body: string, + enabledToolCount: number, + rulesCount: number, + memoriesCount: number, +): TokenBreakdown { + const systemPrompt = Math.ceil(body.length / CHARS_PER_TOKEN); + const toolInjection = enabledToolCount * TOKENS_PER_TOOL; + const rules = rulesCount * TOKENS_PER_RULE; + const memories = memoriesCount * TOKENS_PER_MEMORY; + const total = systemPrompt + toolInjection + rules + memories; + const percentage = ((total / CONTEXT_WINDOW_SIZE) * 100).toFixed(1) + '%'; + + return { + systemPrompt, + toolInjection, + rules, + memories, + total, + contextWindowSize: CONTEXT_WINDOW_SIZE, + percentage, + }; +} + +export function useTokenEstimate( + body: string, + enabledToolCount: number, + rulesCount: number, + memoriesCount: number, +): TokenBreakdown { + return useMemo( + () => estimateTokens(body, enabledToolCount, rulesCount, memoriesCount), + [body, enabledToolCount, rulesCount, memoriesCount], + ); +} + +export function formatTokenCount(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return String(n); +} diff --git a/src/web-ui/src/app/scenes/registry.ts b/src/web-ui/src/app/scenes/registry.ts index fa860603..45059b69 100644 --- a/src/web-ui/src/app/scenes/registry.ts +++ b/src/web-ui/src/app/scenes/registry.ts @@ -20,6 +20,7 @@ import { Boxes, UserCircle2, Globe, + Network, } from 'lucide-react'; import type { SceneTabDef, SceneTabId } from '../components/SceneBar/types'; @@ -129,6 +130,15 @@ export const SCENE_TAB_REGISTRY: SceneTabDef[] = [ singleton: true, defaultOpen: false, }, + { + id: 'mermaid' as SceneTabId, + label: 'Mermaid', + labelKey: 'scenes.mermaidEditor', + Icon: Network, + pinned: false, + singleton: true, + defaultOpen: false, + }, { id: 'my-agent' as SceneTabId, label: 'My Agent', diff --git a/src/web-ui/src/component-library/components/registry.tsx b/src/web-ui/src/component-library/components/registry.tsx index f6b9cb83..32d56441 100644 --- a/src/web-ui/src/component-library/components/registry.tsx +++ b/src/web-ui/src/component-library/components/registry.tsx @@ -33,8 +33,6 @@ import { MCPToolDisplay } from '@/flow_chat/tool-cards/MCPToolDisplay'; import { MermaidInteractiveDisplay } from '@/flow_chat/tool-cards/MermaidInteractiveDisplay'; import { ContextCompressionDisplay } from '@/flow_chat/tool-cards/ContextCompressionDisplay'; import { ImageAnalysisCard } from '@/flow_chat/tool-cards/ImageAnalysisCard'; -import { IdeControlToolCard } from '@/flow_chat/tool-cards/IdeControlToolCard'; -import { LinterToolCard } from '@/flow_chat/tool-cards/LinterToolCard'; import { SkillDisplay } from '@/flow_chat/tool-cards/SkillDisplay'; import { AskUserQuestionCard } from '@/flow_chat/tool-cards/AskUserQuestionCard'; import { GitToolDisplay } from '@/flow_chat/tool-cards/GitToolDisplay'; @@ -1613,93 +1611,6 @@ console.log(user.greet());`);
), }, - { - id: 'ide-control-card', - name: 'IdeControl - IDE控制', - description: '控制IDE的操作卡片', - category: 'flowchat-cards', - component: () => ( -
-

IDE 控制 - 示例

- -
- ), - }, - { - id: 'linter-card', - name: 'ReadLints - 诊断卡片', - description: '代码诊断结果卡片', - category: 'flowchat-cards', - component: () => ( -
-

代码诊断 - 示例

- -
- ), - }, { id: 'skill-card', name: 'Skill - 技能调用', diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index d4d645f2..ceb39924 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -20,27 +20,11 @@ &.bitfun-context-drop-zone--can-accept { .bitfun-chat-input__box { - border-color: transparent; + border-color: rgba(255, 255, 255, 0.22); box-shadow: - 0 12px 32px rgba(0, 0, 0, 0.4), - 0 0 25px rgba(100, 140, 255, 0.2), - 0 0 50px rgba(140, 100, 255, 0.1), + 0 12px 32px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12); - background: linear-gradient(135deg, - var(--color-bg-tertiary) 0%, - var(--color-bg-quaternary) 100%); - animation: bitfun-chat-input-glow 1.5s ease-in-out infinite; - - &::before { - opacity: 1; - background: linear-gradient( - 135deg, - rgba(80, 200, 120, 0.5) 0%, - rgba(100, 180, 255, 0.45) 50%, - rgba(140, 120, 255, 0.5) 100% - ); - animation: bitfun-focus-border-flow 2s linear infinite; - } + background: var(--color-bg-elevated); } } @@ -58,12 +42,16 @@ width: 100%; display: flex; flex-direction: column; - transition: max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1); + transform: translateY(0); + transition: + max-width 0.32s cubic-bezier(0.4, 0, 0.2, 1), + transform 0.32s cubic-bezier(0.4, 0, 0.2, 1); &--collapsed { cursor: text; max-width: 300px; margin: 0 auto; + transform: translateY(3px); .bitfun-chat-input__box { min-height: 42px; @@ -84,13 +72,12 @@ &:hover { background: rgba(25, 25, 40, 0.38); - border: 1px solid rgba(100, 150, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.16); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.08); - transform: translateY(-1px); } } @@ -109,7 +96,7 @@ &:hover { background: rgba(255, 255, 255, 0.45); - border: 1px solid rgba(80, 120, 255, 0.18); + border: 1px solid rgba(0, 0, 0, 0.14); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: @@ -124,7 +111,7 @@ .bitfun-chat-input__recommendations, .bitfun-chat-input__target-switcher, .bitfun-chat-input__actions-left, - .bitfun-chat-input__mode-selector, + .bitfun-chat-input__agent-boost, .bitfun-chat-input__queued-indicator, .bitfun-chat-input__actions-right { display: none !important; @@ -181,7 +168,7 @@ white-space: nowrap; pointer-events: none; letter-spacing: 0.01em; - animation: bitfun-space-hint-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) 0.32s both; + animation: bitfun-space-hint-pop 0.28s cubic-bezier(0.4, 0, 0.2, 1) 0.22s both; } } @@ -298,16 +285,12 @@ } &:focus-within { - background: var(--color-bg-elevated); - border-color: rgba(120, 160, 255, 0.28); - backdrop-filter: blur(20px) saturate(1.3); - -webkit-backdrop-filter: blur(20px) saturate(1.3); + background: var(--color-bg-tertiary); + border-color: rgba(255, 255, 255, 0.18); box-shadow: - 0 12px 32px rgba(0, 0, 0, 0.4), - 0 0 20px rgba(100, 140, 255, 0.12), - 0 0 40px rgba(140, 100, 255, 0.06), - inset 0 1px 0 rgba(255, 255, 255, 0.12), - inset 0 -1px 0 rgba(100, 140, 255, 0.05); + 0 12px 32px rgba(0, 0, 0, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.1), + inset 0 -1px 0 rgba(255, 255, 255, 0.02); } } @@ -364,7 +347,7 @@ position: relative; width: 100%; height: auto; - min-height: 56px; + min-height: 76px; max-height: 500px; display: flex; flex-direction: column; @@ -377,13 +360,12 @@ backdrop-filter: blur(16px) saturate(1.2); -webkit-backdrop-filter: blur(16px) saturate(1.2); transition: - border-radius 0.35s cubic-bezier(0.4, 0, 0.2, 1), - padding 0.35s cubic-bezier(0.4, 0, 0.2, 1), - min-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), - max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.35s cubic-bezier(0.4, 0, 0.2, 1), - border-color 0.35s cubic-bezier(0.4, 0, 0.2, 1), - background 0.35s cubic-bezier(0.4, 0, 0.2, 1); + border-radius 0.32s cubic-bezier(0.4, 0, 0.2, 1), + padding 0.32s cubic-bezier(0.4, 0, 0.2, 1), + min-height 0.32s cubic-bezier(0.4, 0, 0.2, 1), + max-height 0.32s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.32s cubic-bezier(0.4, 0, 0.2, 1), + border-color 0.32s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.08), @@ -391,26 +373,7 @@ cursor: default; &::before { - content: ''; - position: absolute; - inset: -1px; - border-radius: inherit; - padding: 1px; - background: linear-gradient( - 135deg, - rgba(120, 160, 255, 0) 0%, - rgba(120, 160, 255, 0) 100% - ); - -webkit-mask: - linear-gradient(#fff 0 0) content-box, - linear-gradient(#fff 0 0); - -webkit-mask-composite: xor; - mask-composite: exclude; - opacity: 0; - transition: - opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), - background 0.3s cubic-bezier(0.4, 0, 0.2, 1); - pointer-events: none; + content: none; } &--expanded { @@ -428,28 +391,11 @@ } &:focus-within { - background: var(--color-bg-elevated); - border-color: rgba(120, 160, 255, 0.32); - backdrop-filter: blur(20px) saturate(1.3); - -webkit-backdrop-filter: blur(20px) saturate(1.3); + border-color: rgba(255, 255, 255, 0.18); box-shadow: - 0 12px 32px rgba(0, 0, 0, 0.4), - 0 0 20px rgba(100, 140, 255, 0.12), - 0 0 40px rgba(140, 100, 255, 0.06), - inset 0 1px 0 rgba(255, 255, 255, 0.12), - inset 0 -1px 0 rgba(100, 140, 255, 0.05); - - &::before { - opacity: 0.32; - background: linear-gradient( - 135deg, - rgba(100, 160, 255, 0.5) 0%, - rgba(140, 120, 255, 0.4) 25%, - rgba(100, 140, 255, 0.35) 50%, - rgba(160, 140, 255, 0.4) 75%, - rgba(100, 160, 255, 0.5) 100% - ); - } + 0 12px 32px rgba(0, 0, 0, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.1), + inset 0 -1px 0 rgba(255, 255, 255, 0.02); } & > * { @@ -537,7 +483,7 @@ cursor: pointer; transition: opacity 0.2s ease, color 0.2s ease, background 0.2s ease; opacity: 0.4; - animation: bitfun-active-content-appear 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.15s both; + animation: bitfun-stacked-reveal 0.22s cubic-bezier(0.4, 0, 0.2, 1) 0.24s both; &:hover { opacity: 1; @@ -555,7 +501,7 @@ display: flex; align-items: flex-start; width: 100%; - min-height: 40px; + min-height: 56px; overflow: visible; } @@ -590,75 +536,347 @@ } } - &__mode-selector { + &__agent-boost { position: relative; display: inline-flex; align-items: center; + gap: 6px; + flex-shrink: 0; } - - &__mode-selector-button { - height: 20px; - width: auto !important; - min-width: 24px; - padding: 0 8px; - border-radius: 10px; + + &__agent-boost-add { + width: 20px !important; + height: 20px !important; + min-width: 20px !important; + padding: 0 !important; + border-radius: 50% !important; + opacity: 1; + color: var(--color-text-secondary); + background: var(--element-bg-subtle) !important; + + .bitfun-chat-input__box:focus-within & { + color: var(--color-text-primary); + background: var(--element-bg-medium) !important; + } + + &:hover { + color: var(--color-text-primary); + background: var(--element-bg-medium) !important; + } + } + + &__agent-capsule { + display: inline-flex; + align-items: center; + gap: 2px; + max-width: 148px; + padding: 2px 4px 2px 10px; + border-radius: 999px; border: none; - background: var(--element-bg-subtle); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.02em; color: var(--color-text-secondary); - font-size: 9px; - font-weight: 400; - cursor: pointer; - transition: all 0.2s ease; - outline: none; - opacity: 0.3; + background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); + + &--Plan { + background: rgba(6, 182, 212, 0.2); + color: rgba(14, 116, 144, 0.98); + } + + &--debug { + background: rgba(249, 115, 22, 0.2); + color: rgba(180, 83, 9, 0.98); + } + } + + &__agent-capsule-label { + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; - letter-spacing: 0.3px; + min-width: 0; + } + + &__agent-capsule-close { display: inline-flex; align-items: center; + justify-content: center; flex-shrink: 0; - gap: 4px; - - .bitfun-chat-input__box:focus-within & { + width: 18px; + height: 18px; + margin: 0 -2px 0 0; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: inherit; + cursor: pointer; + opacity: 0.9; + transition: opacity 0.15s ease, background 0.15s ease; + + &:hover { opacity: 1; + background: color-mix(in srgb, currentColor 14%, transparent); } - - &--pending { - background: var(--color-warning-bg); - color: var(--color-warning); - opacity: 1; + } + + &__agent-boost-empty { + padding: 12px 14px; + font-size: 11px; + color: var(--color-text-tertiary); + text-align: center; + line-height: 1.35; + + &--inline { + padding: 8px 12px 10px; + text-align: left; } - + } + + &__boost-section { + padding: 2px 0 4px; + } + + &__boost-section-divider { + height: 1px; + margin: 0; + background: var(--border-subtle); + } + + &__boost-context-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + margin: 0; + padding: 8px 12px; + background: transparent; + font-size: 11px; + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; + border-radius: 4px; + transition: background 0.15s ease, color 0.15s ease; + &:hover { - background-color: var(--element-bg-medium); + background: var(--element-bg-medium); color: var(--color-text-primary); + } + } + + &__boost-context-icon { + flex-shrink: 0; + opacity: 0.88; + } + + // Skills row: hover opens secondary panel (similar to @ mention flyout) + &__boost-submenu-host { + position: relative; + border-radius: 4px; + transition: background 0.15s ease; + + &:hover { + background: var(--element-bg-medium); + + .bitfun-chat-input__boost-submenu-trigger { + color: var(--color-text-primary); + } + + .bitfun-chat-input__boost-submenu-chevron { + opacity: 0.85; + } + } + } + + &__boost-submenu-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + margin: 0; + padding: 8px 12px; + background: transparent; + font-size: 11px; + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; + transition: color 0.15s ease; + } + + &__boost-submenu-trigger-main { + display: inline-flex; + align-items: center; + gap: 10px; + min-width: 0; + } + + &__boost-submenu-chevron { + flex-shrink: 0; + opacity: 0.45; + color: var(--color-text-muted); + transition: opacity 0.15s ease, transform 0.15s ease; + } + + // Shell: absolute from host, padding-left/right acts as mouse bridge over the gap + &__boost-submenu-shell { + position: absolute; + top: -4px; + left: 100%; + padding-left: 10px; + z-index: calc(#{$z-dropdown} + 20); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: + opacity 0.12s ease, + visibility 0.12s ease; + + &--open { opacity: 1; + visibility: visible; + pointer-events: auto; } - - &:focus { - background-color: var(--color-bg-tertiary); + + // Open to the left of the host + &--left { + left: auto; + right: 100%; + padding-left: 0; + padding-right: 10px; + } + + // Align upward (panel grows upward from bottom of host) + &--up { + top: auto; + bottom: -4px; + } + } + + &__boost-submenu-panel { + min-width: 220px; + max-width: 300px; + max-height: 280px; + display: flex; + flex-direction: column; + background: linear-gradient( + 135deg, + var(--color-bg-elevated) 0%, + var(--color-bg-tertiary) 100% + ); + border: 1px solid var(--border-subtle); + border-radius: 8px; + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.35), + 0 4px 16px rgba(0, 0, 0, 0.2), + 0 0 1px rgba(96, 165, 250, 0.25), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + backdrop-filter: blur(20px) saturate(1.2); + -webkit-backdrop-filter: blur(20px) saturate(1.2); + overflow: hidden; + animation: mentionPickerSlideUp 0.16s cubic-bezier(0.34, 1.56, 0.64, 1); + } + + &__boost-submenu-loading, + &__boost-submenu-empty { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px 12px; + font-size: 11px; + color: var(--color-text-muted); + } + + &__boost-submenu-spinner { + animation: spin 0.8s linear infinite; + color: var(--color-accent-primary, rgba(96, 165, 250, 0.9)); + } + + &__boost-submenu-list { + flex: 1; + min-height: 0; + max-height: 210px; + overflow-y: auto; + padding: 4px; + + &::-webkit-scrollbar { + width: 3px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; + } + } + + &__boost-submenu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 5px 8px; + border-radius: 4px; + background: transparent; + font-size: 12px; + color: var(--color-text-primary); + cursor: pointer; + user-select: none; + transition: background 0.15s ease, color 0.15s ease; + + &:hover { + background: var(--element-bg-medium); color: var(--color-text-primary); - opacity: 1; } - - &--debug { - background-color: rgba(249, 115, 22, 0.15) !important; - opacity: 1 !important; - - &:hover { - background-color: rgba(249, 115, 22, 0.25) !important; - } + } + + &__boost-submenu-item-icon { + flex-shrink: 0; + opacity: 0.75; + color: rgba(96, 165, 250, 0.88); + } + + &__boost-submenu-item-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + line-height: 1.35; + } + + &__boost-submenu-manage { + flex-shrink: 0; + width: 100%; + margin: 0; + padding: 8px 10px 10px; + border-top: 1px solid var(--border-subtle); + background: transparent; + font-size: 10px; + font-weight: 500; + color: rgba(120, 160, 255, 0.95); + cursor: pointer; + text-align: center; + user-select: none; + transition: background 0.15s ease, color 0.15s ease; + + &:hover { + background: var(--element-bg-medium); + color: rgba(150, 185, 255, 1); } - - &--Plan { - background-color: rgba(6, 182, 212, 0.15) !important; - opacity: 1 !important; - - &:hover { - background-color: rgba(6, 182, 212, 0.25) !important; - } + } + + @keyframes mentionPickerSlideUp { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); } } - + &__mode-pending-indicator { font-size: 9px; color: rgba(251, 146, 60, 0.9); @@ -687,6 +905,13 @@ inset 0 1px 0 rgba(255, 255, 255, 0.06); z-index: $z-dropdown; animation: mode-dropdown-fade-in 0.15s ease-out; + + &--agent-boost { + min-width: 228px; + max-width: 288px; + padding-bottom: 2px; + overflow: visible; + } } &__mode-option { @@ -978,6 +1203,47 @@ font-size: 11px; color: var(--color-text-tertiary); } + + // Popovers: heavy blur + dark shadows read as gray/black on light theme + :root[data-theme="light"] &, + :root[data-theme-type="light"] &, + .light & { + &__slash-command-picker, + &__mode-dropdown { + background: var(--color-bg-elevated); + border: 1px solid var(--border-subtle); + backdrop-filter: none; + -webkit-backdrop-filter: none; + box-shadow: + 0 4px 18px rgba(15, 23, 42, 0.04), + 0 1px 3px rgba(15, 23, 42, 0.03), + inset 0 1px 0 rgba(255, 255, 255, 0.95); + } + + &__mode-option:not(.bitfun-chat-input__mode-option--disabled):hover { + background: var(--element-bg-soft); + } + + &__mode-submenu-content { + background: rgba(15, 23, 42, 0.03); + + .bitfun-chat-input__mode-option:hover:not(.bitfun-chat-input__mode-option--disabled) { + background: var(--element-bg-soft); + } + } + + &__mode-submenu-trigger:hover { + background: var(--element-bg-soft); + } + + &__slash-command-list::-webkit-scrollbar-thumb { + background: rgba(15, 23, 42, 0.08); + + &:hover { + background: rgba(15, 23, 42, 0.14); + } + } + } &__actions { display: flex; @@ -985,13 +1251,13 @@ justify-content: space-between; height: 20px; padding: 0 0 $size-gap-2; - animation: bitfun-active-content-appear 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.12s both; } &__actions-left { display: flex; align-items: center; gap: $size-gap-1; + animation: bitfun-stacked-reveal 0.28s cubic-bezier(0.4, 0, 0.2, 1) 0.17s both; } &__actions-right { @@ -999,6 +1265,7 @@ align-items: center; gap: $size-gap-1; height: 100%; + animation: bitfun-stacked-reveal 0.24s cubic-bezier(0.4, 0, 0.2, 1) 0.22s both; } &__send-button { @@ -1177,75 +1444,6 @@ } } -@keyframes bitfun-focus-border-flow { - 0% { - background: linear-gradient( - 135deg, - rgba(100, 160, 255, 0.55) 0%, - rgba(140, 120, 255, 0.4) 25%, - rgba(100, 140, 255, 0.35) 50%, - rgba(160, 140, 255, 0.4) 75%, - rgba(100, 160, 255, 0.5) 100% - ); - } - 25% { - background: linear-gradient( - 135deg, - rgba(100, 160, 255, 0.5) 0%, - rgba(100, 160, 255, 0.55) 25%, - rgba(140, 120, 255, 0.4) 50%, - rgba(100, 140, 255, 0.35) 75%, - rgba(160, 140, 255, 0.4) 100% - ); - } - 50% { - background: linear-gradient( - 135deg, - rgba(160, 140, 255, 0.4) 0%, - rgba(100, 160, 255, 0.5) 25%, - rgba(100, 160, 255, 0.55) 50%, - rgba(140, 120, 255, 0.4) 75%, - rgba(100, 140, 255, 0.35) 100% - ); - } - 75% { - background: linear-gradient( - 135deg, - rgba(100, 140, 255, 0.35) 0%, - rgba(160, 140, 255, 0.4) 25%, - rgba(100, 160, 255, 0.5) 50%, - rgba(100, 160, 255, 0.55) 75%, - rgba(140, 120, 255, 0.4) 100% - ); - } - 100% { - background: linear-gradient( - 135deg, - rgba(100, 160, 255, 0.55) 0%, - rgba(140, 120, 255, 0.4) 25%, - rgba(100, 140, 255, 0.35) 50%, - rgba(160, 140, 255, 0.4) 75%, - rgba(100, 160, 255, 0.5) 100% - ); - } -} - -@keyframes bitfun-chat-input-glow { - 0%, 100% { - box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.4), - 0 0 20px rgba(100, 140, 255, 0.15), - 0 0 40px rgba(140, 100, 255, 0.08), - inset 0 1px 0 rgba(255, 255, 255, 0.1); - } - 50% { - box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.4), - 0 0 25px rgba(100, 140, 255, 0.2), - 0 0 50px rgba(140, 100, 255, 0.12), - inset 0 1px 0 rgba(255, 255, 255, 0.15); - } -} @keyframes bitfun-chat-input-fade-in { from { @@ -1261,22 +1459,18 @@ @keyframes bitfun-space-hint-pop { 0% { opacity: 0; - transform: scale(0.92); + transform: translateY(3px); } 100% { opacity: 0.62; - transform: scale(1); + transform: translateY(0); } } @keyframes bitfun-box-fade-in { 0% { opacity: 0; - transform: translateY(8px) scale(0.96); - } - 55% { - opacity: 1; - transform: translateY(-3px) scale(1.012); + transform: translateY(6px) scale(0.98); } 100% { opacity: 1; @@ -1295,3 +1489,14 @@ } } +@keyframes bitfun-stacked-reveal { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index d4422da8..0d5891e9 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -5,7 +5,7 @@ import React, { useRef, useCallback, useEffect, useReducer, useState, useMemo } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import { ArrowUp, Image, Network, ChevronsUp, ChevronsDown, RotateCcw } from 'lucide-react'; +import { ArrowUp, Image, ChevronsUp, ChevronsDown, RotateCcw, Plus, X, Sparkles, Loader2, ChevronRight } from 'lucide-react'; import { ContextDropZone, useContextStore } from '../../shared/context-system'; import { useActiveSessionState } from '../hooks/useActiveSessionState'; import { RichTextInput, type MentionState } from './RichTextInput'; @@ -26,7 +26,6 @@ import { notificationService } from '@/shared/notification-system'; import { inputReducer, initialInputState } from '../reducers/inputReducer'; import { modeReducer, initialModeState } from '../reducers/modeReducer'; import { CHAT_INPUT_CONFIG } from '../constants/chatInputConfig'; -import { MERMAID_INTERACTIVE_EXAMPLE } from '../constants/mermaidExamples'; import { useMessageSender } from '../hooks/useMessageSender'; import { useChatInputState } from '../store/chatInputStateStore'; import { useInputHistoryStore } from '../store/inputHistoryStore'; @@ -36,6 +35,9 @@ import { Tooltip, IconButton } from '@/component-library'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { openBtwSessionInAuxPane, selectActiveBtwSessionTab } from '../services/openBtwSession'; import { resolveSessionRelationship } from '../utils/sessionMetadata'; +import { useSceneStore } from '@/app/stores/sceneStore'; +import type { SceneTabId } from '@/app/components/SceneBar/types'; +import type { SkillInfo } from '@/infrastructure/config/types'; import './ChatInput.scss'; const log = createLogger('ChatInput'); @@ -72,7 +74,7 @@ export const ChatInput: React.FC = ({ const [modeState, dispatchMode] = useReducer(modeReducer, initialModeState); const richTextInputRef = useRef(null); - const modeDropdownRef = useRef(null); + const agentBoostRef = useRef(null); const isImeComposingRef = useRef(false); const lastImeCompositionEndAtRef = useRef(0); @@ -137,6 +139,48 @@ export const ChatInput: React.FC = ({ ), [isAssistantWorkspace, modeState.available] ); + + /** Code session: only Plan and debug are optional on top of default agentic */ + const incrementalCodeModes = useMemo( + () => switchableModes.filter(m => m.id === 'Plan' || m.id === 'debug'), + [switchableModes] + ); + + const openScene = useSceneStore(s => s.openScene); + const [boostPanelSkills, setBoostPanelSkills] = useState([]); + const [boostSkillsLoading, setBoostSkillsLoading] = useState(false); + + const [skillsFlyoutOpen, setSkillsFlyoutOpen] = useState(false); + const [skillsFlyoutLeft, setSkillsFlyoutLeft] = useState(false); + const [skillsFlyoutUp, setSkillsFlyoutUp] = useState(false); + const skillsHostRef = useRef(null); + const skillsTimerRef = useRef(null); + + const clearSkillsTimer = useCallback(() => { + if (skillsTimerRef.current !== null) { + window.clearTimeout(skillsTimerRef.current); + skillsTimerRef.current = null; + } + }, []); + + const openSkillsFlyout = useCallback(() => { + clearSkillsTimer(); + const host = skillsHostRef.current; + if (host) { + const r = host.getBoundingClientRect(); + setSkillsFlyoutLeft(r.right + 260 > window.innerWidth - 8); + setSkillsFlyoutUp(r.top + 200 > window.innerHeight - 8); + } + setSkillsFlyoutOpen(true); + }, [clearSkillsTimer]); + + const closeSkillsFlyout = useCallback(() => { + clearSkillsTimer(); + skillsTimerRef.current = window.setTimeout(() => { + skillsTimerRef.current = null; + setSkillsFlyoutOpen(false); + }, 150); + }, [clearSkillsTimer]); const setChatInputActive = useChatInputState(state => state.setActive); const setChatInputExpanded = useChatInputState(state => state.setExpanded); @@ -469,7 +513,7 @@ export const ChatInput: React.FC = ({ React.useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (modeDropdownRef.current && !modeDropdownRef.current.contains(event.target as Node)) { + if (agentBoostRef.current && !agentBoostRef.current.contains(event.target as Node)) { dispatchMode({ type: 'CLOSE_DROPDOWN' }); } }; @@ -483,6 +527,47 @@ export const ChatInput: React.FC = ({ }; }, [modeState.dropdownOpen]); + useEffect(() => { + if (!modeState.dropdownOpen) { + return; + } + let cancelled = false; + setBoostSkillsLoading(true); + (async () => { + try { + const { configAPI } = await import('@/infrastructure/api'); + const list = await configAPI.getSkillConfigs({ + workspacePath: workspacePath || undefined, + }); + if (!cancelled) { + setBoostPanelSkills(list.filter(s => s.enabled)); + } + } catch (err) { + log.error('Failed to load skills for boost panel', { err }); + if (!cancelled) setBoostPanelSkills([]); + } finally { + if (!cancelled) setBoostSkillsLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [modeState.dropdownOpen, workspacePath]); + + useEffect(() => { + if (!modeState.dropdownOpen) { + clearSkillsTimer(); + setSkillsFlyoutOpen(false); + } + }, [clearSkillsTimer, modeState.dropdownOpen]); + + useEffect( + () => () => { + clearSkillsTimer(); + }, + [clearSkillsTimer] + ); + useEffect(() => { const handleImagePaste = async (event: Event) => { const customEvent = event as CustomEvent<{ file: File }>; @@ -736,18 +821,15 @@ export const ChatInput: React.FC = ({ } }, [inputState.value, derivedState, transition, sendMessage, addToHistory, effectiveTargetSessionId, setQueuedInput, submitBtwFromInput]); - const getFilteredModes = useCallback(() => { - if (!canSwitchModes) { - return []; - } - if (!slashCommandState.query) { - return switchableModes; - } - return switchableModes.filter(mode => - mode.name.toLowerCase().includes(slashCommandState.query) || - mode.id.toLowerCase().includes(slashCommandState.query) + const getFilteredIncrementalModes = useCallback(() => { + if (!canSwitchModes) return []; + if (!slashCommandState.query) return incrementalCodeModes; + return incrementalCodeModes.filter( + mode => + mode.name.toLowerCase().includes(slashCommandState.query) || + mode.id.toLowerCase().includes(slashCommandState.query) ); - }, [canSwitchModes, switchableModes, slashCommandState.query]); + }, [canSwitchModes, incrementalCodeModes, slashCommandState.query]); const applyModeChange = useCallback((modeId: string) => { dispatchMode({ @@ -823,13 +905,22 @@ export const ChatInput: React.FC = ({ const getSlashPickerItems = useCallback((): SlashPickerItem[] => { const actions = getFilteredActions(); - const modes: SlashModeItem[] = getFilteredModes().map(mode => ({ + let modeList = incrementalCodeModes; + if (canSwitchModes && slashCommandState.query) { + const q = slashCommandState.query; + modeList = incrementalCodeModes.filter( + mode => + mode.name.toLowerCase().includes(q) || + mode.id.toLowerCase().includes(q) + ); + } + const modes: SlashModeItem[] = (canSwitchModes ? modeList : []).map(mode => ({ kind: 'mode', id: mode.id, name: mode.name, })); return [...actions, ...modes]; - }, [getFilteredActions, getFilteredModes]); + }, [canSwitchModes, getFilteredActions, incrementalCodeModes, slashCommandState.query]); const selectSlashCommandAction = useCallback((actionId: string) => { if (isBtwSession) return; @@ -885,7 +976,7 @@ export const ChatInput: React.FC = ({ if (!(slashCommandState.kind === 'modes' && !canSwitchModes)) { const items = slashCommandState.kind === 'modes' - ? getFilteredModes() + ? getFilteredIncrementalModes() : slashCommandState.kind === 'actions' ? getFilteredActions() : getSlashPickerItems(); @@ -1078,7 +1169,7 @@ export const ChatInput: React.FC = ({ e.preventDefault(); transition(SessionExecutionEvent.USER_CANCEL); } - }, [handleSendOrCancel, submitBtwFromInput, derivedState, transition, slashCommandState, getFilteredModes, getFilteredActions, getSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, canSwitchModes, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, t]); + }, [handleSendOrCancel, submitBtwFromInput, derivedState, transition, slashCommandState, getFilteredIncrementalModes, getFilteredActions, getSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, canSwitchModes, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, t]); const handleImeCompositionStart = useCallback(() => { isImeComposingRef.current = true; @@ -1142,27 +1233,6 @@ export const ChatInput: React.FC = ({ input.click(); }, [addContext, currentImageCount]); - const handleMermaidEditor = useCallback(() => { - window.dispatchEvent(new CustomEvent('expand-right-panel')); - - setTimeout(() => { - const event = new CustomEvent('agent-create-tab', { - detail: { - type: 'mermaid-editor', - title: t('input.mermaidDualModeDemo'), - data: MERMAID_INTERACTIVE_EXAMPLE, - metadata: { - duplicateCheckKey: 'mermaid-dual-mode-demo' - }, - checkDuplicate: true, - duplicateCheckKey: 'mermaid-dual-mode-demo', - replaceExisting: false - } - }); - window.dispatchEvent(event); - }, 250); - }, []); - const toggleExpand = useCallback(() => { dispatchInput({ type: 'TOGGLE_EXPAND' }); }, []); @@ -1172,6 +1242,41 @@ export const ChatInput: React.FC = ({ richTextInputRef.current?.focus(); }); }, []); + + const insertSkillIntoInput = useCallback( + (skillName: string) => { + const line = t('chatInput.insertSkillLine', { name: skillName }); + dispatchInput({ type: 'ACTIVATE' }); + const cur = inputState.value; + const next = cur.trim() ? `${cur.trimEnd()}\n\n${line}` : line; + dispatchInput({ type: 'SET_VALUE', payload: next }); + clearSkillsTimer(); + setSkillsFlyoutOpen(false); + dispatchMode({ type: 'CLOSE_DROPDOWN' }); + focusRichTextInputSoon(); + }, + [clearSkillsTimer, focusRichTextInputSoon, inputState.value, t] + ); + + const handleBoostPickImage = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + dispatchMode({ type: 'CLOSE_DROPDOWN' }); + handleImageInput(); + }, + [handleImageInput] + ); + + const handleOpenSkillsLibrary = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + clearSkillsTimer(); + setSkillsFlyoutOpen(false); + dispatchMode({ type: 'CLOSE_DROPDOWN' }); + openScene('skills' as SceneTabId); + }, + [clearSkillsTimer, openScene] + ); const handleActivate = useCallback((e?: React.MouseEvent) => { if (e?.target instanceof HTMLButtonElement || @@ -1466,11 +1571,11 @@ export const ChatInput: React.FC = ({ if (!canSwitchModes) return null; - const filteredModes = getFilteredModes(); + const filteredModes = getFilteredIncrementalModes(); return (
- {t('chatInput.switchMode')} + {t('chatInput.addModeMenuTitle')} {t('chatInput.selectHint')}
@@ -1526,59 +1631,174 @@ export const ChatInput: React.FC = ({
- {canSwitchModes && ( -
+
+ dispatchMode({ type: 'TOGGLE_DROPDOWN' })} - tooltip={t('chatInput.currentMode', { mode: t(`chatInput.modeNames.${modeState.current}`, { defaultValue: '' }) || modeState.available.find(m => m.id === modeState.current)?.name || modeState.current })} + aria-haspopup="menu" + aria-expanded={modeState.dropdownOpen} + onClick={e => { + e.stopPropagation(); + dispatchMode({ type: 'TOGGLE_DROPDOWN' }); + }} > - {t(`chatInput.modeNames.${modeState.current}`, { defaultValue: '' }) || modeState.available.find(m => m.id === modeState.current)?.name || modeState.current} + - {modeState.dropdownOpen && (() => { - const modeOrder = ['agentic', 'Claw', 'Plan', 'debug']; - - const sortedModes = [...switchableModes].sort((a, b) => { - const aIndex = modeOrder.indexOf(a.id); - const bIndex = modeOrder.indexOf(b.id); - if (aIndex === -1 && bIndex === -1) return 0; - if (aIndex === -1) return 1; - if (bIndex === -1) return -1; - return aIndex - bIndex; - }); - - const renderModeOption = (modeOption: typeof switchableModes[0]) => { - const modeDescription = t(`chatInput.modeDescriptions.${modeOption.id}`, { defaultValue: '' }) || modeOption.description || modeOption.name; - const modeName = t(`chatInput.modeNames.${modeOption.id}`, { defaultValue: '' }) || modeOption.name; - return ( - + + + {canSwitchModes && modeState.current !== 'agentic' && ( +
+ + {t(`chatInput.modeNames.${modeState.current}`, { defaultValue: '' }) || + modeState.available.find(m => m.id === modeState.current)?.name || + modeState.current} + + +
+ )} + + {modeState.dropdownOpen && ( +
+ {canSwitchModes && ( + <> +
+ {incrementalCodeModes.length > 0 ? ( + incrementalCodeModes.map(modeOption => { + const modeDescription = + t(`chatInput.modeDescriptions.${modeOption.id}`, { defaultValue: '' }) || + modeOption.description || + modeOption.name; + const modeName = + t(`chatInput.modeNames.${modeOption.id}`, { defaultValue: '' }) || modeOption.name; + return ( + +
{ + e.stopPropagation(); + requestModeChange(modeOption.id); + }} + > + {modeName} + {modeState.current === modeOption.id && ( + {t('chatInput.current')} + )} +
+
+ ); + }) + ) : ( +
+ {t('chatInput.noIncrementalModes')} +
+ )} +
+ +
+ + )} + +
+
e.key === 'Enter' && handleBoostPickImage(e as any)} + > + + {t('input.addImage')} +
+
{ - e.stopPropagation(); - requestModeChange(modeOption.id); - }} + ref={skillsHostRef} + className="bitfun-chat-input__boost-submenu-host" + onMouseEnter={openSkillsFlyout} + onMouseLeave={closeSkillsFlyout} > - {modeName} - {!modeOrder.includes(modeOption.id) && {t('chatInput.wip')}} +
+ + + {t('chatInput.boostSkills')} + + +
+
+
+ {boostSkillsLoading ? ( +
+ + {t('chatInput.boostSkillsLoading')} +
+ ) : boostPanelSkills.length === 0 ? ( +
{t('chatInput.boostSkillsEmpty')}
+ ) : ( +
+ {boostPanelSkills.map(skill => ( +
{ + e.stopPropagation(); + insertSkillIntoInput(skill.name); + }} + onKeyDown={e => e.key === 'Enter' && insertSkillIntoInput(skill.name)} + > + + {skill.name} +
+ ))} +
+ )} +
e.key === 'Enter' && handleOpenSkillsLibrary(e as any)} + > + {t('chatInput.openSkillsLibrary')} +
+
+
- - ); - }; - - return ( -
- {sortedModes.map(m => renderModeOption(m))}
- ); - })()} -
- )} +
+ )} +
= ({ )} - - - - - - - - {renderActionButton()}
diff --git a/src/web-ui/src/flow_chat/components/FileMentionPicker.scss b/src/web-ui/src/flow_chat/components/FileMentionPicker.scss index a2ea16f8..c563cf22 100644 --- a/src/web-ui/src/flow_chat/components/FileMentionPicker.scss +++ b/src/web-ui/src/flow_chat/components/FileMentionPicker.scss @@ -12,12 +12,14 @@ min-width: 260px; max-width: 400px; max-height: 320px; - background: linear-gradient(135deg, - var(--color-bg-elevated) 0%, - rgba(30, 30, 35, 0.98) 100%); - border: 1px solid rgba(96, 165, 250, 0.25); + background: linear-gradient( + 135deg, + var(--color-bg-elevated) 0%, + var(--color-bg-tertiary) 100% + ); + border: 1px solid var(--border-subtle); border-radius: 8px; - box-shadow: + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35), 0 4px 16px rgba(0, 0, 0, 0.2), 0 0 1px rgba(96, 165, 250, 0.3), @@ -217,5 +219,45 @@ color: var(--color-text-secondary); } } + + :root[data-theme="light"] &, + :root[data-theme-type="light"] &, + .light & { + // Blur over chat content reads as muddy/dark; use a solid elevated surface + background: var(--color-bg-elevated); + backdrop-filter: none; + -webkit-backdrop-filter: none; + border-color: var(--border-subtle); + box-shadow: + 0 4px 18px rgba(15, 23, 42, 0.04), + 0 1px 3px rgba(15, 23, 42, 0.03), + inset 0 1px 0 rgba(255, 255, 255, 0.95); + + &__back-btn { + background: rgba(15, 23, 42, 0.04); + border-color: rgba(15, 23, 42, 0.08); + + &:hover { + background: rgba(15, 23, 42, 0.06); + } + } + + &__item:hover { + background: var(--element-bg-soft); + } + + &__item--selected { + background: color-mix(in srgb, var(--color-accent-primary) 10%, transparent); + + .file-mention-picker__item-name { + color: var(--color-text-primary); + } + } + + &__footer kbd { + background: rgba(15, 23, 42, 0.04); + border-color: rgba(15, 23, 42, 0.08); + } + } } diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.scss b/src/web-ui/src/flow_chat/components/ModelSelector.scss index 2f94c6a3..0bb9f5b4 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.scss +++ b/src/web-ui/src/flow_chat/components/ModelSelector.scss @@ -283,6 +283,33 @@ color: rgba(100, 180, 255, 0.8); flex-shrink: 0; } + + :root[data-theme="light"] &, + :root[data-theme-type="light"] &, + .light & { + &__dropdown { + background: var(--color-bg-elevated); + border: 1px solid var(--border-subtle); + backdrop-filter: none; + -webkit-backdrop-filter: none; + box-shadow: + 0 4px 18px rgba(15, 23, 42, 0.04), + 0 1px 3px rgba(15, 23, 42, 0.03), + inset 0 1px 0 rgba(255, 255, 255, 0.95); + } + + &__dropdown-header { + border-bottom-color: var(--border-subtle); + } + + &__list::-webkit-scrollbar-thumb { + background: rgba(15, 23, 42, 0.08); + + &:hover { + background: rgba(15, 23, 42, 0.14); + } + } + } } // Dropdown fade-in animation diff --git a/src/web-ui/src/flow_chat/tool-cards/IdeControlToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/IdeControlToolCard.scss deleted file mode 100644 index b07cbca6..00000000 --- a/src/web-ui/src/flow_chat/tool-cards/IdeControlToolCard.scss +++ /dev/null @@ -1,40 +0,0 @@ -/** - * IDE control tool specific styles - * Extended styles based on CompactToolCard - */ - -@use './_tool-card-common.scss'; - -.ide-control-details { - - .detail-item { - display: flex; - gap: 8px; - padding: 4px 0; - font-size: 12px; - align-items: flex-start; - - &:first-child { - padding-top: 0; - } - - &:last-child { - padding-bottom: 0; - } - } - - .detail-label { - color: var(--color-text-muted); - font-weight: 500; - min-width: 70px; - flex-shrink: 0; - } - - .detail-value { - color: var(--color-text-secondary); - font-family: var(--tool-card-font-mono); - word-break: break-all; - flex: 1; - } -} - diff --git a/src/web-ui/src/flow_chat/tool-cards/IdeControlToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/IdeControlToolCard.tsx deleted file mode 100644 index 94961776..00000000 --- a/src/web-ui/src/flow_chat/tool-cards/IdeControlToolCard.tsx +++ /dev/null @@ -1,182 +0,0 @@ -/** - * IDE control tool display component - * Displays IDE control operation execution status with concise gray text prompts - * Supports expanding to view detailed configuration information - */ - -import React, { useState, useMemo } from 'react'; -import { Loader2, Clock, Check } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import type { ToolCardProps } from '../types/flow-chat'; -import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; -import './IdeControlToolCard.scss'; - -export const IdeControlToolCard: React.FC = ({ - toolItem -}) => { - const { t } = useTranslation('flow-chat'); - const { toolCall, status } = toolItem; - const toolInput = toolCall?.input; - const [isExpanded, setIsExpanded] = useState(false); - - const getStatusIcon = () => { - switch (status) { - case 'running': - case 'streaming': - return ; - case 'completed': - return ; - case 'pending': - default: - return ; - } - }; - - const getOperationDescription = () => { - if (!toolInput) return t('toolCards.ideControl.parsingOperation'); - - if (Object.keys(toolInput).length === 0) return t('toolCards.ideControl.parsingOperation'); - - try { - const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; - const action = input.action || 'unknown'; - const panelType = input.target?.panel_type || ''; - - const panelNames: Record = { - 'git-settings': 'Git Settings', - 'git-diff': 'Git Diff', - 'planner': t('toolCards.ideControl.planner'), - 'terminal': t('toolCards.ideControl.terminal'), - 'file-viewer': t('toolCards.ideControl.fileViewer'), - 'code-editor': t('toolCards.ideControl.codeEditor'), - 'markdown-editor': 'Markdown Editor', - }; - - const panelName = panelNames[panelType] || panelType; - - switch (action) { - case 'open_panel': - return `Open ${panelName}`; - case 'close_panel': - return `Close ${panelName}`; - case 'toggle_panel': - return `Toggle ${panelName}`; - case 'navigate_to': - return t('toolCards.ideControl.navigateToPosition'); - case 'set_layout': - return t('toolCards.ideControl.adjustIdeLayout'); - case 'manage_tab': - return t('toolCards.ideControl.manageTab'); - case 'focus_view': - return t('toolCards.ideControl.focusView'); - default: - return `${action}`; - } - } catch { - return t('toolCards.ideControl.executeIdeOperation'); - } - }; - - const operationDesc = useMemo(() => getOperationDescription(), [toolInput]); - - const getDetailInfo = useMemo(() => { - if (!toolInput) return null; - - try { - const input = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput; - const details: Array<{ label: string; value: string }> = []; - - if (input.action) { - details.push({ label: t('toolCards.ideControl.operationType'), value: input.action }); - } - - if (input.target?.panel_type) { - details.push({ label: t('toolCards.ideControl.panelType'), value: input.target.panel_type }); - } - - if (input.target?.panel_config) { - const config = input.target.panel_config; - if (config.section) { - details.push({ label: t('toolCards.ideControl.configSection'), value: config.section }); - } - if (config.session_id) { - details.push({ label: t('toolCards.ideControl.sessionId'), value: config.session_id }); - } - if (config.file_path) { - details.push({ label: t('toolCards.ideControl.filePath'), value: config.file_path }); - } - if (config.workspace_path) { - details.push({ label: t('toolCards.ideControl.workspacePath'), value: config.workspace_path }); - } - } - - if (input.position) { - details.push({ label: t('toolCards.ideControl.panelPosition'), value: input.position }); - } - - return details.length > 0 ? details : null; - } catch { - return null; - } - }, [toolInput]); - - const detailInfo = getDetailInfo; - const hasDetails = detailInfo && detailInfo.length > 0; - - const handleCardClick = () => { - if (hasDetails) { - setIsExpanded(!isExpanded); - } - }; - - const renderContent = () => { - if (status === 'completed') { - return <>Executed: {operationDesc}; - } - if (status === 'running' || status === 'streaming') { - return <>Executing {operationDesc}...; - } - if (status === 'pending') { - return <>{operationDesc}; - } - return null; - }; - - const expandedContent = useMemo(() => { - if (!detailInfo) return null; - - return ( -
- {detailInfo.map((item, index) => ( -
- {item.label}: - {item.value} -
- ))} -
- ); - }, [detailInfo]); - - // Important: do not conditionally skip hooks (e.g. useMemo) across renders. - // Returning early here is safe because all hooks above have already run. - if ((status as string) === 'error') { - return null; - } - - return ( - - } - expandedContent={expandedContent} - /> - ); -}; diff --git a/src/web-ui/src/flow_chat/tool-cards/LinterToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/LinterToolCard.scss deleted file mode 100644 index 9895ebea..00000000 --- a/src/web-ui/src/flow_chat/tool-cards/LinterToolCard.scss +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Linter tool specific styles - * Extended styles based on CompactToolCard - */ - -@use './_tool-card-common.scss'; -.linter-details { - /* Parent container .compact-tool-card-expanded already has margin and padding */ - max-height: 400px; - overflow-y: auto; - -} - -.linter-error-item { - padding: 2px 0; - margin-bottom: 2px; - background: none !important; - border: none !important; - border-left: none !important; - border-radius: 0 !important; - - &:last-child { - margin-bottom: 0; - } -} - -.linter-error-item .error-header { - display: flex; - align-items: center; - gap: 6px; - margin-bottom: 4px; - font-size: 11px; - background: none !important; - border: none !important; - padding: 0 !important; -} - -.linter-error-item .error-location { - color: var(--tool-card-text-primary) !important; - font-family: var(--tool-card-font-mono) !important; - font-weight: 600 !important; - background: none !important; - border: none !important; - padding: 0 !important; -} - -.linter-error-item .error-code { - color: var(--tool-card-text-muted) !important; - font-size: 10px !important; - margin-left: auto !important; - background: none !important; - border: none !important; - padding: 0 !important; -} - -.linter-error-item .error-message { - color: var(--tool-card-text-secondary); - font-size: 12px; - line-height: 1.4; - margin-top: 4px; - background: none; - border: none ; - border-left: none ; - padding: 0 ; - border-radius: 0 ; -} - - diff --git a/src/web-ui/src/flow_chat/tool-cards/LinterToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/LinterToolCard.tsx deleted file mode 100644 index 1cf24b07..00000000 --- a/src/web-ui/src/flow_chat/tool-cards/LinterToolCard.tsx +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Linter tool display component - * Simple gray text prompt, supports expanding to view linter error list - */ - -import React, { useState, useMemo } from 'react'; -import { Loader2, CheckCircle, XCircle, AlertTriangle, Clock, Check } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import type { ToolCardProps } from '../types/flow-chat'; -import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; -import { createLogger } from '@/shared/utils/logger'; -import './LinterToolCard.scss'; - -const log = createLogger('LinterToolCard'); - -interface Diagnostic { - severity: number; - severity_text: string; - line: number; - column: number; - message: string; - code?: string; - source?: string; -} - -interface FileDiagnostics { - file_path: string; - language?: string; - lsp_status: string; - items: Diagnostic[]; - error_count: number; - warning_count: number; - info_count: number; - hint_count: number; -} - -interface DiagnosticSummary { - total_files: number; - files_with_issues: number; - total_diagnostics: number; - error_count: number; - warning_count: number; - info_count: number; - hint_count: number; -} - -interface LinterResult { - path_type: string; - path: string; - diagnostics: Record; - summary: DiagnosticSummary; - warnings: string[]; -} - -interface FlattenedError extends Diagnostic { - file_path: string; -} - -export const LinterToolCard: React.FC = React.memo(({ - toolItem -}) => { - const { t } = useTranslation('flow-chat'); - const { toolCall, toolResult, status } = toolItem; - const [isExpanded, setIsExpanded] = useState(false); - - const getStatusIcon = () => { - switch (status) { - case 'running': - case 'streaming': - return ; - case 'completed': - return ; - case 'pending': - default: - return ; - } - }; - - const linterData = useMemo(() => { - if (!toolResult?.result) return null; - - try { - const result = toolResult.result; - - if (typeof result === 'string') { - const parsed = JSON.parse(result); - return parsed; - } - - if (typeof result === 'object' && result.summary) { - return result as LinterResult; - } - - return null; - } catch (error) { - log.error('Failed to parse linter result', error); - return null; - } - }, [toolResult?.result]); - - const filePaths = useMemo(() => { - const path = toolCall?.input?.path; - if (!path) { - const isEarlyDetection = toolCall?.input?._early_detection === true; - const isPartialParams = toolCall?.input?._partial_params === true; - if (isEarlyDetection || isPartialParams) { - return t('toolCards.linter.parsingParams'); - } - } - return path || t('toolCards.linter.parsingParams'); - }, [toolCall?.input]); - - const summary = useMemo(() => { - if (!linterData) return null; - - const flattenedErrors: FlattenedError[] = []; - Object.entries(linterData.diagnostics).forEach(([_, fileDiag]) => { - fileDiag.items.forEach(item => { - flattenedErrors.push({ - ...item, - file_path: fileDiag.file_path - }); - }); - }); - - return { - totalErrors: linterData.summary.error_count || 0, - totalWarnings: linterData.summary.warning_count || 0, - totalFiles: linterData.summary.total_files || 0, - errorList: flattenedErrors - }; - }, [linterData]); - - const getSeverityIcon = (severity: number) => { - switch (severity) { - case 1: - return ; - case 2: - return ; - case 3: - return ; - case 4: - return ; - default: - return ; - } - }; - - const getSeverityClass = (severity: number) => { - switch (severity) { - case 1: return 'error'; - case 2: return 'warning'; - case 3: return 'info'; - case 4: return 'hint'; - default: return 'info'; - } - }; - - const hasErrors = summary && (summary.totalErrors > 0 || summary.totalWarnings > 0); - - const handleCardClick = () => { - if (hasErrors) { - setIsExpanded(!isExpanded); - } - }; - - const renderContent = () => { - if (status === 'completed') { - return ( - <> - {t('toolCards.linter.checkingCode')}: {filePaths} - {summary && ( - - — {summary.totalErrors > 0 && ( - - {summary.totalErrors} {t('toolCards.linter.errors')} - - )} - {summary.totalErrors > 0 && summary.totalWarnings > 0 && ', '} - {summary.totalWarnings > 0 && ( - - {summary.totalWarnings} {t('toolCards.linter.warnings')} - - )} - {summary.totalErrors === 0 && summary.totalWarnings === 0 && ( - - {t('toolCards.linter.noIssues')} - - )} - - )} - - ); - } - if (status === 'running' || status === 'streaming') { - return <>{t('toolCards.linter.checking')} {filePaths}...; - } - if (status === 'pending') { - return <>{t('toolCards.linter.preparing')} {filePaths}; - } - if (status === 'error') { - return <>{t('toolCards.linter.checkFailed')} {toolResult?.error || t('toolCards.linter.unknownError')}; - } - return null; - }; - - const expandedContent = useMemo(() => { - if (!hasErrors || !summary) return null; - - return ( -
- {summary.errorList.map((error, index) => ( -
-
- {getSeverityIcon(error.severity)} - - {error.file_path}:{error.line}:{error.column} - - {error.code && ( - [{error.code}] - )} -
-
{error.message}
-
- ))} -
- ); - }, [hasErrors, summary]); - - const normalizedStatus = status === 'analyzing' ? 'running' : status; - - return ( - - } - expandedContent={expandedContent ?? undefined} - /> - ); -}); - diff --git a/src/web-ui/src/flow_chat/tool-cards/index.ts b/src/web-ui/src/flow_chat/tool-cards/index.ts index 73e10744..f733ad7e 100644 --- a/src/web-ui/src/flow_chat/tool-cards/index.ts +++ b/src/web-ui/src/flow_chat/tool-cards/index.ts @@ -14,9 +14,7 @@ import { GlobSearchDisplay } from './GlobSearchDisplay'; import { LSDisplay } from './LSDisplay'; import { TodoWriteDisplay } from './TodoWriteDisplay'; import { TaskToolDisplay } from './TaskToolDisplay'; -import { IdeControlToolCard } from './IdeControlToolCard'; import { MermaidInteractiveDisplay } from './MermaidInteractiveDisplay'; -import { LinterToolCard } from './LinterToolCard'; import { CodeReviewToolCard } from './CodeReviewToolCard'; import { FileOperationToolCard } from './FileOperationToolCard'; import { DefaultToolCard } from './DefaultToolCard'; @@ -155,16 +153,6 @@ export const TOOL_CARD_CONFIGS: Record = { displayMode: 'standard', primaryColor: '#0d9488' }, - 'IdeControl': { - toolName: 'IdeControl', - displayName: 'IDE Control', - icon: 'IDE', - requiresConfirmation: false, - resultDisplayType: 'summary', - description: 'Control IDE UI actions', - displayMode: 'compact', - primaryColor: '#6b7280' - }, 'MermaidInteractive': { toolName: 'MermaidInteractive', displayName: 'Mermaid Interactive', @@ -175,16 +163,6 @@ export const TOOL_CARD_CONFIGS: Record = { displayMode: 'compact', primaryColor: '#06b6d4' }, - 'ReadLints': { - toolName: 'ReadLints', - displayName: 'Lint Check', - icon: 'L', - requiresConfirmation: false, - resultDisplayType: 'summary', - description: 'Check lint errors and warnings', - displayMode: 'compact', - primaryColor: '#8b5cf6' - }, 'submit_code_review': { toolName: 'submit_code_review', displayName: 'Code Review', @@ -368,14 +346,9 @@ export const TOOL_CARD_COMPONENTS = { 'Task': TaskToolDisplay, 'TodoWrite': TodoWriteDisplay, - // IDE control - 'IdeControl': IdeControlToolCard, - // Mermaid interactive 'MermaidInteractive': MermaidInteractiveDisplay, - // Linting tools - 'ReadLints': LinterToolCard, 'submit_code_review': CodeReviewToolCard, // Image analysis tools diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 8addf33f..63f0bfc9 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -77,7 +77,7 @@ "items": { "sessions": "Sessions", "project": "Directory", - "persona": "Profile", + "persona": "Nursery", "agents": "Agents", "skills": "Skills", "tools": "Tools", @@ -87,7 +87,7 @@ "insights": "Insights" }, "tooltips": { - "persona": "Agent profile — identity, rules, memory, knowledge & capabilities", + "persona": "Nursery — create and manage all assistant instances", "agents": "Which Agents and Agent Teams it can call", "skills": "What it knows — specialized knowledge files", "tools": "What it can use — built-in tools & MCP services" @@ -108,8 +108,8 @@ "myAgent": { "title": "My Agent", "categories": { - "identity": "Identity", - "collaboration": "Collaboration", + "agents": "Agents", + "extensions": "Extensions", "analytics": "Analytics" } }, @@ -320,6 +320,7 @@ "openMyAgent": "My Agent" }, "moreOptions": "More options", + "multimodalTools": "Multimodal Tools", "notifications": "Notifications" }, "window": { @@ -760,6 +761,7 @@ "skills": "Skills", "miniApps": "Mini App", "browser": "Browser", + "mermaidEditor": "Mermaid", "myAgent": "My Agent", "shell": "Shell" }, diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index bd621fc0..9891663f 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -118,8 +118,6 @@ "imageAddedSuccess": "Successfully added {{count}} images", "imageAddedSingle": "Added clipboard image: {{name}}", "imagePasteFailed": "Image paste failed", - "openMermaidEditor": "Open Mermaid Editor", - "mermaidDualModeDemo": "Mermaid Editor", "willSendAfterStop": "Will send after stop", "openWorkspaceFolder": "Open workspace folder", "openWorkspaceFolderFailed": "Failed to open workspace folder: {{error}}", @@ -183,6 +181,18 @@ "unknown": "Unknown error" }, "chatInput": { + "addBoostTooltip": "Agent modes, image, or skills", + "addModeTooltip": "Add Plan or Debug", + "boostSectionAgent": "Agent", + "boostSectionContext": "Context", + "boostSkills": "Skills", + "boostSkillsLoading": "Loading skills…", + "boostSkillsEmpty": "No enabled skills", + "openSkillsLibrary": "Manage skills…", + "insertSkillLine": "Please use the Skill tool with command \"{{name}}\".", + "addModeMenuTitle": "Add mode", + "resetToAgentic": "Back to default mode", + "noIncrementalModes": "No add-on modes available", "switchMode": "Switch Mode", "quickAction": "Quick action", "currentMode": "Current mode: {{mode}}", diff --git a/src/web-ui/src/locales/en-US/scenes/agents.json b/src/web-ui/src/locales/en-US/scenes/agents.json index 86adc4ef..632e992e 100644 --- a/src/web-ui/src/locales/en-US/scenes/agents.json +++ b/src/web-ui/src/locales/en-US/scenes/agents.json @@ -21,6 +21,8 @@ }, "filters": { "all": "All", + "source": "Source", + "kind": "Type", "builtin": "Built-in", "user": "User", "project": "Project", @@ -120,7 +122,11 @@ "title": "Core Agents", "subtitle": "Built-in core agent modes covering mainstream AI workflows, ready to use out of the box.", "empty": "No core agents detected", - "roleLabel": "Primary Use · " + "roleLabel": "Primary Use · ", + "modes": { + "agentic": { "role": "Coding specialist agent" }, + "cowork": { "role": "Office & collaboration agent" } + } }, "agentsZone": { "title": "Agents Overview", @@ -182,11 +188,13 @@ "tools": "Tools", "toolsReset": "Reset default", "toolsCancel": "Cancel editing", + "toolsSave": "Save", "toolsEdit": "Manage tools", "toolsResetSuccess": "Reset to default tools", "toolToggleFailed": "Failed to toggle tool", "skills": "Skills", "skillsCancel": "Cancel editing", + "skillsSave": "Save", "skillsEdit": "Manage skills", "skillToggleFailed": "Failed to toggle skill", "noSkills": "No skills enabled", diff --git a/src/web-ui/src/locales/en-US/scenes/profile.json b/src/web-ui/src/locales/en-US/scenes/profile.json index 15bf7435..dce7e799 100644 --- a/src/web-ui/src/locales/en-US/scenes/profile.json +++ b/src/web-ui/src/locales/en-US/scenes/profile.json @@ -116,5 +116,62 @@ "sessionTitleDesc": "Auto-generate titles for new sessions", "welcomeAnalysis": "Welcome AI Analysis", "welcomeAnalysisDesc": "Analyze project status on open" + }, + + "nursery": { + "backToGallery": "Nursery", + + "gallery": { + "title": "Nursery", + "subtitle": "Manage all your assistant instances", + "newAssistant": "New Assistant", + "assistantsTitle": "Hatched Assistants", + "assistantsSubtitle": "Click a card to configure" + }, + + "template": { + "tag": "Global Default", + "title": "Hatch Template", + "subtitle": "New assistants inherit these settings", + "configure": "Configure", + "tokenTitle": "Context Usage Estimate", + "tokenSystemPrompt": "System Prompt", + "tokenToolInjection": "Tool Injection", + "tokenRules": "Rules", + "tokenMemories": "Memories", + "tokenTotal": "Default Total", + "stats": { + "primaryDefault": "Primary model", + "fastDefault": "Fast model", + "tools": "{{count}} tools", + "skills": "{{count}} skills" + }, + "groupCountEmpty": "None", + "builtinToolsSection": "Built-in tools", + "mcpToolsSection": "MCP tools", + "mcpEmptyTitle": "No MCP servers connected", + "mcpEmptyHint": "Add MCP servers in Settings to see their tools here.", + "mcpServerNoTools": "No tools available from this server" + }, + + "tabs": { + "identity": "Identity", + "personality": "Personality", + "ability": "Ability", + "memory": "Memory" + }, + + "card": { + "unnamed": "Unnamed Assistant", + "noVibe": "An assistant waiting to be defined", + "configure": "Click to configure", + "tokenTitle": "Context usage estimate" + }, + + "assistant": { + "inheritGroup": "Inherit from Template", + "inheritPrimary": "Inherit from template (primary)", + "inheritFast": "Inherit from template (fast)" + } } } diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 54bbabb7..393b04dc 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -77,8 +77,8 @@ "items": { "sessions": "会话", "project": "目录", - "persona": "档案", - "agents": "智能体", + "persona": "助理", + "agents": "专业智能体", "skills": "技能", "tools": "工具", "terminal": "Shell", @@ -87,7 +87,7 @@ "insights": "洞察" }, "tooltips": { - "persona": "智能体档案 — 身份、规则、记忆、知识与能力", + "persona": "助理 — 创建与管理所有助理实例", "agents": "它能调用哪些 Agent 与 Agent Team", "skills": "它懂什么专项知识 — 技能知识文件", "tools": "它能用什么工具 — 内置工具与 MCP 服务" @@ -108,8 +108,8 @@ "myAgent": { "title": "我的智能体", "categories": { - "identity": "身份", - "collaboration": "协作", + "agents": "智能体", + "extensions": "扩展", "analytics": "分析" } }, @@ -320,6 +320,7 @@ "openMyAgent": "我的智能体" }, "moreOptions": "更多选项", + "multimodalTools": "多模态工具", "notifications": "通知" }, "window": { @@ -760,6 +761,7 @@ "skills": "技能", "miniApps": "小应用", "browser": "浏览器", + "mermaidEditor": "Mermaid 图表", "myAgent": "我的智能体", "shell": "Shell" }, diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 5a695242..c437f8fc 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -118,8 +118,6 @@ "imageAddedSuccess": "成功添加 {{count}} 张图片", "imageAddedSingle": "已添加剪贴板图片: {{name}}", "imagePasteFailed": "图片粘贴失败", - "openMermaidEditor": "打开 Mermaid 编辑器", - "mermaidDualModeDemo": "Mermaid 编辑器", "willSendAfterStop": "将在停止后发送", "openWorkspaceFolder": "打开工作区文件夹", "openWorkspaceFolderFailed": "打开工作区文件夹失败:{{error}}", @@ -183,6 +181,18 @@ "unknown": "未知错误" }, "chatInput": { + "addBoostTooltip": "智能体模式、图片或 Skill", + "addModeTooltip": "附加 Plan 或 Debug", + "boostSectionAgent": "智能体", + "boostSectionContext": "上下文", + "boostSkills": "Skills", + "boostSkillsLoading": "正在加载 Skills…", + "boostSkillsEmpty": "暂无已启用的 Skill", + "openSkillsLibrary": "管理 Skills…", + "insertSkillLine": "Please use the Skill tool with command \"{{name}}\".", + "addModeMenuTitle": "附加模式", + "resetToAgentic": "恢复默认模式", + "noIncrementalModes": "暂无可附加模式", "switchMode": "切换模式", "quickAction": "快捷操作", "currentMode": "当前模式: {{mode}}", diff --git a/src/web-ui/src/locales/zh-CN/scenes/agents.json b/src/web-ui/src/locales/zh-CN/scenes/agents.json index 40af342a..1d2992ba 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/agents.json +++ b/src/web-ui/src/locales/zh-CN/scenes/agents.json @@ -8,7 +8,7 @@ "goManage": "前往管理", "loading": "加载中…", "page": { - "title": "智能体", + "title": "专业智能体", "subtitle": "统一查看 Agent 与 Agent Team,进入编排器完成多人协作配置。", "searchPlaceholder": "搜索 Agent、Agent Team 名称或描述…", "newAgent": "新建 Agent", @@ -21,6 +21,8 @@ }, "filters": { "all": "全部", + "source": "来源", + "kind": "类型", "builtin": "内置", "user": "用户", "project": "项目", @@ -120,7 +122,11 @@ "title": "核心智能体", "subtitle": "平台内置的核心 Agent 模式,覆盖主流 AI 工作流,开箱即用。", "empty": "暂未检测到核心智能体", - "roleLabel": "主要应用 · " + "roleLabel": "主要应用 · ", + "modes": { + "agentic": { "role": "编码专业智能体" }, + "cowork": { "role": "办公智能体" } + } }, "agentsZone": { "title": "Agent 总览", @@ -182,11 +188,13 @@ "tools": "工具", "toolsReset": "重置默认", "toolsCancel": "取消编辑", + "toolsSave": "保存", "toolsEdit": "管理工具", "toolsResetSuccess": "已重置为默认工具", "toolToggleFailed": "工具切换失败", "skills": "Skills", "skillsCancel": "取消编辑", + "skillsSave": "保存", "skillsEdit": "管理 Skills", "skillToggleFailed": "Skill 切换失败", "noSkills": "未启用任何 Skill", diff --git a/src/web-ui/src/locales/zh-CN/scenes/profile.json b/src/web-ui/src/locales/zh-CN/scenes/profile.json index 89b54141..2be5dd2c 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/profile.json +++ b/src/web-ui/src/locales/zh-CN/scenes/profile.json @@ -116,5 +116,62 @@ "sessionTitleDesc": "新会话自动生成标题", "welcomeAnalysis": "欢迎页智能分析", "welcomeAnalysisDesc": "打开项目时分析状态" + }, + + "nursery": { + "backToGallery": "育儿室", + + "gallery": { + "title": "育儿室", + "subtitle": "管理你的所有助理实例", + "newAssistant": "新建助理", + "assistantsTitle": "已孵化的助理", + "assistantsSubtitle": "点击助理卡片进行配置" + }, + + "template": { + "tag": "全局默认", + "title": "孵化模板", + "subtitle": "新助理孵化时继承这里的配置", + "configure": "配置模板", + "tokenTitle": "上下文占用估算", + "tokenSystemPrompt": "系统提示词", + "tokenToolInjection": "工具描述注入", + "tokenRules": "行为守则", + "tokenMemories": "记忆", + "tokenTotal": "默认总占用", + "stats": { + "primaryDefault": "主力模型", + "fastDefault": "快速模型", + "tools": "{{count}} 个工具", + "skills": "{{count}} 个技能" + }, + "groupCountEmpty": "空", + "builtinToolsSection": "内置工具", + "mcpToolsSection": "MCP 工具", + "mcpEmptyTitle": "未连接 MCP 服务器", + "mcpEmptyHint": "在设置中添加 MCP 服务器后,其工具将显示于此", + "mcpServerNoTools": "该服务器暂无可用工具" + }, + + "tabs": { + "identity": "身份", + "personality": "性格", + "ability": "能力", + "memory": "记忆" + }, + + "card": { + "unnamed": "未命名助理", + "noVibe": "一个等待定义的助理", + "configure": "点击配置", + "tokenTitle": "上下文占用估算" + }, + + "assistant": { + "inheritGroup": "继承模板", + "inheritPrimary": "继承模板(主力)", + "inheritFast": "继承模板(快速)" + } } } diff --git a/src/web-ui/src/shared/types/global-state.ts b/src/web-ui/src/shared/types/global-state.ts index 9214de4b..8da387f3 100644 --- a/src/web-ui/src/shared/types/global-state.ts +++ b/src/web-ui/src/shared/types/global-state.ts @@ -70,6 +70,8 @@ export interface WorkspaceIdentity { creature?: string; vibe?: string; emoji?: string; + modelPrimary?: string; + modelFast?: string; } diff --git a/src/web-ui/src/tools/mermaid-editor/components/MermaidEditor.tsx b/src/web-ui/src/tools/mermaid-editor/components/MermaidEditor.tsx index aec1ab3d..c8f57504 100644 --- a/src/web-ui/src/tools/mermaid-editor/components/MermaidEditor.tsx +++ b/src/web-ui/src/tools/mermaid-editor/components/MermaidEditor.tsx @@ -165,6 +165,7 @@ export const MermaidEditor: React.FC = React.memo(({ mode, nodeMetadata, enableTooltips = true, + onFileNavigate, }) => { const { t } = useI18n('mermaid-editor'); @@ -586,6 +587,7 @@ export const MermaidEditor: React.FC = React.memo(({ onError={setError} onRender={() => setError(null)} onZoomChange={setZoomLevel} + onFileNavigate={onFileNavigate} /> {(error || isFixing) && ( diff --git a/src/web-ui/src/tools/mermaid-editor/components/MermaidPanel.scss b/src/web-ui/src/tools/mermaid-editor/components/MermaidPanel.scss index 83df69ed..abfc9410 100644 --- a/src/web-ui/src/tools/mermaid-editor/components/MermaidPanel.scss +++ b/src/web-ui/src/tools/mermaid-editor/components/MermaidPanel.scss @@ -1,6 +1,5 @@ /** - * Mermaid dual-mode panel styles - * All colors use CSS variables + * Mermaid dual-mode panel styles (AuxPane embed — layout mirrors browser-panel) */ @use '../theme/_tokens.scss'; @@ -8,10 +7,12 @@ .mermaid-panel { display: flex; flex-direction: column; - height: 100%; width: 100%; + height: 100%; + min-width: 0; + min-height: 0; background: var(--color-bg-primary, #121214); - border-radius: var(--size-radius-sm, 6px); + border-radius: 0; overflow: hidden; .mermaid-panel-editor { diff --git a/src/web-ui/src/tools/mermaid-editor/components/MermaidPanel.tsx b/src/web-ui/src/tools/mermaid-editor/components/MermaidPanel.tsx index 0d56076d..0da19e2f 100644 --- a/src/web-ui/src/tools/mermaid-editor/components/MermaidPanel.tsx +++ b/src/web-ui/src/tools/mermaid-editor/components/MermaidPanel.tsx @@ -10,7 +10,7 @@ import React, { useCallback } from 'react'; import { MermaidEditor } from './MermaidEditor'; import { useI18n } from '@/infrastructure/i18n'; -import type { MermaidPanelData } from '../types/MermaidPanelTypes'; +import type { MermaidPanelData, NodeMetadata } from '../types/MermaidPanelTypes'; import './MermaidPanel.scss'; export interface MermaidPanelProps { @@ -18,6 +18,8 @@ export interface MermaidPanelProps { onDataChange?: (data: MermaidPanelData) => void; onInteraction?: (action: string, payload: string) => Promise; className?: string; + /** Override default file navigation on node click. */ + onFileNavigate?: (filePath: string, line: number, metadata: NodeMetadata) => void; } export const MermaidPanel: React.FC = ({ @@ -25,6 +27,7 @@ export const MermaidPanel: React.FC = ({ onDataChange, onInteraction, className = '', + onFileNavigate, }) => { const { t } = useI18n('mermaid-editor'); @@ -52,6 +55,7 @@ export const MermaidPanel: React.FC = ({ mode={data.mode} nodeMetadata={data.interactive_config?.node_metadata} enableTooltips={data.interactive_config?.enable_tooltips ?? true} + onFileNavigate={onFileNavigate} />
); diff --git a/src/web-ui/src/tools/mermaid-editor/components/MermaidPreview.tsx b/src/web-ui/src/tools/mermaid-editor/components/MermaidPreview.tsx index d5849c7c..1b25d5d8 100644 --- a/src/web-ui/src/tools/mermaid-editor/components/MermaidPreview.tsx +++ b/src/web-ui/src/tools/mermaid-editor/components/MermaidPreview.tsx @@ -28,6 +28,8 @@ export interface MermaidPreviewProps { /** Edge click callback in edit mode. */ onEdgeClick?: (edgeInfo: EdgeInfo, event?: MouseEvent) => void; onZoomChange?: (zoomLevel: number) => void; + /** Override the default file-open behavior for node navigation. */ + onFileNavigate?: (filePath: string, line: number, metadata: import('../types/MermaidPanelTypes').NodeMetadata) => void; } export interface MermaidPreviewRef { @@ -131,6 +133,7 @@ export const MermaidPreview = React.memo(forwardRef { const { t } = useI18n('mermaid-editor'); @@ -170,6 +173,7 @@ export const MermaidPreview = React.memo(forwardRef { setTooltipData(data); setTooltipPosition(position); diff --git a/src/web-ui/src/tools/mermaid-editor/hooks/useSvgInteraction.ts b/src/web-ui/src/tools/mermaid-editor/hooks/useSvgInteraction.ts index 99bb29c0..0cfcafa9 100644 --- a/src/web-ui/src/tools/mermaid-editor/hooks/useSvgInteraction.ts +++ b/src/web-ui/src/tools/mermaid-editor/hooks/useSvgInteraction.ts @@ -54,6 +54,12 @@ export interface SvgInteractionOptions { hasDragged?: boolean; /** Reset drag state callback. */ resetDragState?: () => void; + /** + * Override the default file navigation behavior (which dispatches agent-create-tab). + * Receives the resolved file path, line number, and the full node metadata. + * When provided, the default fileTabManager call is skipped entirely. + */ + onFileNavigate?: (filePath: string, line: number, metadata: NodeMetadata) => void; } export interface SvgInteractionReturn { @@ -161,7 +167,8 @@ export function useSvgInteraction(options: SvgInteractionOptions): SvgInteractio onTooltipUpdate, onTooltipHide, hasDragged, - resetDragState + resetDragState, + onFileNavigate, } = options; // Store callbacks in refs to avoid stale closures. @@ -173,6 +180,7 @@ export function useSvgInteraction(options: SvgInteractionOptions): SvgInteractio const hasDraggedRef = useRef(hasDragged); const isEditModeRef = useRef(isEditMode); const nodeMetadataRef = useRef(nodeMetadata); + const onFileNavigateRef = useRef(onFileNavigate); useEffect(() => { onNodeClickRef.current = onNodeClick; @@ -182,6 +190,7 @@ export function useSvgInteraction(options: SvgInteractionOptions): SvgInteractio onTooltipHideRef.current = onTooltipHide; hasDraggedRef.current = hasDragged; isEditModeRef.current = isEditMode; + onFileNavigateRef.current = onFileNavigate; nodeMetadataRef.current = nodeMetadata; }); @@ -329,7 +338,9 @@ export function useSvgInteraction(options: SvgInteractionOptions): SvgInteractio const metadata = nodeMetadataRef.current?.[nodeId]; if (metadata?.file_path) { try { - if (metadata.node_type === 'directory') { + if (onFileNavigateRef.current) { + onFileNavigateRef.current(metadata.file_path, metadata.line_number || 1, metadata); + } else if (metadata.node_type === 'directory') { const { appManager } = await import('@/app/services/AppManager'); const { globalEventBus } = await import('@/infrastructure/event-bus/EventBus'); diff --git a/src/web-ui/src/tools/mermaid-editor/types/index.ts b/src/web-ui/src/tools/mermaid-editor/types/index.ts index 2b0a5649..4edd6cd3 100644 --- a/src/web-ui/src/tools/mermaid-editor/types/index.ts +++ b/src/web-ui/src/tools/mermaid-editor/types/index.ts @@ -16,6 +16,11 @@ export interface MermaidEditorProps { nodeMetadata?: Record; /** Whether to show tooltips. */ enableTooltips?: boolean; + /** + * Override default file navigation on node click. + * When provided, this is called instead of dispatching agent-create-tab. + */ + onFileNavigate?: (filePath: string, line: number, metadata: NodeMetadata) => void; } export interface MermaidEditorState {