diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 17a5e6612..79a04f91f 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -43,6 +43,8 @@ 这些命令属于当前设置主路径,不应再在页面组件里散落裸 `invoke`。 +旧设置页里“安全与性能 / 容错配置”那组命令已经下线。`get_retry_config`、`update_retry_config`、`get_failover_config`、`update_failover_config`、`get_switch_log`、`clear_switch_log`、`get_rate_limit_config`、`update_rate_limit_config`、`get_conversation_config`、`update_conversation_config`、`update_hint_routes`、`get_pairing_config`、`update_pairing_config` 都应视为 `dead`,不允许重新接回前端网关、Rust 注册或 mock。提示路由当前只保留只读的 `get_hint_routes` 读取面;如果未来确实要恢复编辑入口,必须重新定义 `current` 主链,而不是直接复活旧设置页命令。 + 图库素材链路也遵循同一原则。当前主入口为 `src/lib/api/galleryMaterials.ts`,统一承接: - `create_gallery_material_metadata` diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index 2ef6d58b5..35b62fed0 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -29,6 +29,7 @@ - `lime-pet` 原生桌宠属于独立仓库与原生窗口壳,不纳入当前 WebView Playwright 的直接操控范围;在 Lime 主仓里只验证 `companion_*` API、状态事件与主窗口唤起链路,桌宠窗口移动、点击命中与原生层动画仍需额外手工 smoke - 如果 companion 协议新增了 provider 摘要、桌宠回跳设置、桌宠主动请求同步,或双击 / 三击 / 文本对话触发的桌宠 LLM 交互事件,Playwright 续测只覆盖 Lime 主仓内的“状态事件是否触发”“是否跳到 `设置 -> AI 服务商`”“是否重发脱敏摘要”“是否调用宿主侧 LLM 代理逻辑”和“主窗口是否被唤起”,不在 WebView 层尝试直接操控原生桌宠 UI - 共享网关控制页已下线,托盘也不再展示网关状态或地址;共享网关 `/v1/routes` 与 selector HTTP 路由也已下线,不再对“启动/停止网关、复制网关地址、路由/curl 示例、selector 路由、托盘运行态文案”做 GUI 续测;server 验证只关注标准 `/v1/messages` 与 `/v1/chat/completions` 主链,如需看运行时状态,走开发者页或实验页的诊断面板 +- 旧设置页里的“安全与性能 / 容错配置”已经下线,不再对这些页签、表单或命令写入路径做 GUI 续测;如果还要验证提示路由,只围绕当前输入框 `get_hint_routes` 读取链与提示展示,不再寻找旧设置页入口 - 项目排版模板与品牌人设扩展旧链路已下线,不再对相关弹窗、模板列表、默认模板、人设扩展表单做 GUI 续测;项目与工作台回归只围绕当前 `Claw` / `workspace` / 现役 `persona` 主链 - 如果只是模块级代码修改、并不需要真实页面交互,优先跑最小单测或 `verify:local` diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index ae1ca6c42..93e27a6ce 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -76,6 +76,8 @@ 如果本轮是在清退旧图库素材命名,`create_poster_metadata` / `get_poster_metadata` / `get_poster_material` / `update_poster_metadata` / `delete_poster_metadata` / `list_by_*`,以及 `PosterMaterial*` / `poster_material_*` 表名与模块名也必须同步从前端网关、Rust 注册、DAO 与治理目录册中撤掉;如需保留历史数据,只允许在 schema 迁移中短暂停留旧表名。最低校验至少包含 `npm run test:contracts` 与 `npm run governance:legacy-report`。 +如果本轮是在清退旧设置页的“安全与性能 / 容错配置”命令面,`get_retry_config`、`update_retry_config`、`get_failover_config`、`update_failover_config`、`get_switch_log`、`clear_switch_log`、`get_rate_limit_config`、`update_rate_limit_config`、`get_conversation_config`、`update_conversation_config`、`update_hint_routes`、`get_pairing_config`、`update_pairing_config` 也必须同步从前端网关、Rust 注册和默认 mock 中撤掉;若当前输入框提示仍依赖 `get_hint_routes`,则只保留该只读读取面。最低校验至少包含 `npm run test:contracts` 与 `npm run governance:legacy-report`。 + 如果本轮涉及 `companion_*` 桌宠命令族,还要同步检查本地 companion `WebSocket` 入口、前端 `src/lib/api/companion.ts` 网关、Rust 注册、治理目录册以及浏览器模式 mock 返回形态;浏览器模式下这组命令默认也要保持可 mock,不要让桌宠接入把默认页面渲染链路卡死。 如果本轮涉及 team runtime 工具面或主线程用户消息工具,还要同步检查 Rust catalog / inventory、runtime 注册、浏览器 fallback mock 与前端 tool display;`Agent / TeamCreate / TeamDelete / SendMessage / ListPeers` 必须保持同一组 current surface,`SendUserMessage` 也必须继续停留在 current 主线程工具面,`SubAgentTask` 只能继续停留在 compat 读取边界。 diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 88988520b..d3af3a79b 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -15,7 +15,6 @@ use crate::commands::orchestrator_cmd::OrchestratorState; use crate::commands::plugin_cmd::PluginManagerState; use crate::commands::plugin_install_cmd::PluginInstallerState; use crate::commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoolServiceState}; -use crate::commands::resilience_cmd::ResilienceConfigState; use crate::commands::session_files_cmd::SessionFilesState; use crate::commands::skill_cmd::SkillServiceState; use crate::commands::terminal_cmd::TerminalManagerState; @@ -56,7 +55,6 @@ pub struct AppStates { pub credential_sync_service: CredentialSyncServiceState, pub token_cache_service: TokenCacheServiceState, pub machine_id_service: MachineIdState, - pub resilience_config: ResilienceConfigState, pub plugin_manager: PluginManagerState, pub plugin_installer: PluginInstallerState, pub plugin_rpc_manager: crate::commands::plugin_rpc_cmd::PluginRpcManagerState, @@ -177,8 +175,6 @@ pub fn init_states(config: &Config) -> Result { .map_err(|e| format!("MachineIdService 初始化失败: {e}"))?; let machine_id_service_state: MachineIdState = Arc::new(RwLock::new(machine_id_service)); - let resilience_config_state = ResilienceConfigState::default(); - // 插件管理器 let plugin_manager = plugin::PluginManager::with_defaults(); let plugin_manager_state = PluginManagerState(Arc::new(RwLock::new(plugin_manager))); @@ -281,7 +277,6 @@ pub fn init_states(config: &Config) -> Result { credential_sync_service: credential_sync_service_state, token_cache_service: token_cache_service_state, machine_id_service: machine_id_service_state, - resilience_config: resilience_config_state, plugin_manager: plugin_manager_state, plugin_installer: plugin_installer_state, plugin_rpc_manager: plugin_rpc_manager_state, diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 57c483e5e..4ac38da1a 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -107,7 +107,6 @@ pub fn run() { credential_sync_service: credential_sync_service_state, token_cache_service: token_cache_service_state, machine_id_service: machine_id_service_state, - resilience_config: resilience_config_state, plugin_manager: plugin_manager_state, plugin_installer: plugin_installer_state, plugin_rpc_manager: plugin_rpc_manager_state, @@ -214,7 +213,6 @@ pub fn run() { .manage(credential_sync_service_state) .manage(token_cache_service_state) .manage(machine_id_service_state) - .manage(resilience_config_state) .manage(telemetry_state) .manage(plugin_manager_state) .manage(plugin_installer_state) @@ -1344,13 +1342,6 @@ pub fn run() { // API Key Provider connection test command commands::api_key_provider_cmd::test_api_key_provider_connection, commands::api_key_provider_cmd::test_api_key_provider_chat, - // Resilience config commands - commands::resilience_cmd::get_retry_config, - commands::resilience_cmd::update_retry_config, - commands::resilience_cmd::get_failover_config, - commands::resilience_cmd::update_failover_config, - commands::resilience_cmd::get_switch_log, - commands::resilience_cmd::clear_switch_log, // Telemetry commands commands::telemetry_cmd::get_request_logs, commands::telemetry_cmd::get_request_log_detail, @@ -1369,15 +1360,8 @@ pub fn run() { commands::injection_cmd::add_injection_rule, commands::injection_cmd::remove_injection_rule, commands::injection_cmd::update_injection_rule, - // Security & Performance commands - commands::security_perf_cmd::get_rate_limit_config, - commands::security_perf_cmd::update_rate_limit_config, - commands::security_perf_cmd::get_conversation_config, - commands::security_perf_cmd::update_conversation_config, + // Hint route commands commands::security_perf_cmd::get_hint_routes, - commands::security_perf_cmd::update_hint_routes, - commands::security_perf_cmd::get_pairing_config, - commands::security_perf_cmd::update_pairing_config, // Usage commands commands::usage_cmd::get_kiro_usage, // Tray commands diff --git a/src-tauri/src/app/state.rs b/src-tauri/src/app/state.rs index 80c17df42..3a685be21 100644 --- a/src-tauri/src/app/state.rs +++ b/src-tauri/src/app/state.rs @@ -12,7 +12,6 @@ use crate::commands::orchestrator_cmd::OrchestratorState; use crate::commands::plugin_cmd::PluginManagerState; use crate::commands::plugin_install_cmd::PluginInstallerState; use crate::commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoolServiceState}; -use crate::commands::resilience_cmd::ResilienceConfigState; use crate::commands::skill_cmd::SkillServiceState; use crate::config::{GlobalConfigManager, GlobalConfigManagerState}; use crate::database; @@ -53,7 +52,6 @@ pub struct ServiceStates { pub credential_sync_service: CredentialSyncServiceState, pub token_cache_service: TokenCacheServiceState, pub machine_id_service: MachineIdState, - pub resilience_config: ResilienceConfigState, pub plugin_manager: PluginManagerState, pub plugin_installer: PluginInstallerState, pub orchestrator: OrchestratorState, @@ -87,9 +85,6 @@ pub fn init_service_states() -> ServiceStates { .expect("Failed to initialize MachineIdService"); let machine_id_service_state: MachineIdState = Arc::new(RwLock::new(machine_id_service)); - // Initialize ResilienceConfigState - let resilience_config_state = ResilienceConfigState::default(); - // Initialize PluginManager let plugin_manager = plugin::PluginManager::with_defaults(); let plugin_manager_state = PluginManagerState(Arc::new(RwLock::new(plugin_manager))); @@ -114,7 +109,6 @@ pub fn init_service_states() -> ServiceStates { credential_sync_service: credential_sync_service_state, token_cache_service: token_cache_service_state, machine_id_service: machine_id_service_state, - resilience_config: resilience_config_state, plugin_manager: plugin_manager_state, plugin_installer: plugin_installer_state, orchestrator: orchestrator_state, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6b8980b70..ba1602ddf 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -48,7 +48,6 @@ pub mod plugin_install_cmd; pub mod plugin_rpc_cmd; pub mod prompt_cmd; pub mod provider_pool_cmd; -pub mod resilience_cmd; pub mod screenshot_cmd; pub mod security_perf_cmd; pub mod session_files_cmd; diff --git a/src-tauri/src/commands/resilience_cmd.rs b/src-tauri/src/commands/resilience_cmd.rs deleted file mode 100644 index 4a4417aa3..000000000 --- a/src-tauri/src/commands/resilience_cmd.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! 容错配置相关 Tauri 命令 - -use crate::resilience::{FailoverConfig, RetryConfig}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use tokio::sync::RwLock; - -/// 容错配置状态 -pub struct ResilienceConfigState { - pub retry_config: Arc>, - pub failover_config: Arc>, - pub switch_log: Arc>>, -} - -impl Default for ResilienceConfigState { - fn default() -> Self { - Self { - retry_config: Arc::new(RwLock::new(RetryConfig::default())), - failover_config: Arc::new(RwLock::new(FailoverConfig::default())), - switch_log: Arc::new(RwLock::new(Vec::new())), - } - } -} - -/// 切换日志条目(用于前端显示) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SwitchLogEntry { - pub from_provider: String, - pub to_provider: String, - pub failure_type: String, - pub timestamp: String, -} - -/// 重试配置 DTO(用于前端) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RetryConfigDto { - pub max_retries: u32, - pub base_delay_ms: u64, - pub max_delay_ms: u64, - pub retryable_codes: Vec, -} - -impl From for RetryConfigDto { - fn from(config: RetryConfig) -> Self { - Self { - max_retries: config.max_retries, - base_delay_ms: config.base_delay_ms, - max_delay_ms: config.max_delay_ms, - retryable_codes: config.retryable_codes, - } - } -} - -impl From for RetryConfig { - fn from(dto: RetryConfigDto) -> Self { - Self { - max_retries: dto.max_retries, - base_delay_ms: dto.base_delay_ms, - max_delay_ms: dto.max_delay_ms, - retryable_codes: dto.retryable_codes, - } - } -} - -/// 故障转移配置 DTO(用于前端) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FailoverConfigDto { - pub auto_switch: bool, - pub switch_on_quota: bool, -} - -impl From for FailoverConfigDto { - fn from(config: FailoverConfig) -> Self { - Self { - auto_switch: config.auto_switch, - switch_on_quota: config.switch_on_quota, - } - } -} - -impl From for FailoverConfig { - fn from(dto: FailoverConfigDto) -> Self { - Self { - auto_switch: dto.auto_switch, - switch_on_quota: dto.switch_on_quota, - } - } -} - -/// 获取重试配置 -#[tauri::command] -pub async fn get_retry_config( - state: tauri::State<'_, ResilienceConfigState>, -) -> Result { - let config = state.retry_config.read().await; - Ok(RetryConfigDto::from(config.clone())) -} - -/// 更新重试配置 -#[tauri::command] -pub async fn update_retry_config( - state: tauri::State<'_, ResilienceConfigState>, - config: RetryConfigDto, -) -> Result<(), String> { - // 验证配置 - if config.max_retries > 10 { - return Err("最大重试次数不能超过 10".to_string()); - } - if config.base_delay_ms < 100 { - return Err("基础延迟不能小于 100ms".to_string()); - } - if config.max_delay_ms < config.base_delay_ms { - return Err("最大延迟不能小于基础延迟".to_string()); - } - if config.max_delay_ms > 120000 { - return Err("最大延迟不能超过 120 秒".to_string()); - } - - let mut retry_config = state.retry_config.write().await; - *retry_config = RetryConfig::from(config); - Ok(()) -} - -/// 获取故障转移配置 -#[tauri::command] -pub async fn get_failover_config( - state: tauri::State<'_, ResilienceConfigState>, -) -> Result { - let config = state.failover_config.read().await; - Ok(FailoverConfigDto::from(config.clone())) -} - -/// 更新故障转移配置 -#[tauri::command] -pub async fn update_failover_config( - state: tauri::State<'_, ResilienceConfigState>, - config: FailoverConfigDto, -) -> Result<(), String> { - let mut failover_config = state.failover_config.write().await; - *failover_config = FailoverConfig::from(config); - Ok(()) -} - -/// 获取切换日志 -#[tauri::command] -pub async fn get_switch_log( - state: tauri::State<'_, ResilienceConfigState>, -) -> Result, String> { - let log = state.switch_log.read().await; - Ok(log.clone()) -} - -/// 清除切换日志 -#[tauri::command] -pub async fn clear_switch_log( - state: tauri::State<'_, ResilienceConfigState>, -) -> Result<(), String> { - let mut log = state.switch_log.write().await; - log.clear(); - Ok(()) -} - -/// 添加切换日志条目(内部使用) -#[allow(dead_code)] -pub async fn add_switch_log_entry( - state: &ResilienceConfigState, - from_provider: &str, - to_provider: &str, - failure_type: &str, -) { - let mut log = state.switch_log.write().await; - log.push(SwitchLogEntry { - from_provider: from_provider.to_string(), - to_provider: to_provider.to_string(), - failure_type: failure_type.to_string(), - timestamp: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(), - }); - // 保留最近 100 条记录 - if log.len() > 100 { - log.remove(0); - } -} diff --git a/src-tauri/src/commands/security_perf_cmd.rs b/src-tauri/src/commands/security_perf_cmd.rs index 9e7613fbe..17dc885b5 100644 --- a/src-tauri/src/commands/security_perf_cmd.rs +++ b/src-tauri/src/commands/security_perf_cmd.rs @@ -1,79 +1,8 @@ -//! 安全与性能配置命令 +//! 提示路由命令 -use crate::config::save_config; use crate::AppState; use serde::{Deserialize, Serialize}; -// ========== 速率限制 ========== - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RateLimitConfigResponse { - pub enabled: bool, - pub requests_per_minute: u32, - pub window_secs: u64, -} - -#[tauri::command] -pub async fn get_rate_limit_config( - state: tauri::State<'_, AppState>, -) -> Result { - let s = state.read().await; - let c = &s.config.rate_limit; - Ok(RateLimitConfigResponse { - enabled: c.enabled, - requests_per_minute: c.requests_per_minute, - window_secs: c.window_secs, - }) -} - -#[tauri::command] -pub async fn update_rate_limit_config( - state: tauri::State<'_, AppState>, - config: RateLimitConfigResponse, -) -> Result<(), String> { - let mut s = state.write().await; - s.config.rate_limit.enabled = config.enabled; - s.config.rate_limit.requests_per_minute = config.requests_per_minute; - s.config.rate_limit.window_secs = config.window_secs; - save_config(&s.config).map_err(|e| e.to_string()) -} - -// ========== 对话管理 ========== - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConversationConfigResponse { - pub trim_enabled: bool, - pub max_messages: usize, - pub summary_enabled: bool, -} - -#[tauri::command] -pub async fn get_conversation_config( - state: tauri::State<'_, AppState>, -) -> Result { - let s = state.read().await; - let c = &s.config.conversation; - Ok(ConversationConfigResponse { - trim_enabled: c.trim_enabled, - max_messages: c.max_messages, - summary_enabled: c.summary_enabled, - }) -} - -#[tauri::command] -pub async fn update_conversation_config( - state: tauri::State<'_, AppState>, - config: ConversationConfigResponse, -) -> Result<(), String> { - let mut s = state.write().await; - s.config.conversation.trim_enabled = config.trim_enabled; - s.config.conversation.max_messages = config.max_messages; - s.config.conversation.summary_enabled = config.summary_enabled; - save_config(&s.config).map_err(|e| e.to_string()) -} - -// ========== 提示路由 ========== - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HintRouteEntryResponse { pub hint: String, @@ -97,48 +26,3 @@ pub async fn get_hint_routes( }) .collect()) } - -#[tauri::command] -pub async fn update_hint_routes( - state: tauri::State<'_, AppState>, - routes: Vec, -) -> Result<(), String> { - let mut s = state.write().await; - s.config.hint_router.routes = routes - .into_iter() - .map(|r| lime_core::config::HintRouteSettingsEntry { - hint: r.hint, - provider: r.provider, - model: r.model, - }) - .collect(); - s.config.hint_router.enabled = !s.config.hint_router.routes.is_empty(); - save_config(&s.config).map_err(|e| e.to_string()) -} - -// ========== 配对认证 ========== - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PairingConfigResponse { - pub enabled: bool, -} - -#[tauri::command] -pub async fn get_pairing_config( - state: tauri::State<'_, AppState>, -) -> Result { - let s = state.read().await; - Ok(PairingConfigResponse { - enabled: s.config.pairing.enabled, - }) -} - -#[tauri::command] -pub async fn update_pairing_config( - state: tauri::State<'_, AppState>, - config: PairingConfigResponse, -) -> Result<(), String> { - let mut s = state.write().await; - s.config.pairing.enabled = config.enabled; - save_config(&s.config).map_err(|e| e.to_string()) -} diff --git a/src/components/README.md b/src/components/README.md index ed4dea9c7..d0fe58bc2 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -10,20 +10,15 @@ React 组件层,包含 UI 组件和业务组件。 ## 文件索引 - `agent/` - AI Agent 聊天页面组件 -- `clients/` - 客户端管理组件 -- `config/` - 配置管理组件 - `connect/` - Lime Connect 组件(中转商 API Key 添加) - `flow-monitor/` - LLM 流量监控组件 -- `general-chat/` - 通用对话功能组件(三栏布局:会话列表 + 聊天区域 + 画布) - `mcp/` - MCP 服务器管理组件(配置管理、运行时控制、工具/提示词/资源浏览与调用) - `plugins/` - 插件管理组件 - `provider-pool/` - Provider 凭证池管理组件 -- `resilience/` - 弹性策略配置组件 - `routing/` - 路由规则配置组件 - `screenshot-chat/` - 截图对话功能组件(实验室功能) - `settings-v2/` - 设置页面组件(当前主实现) - `skills/` - 技能管理组件 -- `switch/` - 开关控制组件 - `terminal/` - 内置终端组件(使用 Tauri Commands) - `tools/` - 工具页面组件 - `widgets/` - 右侧小部件栏组件(移植自 Waveterm) diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 3a5633ae4..de523a196 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -2058,40 +2058,40 @@ export function AgentChatWorkspace({ setTaskFiles, }); const { handleBackHome, resetTopicLocalState } = useWorkspaceResetRuntime({ - clearMessages, - clearRuntimeTeamState, - clearProjectSelectionRuntime, - resetRestoredSessionState, - resetProjectSelection, - resetGuideState, - hasHandledNewChatRequest, - markNewChatRequestHandled, - createFreshSession, - defaultTopicSidebarVisible, - normalizedInitialTheme: normalizedEntryTheme, - initialCreationMode, - newChatAt, - initialSessionName, - projectId, - externalProjectId, - onNavigate: _onNavigate, - autoCollapsedTopicSidebarRef, - processedMessageIdsRef: processedMessageIds, - setInput, - setSelectedText, - setLayoutMode, - setShowSidebar, - setCanvasState, - setGeneralCanvasState, - setTaskFiles, - setSelectedFileId, - setBrowserTaskPreflight, - setMentionedCharacters, - setProject, - setProjectMemory, - setActiveTheme, - setCreationMode, - }); + clearMessages, + clearRuntimeTeamState, + clearProjectSelectionRuntime, + resetRestoredSessionState, + resetProjectSelection, + resetGuideState, + hasHandledNewChatRequest, + markNewChatRequestHandled, + createFreshSession, + defaultTopicSidebarVisible, + normalizedInitialTheme: normalizedEntryTheme, + initialCreationMode, + newChatAt, + initialSessionName, + projectId, + externalProjectId, + onNavigate: _onNavigate, + autoCollapsedTopicSidebarRef, + processedMessageIdsRef: processedMessageIds, + setInput, + setSelectedText, + setLayoutMode, + setShowSidebar, + setCanvasState, + setGeneralCanvasState, + setTaskFiles, + setSelectedFileId, + setBrowserTaskPreflight, + setMentionedCharacters, + setProject, + setProjectMemory, + setActiveTheme, + setCreationMode, + }); const { switchTopic } = useWorkspaceTopicSwitch({ projectId, @@ -2216,6 +2216,7 @@ export function AgentChatWorkspace({ ensureBrowserAssistCanvas, handleAutoLaunchMatchedSiteSkill: workspaceServiceSkillEntryActions.handleAutoLaunchMatchedSiteSkill, + handleImageWorkbenchCommand, resolveImageWorkbenchSkillRequest, }); @@ -2739,9 +2740,6 @@ export function AgentChatWorkspace({ input, setInput, currentGate, - pendingA2UIForm, - handleInputbarA2UISubmit, - a2uiSubmissionNotice, themeWorkbenchSidebarRuntime, steps, themeWorkbenchRunState, diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx index 18efcd6a7..a63fd0409 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -119,6 +119,9 @@ export const InputbarComposerSection: React.FC< buildSkillSelectionBindings(skillSelection); const resolvedProviderType = inputAdapter.model?.providerType; const resolvedModel = inputAdapter.model?.model; + const resolvedSetProviderType = + inputAdapter.actions.setProviderType || (() => undefined); + const resolvedSetModel = inputAdapter.actions.setModel || (() => undefined); const shouldShowVisionNotice = currentPendingImages.length > 0 && Boolean(resolvedProviderType?.trim()) && @@ -127,7 +130,7 @@ export const InputbarComposerSection: React.FC< topExtra || shouldShowVisionNotice ? ( <> {topExtra} - {shouldShowVisionNotice ? ( + {shouldShowVisionNotice && resolvedProviderType && resolvedModel ? ( { }); it("命中稳妥模式模型时不应再展示 Team 横幅提示", async () => { - const { container } = renderPanel({ - providerType: "openai", - model: "glm-4.7", - }); + const { container } = renderPanel(); await flushEffects(); diff --git a/src/components/agent/chat/hooks/skillCommand.ts b/src/components/agent/chat/hooks/skillCommand.ts index c1d2e4d4b..0ad64cb40 100644 --- a/src/components/agent/chat/hooks/skillCommand.ts +++ b/src/components/agent/chat/hooks/skillCommand.ts @@ -728,12 +728,12 @@ export async function tryExecuteSlashSkillCommand( let imageTaskPreview: MessageImageWorkbenchPreview | null = null; const patched = prev.map((msg) => { if (msg.id !== assistantMsgId) return msg; - const currentToolArguments = - streamEvent.arguments || - msg.toolCalls?.find((tc) => tc.id === streamEvent.tool_id) - ?.arguments; + const currentToolCall = msg.toolCalls?.find( + (tc) => tc.id === streamEvent.tool_id, + ); + const currentToolArguments = currentToolCall?.arguments; imageTaskPreview = buildImageTaskPreviewFromToolResult({ - toolName: streamEvent.tool_name, + toolName: currentToolCall?.name || "", toolArguments: currentToolArguments, toolResult: asRecord(normalizedResult) || undefined, fallbackPrompt: command.userInput || rawContent, diff --git a/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx index 19104af8f..6e8bdb98c 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx @@ -7,6 +7,11 @@ import { readFilePreview, type FilePreview, } from "@/lib/api/fileBrowser"; +import { + getMediaTaskArtifact, + listMediaTaskArtifacts, + type MediaTaskArtifactOutput, +} from "@/lib/api/mediaTasks"; import { safeListen } from "@/lib/dev-bridge"; import type { Message } from "../types"; import { @@ -26,6 +31,11 @@ vi.mock("@/lib/api/fileBrowser", () => ({ readFilePreview: vi.fn(), })); +vi.mock("@/lib/api/mediaTasks", () => ({ + getMediaTaskArtifact: vi.fn(), + listMediaTaskArtifacts: vi.fn(), +})); + type HookProps = Parameters[0]; type RuntimeHarnessProps = Omit< HookProps, @@ -85,6 +95,27 @@ function createDirectoryListingResult( }; } +function createArtifactOutput( + overrides: Partial & { + record: MediaTaskArtifactOutput["record"]; + task_id: string; + task_type: string; + }, +): MediaTaskArtifactOutput { + return { + success: true, + task_family: "image", + status: "completed", + normalized_status: "succeeded", + path: `.lime/tasks/${overrides.task_type}/${overrides.task_id}.json`, + absolute_path: `/workspace/project-image-1/.lime/tasks/${overrides.task_type}/${overrides.task_id}.json`, + artifact_path: `.lime/tasks/${overrides.task_type}/${overrides.task_id}.json`, + absolute_artifact_path: `/workspace/project-image-1/.lime/tasks/${overrides.task_type}/${overrides.task_id}.json`, + reused_existing: false, + ...overrides, + }; +} + function renderHook( props?: Partial, options?: { initialMessages?: Message[] }, @@ -160,6 +191,15 @@ function renderHook( }; } +function getDocumentCanvasContent( + canvasState: RuntimeHarnessProps["canvasState"], +): string { + if (!canvasState || canvasState.type !== "document") { + throw new Error("期望 document canvasState"); + } + return canvasState.content; +} + describe("useWorkspaceImageTaskPreviewRuntime", () => { beforeEach(() => { ( @@ -170,6 +210,12 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => { vi.resetAllMocks(); vi.useFakeTimers(); vi.mocked(safeListen).mockResolvedValue(vi.fn()); + vi.mocked(getMediaTaskArtifact).mockRejectedValue( + new Error("task artifact unavailable"), + ); + vi.mocked(listMediaTaskArtifacts).mockRejectedValue( + new Error("task artifact list unavailable"), + ); vi.mocked(listDirectory).mockResolvedValue( createDirectoryListingResult( "/workspace/project-image-1/.lime/tasks", @@ -419,6 +465,98 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => { ]); }); + it("task file 预览被截断时,应回退到媒体任务接口继续回填图片结果", async () => { + let listener: CreationTaskListener | null = null; + vi.mocked(safeListen).mockImplementationOnce(async (_event, handler) => { + listener = handler; + return vi.fn(); + }); + + const taskId = "task-image-large-preview-1"; + const dataUrl = `data:image/png;base64,${"A".repeat(4096)}`; + vi.mocked(readFilePreview).mockResolvedValueOnce({ + path: `/workspace/project-image-1/.lime/tasks/image_generate/${taskId}.json`, + content: + '{"task_id":"task-image-large-preview-1","result":{"images":[{"url":"data:image/png;base64,AAAA', + isBinary: false, + size: 512 * 1024, + error: null, + }); + vi.mocked(getMediaTaskArtifact).mockResolvedValueOnce( + createArtifactOutput({ + task_id: taskId, + task_type: "image_generate", + current_attempt_id: "attempt-large-preview-1", + record: withDefaultTaskContext({ + task_id: taskId, + task_type: "image_generate", + task_family: "image", + status: "completed", + normalized_status: "succeeded", + created_at: "2026-04-04T11:00:00Z", + current_attempt_id: "attempt-large-preview-1", + payload: { + prompt: "[img:高保真青柠品牌主视觉]", + count: 1, + size: "1024x1024", + }, + result: { + images: [{ url: dataUrl }], + }, + attempts: [ + { + attempt_id: "attempt-large-preview-1", + provider: "fal", + model: "flux-pro", + result_snapshot: { + images: [{ url: dataUrl }], + }, + }, + ], + }), + }), + ); + + const { render, getValue } = renderHook(); + await render(); + + await act(async () => { + listener?.({ + payload: withDefaultTaskContext({ + task_id: taskId, + task_type: "image_generate", + task_family: "image", + status: "completed", + path: `.lime/tasks/image_generate/${taskId}.json`, + }), + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(getMediaTaskArtifact).toHaveBeenCalledWith({ + projectRootPath: DEFAULT_PROJECT_ROOT_PATH, + taskRef: taskId, + }); + expect(getValue().messages).toEqual([ + expect.objectContaining({ + id: `image-workbench:${taskId}:assistant`, + imageWorkbenchPreview: expect.objectContaining({ + taskId, + status: "complete", + imageUrl: dataUrl, + prompt: "高保真青柠品牌主视觉", + }), + }), + ]); + expect(getValue().imageWorkbenchState.outputs).toEqual([ + expect.objectContaining({ + taskId, + url: dataUrl, + }), + ]); + }); + it("已存在同 taskId 的 skill 消息时,应把 task 预览合并回原消息而不是再插一条伪造消息", async () => { let listener: CreationTaskListener | null = null; vi.mocked(safeListen).mockImplementationOnce(async (event, handler) => { @@ -713,6 +851,66 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => { ]); }); + it("应优先通过媒体任务接口恢复最近的图片任务,避免大 task file 截断", async () => { + const taskId = "task-image-restored-api"; + vi.mocked(listMediaTaskArtifacts).mockResolvedValueOnce({ + success: true, + workspace_root: DEFAULT_PROJECT_ROOT_PATH, + artifact_root: `${DEFAULT_PROJECT_ROOT_PATH}/.lime/tasks`, + filters: { + task_family: "image", + limit: 32, + }, + total: 1, + tasks: [ + createArtifactOutput({ + task_id: taskId, + task_type: "image_generate", + record: withDefaultTaskContext({ + task_id: taskId, + task_type: "image_generate", + task_family: "image", + status: "completed", + normalized_status: "succeeded", + created_at: "2026-04-04T10:00:00Z", + payload: { + prompt: "[img:通过 API 恢复的青柠主视觉]", + count: 1, + size: "1024x1024", + }, + result: { + images: [{ url: "https://example.com/restored-api-lime.png" }], + }, + }), + }), + ], + }); + + const { render, getValue } = renderHook(); + await render(); + + await vi.waitFor(() => { + expect(getValue().messages).toEqual([ + expect.objectContaining({ + id: `image-workbench:${taskId}:assistant`, + imageWorkbenchPreview: expect.objectContaining({ + taskId, + imageUrl: "https://example.com/restored-api-lime.png", + prompt: "通过 API 恢复的青柠主视觉", + }), + }), + ]); + }); + + expect(listMediaTaskArtifacts).toHaveBeenCalledWith({ + projectRootPath: DEFAULT_PROJECT_ROOT_PATH, + taskFamily: "image", + limit: 32, + }); + expect(listDirectory).not.toHaveBeenCalled(); + expect(readFilePreview).not.toHaveBeenCalled(); + }); + it("应在进入会话时从 task file 恢复最近的图片任务", async () => { vi.mocked(listDirectory) .mockResolvedValueOnce( @@ -1022,10 +1220,10 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => { }); expect(getValue().canvasState?.type).toBe("document"); - expect(getValue().canvasState?.content).toContain( + expect(getDocumentCanvasContent(getValue().canvasState)).toContain( "https://example.com/restored-inline-lime.png", ); - expect(getValue().canvasState?.content).not.toContain( + expect(getDocumentCanvasContent(getValue().canvasState)).not.toContain( "pending-image-task://", ); }); @@ -1122,7 +1320,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => { await Promise.resolve(); }); - const nextContent = getValue().canvasState?.content || ""; + const nextContent = getDocumentCanvasContent(getValue().canvasState); const imageIndex = nextContent.indexOf( "https://example.com/restored-inline-section.png", ); @@ -1442,11 +1640,13 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => { }); expect(getValue().canvasState?.type).toBe("document"); - expect(getValue().canvasState?.content).toContain("pending-image-task://"); - expect(getValue().canvasState?.content).toContain( + expect(getDocumentCanvasContent(getValue().canvasState)).toContain( + "pending-image-task://", + ); + expect(getDocumentCanvasContent(getValue().canvasState)).toContain( "lime:image-task-slot:document-slot-inline-1", ); - const pendingContent = getValue().canvasState?.content || ""; + const pendingContent = getDocumentCanvasContent(getValue().canvasState); const placeholderIndex = pendingContent.indexOf("pending-image-task://"); expect(placeholderIndex).toBeGreaterThan( pendingContent.indexOf("这里是被选中的核心观点段落。"), @@ -1499,10 +1699,10 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => { await Promise.resolve(); }); - expect(getValue().canvasState?.content).toContain( + expect(getDocumentCanvasContent(getValue().canvasState)).toContain( "https://example.com/generated-inline-lime.png", ); - const finalContent = getValue().canvasState?.content || ""; + const finalContent = getDocumentCanvasContent(getValue().canvasState); const imageIndex = finalContent.indexOf( "https://example.com/generated-inline-lime.png", ); @@ -1512,7 +1712,7 @@ describe("useWorkspaceImageTaskPreviewRuntime", () => { expect(imageIndex).toBeLessThan( finalContent.indexOf("这里是核心观点补充说明。"), ); - expect(getValue().canvasState?.content).not.toContain( + expect(getDocumentCanvasContent(getValue().canvasState)).not.toContain( "pending-image-task://", ); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.ts index d2800e517..68077b888 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.ts @@ -1,6 +1,10 @@ import { useEffect, useRef, type Dispatch, type SetStateAction } from "react"; import { listDirectory, readFilePreview } from "@/lib/api/fileBrowser"; -import type { MediaTaskArtifactOutput } from "@/lib/api/mediaTasks"; +import { + getMediaTaskArtifact, + listMediaTaskArtifacts, + type MediaTaskArtifactOutput, +} from "@/lib/api/mediaTasks"; import { safeListen } from "@/lib/dev-bridge"; import { resolveAbsoluteWorkspacePath } from "./workspacePath"; import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas"; @@ -92,6 +96,17 @@ interface TaskContextMetadata { contentId?: string; } +interface LoadedImageTaskSnapshot { + snapshot: ParsedImageTaskSnapshot; + taskRecord: Record; +} + +interface RestoredImageTaskSnapshot extends LoadedImageTaskSnapshot { + absolutePath: string; + taskType: string; + taskFamily: string; +} + function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -1257,12 +1272,7 @@ export function buildImageTaskSnapshotFromArtifactOutput(params: { contentId?: string | null; canvasState: CanvasStateUnion | null; }): ParsedImageTaskSnapshot | null { - const record = - params.artifact.record && - typeof params.artifact.record === "object" && - !Array.isArray(params.artifact.record) - ? (params.artifact.record as Record) - : null; + const record = asRecord(params.artifact.record); if (record) { return ( @@ -1313,12 +1323,17 @@ function upsertPreviewMessage( ); if (taskMatchedIndex >= 0) { const existingMessage = nextMessages[taskMatchedIndex]; + const existingPreview = existingMessage.imageWorkbenchPreview; + const nextPreview = nextMessage.imageWorkbenchPreview; nextMessages[taskMatchedIndex] = { ...existingMessage, - imageWorkbenchPreview: { - ...(existingMessage.imageWorkbenchPreview || {}), - ...nextMessage.imageWorkbenchPreview, - }, + imageWorkbenchPreview: + existingPreview && nextPreview + ? { + ...existingPreview, + ...nextPreview, + } + : nextPreview || existingPreview, runtimeStatus: nextMessage.runtimeStatus ?? existingMessage.runtimeStatus, }; @@ -1419,6 +1434,63 @@ export function useWorkspaceImageTaskPreviewRuntime({ let cancelled = false; let unlisten: (() => void) | null = null; + const loadTaskSnapshotFromArtifactApi = async ( + taskId: string, + ): Promise => { + const currentProjectRootPath = + runtimeContextRef.current.projectRootPath?.trim(); + if (!currentProjectRootPath) { + return null; + } + + try { + const artifact = await getMediaTaskArtifact({ + projectRootPath: currentProjectRootPath, + taskRef: taskId, + }); + if (cancelled) { + return null; + } + + const taskRecord = asRecord(artifact.record); + if (!taskRecord) { + return null; + } + + const snapshot = buildImageTaskSnapshotFromArtifactOutput({ + artifact, + projectId: runtimeContextRef.current.projectId, + contentId: runtimeContextRef.current.contentId, + canvasState: runtimeContextRef.current.canvasState, + }); + if (!snapshot) { + return null; + } + + return { + snapshot, + taskRecord, + }; + } catch { + return null; + } + }; + + const applyLoadedTaskSnapshot = (params: LoadedImageTaskSnapshot) => { + setChatMessages((previous) => + upsertPreviewMessage(previous, params.snapshot.message), + ); + updateCurrentImageWorkbenchState((current) => + mergeImageTaskSnapshot(current, params.snapshot), + ); + syncDocumentInlineImageTask({ + taskRecord: params.taskRecord, + taskId: params.snapshot.taskId, + outputs: params.snapshot.outputs, + setCanvasState, + }); + }; + const scheduleNextPoll = (taskId: string) => { const trackedTask = trackedTasks.get(taskId); if (!trackedTask || cancelled) { @@ -1442,47 +1514,52 @@ export function useWorkspaceImageTaskPreviewRuntime({ trackedTask.polling = true; try { - const preview = await readFilePreview( - trackedTask.absolutePath, - IMAGE_TASK_FILE_PREVIEW_MAX_SIZE, - ); - if (cancelled || !trackedTasks.has(taskId)) { - return; + let loadedSnapshot: LoadedImageTaskSnapshot | null = null; + + try { + const preview = await readFilePreview( + trackedTask.absolutePath, + IMAGE_TASK_FILE_PREVIEW_MAX_SIZE, + ); + if (cancelled || !trackedTasks.has(taskId)) { + return; + } + + if (!preview.error && preview.content?.trim()) { + const parsed = JSON.parse(preview.content) as Record< + string, + unknown + >; + const snapshot = buildParsedImageTaskSnapshot({ + taskRecord: parsed, + taskId: trackedTask.taskId, + taskType: trackedTask.taskType, + projectId: runtimeContextRef.current.projectId, + contentId: runtimeContextRef.current.contentId, + canvasState: runtimeContextRef.current.canvasState, + }); + if (snapshot) { + loadedSnapshot = { + snapshot, + taskRecord: parsed, + }; + } + } + } catch { + loadedSnapshot = null; } - if (preview.error || !preview.content?.trim()) { + if (!loadedSnapshot) { + loadedSnapshot = await loadTaskSnapshotFromArtifactApi(taskId); + } + if (!loadedSnapshot) { scheduleNextPoll(taskId); return; } - const parsed = JSON.parse(preview.content) as Record; - const snapshot = buildParsedImageTaskSnapshot({ - taskRecord: parsed, - taskId: trackedTask.taskId, - taskType: trackedTask.taskType, - projectId: runtimeContextRef.current.projectId, - contentId: runtimeContextRef.current.contentId, - canvasState: runtimeContextRef.current.canvasState, - }); - if (!snapshot) { - scheduleNextPoll(taskId); - return; - } + applyLoadedTaskSnapshot(loadedSnapshot); - setChatMessages((previous) => - upsertPreviewMessage(previous, snapshot.message), - ); - updateCurrentImageWorkbenchState((current) => - mergeImageTaskSnapshot(current, snapshot), - ); - syncDocumentInlineImageTask({ - taskRecord: parsed, - taskId, - outputs: snapshot.outputs, - setCanvasState, - }); - - if (snapshot.terminal) { + if (loadedSnapshot.snapshot.terminal) { trackedTasks.delete(taskId); return; } @@ -1505,81 +1582,141 @@ export function useWorkspaceImageTaskPreviewRuntime({ return; } - const candidatePaths = await collectImageTaskCandidatePaths( - currentProjectRootPath, - ); - if (cancelled || candidatePaths.length === 0) { - return; - } + let restoredSnapshots: RestoredImageTaskSnapshot[] | null = null; - const restoredSnapshots: Array<{ - snapshot: ParsedImageTaskSnapshot; - taskRecord: Record; - absolutePath: string; - taskType: string; - taskFamily: string; - }> = []; - const seenTaskIds = new Set(); + try { + const artifactList = await listMediaTaskArtifacts({ + projectRootPath: currentProjectRootPath, + taskFamily: "image", + limit: IMAGE_TASK_RESTORE_LIMIT * 4, + }); + if (cancelled) { + return; + } - for (const candidatePath of candidatePaths) { - try { - const preview = await readFilePreview( - candidatePath, - IMAGE_TASK_FILE_PREVIEW_MAX_SIZE, - ); - if (cancelled || preview.error || !preview.content?.trim()) { - continue; + restoredSnapshots = artifactList.tasks.reduce< + RestoredImageTaskSnapshot[] + >((items, artifact) => { + const taskRecord = asRecord(artifact.record); + if (!taskRecord) { + return items; } - - const parsed = JSON.parse(preview.content) as Record; if ( !shouldRestoreImageTaskRecord({ - taskRecord: parsed, + taskRecord, sessionId: runtimeContextRef.current.sessionId, projectId: runtimeContextRef.current.projectId, contentId: runtimeContextRef.current.contentId, }) ) { - continue; + return items; } - const taskId = readString([parsed], ["task_id", "taskId"]); - const taskType = readString([parsed], ["task_type", "taskType"]); const taskFamily = normalizeTaskFamily( - taskType || "", - readString([parsed], ["task_family", "taskFamily"]), + artifact.task_type, + artifact.task_family, ); - if ( - !taskId || - !taskType || - taskFamily !== "image" || - seenTaskIds.has(taskId) - ) { - continue; + if (taskFamily !== "image") { + return items; } - const snapshot = buildParsedImageTaskSnapshot({ - taskRecord: parsed, - taskId, - taskType, + const snapshot = buildImageTaskSnapshotFromArtifactOutput({ + artifact, projectId: runtimeContextRef.current.projectId, contentId: runtimeContextRef.current.contentId, canvasState: runtimeContextRef.current.canvasState, }); if (!snapshot) { - continue; + return items; } - seenTaskIds.add(taskId); - restoredSnapshots.push({ + items.push({ snapshot, - taskRecord: parsed, - absolutePath: candidatePath, - taskType, + taskRecord, + absolutePath: artifact.absolute_path, + taskType: artifact.task_type, taskFamily, }); - } catch { - continue; + return items; + }, []); + } catch { + restoredSnapshots = null; + } + + if (restoredSnapshots === null) { + const candidatePaths = await collectImageTaskCandidatePaths( + currentProjectRootPath, + ); + if (cancelled || candidatePaths.length === 0) { + return; + } + + restoredSnapshots = []; + const seenTaskIds = new Set(); + + for (const candidatePath of candidatePaths) { + try { + const preview = await readFilePreview( + candidatePath, + IMAGE_TASK_FILE_PREVIEW_MAX_SIZE, + ); + if (cancelled || preview.error || !preview.content?.trim()) { + continue; + } + + const parsed = JSON.parse(preview.content) as Record< + string, + unknown + >; + if ( + !shouldRestoreImageTaskRecord({ + taskRecord: parsed, + sessionId: runtimeContextRef.current.sessionId, + projectId: runtimeContextRef.current.projectId, + contentId: runtimeContextRef.current.contentId, + }) + ) { + continue; + } + + const taskId = readString([parsed], ["task_id", "taskId"]); + const taskType = readString([parsed], ["task_type", "taskType"]); + const taskFamily = normalizeTaskFamily( + taskType || "", + readString([parsed], ["task_family", "taskFamily"]), + ); + if ( + !taskId || + !taskType || + taskFamily !== "image" || + seenTaskIds.has(taskId) + ) { + continue; + } + + const snapshot = buildParsedImageTaskSnapshot({ + taskRecord: parsed, + taskId, + taskType, + projectId: runtimeContextRef.current.projectId, + contentId: runtimeContextRef.current.contentId, + canvasState: runtimeContextRef.current.canvasState, + }); + if (!snapshot) { + continue; + } + + seenTaskIds.add(taskId); + restoredSnapshots.push({ + snapshot, + taskRecord: parsed, + absolutePath: candidatePath, + taskType, + taskFamily, + }); + } catch { + continue; + } } } @@ -1689,7 +1826,7 @@ export function useWorkspaceImageTaskPreviewRuntime({ canvasState: runtimeContextRef.current.canvasState, }) : null; - if (pendingSnapshot) { + if (pendingSnapshot && taskId && taskType) { setChatMessages((previous) => upsertPreviewMessage(previous, pendingSnapshot.message), ); diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts index b0a91cb3e..6252f58e4 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts @@ -906,8 +906,8 @@ export function useWorkspaceImageWorkbenchActionRuntime({ : "claw-image-workbench", slotId: documentInlineSlotId, anchorHint: documentInlineAnchorHint, - anchorSectionTitle: documentInlineAnchorSectionTitle, - anchorText: documentInlineAnchorText, + anchorSectionTitle: documentInlineAnchorSectionTitle ?? undefined, + anchorText: documentInlineAnchorText ?? undefined, providerId: imageWorkbenchSelectedProviderId, model: imageWorkbenchSelectedModelId, sessionId: imageWorkbenchSessionKey, @@ -932,11 +932,7 @@ export function useWorkspaceImageWorkbenchActionRuntime({ ); syncDocumentInlineImageTask({ taskRecord: - createdArtifact.record && - typeof createdArtifact.record === "object" && - !Array.isArray(createdArtifact.record) - ? (createdArtifact.record as Record) - : { + asRecord(createdArtifact.record) || { task_id: createdArtifact.task_id, task_type: createdArtifact.task_type, status: createdArtifact.status, diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx index a7f18c5fa..c7fa97d37 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx @@ -31,7 +31,12 @@ const mockSetChatToolPreferences = vi.fn(); const mockSetRuntimeTeamDispatchPreview = vi.fn(); const mockEnsureBrowserAssistCanvas = vi.fn(async () => true); const mockHandleAutoLaunchMatchedSiteSkill = vi.fn(async () => undefined); -const mockResolveImageWorkbenchSkillRequest = vi.fn(() => null); +const mockHandleImageWorkbenchCommand = vi.fn< + HookProps["handleImageWorkbenchCommand"] +>(async () => true); +const mockResolveImageWorkbenchSkillRequest = vi.fn< + HookProps["resolveImageWorkbenchSkillRequest"] +>(() => null); function createDeferred() { let resolve!: (value: T | PromiseLike) => void; @@ -198,6 +203,8 @@ function mountHook(initialProps?: Partial): HookHarness { mockEnsureBrowserAssistCanvas as HookProps["ensureBrowserAssistCanvas"], handleAutoLaunchMatchedSiteSkill: mockHandleAutoLaunchMatchedSiteSkill as HookProps["handleAutoLaunchMatchedSiteSkill"], + handleImageWorkbenchCommand: + mockHandleImageWorkbenchCommand as HookProps["handleImageWorkbenchCommand"], resolveImageWorkbenchSkillRequest: mockResolveImageWorkbenchSkillRequest as HookProps["resolveImageWorkbenchSkillRequest"], ...initialProps, @@ -237,6 +244,7 @@ describe("useWorkspaceSendActions", () => { ).IS_REACT_ACT_ENVIRONMENT = true; vi.clearAllMocks(); + mockHandleImageWorkbenchCommand.mockResolvedValue(true); mockResolveImageWorkbenchSkillRequest.mockReturnValue(null); }); @@ -433,17 +441,7 @@ describe("useWorkspaceSendActions", () => { } }); - it("纯文本 @配图 应保留原始消息,并通过 image_skill_launch metadata 交给 Agent 调度技能", async () => { - mockResolveImageWorkbenchSkillRequest.mockReturnValueOnce({ - images: [], - requestContext: { - kind: "image_task", - image_task: { - mode: "generate", - prompt: "一张春日咖啡馆插画", - }, - }, - }); + it("纯文本 @配图 应直接命中本地图片任务主链,而不是走 Agent 工具搜索", async () => { const harness = mountHook({ input: "@配图 生成 一张春日咖啡馆插画,16:9,出 2 张", }); @@ -454,22 +452,22 @@ describe("useWorkspaceSendActions", () => { expect(started).toBe(true); }); - expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledTimes(1); - expect(mockSendMessage).toHaveBeenCalledTimes(1); - expect(mockSendMessage.mock.calls[0]?.[0]).toBe( - "@配图 生成 一张春日咖啡馆插画,16:9,出 2 张", + expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledTimes(1); + expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + rawText: "@配图 生成 一张春日咖啡馆插画,16:9,出 2 张", + parsedCommand: expect.objectContaining({ + trigger: "@配图", + mode: "generate", + prompt: "一张春日咖啡馆插画", + aspectRatio: "16:9", + count: 2, + }), + images: [], + }), ); - expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ - requestMetadata: { - harness: { - allow_model_skills: true, - image_skill_launch: { - skill_name: "image_generate", - kind: "image_task", - }, - }, - }, - }); + expect(mockResolveImageWorkbenchSkillRequest).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); } finally { harness.unmount(); } @@ -516,16 +514,7 @@ describe("useWorkspaceSendActions", () => { } }); - it("带引用图的图片命令应保留原始消息,并把结构化 image_skill_launch metadata 交给 Agent", async () => { - mockResolveImageWorkbenchSkillRequest.mockReturnValueOnce({ - images: [], - requestContext: { - kind: "image_task", - image_task: { - mode: "edit", - }, - }, - }); + it("带引用图的图片命令应直接交给本地图片任务运行时", async () => { const harness = mountHook({ input: "@配图 编辑 #img-2 去掉角标,保留主体", }); @@ -536,8 +525,8 @@ describe("useWorkspaceSendActions", () => { expect(started).toBe(true); }); - expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledTimes(1); - expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledWith( + expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledTimes(1); + expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledWith( expect.objectContaining({ rawText: "@配图 编辑 #img-2 去掉角标,保留主体", parsedCommand: expect.objectContaining({ @@ -548,40 +537,14 @@ describe("useWorkspaceSendActions", () => { images: [], }), ); - expect(mockSendMessage).toHaveBeenCalledTimes(1); - expect(mockSendMessage.mock.calls[0]?.[0]).toBe( - "@配图 编辑 #img-2 去掉角标,保留主体", - ); - expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ - requestMetadata: { - harness: { - allow_model_skills: true, - image_skill_launch: { - skill_name: "image_generate", - kind: "image_task", - image_task: { - mode: "edit", - }, - }, - }, - }, - }); + expect(mockResolveImageWorkbenchSkillRequest).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); } finally { harness.unmount(); } }); - it("@修图 应接入当前图片 skillRequest 主链", async () => { - mockResolveImageWorkbenchSkillRequest.mockReturnValueOnce({ - images: [], - requestContext: { - kind: "image_task", - image_task: { - mode: "edit", - target_output_ref_id: "img-2", - }, - }, - }); + it("@修图 应直达当前图片任务主链", async () => { const harness = mountHook({ input: "@修图 #img-2 去掉角标,保留主体", }); @@ -592,8 +555,8 @@ describe("useWorkspaceSendActions", () => { expect(started).toBe(true); }); - expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledTimes(1); - expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledWith( + expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledTimes(1); + expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledWith( expect.objectContaining({ rawText: "@修图 #img-2 去掉角标,保留主体", parsedCommand: expect.objectContaining({ @@ -604,41 +567,14 @@ describe("useWorkspaceSendActions", () => { images: [], }), ); - expect(mockSendMessage).toHaveBeenCalledTimes(1); - expect(mockSendMessage.mock.calls[0]?.[0]).toBe( - "@修图 #img-2 去掉角标,保留主体", - ); - expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ - requestMetadata: { - harness: { - allow_model_skills: true, - image_skill_launch: { - skill_name: "image_generate", - kind: "image_task", - image_task: { - mode: "edit", - target_output_ref_id: "img-2", - }, - }, - }, - }, - }); + expect(mockResolveImageWorkbenchSkillRequest).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); } finally { harness.unmount(); } }); - it("@重绘 应接入当前图片 skillRequest 主链", async () => { - mockResolveImageWorkbenchSkillRequest.mockReturnValueOnce({ - images: [], - requestContext: { - kind: "image_task", - image_task: { - mode: "variation", - target_output_ref_id: "img-2", - }, - }, - }); + it("@重绘 应直达当前图片任务主链", async () => { const harness = mountHook({ input: "@重绘 #img-2 更偏插画风,保留主视觉", }); @@ -649,8 +585,8 @@ describe("useWorkspaceSendActions", () => { expect(started).toBe(true); }); - expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledTimes(1); - expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledWith( + expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledTimes(1); + expect(mockHandleImageWorkbenchCommand).toHaveBeenCalledWith( expect.objectContaining({ rawText: "@重绘 #img-2 更偏插画风,保留主视觉", parsedCommand: expect.objectContaining({ @@ -661,9 +597,39 @@ describe("useWorkspaceSendActions", () => { images: [], }), ); + expect(mockResolveImageWorkbenchSkillRequest).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); + } finally { + harness.unmount(); + } + }); + + it("/image 仍应保留原始消息,并通过 image_skill_launch metadata 交给 Agent", async () => { + mockResolveImageWorkbenchSkillRequest.mockReturnValueOnce({ + images: [], + requestContext: { + kind: "image_task", + image_task: { + mode: "generate", + prompt: "春日咖啡馆插画", + }, + }, + }); + const harness = mountHook({ + input: "/image 春日咖啡馆插画,16:9,出 2 张", + }); + + try { + await act(async () => { + const started = await harness.getValue().handleSend(); + expect(started).toBe(true); + }); + + expect(mockHandleImageWorkbenchCommand).not.toHaveBeenCalled(); + expect(mockResolveImageWorkbenchSkillRequest).toHaveBeenCalledTimes(1); expect(mockSendMessage).toHaveBeenCalledTimes(1); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( - "@重绘 #img-2 更偏插画风,保留主视觉", + "/image 春日咖啡馆插画,16:9,出 2 张", ); expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ requestMetadata: { @@ -672,10 +638,6 @@ describe("useWorkspaceSendActions", () => { image_skill_launch: { skill_name: "image_generate", kind: "image_task", - image_task: { - mode: "variation", - target_output_ref_id: "img-2", - }, }, }, }, diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts index e2751493a..fa41ef78f 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts @@ -358,6 +358,11 @@ interface UseWorkspaceSendActionsParams { handleAutoLaunchMatchedSiteSkill: ( match: AutoMatchedSiteSkill, ) => Promise; + handleImageWorkbenchCommand: (input: { + rawText: string; + parsedCommand: ParsedImageWorkbenchCommand; + images: MessageImage[]; + }) => Promise; resolveImageWorkbenchSkillRequest: (input: { rawText: string; parsedCommand: ParsedImageWorkbenchCommand; @@ -440,6 +445,7 @@ export function useWorkspaceSendActions({ setRuntimeTeamDispatchPreview, ensureBrowserAssistCanvas, handleAutoLaunchMatchedSiteSkill, + handleImageWorkbenchCommand, resolveImageWorkbenchSkillRequest, }: UseWorkspaceSendActionsParams) { const [submissionPreview, setSubmissionPreview] = @@ -494,6 +500,16 @@ export function useWorkspaceSendActions({ ? parseImageWorkbenchCommand(sourceText) : null; if (parsedImageWorkbenchCommand) { + if (parsedImageWorkbenchCommand.trigger.trim().startsWith("@")) { + return { + kind: "done", + result: await handleImageWorkbenchCommand({ + rawText: sourceText, + parsedCommand: parsedImageWorkbenchCommand, + images: effectiveImages, + }), + }; + } const skillRequest = resolveImageWorkbenchSkillRequest({ rawText: sourceText, parsedCommand: parsedImageWorkbenchCommand, @@ -712,6 +728,7 @@ export function useWorkspaceSendActions({ ensureBrowserAssistCanvas, executionStrategy, handleAutoLaunchMatchedSiteSkill, + handleImageWorkbenchCommand, resolveImageWorkbenchSkillRequest, input, maybeStartBrowserTaskPreflight, diff --git a/src/i18n/patches/en.json b/src/i18n/patches/en.json index b88115747..5cb85dbfc 100644 --- a/src/i18n/patches/en.json +++ b/src/i18n/patches/en.json @@ -429,7 +429,6 @@ "查看会话详情回调": "View Session Detail Callback", "主组件": "Main Component", "状态": "Status", - "// === Resilience Settings (src/components/resilience/*.tsx) ===": "", "重试配置": "Retry Configuration", "故障转移": "Failover", "容错配置": "Fault Tolerance Configuration", @@ -2602,7 +2601,6 @@ "系统每": "System checks every", "秒自动检查凭证文件变化": "seconds for credential file changes automatically", "如有更新会自动重新加载并记录日志": "Will automatically reload and log if there are updates", - "// === components\\resilience\\FailoverSettings.tsx ===": "", "发生故障时": "When a failure occurs", "系统可自动切换到其他可用": "System can automatically switch to other available", "切换日志记录所有自动切换事件": "Switch log records all automatic switching events", @@ -2617,7 +2615,6 @@ "暂无切换记录": "No switch records", "当发生自动切换时": "When automatic switching occurs", "记录将显示在这里": "Records will be displayed here", - "// === components\\resilience\\RetrySettings.tsx ===": "", "当请求失败时": "When a request fails", "系统会自动重试指定次数": "System will automatically retry the specified number of times", "使用指数退避策略": "Using exponential backoff strategy", diff --git a/src/i18n/patches/zh.json b/src/i18n/patches/zh.json index d1b9c68c0..436338a86 100644 --- a/src/i18n/patches/zh.json +++ b/src/i18n/patches/zh.json @@ -404,7 +404,6 @@ "查看会话详情回调": "查看会话详情回调", "主组件": "主组件", "状态": "状态", - "// === Resilience Settings (src/components/resilience/*.tsx) ===": "", "重试配置": "重试配置", "故障转移": "故障转移", "容错配置": "容错配置", @@ -2584,7 +2583,6 @@ "系统每": "系统每", "秒自动检查凭证文件变化": "秒自动检查凭证文件变化", "如有更新会自动重新加载并记录日志": "如有更新会自动重新加载并记录日志", - "// === components\\resilience\\FailoverSettings.tsx ===": "", "发生故障时": "发生故障时", "系统可自动切换到其他可用": "系统可自动切换到其他可用", "切换日志记录所有自动切换事件": "切换日志记录所有自动切换事件", @@ -2599,7 +2597,6 @@ "暂无切换记录": "暂无切换记录", "当发生自动切换时": "当发生自动切换时", "记录将显示在这里": "记录将显示在这里", - "// === components\\resilience\\RetrySettings.tsx ===": "", "当请求失败时": "当请求失败时", "系统会自动重试指定次数": "系统会自动重试指定次数", "使用指数退避策略": "使用指数退避策略", diff --git a/src/lib/api/resilience.ts b/src/lib/api/resilience.ts deleted file mode 100644 index a6d225574..000000000 --- a/src/lib/api/resilience.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { safeInvoke } from "@/lib/dev-bridge"; - -// Retry configuration -export interface RetryConfig { - max_retries: number; - base_delay_ms: number; - max_delay_ms: number; - retryable_codes: number[]; -} - -// Failover configuration -export interface FailoverConfig { - auto_switch: boolean; - switch_on_quota: boolean; -} - -// Switch log entry -export interface SwitchLogEntry { - from_provider: string; - to_provider: string; - failure_type: string; - timestamp: string; -} - -export const resilienceApi = { - // Retry config - async getRetryConfig(): Promise { - return safeInvoke("get_retry_config"); - }, - - async updateRetryConfig(config: RetryConfig): Promise { - return safeInvoke("update_retry_config", { config }); - }, - - // Failover config - async getFailoverConfig(): Promise { - return safeInvoke("get_failover_config"); - }, - - async updateFailoverConfig(config: FailoverConfig): Promise { - return safeInvoke("update_failover_config", { config }); - }, - - // Switch log - async getSwitchLog(): Promise { - return safeInvoke("get_switch_log"); - }, - - async clearSwitchLog(): Promise { - return safeInvoke("clear_switch_log"); - }, -}; diff --git a/src/lib/api/securityPerformance.ts b/src/lib/api/securityPerformance.ts deleted file mode 100644 index 09c8836ac..000000000 --- a/src/lib/api/securityPerformance.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { safeInvoke } from "@/lib/dev-bridge"; - -export interface RateLimitConfig { - enabled: boolean; - requests_per_minute: number; - window_secs: number; -} - -export interface ConversationConfig { - trim_enabled: boolean; - max_messages: number; - summary_enabled: boolean; -} - -export interface HintRouteEntry { - hint: string; - provider: string; - model: string; -} - -export interface PairingConfig { - enabled: boolean; -} - -export async function getRateLimitConfig(): Promise { - return await safeInvoke("get_rate_limit_config"); -} - -export async function updateRateLimitConfig(config: RateLimitConfig): Promise { - return await safeInvoke("update_rate_limit_config", { config }); -} - -export async function getConversationConfig(): Promise { - return await safeInvoke("get_conversation_config"); -} - -export async function updateConversationConfig(config: ConversationConfig): Promise { - return await safeInvoke("update_conversation_config", { config }); -} - -export async function getHintRoutes(): Promise { - return await safeInvoke("get_hint_routes"); -} - -export async function updateHintRoutes(routes: HintRouteEntry[]): Promise { - return await safeInvoke("update_hint_routes", { routes }); -} - -export async function getPairingConfig(): Promise { - return await safeInvoke("get_pairing_config"); -} - -export async function updatePairingConfig(config: PairingConfig): Promise { - return await safeInvoke("update_pairing_config", { config }); -} diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index 61c9ebbb5..071f647f1 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -3858,14 +3858,6 @@ const defaultMocks: Record = { // Usage 相关 get_kiro_usage: () => ({ usage: {} }), - // Resilience 相关 - get_retry_config: () => ({ config: {} }), - update_retry_config: () => ({ success: true }), - get_failover_config: () => ({ config: {} }), - update_failover_config: () => ({ success: true }), - get_switch_log: () => ({ logs: [] }), - clear_switch_log: () => ({ success: true }), - // Machine ID 相关 get_current_machine_id: () => ({ machine_id: "" }), set_machine_id: () => ({ success: true }),