From bfacf764b7b69c8a3e247225c22222867de959ea Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 10 Mar 2026 19:17:54 +0800 Subject: [PATCH] feat: release v0.83.2 with full pending changes --- RELEASE_NOTES.md | 40 +- package.json | 2 +- src-tauri/Cargo.lock | 32 +- src-tauri/Cargo.toml | 4 +- src-tauri/crates/agent/src/event_converter.rs | 83 ++- .../crates/agent/src/request_tool_policy.rs | 79 +++ src-tauri/crates/core/src/app_paths.rs | 579 ++++++++++++++++++ src-tauri/crates/core/src/database/mod.rs | 6 +- src-tauri/crates/core/src/lib.rs | 1 + src-tauri/crates/core/src/logger.rs | 11 +- .../crates/infra/src/telemetry/logger.rs | 11 +- .../crates/providers/src/providers/kiro.rs | 6 +- .../server/src/handlers/provider_calls.rs | 16 +- .../src/agent/heartbeat_service_adapter.rs | 7 +- src-tauri/src/app/commands/logs.rs | 8 +- src-tauri/src/app/runner.rs | 2 +- src-tauri/src/commands/config_cmd.rs | 7 +- src-tauri/src/commands/heartbeat_cmd.rs | 63 +- src-tauri/src/commands/webview_cmd.rs | 10 +- src-tauri/src/commands/windows_startup_cmd.rs | 55 +- src-tauri/src/services/openclaw_service.rs | 28 +- src-tauri/src/terminal/mod.rs | 6 +- src-tauri/src/tray/menu_handler.rs | 14 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 6 +- .../chat/components/MarkdownRenderer.test.tsx | 40 ++ .../chat/components/MarkdownRenderer.tsx | 16 +- .../components/StreamingRenderer.test.tsx | 150 +++++ .../chat/components/StreamingRenderer.tsx | 121 +++- .../agent/chat/hooks/useAgentChat.ts | 36 +- src/components/agent/chat/index.test.tsx | 38 ++ src/components/agent/chat/index.tsx | 52 +- .../utils/providerModelCompatibility.test.ts | 78 +++ .../chat/utils/providerModelCompatibility.ts | 105 ++++ .../chat/utils/streamDiagnostics.test.ts | 92 +++ .../agent/chat/utils/streamDiagnostics.ts | 218 +++++++ .../general-chat/hooks/useProvider.ts | 32 +- .../input-kit/ModelSelector.test.tsx | 123 ++++ src/components/input-kit/ModelSelector.tsx | 69 ++- .../ProviderModelSelector.test.tsx | 129 ++++ .../model-selector/ProviderModelSelector.tsx | 32 +- src/components/openclaw/OpenClawPage.tsx | 31 +- .../settings-v2/system/channels/index.tsx | 46 +- src/components/terminal/TerminalWorkspace.tsx | 24 +- .../ai/TerminalAIModeSelector.test.tsx | 107 ++++ .../terminal/ai/TerminalAIModeSelector.tsx | 39 +- src/components/voice/PolishModelSelector.tsx | 18 +- src/components/workspace/WorkbenchPage.tsx | 41 +- src/hooks/useProjects.ts | 18 +- 49 files changed, 2496 insertions(+), 237 deletions(-) create mode 100644 src-tauri/crates/core/src/app_paths.rs create mode 100644 src/components/agent/chat/components/StreamingRenderer.test.tsx create mode 100644 src/components/agent/chat/utils/providerModelCompatibility.test.ts create mode 100644 src/components/agent/chat/utils/providerModelCompatibility.ts create mode 100644 src/components/agent/chat/utils/streamDiagnostics.test.ts create mode 100644 src/components/agent/chat/utils/streamDiagnostics.ts create mode 100644 src/components/input-kit/ModelSelector.test.tsx create mode 100644 src/components/model-selector/ProviderModelSelector.test.tsx create mode 100644 src/components/terminal/ai/TerminalAIModeSelector.test.tsx diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e4410e791..2941b910c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,17 +1,37 @@ -## ProxyCast v0.83.1 +## ProxyCast v0.83.2 ### ✨ 新功能 -- 新增 OpenClaw 命令注册和集成 (dce47534) -- 新增 MarkdownRenderer 测试覆盖 -- 新增视频主题面板渲染器 +- 新增跨平台应用路径解析模块 `app_paths`,支持 macOS/Windows 目录迁移 +- Agent 事件转换器增强,支持更多事件类型处理 +- Agent 请求工具策略扩展,新增策略规则 +- 流式渲染器增强,新增流诊断工具和 Provider 模型兼容性检测 +- 终端 AI 模式选择器功能增强 +- OpenClaw 页面功能扩展 +- Windows 启动命令模块增强 + +### 🐛 修复 +- 修复 useMemo 依赖缺失导致的 React Hook 警告 +- 修复 Kiro Provider 凭证处理逻辑 +- 修复心跳服务适配器和心跳命令的稳定性问题 +- 修复日志模块和遥测日志的路径处理 +- 修复数据库模块初始化问题 +- 修复托盘菜单事件处理逻辑 ### 🔧 优化与重构 -- 优化视频画布和侧边栏组件 -- 优化工作台控制器和快捷操作 -- 优化工作台右侧面板功能 -- 优化 OpenClaw 页面和类型定义 -- 优化 Agent 事件转换器 +- Provider 模型选择器组件重构,提升可维护性 +- ModelSelector 组件优化,增加测试覆盖 +- 通用聊天 useProvider Hook 重构 +- Workbench 页面布局优化 +- 频道设置页面改进 +- 终端工作区组件优化 +- 语音润色模型选择器改进 +- useProjects Hook 优化 + +### 📦 其他 +- 新增多个组件单元测试(StreamingRenderer、ProviderModelSelector、TerminalAIModeSelector、ModelSelector) +- 新增流诊断和 Provider 模型兼容性工具测试 +- Cargo.lock 依赖更新 --- -**完整变更**: v0.83.0...v0.83.1 +**完整变更**: v0.83.1...v0.83.2 diff --git a/package.json b/package.json index 9043a8c41..b141ca220 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.83.1", + "version": "0.83.2", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 81ace37bc..c053a8364 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6952,7 +6952,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.83.0" +version = "0.83.1" dependencies = [ "anyhow", "arboard", @@ -7054,7 +7054,7 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.83.0" +version = "0.83.1" dependencies = [ "aster-core", "async-trait", @@ -7079,7 +7079,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.83.0" +version = "0.83.1" dependencies = [ "async-trait", "parking_lot", @@ -7095,7 +7095,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.83.0" +version = "0.83.1" dependencies = [ "aster-models", "async-trait", @@ -7135,7 +7135,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.83.0" +version = "0.83.1" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -7170,7 +7170,7 @@ dependencies = [ [[package]] name = "proxycast-gateway" -version = "0.83.0" +version = "0.83.1" dependencies = [ "axum 0.7.9", "chrono", @@ -7191,7 +7191,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.83.0" +version = "0.83.1" dependencies = [ "chrono", "dashmap 5.5.3", @@ -7211,7 +7211,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.83.0" +version = "0.83.1" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7243,7 +7243,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.83.0" +version = "0.83.1" dependencies = [ "async-trait", "parking_lot", @@ -7262,7 +7262,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.83.0" +version = "0.83.1" dependencies = [ "anyhow", "async-stream", @@ -7316,7 +7316,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.83.0" +version = "0.83.1" dependencies = [ "aster-core", "async-stream", @@ -7361,7 +7361,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.83.0" +version = "0.83.1" dependencies = [ "axum 0.7.9", "futures", @@ -7376,7 +7376,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.83.0" +version = "0.83.1" dependencies = [ "anyhow", "aster-core", @@ -7417,7 +7417,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.83.0" +version = "0.83.1" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7433,7 +7433,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.83.0" +version = "0.83.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -7460,7 +7460,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.83.0" +version = "0.83.1" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e82dbb7c1..aa5bbe629 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.83.1" +version = "0.83.2" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/proxycast" @@ -191,7 +191,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.83.1" +version = "0.83.2" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index 24aeb5047..b608c9d28 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -14,6 +14,9 @@ const TOOL_RESULT_MAX_TEXT_PARTS: usize = 256; const TOOL_RESULT_MAX_OUTPUT_CHARS: usize = 16_000; const TOOL_RESULT_MAX_IMAGES: usize = 12; const TOOL_RESULT_TRUNCATED_NOTICE: &str = "\n\n[event_converter] 工具输出已截断"; +const TOOL_RESULT_DIAG_WARN_JSON_BYTES: usize = 64 * 1024; +const TOOL_RESULT_DIAG_WARN_OUTPUT_CHARS: usize = 8_000; +const TOOL_RESULT_DIAG_WARN_IMAGE_COUNT: usize = 4; fn enhance_execution_error_text(raw: &str) -> String { if !raw.contains("Execution error: No such file or directory (os error 2)") { @@ -221,6 +224,45 @@ fn maybe_filter_web_content(raw: &str) -> String { struct ExtractedToolResult { output: String, images: Vec, + diagnostics: ToolResultDiagnostics, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ToolResultDiagnostics { + raw_json_bytes: Option, + output_chars: usize, + image_count: usize, + text_truncated: bool, + images_truncated: bool, +} + +fn log_tool_result_diagnostics(tool_id: &str, diagnostics: &ToolResultDiagnostics) { + let raw_json_bytes = diagnostics.raw_json_bytes.unwrap_or(0); + let should_warn = diagnostics.text_truncated + || diagnostics.images_truncated + || raw_json_bytes >= TOOL_RESULT_DIAG_WARN_JSON_BYTES + || diagnostics.output_chars >= TOOL_RESULT_DIAG_WARN_OUTPUT_CHARS + || diagnostics.image_count >= TOOL_RESULT_DIAG_WARN_IMAGE_COUNT; + + if should_warn { + tracing::warn!( + "[AsterAgent][Diag] tool_end payload summary: tool_id={}, raw_json_bytes={}, output_chars={}, image_count={}, text_truncated={}, images_truncated={}", + tool_id, + raw_json_bytes, + diagnostics.output_chars, + diagnostics.image_count, + diagnostics.text_truncated, + diagnostics.images_truncated + ); + } else { + tracing::debug!( + "[AsterAgent][Diag] tool_end payload summary: tool_id={}, raw_json_bytes={}, output_chars={}, image_count={}", + tool_id, + raw_json_bytes, + diagnostics.output_chars, + diagnostics.image_count + ); + } } fn parse_mime_type_from_data_url(data_url: &str) -> Option { @@ -387,6 +429,8 @@ fn extract_tool_result_data(result: &T) -> ExtractedToolRes let output = extract_tool_result_text(result); let mut images = Vec::new(); let mut seen_sources = std::collections::HashSet::new(); + let mut raw_json_bytes = None; + let mut images_truncated = false; for data_url in extract_data_urls_from_text(&output) { push_tool_image_if_new( @@ -397,10 +441,25 @@ fn extract_tool_result_data(result: &T) -> ExtractedToolRes } if let Ok(json) = serde_json::to_value(result) { - let _ = collect_tool_result_images(&json, &mut images, &mut seen_sources); + raw_json_bytes = serde_json::to_vec(&json).ok().map(|bytes| bytes.len()); + images_truncated = collect_tool_result_images(&json, &mut images, &mut seen_sources); } - ExtractedToolResult { output, images } + let output_chars = output.chars().count(); + let image_count = images.len(); + let text_truncated = output.contains(TOOL_RESULT_TRUNCATED_NOTICE); + + ExtractedToolResult { + output, + images, + diagnostics: ToolResultDiagnostics { + raw_json_bytes, + output_chars, + image_count, + text_truncated, + images_truncated, + }, + } } /// Tauri Agent 事件 @@ -629,6 +688,7 @@ fn convert_message(message: Message) -> Vec { let (success, output, error, images) = match &tool_response.tool_result { Ok(result) => { let extracted = extract_tool_result_data(result); + log_tool_result_diagnostics(&tool_response.id, &extracted.diagnostics); ( true, extracted.output, @@ -1022,5 +1082,24 @@ mod tests { let extracted = extract_tool_result_data(&payload); assert_eq!(extracted.images.len(), TOOL_RESULT_MAX_IMAGES); + assert!(extracted.diagnostics.images_truncated); + } + + #[test] + fn test_extract_tool_result_data_should_record_diagnostics() { + let payload = serde_json::json!({ + "content": [ + { + "type": "text", + "text": "hello" + } + ] + }); + + let extracted = extract_tool_result_data(&payload); + assert_eq!(extracted.diagnostics.output_chars, 5); + assert_eq!(extracted.diagnostics.image_count, 0); + assert_eq!(extracted.diagnostics.text_truncated, false); + assert!(extracted.diagnostics.raw_json_bytes.is_some()); } } diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index aa17d4ebf..76e20158e 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -21,6 +21,9 @@ const WEB_SEARCH_REQUIRED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_REQUIRED_TOOLS const WEB_SEARCH_ALLOWED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_ALLOWED_TOOLS"; const WEB_SEARCH_DISALLOWED_TOOLS_ENV: &str = "PROXYCAST_WEB_SEARCH_DISALLOWED_TOOLS"; const WEB_SEARCH_PREFLIGHT_ENABLED_ENV: &str = "PROXYCAST_WEB_SEARCH_PREFLIGHT_ENABLED"; +const STREAM_EVENT_DIAG_WARN_TEXT_DELTA_CHARS: usize = 2_000; +const STREAM_EVENT_DIAG_WARN_TOOL_OUTPUT_CHARS: usize = 8_000; +const STREAM_EVENT_DIAG_WARN_CONTEXT_STEPS: usize = 24; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RequestToolPolicy { @@ -188,6 +191,68 @@ pub struct ReplyAttemptError { pub emitted_any: bool, } +#[derive(Debug, Default)] +struct StreamEventDiagnostics { + text_delta_count: usize, + tool_start_count: usize, + tool_end_count: usize, + error_count: usize, + context_trace_events: usize, + max_text_delta_chars: usize, + max_tool_output_chars: usize, + max_context_trace_steps: usize, +} + +fn update_stream_event_diagnostics( + diagnostics: &mut StreamEventDiagnostics, + event: &TauriAgentEvent, +) { + match event { + TauriAgentEvent::TextDelta { text } => { + diagnostics.text_delta_count += 1; + let char_count = text.chars().count(); + diagnostics.max_text_delta_chars = diagnostics.max_text_delta_chars.max(char_count); + if char_count >= STREAM_EVENT_DIAG_WARN_TEXT_DELTA_CHARS { + tracing::warn!( + "[AsterAgent][Diag] large text_delta observed: chars={}", + char_count + ); + } + } + TauriAgentEvent::ToolStart { .. } => { + diagnostics.tool_start_count += 1; + } + TauriAgentEvent::ToolEnd { tool_id, result } => { + diagnostics.tool_end_count += 1; + let output_chars = result.output.chars().count(); + diagnostics.max_tool_output_chars = diagnostics.max_tool_output_chars.max(output_chars); + if output_chars >= STREAM_EVENT_DIAG_WARN_TOOL_OUTPUT_CHARS { + tracing::warn!( + "[AsterAgent][Diag] large tool_end output observed: tool_id={}, output_chars={}, success={}", + tool_id, + output_chars, + result.success + ); + } + } + TauriAgentEvent::ContextTrace { steps } => { + diagnostics.context_trace_events += 1; + diagnostics.max_context_trace_steps = + diagnostics.max_context_trace_steps.max(steps.len()); + if steps.len() >= STREAM_EVENT_DIAG_WARN_CONTEXT_STEPS { + tracing::warn!( + "[AsterAgent][Diag] large context_trace observed: steps={}", + steps.len() + ); + } + } + TauriAgentEvent::Error { .. } => { + diagnostics.error_count += 1; + } + _ => {} + } +} + #[derive(Debug, Clone, Default)] pub struct StreamReplyExecution { pub text_output: String, @@ -541,6 +606,7 @@ where let mut emitted_any = false; let mut text_chunks: Vec = Vec::new(); let mut event_errors: Vec = Vec::new(); + let mut diagnostics = StreamEventDiagnostics::default(); while let Some(event_result) = stream.next().await { match event_result { @@ -580,6 +646,7 @@ where } _ => {} } + update_stream_event_diagnostics(&mut diagnostics, &tauri_event); on_event(&tauri_event); } if let Some(message) = inline_provider_error { @@ -607,6 +674,18 @@ where }); } + tracing::info!( + "[AsterAgent][Diag] stream summary: text_deltas={}, tool_starts={}, tool_ends={}, context_traces={}, errors={}, max_text_delta_chars={}, max_tool_output_chars={}, max_context_trace_steps={}", + diagnostics.text_delta_count, + diagnostics.tool_start_count, + diagnostics.tool_end_count, + diagnostics.context_trace_events, + diagnostics.error_count, + diagnostics.max_text_delta_chars, + diagnostics.max_tool_output_chars, + diagnostics.max_context_trace_steps + ); + Ok(StreamReplyExecution { text_output: text_chunks.join(""), event_errors, diff --git a/src-tauri/crates/core/src/app_paths.rs b/src-tauri/crates/core/src/app_paths.rs new file mode 100644 index 000000000..e090126ad --- /dev/null +++ b/src-tauri/crates/core/src/app_paths.rs @@ -0,0 +1,579 @@ +use rusqlite::{Connection, DatabaseName}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const APP_DATA_DIR_NAME: &str = "proxycast"; +const LEGACY_HOME_DIR_NAME: &str = ".proxycast"; +const DATABASE_FILE_NAME: &str = "proxycast.db"; +const MIGRATION_MARKER_FILE: &str = ".migration_completed"; +const USER_SIGNAL_TABLES: &[&str] = &[ + "contents", + "agent_sessions", + "general_chat_sessions", + "materials", + "api_keys", + "heartbeat_executions", +]; + +pub fn preferred_data_dir() -> Result { + let dir = dirs::data_dir() + .ok_or_else(|| "无法获取应用数据目录".to_string())? + .join(APP_DATA_DIR_NAME); + fs::create_dir_all(&dir).map_err(|e| format!("无法创建应用数据目录 {}: {e}", dir.display()))?; + Ok(dir) +} + +pub fn legacy_home_dir() -> Result { + Ok(dirs::home_dir() + .ok_or_else(|| "无法获取主目录".to_string())? + .join(LEGACY_HOME_DIR_NAME)) +} + +pub fn preferred_database_path() -> Result { + Ok(preferred_data_dir()?.join(DATABASE_FILE_NAME)) +} + +pub fn legacy_database_path() -> Result { + Ok(legacy_home_dir()?.join(DATABASE_FILE_NAME)) +} + +pub fn resolve_database_path() -> Result { + let preferred_root = preferred_data_dir()?; + let legacy_root = legacy_home_dir()?; + resolve_database_path_from_roots(&preferred_root, &legacy_root) +} + +pub fn resolve_logs_dir() -> Result { + let preferred_root = preferred_data_dir()?; + let legacy_root = legacy_home_dir()?; + resolve_subdir_with_legacy_copy_from_roots(&preferred_root, &legacy_root, "logs") +} + +pub fn resolve_request_logs_dir() -> Result { + let preferred_root = preferred_data_dir()?; + let legacy_root = legacy_home_dir()?; + resolve_subdir_with_legacy_copy_from_roots(&preferred_root, &legacy_root, "request_logs") +} + +fn resolve_database_path_from_roots( + preferred_root: &Path, + legacy_root: &Path, +) -> Result { + fs::create_dir_all(preferred_root) + .map_err(|e| format!("无法创建数据库目录 {}: {e}", preferred_root.display()))?; + + let preferred_path = preferred_root.join(DATABASE_FILE_NAME); + let marker_path = preferred_root.join(MIGRATION_MARKER_FILE); + + // 标记文件存在 → 迁移已完成,直接用 preferred 路径 + if marker_path.exists() { + return Ok(preferred_path); + } + + let legacy_path = legacy_root.join(DATABASE_FILE_NAME); + + // 无旧库 → 全新安装,写标记后直接返回 + if !legacy_path.exists() { + write_migration_marker(&marker_path); + return Ok(preferred_path); + } + + // preferred 库不存在 → 首次迁移 + if !preferred_path.exists() { + let result = migrate_or_fallback_to_legacy(&legacy_path, &preferred_path); + if result + .as_ref() + .map(|p| p == &preferred_path) + .unwrap_or(false) + { + write_migration_marker(&marker_path); + } + return result; + } + + // 两个库都存在,检查是否需要用旧库覆盖空的新库 + let preferred_signal = inspect_database_signal(&preferred_path); + let legacy_signal = inspect_database_signal(&legacy_path); + + if should_replace_preferred_with_legacy( + preferred_path.as_path(), + preferred_signal.as_ref(), + legacy_path.as_path(), + legacy_signal.as_ref(), + ) { + let result = migrate_or_fallback_to_legacy(&legacy_path, &preferred_path); + if result + .as_ref() + .map(|p| p == &preferred_path) + .unwrap_or(false) + { + write_migration_marker(&marker_path); + } + return result; + } + + // preferred 库已有用户数据,迁移完成 + write_migration_marker(&marker_path); + Ok(preferred_path) +} + +fn write_migration_marker(marker_path: &Path) { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs().to_string()) + .unwrap_or_default(); + if let Err(e) = fs::write(marker_path, timestamp) { + tracing::warn!( + "[路径迁移] 写入迁移标记失败 {}(下次启动会重新检测): {e}", + marker_path.display() + ); + } +} + +fn migrate_or_fallback_to_legacy( + legacy_path: &Path, + preferred_path: &Path, +) -> Result { + match migrate_legacy_database(legacy_path, preferred_path) { + Ok(()) => { + tracing::info!( + "[路径迁移] 数据库已从旧路径迁移到 {}", + preferred_path.display() + ); + Ok(preferred_path.to_path_buf()) + } + Err(error) => { + tracing::warn!( + "[路径迁移] 数据库迁移失败,回退旧路径 {}: {}", + legacy_path.display(), + error + ); + Ok(legacy_path.to_path_buf()) + } + } +} + +fn resolve_subdir_with_legacy_copy_from_roots( + preferred_root: &Path, + legacy_root: &Path, + subdir: &str, +) -> Result { + let preferred_dir = preferred_root.join(subdir); + fs::create_dir_all(&preferred_dir) + .map_err(|e| format!("无法创建目录 {}: {e}", preferred_dir.display()))?; + + // 标记文件存在 → 迁移已完成,跳过旧目录扫描 + let marker_path = preferred_root.join(MIGRATION_MARKER_FILE); + if marker_path.exists() { + return Ok(preferred_dir); + } + + let legacy_dir = legacy_root.join(subdir); + if legacy_dir.exists() { + copy_dir_contents_if_missing(&legacy_dir, &preferred_dir)?; + } + + Ok(preferred_dir) +} + +fn migrate_legacy_database(legacy_path: &Path, preferred_path: &Path) -> Result<(), String> { + if let Some(parent) = preferred_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("无法创建数据库目录 {}: {e}", parent.display()))?; + } + + let source = Connection::open(legacy_path) + .map_err(|e| format!("打开旧数据库失败 {}: {e}", legacy_path.display()))?; + source + .busy_timeout(std::time::Duration::from_secs(5)) + .map_err(|e| format!("设置旧数据库 busy_timeout 失败: {e}"))?; + let _ = source.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);"); + + backup_existing_database(preferred_path)?; + remove_database_with_sidecars(preferred_path)?; + + match source.backup(DatabaseName::Main, preferred_path, None) { + Ok(()) => Ok(()), + Err(error) => { + let _ = remove_database_with_sidecars(preferred_path); + Err(format!( + "复制旧数据库 {} -> {} 失败: {error}", + legacy_path.display(), + preferred_path.display() + )) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DatabaseSignal { + user_signal: u64, + has_schema: bool, +} + +fn inspect_database_signal(path: &Path) -> Option { + if !path.exists() { + return None; + } + + let conn = Connection::open(path).ok()?; + let has_schema = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table'", + [], + |row| row.get::<_, u64>(0), + ) + .ok() + .map(|count| count > 0) + .unwrap_or(false); + + let user_signal = USER_SIGNAL_TABLES + .iter() + .map(|table| { + let sql = format!("SELECT COUNT(*) FROM {table}"); + conn.query_row(&sql, [], |row| row.get::<_, u64>(0)) + .unwrap_or(0) + }) + .sum(); + + Some(DatabaseSignal { + user_signal, + has_schema, + }) +} + +fn should_replace_preferred_with_legacy( + preferred_path: &Path, + preferred_signal: Option<&DatabaseSignal>, + legacy_path: &Path, + legacy_signal: Option<&DatabaseSignal>, +) -> bool { + let Some(legacy_signal) = legacy_signal else { + return false; + }; + + let Some(preferred_signal) = preferred_signal else { + return true; + }; + + if !preferred_signal.has_schema && legacy_signal.has_schema { + tracing::warn!( + "[路径迁移] 当前数据库 {} 无有效 schema,准备回退旧库 {}", + preferred_path.display(), + legacy_path.display() + ); + return true; + } + + if preferred_signal.user_signal == 0 && legacy_signal.user_signal > 0 { + tracing::warn!( + "[路径迁移] 当前数据库 {} 缺少用户数据,检测到旧库 {} 含历史数据,准备自动恢复", + preferred_path.display(), + legacy_path.display() + ); + return true; + } + + false +} + +fn backup_existing_database(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + let backup_path = path.with_file_name(format!( + "{DATABASE_FILE_NAME}.bootstrap-backup-{suffix}.bak" + )); + fs::copy(path, &backup_path).map_err(|e| { + format!( + "备份当前数据库失败 {} -> {}: {e}", + path.display(), + backup_path.display() + ) + })?; + Ok(()) +} + +fn remove_database_with_sidecars(path: &Path) -> Result<(), String> { + if path.exists() { + fs::remove_file(path) + .map_err(|e| format!("删除旧数据库文件失败 {}: {e}", path.display()))?; + } + + for suffix in ["-wal", "-shm"] { + let sidecar = PathBuf::from(format!("{}{}", path.display(), suffix)); + if sidecar.exists() { + fs::remove_file(&sidecar) + .map_err(|e| format!("删除数据库伴生文件失败 {}: {e}", sidecar.display()))?; + } + } + + Ok(()) +} + +fn copy_dir_contents_if_missing(from: &Path, to: &Path) -> Result<(), String> { + let entries = + fs::read_dir(from).map_err(|e| format!("读取目录失败 {}: {e}", from.display()))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("读取目录项失败 {}: {e}", from.display()))?; + let source_path = entry.path(); + let target_path = to.join(entry.file_name()); + + if source_path.is_dir() { + fs::create_dir_all(&target_path) + .map_err(|e| format!("创建目录失败 {}: {e}", target_path.display()))?; + copy_dir_contents_if_missing(&source_path, &target_path)?; + continue; + } + + if target_path.exists() { + continue; + } + + fs::copy(&source_path, &target_path).map_err(|e| { + format!( + "复制文件失败 {} -> {}: {e}", + source_path.display(), + target_path.display() + ) + })?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn resolve_database_path_migrates_legacy_database() { + let temp = tempdir().unwrap(); + let preferred_root = temp.path().join("appdata").join("proxycast"); + let legacy_root = temp.path().join("home").join(".proxycast"); + fs::create_dir_all(&legacy_root).unwrap(); + + let legacy_db = legacy_root.join(DATABASE_FILE_NAME); + let conn = Connection::open(&legacy_db).unwrap(); + conn.execute( + "CREATE TABLE sample (id INTEGER PRIMARY KEY, name TEXT)", + [], + ) + .unwrap(); + conn.execute("INSERT INTO sample (name) VALUES ('proxycast')", []) + .unwrap(); + + let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap(); + assert_eq!(resolved, preferred_root.join(DATABASE_FILE_NAME)); + assert!(resolved.exists()); + + let migrated = Connection::open(resolved).unwrap(); + let name: String = migrated + .query_row("SELECT name FROM sample LIMIT 1", [], |row| row.get(0)) + .unwrap(); + assert_eq!(name, "proxycast"); + } + + #[test] + fn resolve_logs_dir_copies_legacy_files() { + let temp = tempdir().unwrap(); + let preferred_root = temp.path().join("appdata").join("proxycast"); + let legacy_root = temp.path().join("home").join(".proxycast"); + let legacy_logs = legacy_root.join("logs"); + fs::create_dir_all(&legacy_logs).unwrap(); + fs::write(legacy_logs.join("proxycast.log"), "legacy log").unwrap(); + + let resolved = + resolve_subdir_with_legacy_copy_from_roots(&preferred_root, &legacy_root, "logs") + .unwrap(); + + assert_eq!(resolved, preferred_root.join("logs")); + assert_eq!( + fs::read_to_string(resolved.join("proxycast.log")).unwrap(), + "legacy log" + ); + } + + #[test] + fn resolve_database_path_replaces_bootstrap_db_with_legacy_data() { + let temp = tempdir().unwrap(); + let preferred_root = temp.path().join("appdata").join("proxycast"); + let legacy_root = temp.path().join("home").join(".proxycast"); + fs::create_dir_all(&preferred_root).unwrap(); + fs::create_dir_all(&legacy_root).unwrap(); + + let preferred_db = preferred_root.join(DATABASE_FILE_NAME); + let preferred_conn = Connection::open(&preferred_db).unwrap(); + preferred_conn + .execute( + "CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)", + [], + ) + .unwrap(); + + let legacy_db = legacy_root.join(DATABASE_FILE_NAME); + let legacy_conn = Connection::open(&legacy_db).unwrap(); + legacy_conn + .execute( + "CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)", + [], + ) + .unwrap(); + legacy_conn + .execute( + "CREATE TABLE agent_sessions (id INTEGER PRIMARY KEY, name TEXT)", + [], + ) + .unwrap(); + legacy_conn + .execute("INSERT INTO contents (title) VALUES ('legacy')", []) + .unwrap(); + + let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap(); + let conn = Connection::open(resolved).unwrap(); + let count: u64 = conn + .query_row("SELECT COUNT(*) FROM contents", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn resolve_database_path_keeps_preferred_when_it_has_user_data() { + let temp = tempdir().unwrap(); + let preferred_root = temp.path().join("appdata").join("proxycast"); + let legacy_root = temp.path().join("home").join(".proxycast"); + fs::create_dir_all(&preferred_root).unwrap(); + fs::create_dir_all(&legacy_root).unwrap(); + + let preferred_db = preferred_root.join(DATABASE_FILE_NAME); + let preferred_conn = Connection::open(&preferred_db).unwrap(); + preferred_conn + .execute( + "CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)", + [], + ) + .unwrap(); + preferred_conn + .execute("INSERT INTO contents (title) VALUES ('preferred')", []) + .unwrap(); + + let legacy_db = legacy_root.join(DATABASE_FILE_NAME); + let legacy_conn = Connection::open(&legacy_db).unwrap(); + legacy_conn + .execute( + "CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)", + [], + ) + .unwrap(); + legacy_conn + .execute("INSERT INTO contents (title) VALUES ('legacy')", []) + .unwrap(); + + let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap(); + let conn = Connection::open(resolved).unwrap(); + let title: String = conn + .query_row("SELECT title FROM contents LIMIT 1", [], |row| row.get(0)) + .unwrap(); + assert_eq!(title, "preferred"); + } + + #[test] + fn resolve_database_path_skips_migration_when_marker_exists() { + let temp = tempdir().unwrap(); + let preferred_root = temp.path().join("appdata").join("proxycast"); + let legacy_root = temp.path().join("home").join(".proxycast"); + fs::create_dir_all(&preferred_root).unwrap(); + fs::create_dir_all(&legacy_root).unwrap(); + + // preferred 库为空(只有 schema) + let preferred_db = preferred_root.join(DATABASE_FILE_NAME); + let preferred_conn = Connection::open(&preferred_db).unwrap(); + preferred_conn + .execute( + "CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)", + [], + ) + .unwrap(); + drop(preferred_conn); + + // 旧库有数据 + let legacy_db = legacy_root.join(DATABASE_FILE_NAME); + let legacy_conn = Connection::open(&legacy_db).unwrap(); + legacy_conn + .execute( + "CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)", + [], + ) + .unwrap(); + legacy_conn + .execute("INSERT INTO contents (title) VALUES ('legacy')", []) + .unwrap(); + drop(legacy_conn); + + // 写入标记文件 → 模拟已迁移过 + fs::write(preferred_root.join(MIGRATION_MARKER_FILE), "1700000000").unwrap(); + + // 即使旧库有数据、新库为空,也不应触发迁移 + let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap(); + assert_eq!(resolved, preferred_db); + + let conn = Connection::open(&resolved).unwrap(); + let count: u64 = conn + .query_row("SELECT COUNT(*) FROM contents", [], |row| row.get(0)) + .unwrap(); + // 新库仍为空,说明没有被旧库覆盖 + assert_eq!(count, 0); + } + + #[test] + fn resolve_database_path_writes_marker_after_successful_migration() { + let temp = tempdir().unwrap(); + let preferred_root = temp.path().join("appdata").join("proxycast"); + let legacy_root = temp.path().join("home").join(".proxycast"); + fs::create_dir_all(&legacy_root).unwrap(); + + let legacy_db = legacy_root.join(DATABASE_FILE_NAME); + let conn = Connection::open(&legacy_db).unwrap(); + conn.execute( + "CREATE TABLE contents (id INTEGER PRIMARY KEY, title TEXT)", + [], + ) + .unwrap(); + conn.execute("INSERT INTO contents (title) VALUES ('data')", []) + .unwrap(); + drop(conn); + + let marker_path = preferred_root.join(MIGRATION_MARKER_FILE); + assert!(!marker_path.exists()); + + let _ = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap(); + + // 迁移成功后标记文件应存在 + assert!(marker_path.exists()); + } + + #[test] + fn resolve_database_path_writes_marker_for_fresh_install() { + let temp = tempdir().unwrap(); + let preferred_root = temp.path().join("appdata").join("proxycast"); + let legacy_root = temp.path().join("home").join(".proxycast"); + // 不创建 legacy_root → 模拟全新安装 + + let marker_path = preferred_root.join(MIGRATION_MARKER_FILE); + assert!(!marker_path.exists()); + + let resolved = resolve_database_path_from_roots(&preferred_root, &legacy_root).unwrap(); + assert_eq!(resolved, preferred_root.join(DATABASE_FILE_NAME)); + + // 全新安装也应写标记 + assert!(marker_path.exists()); + } +} diff --git a/src-tauri/crates/core/src/database/mod.rs b/src-tauri/crates/core/src/database/mod.rs index b0fd5c048..6cc9286b0 100644 --- a/src-tauri/crates/core/src/database/mod.rs +++ b/src-tauri/crates/core/src/database/mod.rs @@ -6,6 +6,7 @@ pub mod migration_v4; pub mod schema; pub mod system_providers; +use crate::app_paths; use rusqlite::Connection; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -26,10 +27,7 @@ pub fn lock_db(db: &DbConnection) -> Result Result { - let home = dirs::home_dir().ok_or_else(|| "无法获取主目录".to_string())?; - let db_dir = home.join(".proxycast"); - std::fs::create_dir_all(&db_dir).map_err(|e| format!("无法创建数据库目录 {db_dir:?}: {e}"))?; - Ok(db_dir.join("proxycast.db")) + app_paths::resolve_database_path() } /// 初始化数据库连接 diff --git a/src-tauri/crates/core/src/lib.rs b/src-tauri/crates/core/src/lib.rs index 1c3042fb5..2b9c6ec47 100644 --- a/src-tauri/crates/core/src/lib.rs +++ b/src-tauri/crates/core/src/lib.rs @@ -18,6 +18,7 @@ //! - `session_files`: 会话文件存储 pub mod app_bootstrap; +pub mod app_paths; pub mod app_utils; pub mod data; pub mod logger; diff --git a/src-tauri/crates/core/src/logger.rs b/src-tauri/crates/core/src/logger.rs index 1908be37e..cadc9f422 100644 --- a/src-tauri/crates/core/src/logger.rs +++ b/src-tauri/crates/core/src/logger.rs @@ -1,4 +1,5 @@ //! 日志管理模块 +use crate::app_paths; use crate::config::LoggingConfig; use chrono::{Duration, Local, Utc}; use regex::Regex; @@ -44,10 +45,12 @@ pub struct LogStore { impl Default for LogStore { fn default() -> Self { - let log_dir = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".proxycast") - .join("logs"); + let log_dir = app_paths::resolve_logs_dir().unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".proxycast") + .join("logs") + }); let _ = fs::create_dir_all(&log_dir); let log_file = log_dir.join("proxycast.log"); let config = LogStoreConfig::default(); diff --git a/src-tauri/crates/infra/src/telemetry/logger.rs b/src-tauri/crates/infra/src/telemetry/logger.rs index d0337df7c..bfa0f4900 100644 --- a/src-tauri/crates/infra/src/telemetry/logger.rs +++ b/src-tauri/crates/infra/src/telemetry/logger.rs @@ -100,15 +100,8 @@ pub struct RequestLogger { impl RequestLogger { /// 创建新的日志记录器 pub fn new(config: LogRotationConfig) -> Result { - let log_dir = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".proxycast") - .join("request_logs"); - - // 创建日志目录 - fs::create_dir_all(&log_dir).map_err(|e| { - LoggerError::DirectoryCreation(format!("无法创建日志目录 {log_dir:?}: {e}")) - })?; + let log_dir = proxycast_core::app_paths::resolve_request_logs_dir() + .map_err(LoggerError::DirectoryCreation)?; let logger = Self { logs: RwLock::new(VecDeque::with_capacity(config.max_memory_logs)), diff --git a/src-tauri/crates/providers/src/providers/kiro.rs b/src-tauri/crates/providers/src/providers/kiro.rs index ac89a07bf..e9f4466d0 100644 --- a/src-tauri/crates/providers/src/providers/kiro.rs +++ b/src-tauri/crates/providers/src/providers/kiro.rs @@ -1006,10 +1006,8 @@ impl KiroProvider { .unwrap_or(false); if debug_enabled { if let Ok(json_str) = serde_json::to_string_pretty(&cw_request) { - let debug_dir = dirs::home_dir() - .unwrap_or_default() - .join(".proxycast") - .join("logs"); + let debug_dir = proxycast_core::app_paths::resolve_logs_dir() + .unwrap_or_else(|_| std::env::temp_dir().join("proxycast").join("logs")); let uuid_prefix = uuid::Uuid::new_v4() .to_string() .split('-') diff --git a/src-tauri/crates/server/src/handlers/provider_calls.rs b/src-tauri/crates/server/src/handlers/provider_calls.rs index 6372ffc49..98a0fd81b 100644 --- a/src-tauri/crates/server/src/handlers/provider_calls.rs +++ b/src-tauri/crates/server/src/handlers/provider_calls.rs @@ -1329,9 +1329,8 @@ pub async fn call_provider_openai( Ok(resp) => { let resp_str = serde_json::to_string_pretty(&resp).unwrap_or_default(); if is_proxycast_debug_enabled() { - let debug_dir = dirs::home_dir() - .map(|h| h.join(".proxycast/logs")) - .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); + let debug_dir = proxycast_core::app_paths::resolve_logs_dir() + .unwrap_or_else(|_| std::env::temp_dir().join("proxycast").join("logs")); let _ = std::fs::create_dir_all(&debug_dir); let debug_file = debug_dir.join("antigravity_image_response.json"); let _ = std::fs::write(&debug_file, &resp_str); @@ -1354,9 +1353,8 @@ pub async fn call_provider_openai( let openai_str = serde_json::to_string_pretty(&openai_response).unwrap_or_default(); if is_proxycast_debug_enabled() { - let debug_dir = dirs::home_dir() - .map(|h| h.join(".proxycast/logs")) - .unwrap_or_else(|| std::path::PathBuf::from("/tmp")); + let debug_dir = proxycast_core::app_paths::resolve_logs_dir() + .unwrap_or_else(|_| std::env::temp_dir().join("proxycast").join("logs")); let _ = std::fs::create_dir_all(&debug_dir); let openai_debug_file = debug_dir.join("antigravity_image_openai_response.json"); @@ -2887,9 +2885,9 @@ fn parse_antigravity_accumulated_response(data: &str, model: &str) -> Result Result { - self.app_handle - .path() - .app_data_dir() + app_paths::preferred_data_dir() .map_err(|e| HeartbeatToolError::IoError(format!("获取应用数据目录失败: {}", e))) } diff --git a/src-tauri/src/app/commands/logs.rs b/src-tauri/src/app/commands/logs.rs index 0109d51ee..a148e5b0c 100644 --- a/src-tauri/src/app/commands/logs.rs +++ b/src-tauri/src/app/commands/logs.rs @@ -12,7 +12,7 @@ use std::fs; use std::io::{Read, Seek, Write}; use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; -use tauri::{AppHandle, Manager}; +use tauri::AppHandle; use zip::write::FileOptions; use zip::{CompressionMethod, ZipWriter}; @@ -522,9 +522,7 @@ pub async fn export_support_bundle( get_log_storage_diagnostics_from_path(log_file_path.clone(), in_memory_log_count); let persisted_log_tail = read_persisted_logs_tail_from_path(log_file_path, 200)?; - let app_data_dir = app - .path() - .app_data_dir() + let app_data_dir = proxycast_core::app_paths::preferred_data_dir() .ok() .or_else(guess_proxycast_app_data_dir); let config_path = guess_proxycast_config_path(); @@ -564,7 +562,7 @@ pub async fn export_support_bundle( } fn guess_proxycast_app_data_dir() -> Option { - dirs::data_dir().map(|dir| dir.join("proxycast")) + proxycast_core::app_paths::preferred_data_dir().ok() } fn guess_proxycast_config_path() -> Option { diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 711e3a082..e7538e798 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -757,7 +757,7 @@ pub fn run() { service.set_app_handle(app_handle.clone()); if service.get_config().enabled { - let app_data_dir = match app_handle.path().app_data_dir() { + let app_data_dir = match proxycast_core::app_paths::preferred_data_dir() { Ok(dir) => dir, Err(e) => { tracing::error!("[启动] 无法获取应用数据目录: {}", e); diff --git a/src-tauri/src/commands/config_cmd.rs b/src-tauri/src/commands/config_cmd.rs index b046df09a..b1d24547b 100644 --- a/src-tauri/src/commands/config_cmd.rs +++ b/src-tauri/src/commands/config_cmd.rs @@ -6,7 +6,7 @@ use crate::models::app_type::AppType; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Manager}; +use tauri::AppHandle; use tauri_plugin_autostart::ManagerExt; #[cfg(target_os = "windows")] @@ -1119,9 +1119,8 @@ fn get_download_directory(app_handle: &AppHandle) -> Result { } // 回退到应用数据目录 - let app_data_dir = app_handle - .path() - .app_data_dir() + let _ = app_handle; + let app_data_dir = proxycast_core::app_paths::preferred_data_dir() .map_err(|e| format!("无法获取应用数据目录: {e}"))?; let download_dir = app_data_dir.join("downloads"); diff --git a/src-tauri/src/commands/heartbeat_cmd.rs b/src-tauri/src/commands/heartbeat_cmd.rs index b38f9cd52..b6b8216ce 100644 --- a/src-tauri/src/commands/heartbeat_cmd.rs +++ b/src-tauri/src/commands/heartbeat_cmd.rs @@ -12,12 +12,12 @@ use crate::services::heartbeat_service::{ CycleResult, HeartbeatServiceState, HeartbeatStatus, HeartbeatTaskPreview, }; use crate::AppState; +use proxycast_core::app_paths; use proxycast_core::config::{DeliveryConfig, HeartbeatSecurityConfig, TaskSchedule}; use proxycast_core::database::dao::heartbeat::HeartbeatExecution; use proxycast_websocket::handlers::{RpcHandler, RpcHandlerState}; use proxycast_websocket::protocol::{CronHealthResult, GatewayRpcRequest, RpcMethod}; use serde::{Deserialize, Serialize}; -use tauri::Manager; use uuid::Uuid; // ========== 配置响应类型 ========== @@ -135,9 +135,7 @@ pub async fn update_heartbeat_config( // 处理启停逻辑 if config.enabled && !was_enabled { service.set_app_handle(app.clone()); - let app_data_dir = app - .path() - .app_data_dir() + let app_data_dir = app_paths::preferred_data_dir() .map_err(|e| format!("无法获取应用数据目录: {e}"))?; let self_ref = hb_state.0.clone(); service.start(app_data_dir, self_ref).await?; @@ -162,12 +160,10 @@ pub async fn get_heartbeat_status( #[tauri::command] pub async fn get_heartbeat_tasks( hb_state: tauri::State<'_, HeartbeatServiceState>, - app: tauri::AppHandle, + _app: tauri::AppHandle, ) -> Result, String> { - let app_data_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("无法获取应用数据目录: {e}"))?; + let app_data_dir = + app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; let service = hb_state.0.read().await; service.preview_tasks(&app_data_dir) } @@ -303,11 +299,12 @@ pub async fn get_task_templates() -> Result, String> { } #[tauri::command] -pub async fn apply_task_template(template_id: String, app: tauri::AppHandle) -> Result<(), String> { - let app_data_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("无法获取应用数据目录: {e}"))?; +pub async fn apply_task_template( + template_id: String, + _app: tauri::AppHandle, +) -> Result<(), String> { + let app_data_dir = + app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; let template = TaskTemplateRegistry::get_template_by_id(&template_id) .ok_or_else(|| format!("模板不存在: {}", template_id))?; @@ -320,17 +317,15 @@ pub async fn apply_task_template(template_id: String, app: tauri::AppHandle) -> #[tauri::command] pub async fn add_heartbeat_task( hb_state: tauri::State<'_, HeartbeatServiceState>, - app: tauri::AppHandle, + _app: tauri::AppHandle, description: String, priority: Option, timeout_secs: Option, once: Option, model: Option, ) -> Result<(), String> { - let app_data_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("无法获取应用数据目录: {e}"))?; + let app_data_dir = + app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; let service = hb_state.0.read().await; service.add_task( &app_data_dir, @@ -345,13 +340,11 @@ pub async fn add_heartbeat_task( #[tauri::command] pub async fn delete_heartbeat_task( hb_state: tauri::State<'_, HeartbeatServiceState>, - app: tauri::AppHandle, + _app: tauri::AppHandle, index: usize, ) -> Result<(), String> { - let app_data_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("无法获取应用数据目录: {e}"))?; + let app_data_dir = + app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; let service = hb_state.0.read().await; service.delete_task(&app_data_dir, index) } @@ -359,7 +352,7 @@ pub async fn delete_heartbeat_task( #[tauri::command] pub async fn update_heartbeat_task( hb_state: tauri::State<'_, HeartbeatServiceState>, - app: tauri::AppHandle, + _app: tauri::AppHandle, index: usize, description: String, priority: Option, @@ -367,10 +360,8 @@ pub async fn update_heartbeat_task( once: Option, model: Option, ) -> Result<(), String> { - let app_data_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("无法获取应用数据目录: {e}"))?; + let app_data_dir = + app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; let service = hb_state.0.read().await; service.update_task( &app_data_dir, @@ -388,7 +379,7 @@ pub async fn update_heartbeat_task( #[tauri::command] pub async fn generate_content_creator_tasks( state: tauri::State<'_, AppState>, - app: tauri::AppHandle, + _app: tauri::AppHandle, ) -> Result { use crate::services::heartbeat_service::templates::ContentCreatorTaskGenerator; @@ -401,10 +392,8 @@ pub async fn generate_content_creator_tasks( let count = tasks.len(); if count > 0 { - let app_data_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("无法获取应用数据目录: {e}"))?; + let app_data_dir = + app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; ContentCreatorTaskGenerator::append_to_heartbeat(tasks, &app_data_dir)?; } @@ -418,10 +407,8 @@ pub async fn trigger_heartbeat_now( hb_state: tauri::State<'_, HeartbeatServiceState>, app: tauri::AppHandle, ) -> Result { - let app_data_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("无法获取应用数据目录: {e}"))?; + let app_data_dir = + app_paths::preferred_data_dir().map_err(|e| format!("无法获取应用数据目录: {e}"))?; let result = { let service = hb_state.0.read().await; diff --git a/src-tauri/src/commands/webview_cmd.rs b/src-tauri/src/commands/webview_cmd.rs index 35af24e9d..c449f6ff0 100644 --- a/src-tauri/src/commands/webview_cmd.rs +++ b/src-tauri/src/commands/webview_cmd.rs @@ -1698,17 +1698,15 @@ fn resolve_profile_data_dir_from_base(base_dir: &Path, profile_key: &str) -> Pat } fn resolve_profile_data_dir(app: &AppHandle, profile_key: &str) -> Result { - let base_dir = app - .path() - .app_data_dir() + let _ = app; + let base_dir = proxycast_core::app_paths::preferred_data_dir() .map_err(|e| format!("获取应用数据目录失败: {e}"))?; Ok(resolve_profile_data_dir_from_base(&base_dir, profile_key)) } fn resolve_chrome_profile_data_dir(app: &AppHandle, profile_key: &str) -> Result { - let base_dir = app - .path() - .app_data_dir() + let _ = app; + let base_dir = proxycast_core::app_paths::preferred_data_dir() .map_err(|e| format!("获取应用数据目录失败: {e}"))?; Ok(base_dir .join("chrome_profiles") diff --git a/src-tauri/src/commands/windows_startup_cmd.rs b/src-tauri/src/commands/windows_startup_cmd.rs index 2dc02b302..046812f5b 100644 --- a/src-tauri/src/commands/windows_startup_cmd.rs +++ b/src-tauri/src/commands/windows_startup_cmd.rs @@ -10,6 +10,8 @@ use std::path::PathBuf; #[cfg(target_os = "windows")] use std::io::Write; #[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; +#[cfg(target_os = "windows")] use std::path::Path; #[cfg(target_os = "windows")] use std::process::Command; @@ -18,6 +20,9 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; #[cfg(target_os = "windows")] use winreg::{enums::*, RegKey}; +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; + #[derive(Debug, Clone, Serialize)] pub struct WindowsStartupCheck { pub key: String, @@ -129,7 +134,7 @@ pub fn collect_windows_startup_diagnostics(app: &AppHandle) -> WindowsStartupDia let mut errors = Vec::new(); let mut warnings = Vec::new(); - let app_data_dir: Option = app.path().app_data_dir().ok(); + let app_data_dir: Option = proxycast_core::app_paths::preferred_data_dir().ok(); let home_dir = dirs::home_dir(); let legacy_proxycast_dir = home_dir.clone().map(|home| home.join(".proxycast")); let db_path = crate::database::get_db_path().ok(); @@ -171,23 +176,27 @@ pub fn collect_windows_startup_diagnostics(app: &AppHandle) -> WindowsStartupDia } match &legacy_proxycast_dir { - Some(path) => match ensure_dir_writable(path) { + Some(path) if path.exists() => match ensure_existing_dir_writable(path) { Ok(()) => checks.push(ok_check( "legacy_proxycast_dir", - format!("用户目录数据根可写: {}", path.display()), + format!("检测到旧版数据目录且可访问: {}", path.display()), )), Err(error) => { - errors.push(format!("用户目录数据根不可写: {}", path.display())); - checks.push(error_check( + warnings.push(format!("旧版数据目录不可访问: {}", path.display())); + checks.push(warn_check( "legacy_proxycast_dir", - format!("用户目录数据根不可写: {}", path.display()), + format!("旧版数据目录不可访问: {}", path.display()), Some(error), )); } }, + Some(path) => checks.push(ok_check( + "legacy_proxycast_dir", + format!("未检测到旧版数据目录: {}", path.display()), + )), None => { - errors.push("无法解析用户 Home 目录".to_string()); - checks.push(error_check( + warnings.push("无法解析用户 Home 目录".to_string()); + checks.push(warn_check( "legacy_proxycast_dir", "无法解析用户 Home 目录".to_string(), None, @@ -439,6 +448,25 @@ fn ensure_dir_writable(path: &Path) -> Result<(), String> { Ok(()) } +#[cfg(target_os = "windows")] +fn ensure_existing_dir_writable(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + + let probe = path.join("proxycast-write-test.tmp"); + let mut file = std::fs::File::create(&probe) + .map_err(|e| format!("创建测试文件失败 {}: {e}", probe.display()))?; + file.write_all(b"proxycast") + .map_err(|e| format!("写入测试文件失败 {}: {e}", probe.display()))?; + file.sync_all() + .map_err(|e| format!("刷新测试文件失败 {}: {e}", probe.display()))?; + std::fs::remove_file(&probe) + .map_err(|e| format!("删除测试文件失败 {}: {e}", probe.display()))?; + + Ok(()) +} + #[cfg(target_os = "windows")] fn check_database_file(path: &Path) -> Result<(), String> { if let Some(parent) = path.parent() { @@ -447,11 +475,16 @@ fn check_database_file(path: &Path) -> Result<(), String> { let conn = rusqlite::Connection::open(path) .map_err(|e| format!("打开数据库失败 {}: {e}", path.display()))?; - conn.execute("PRAGMA user_version", []) + conn.query_row("PRAGMA user_version", [], |_| Ok(())) .map_err(|e| format!("执行数据库探测失败 {}: {e}", path.display()))?; Ok(()) } +#[cfg(target_os = "windows")] +fn apply_windows_no_window(command: &mut Command) { + command.creation_flags(CREATE_NO_WINDOW); +} + #[cfg(target_os = "windows")] fn detect_webview2_runtime_version() -> Option { const VALUE_NAME: &str = "pv"; @@ -579,7 +612,9 @@ fn detect_shell_availability() -> Option { } } - let pwsh_check = Command::new("pwsh").args(["-v"]).output(); + let mut pwsh_check = Command::new("pwsh"); + apply_windows_no_window(&mut pwsh_check); + let pwsh_check = pwsh_check.args(["-v"]).output(); if pwsh_check .map(|output| output.status.success()) .unwrap_or(false) diff --git a/src-tauri/src/services/openclaw_service.rs b/src-tauri/src/services/openclaw_service.rs index fbb9ad946..9cf1642ac 100644 --- a/src-tauri/src/services/openclaw_service.rs +++ b/src-tauri/src/services/openclaw_service.rs @@ -7,6 +7,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::{HashSet, VecDeque}; use std::ffi::OsString; +#[cfg(target_os = "windows")] +use std::os::windows::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::OnceLock; @@ -28,6 +30,8 @@ const NODE_MIN_VERSION: (u64, u64, u64) = (22, 0, 0); const OPENCLAW_PROGRESS_LOG_LIMIT: usize = 400; const OPENCLAW_INSTALLER_USER_AGENT: &str = "ProxyCast-OpenClaw"; const OPENCLAW_TEMP_CARGO_CHECK_DIR: &str = "/tmp/proxycast-cargo-check"; +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x08000000; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1518,9 +1522,8 @@ fn openclaw_proxycast_config_path() -> PathBuf { } fn openclaw_installer_download_dir(app: &AppHandle) -> Result { - let app_data_dir = app - .path() - .app_data_dir() + let _ = app; + let app_data_dir = proxycast_core::app_paths::preferred_data_dir() .map_err(|e| format!("无法获取应用数据目录: {e}"))?; let dir = app_data_dir.join("downloads").join("openclaw-installers"); std::fs::create_dir_all(&dir).map_err(|e| format!("创建 OpenClaw 下载目录失败: {e}"))?; @@ -2200,6 +2203,8 @@ fn prepend_path(dir: &Path) -> Option { } fn apply_binary_runtime_path(command: &mut Command, binary_path: &str) { + apply_windows_no_window(command); + let Some(bin_dir) = Path::new(binary_path).parent() else { return; }; @@ -2208,9 +2213,18 @@ fn apply_binary_runtime_path(command: &mut Command, binary_path: &str) { } } +fn apply_windows_no_window(_command: &mut Command) { + #[cfg(target_os = "windows")] + { + _command.creation_flags(CREATE_NO_WINDOW); + } +} + async fn find_command_in_shell(command_name: &str) -> Result, String> { if cfg!(target_os = "windows") { - let output = Command::new("cmd") + let mut command = Command::new("cmd"); + apply_windows_no_window(&mut command); + let output = command .arg("/C") .arg("where") .arg(command_name) @@ -2384,7 +2398,9 @@ fn sibling_node_path(command_path: &Path) -> Option { } async fn read_binary_semver(path: &Path) -> Option<(u64, u64, u64)> { - let output = Command::new(path) + let mut command = Command::new(path); + apply_windows_no_window(&mut command); + let output = command .arg("--version") .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -2494,6 +2510,8 @@ fn spawn_shell_command(command_line: &str) -> Result { cmd }; + apply_windows_no_window(&mut command); + command .env("NO_COLOR", "1") .env("CLICOLOR", "0") diff --git a/src-tauri/src/terminal/mod.rs b/src-tauri/src/terminal/mod.rs index 185efd493..baad5b610 100644 --- a/src-tauri/src/terminal/mod.rs +++ b/src-tauri/src/terminal/mod.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; -use tauri::{Emitter, Manager}; +use tauri::Emitter; use proxycast_terminal::emitter::TerminalEventEmit; @@ -23,9 +23,7 @@ impl TerminalEventEmit for TauriEmitter { } fn app_data_dir(&self) -> Result { - self.0 - .path() - .app_data_dir() + proxycast_core::app_paths::preferred_data_dir() .map_err(|e| format!("获取应用数据目录失败: {e}")) } } diff --git a/src-tauri/src/tray/menu_handler.rs b/src-tauri/src/tray/menu_handler.rs index 4d570e35f..293f33f5b 100644 --- a/src-tauri/src/tray/menu_handler.rs +++ b/src-tauri/src/tray/menu_handler.rs @@ -236,13 +236,13 @@ fn handle_open_log_dir(app: &AppHandle) { info!("[托盘] 用户请求打开日志目录"); // 获取日志目录路径 - let log_dir = if let Ok(data_dir) = app.path().app_data_dir() { - data_dir.join("logs") - } else if let Some(home) = dirs::home_dir() { - home.join(".proxycast").join("logs") - } else { - error!("[托盘] 无法确定日志目录路径"); - return; + let _ = app; + let log_dir = match proxycast_core::app_paths::resolve_logs_dir() { + Ok(dir) => dir, + Err(error) => { + error!("[托盘] 无法确定日志目录路径: {}", error); + return; + } }; // 确保目录存在 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 106fd6796..4aacab948 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.83.1", + "version": "0.83.2", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 0796f2b85..5d1276e2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -564,7 +564,10 @@ function AppContent() { flexDirection: "column", }} > - + @@ -637,6 +640,7 @@ function AppContent() { diff --git a/src/components/agent/chat/components/MarkdownRenderer.test.tsx b/src/components/agent/chat/components/MarkdownRenderer.test.tsx index 0ea5fc335..46793e08f 100644 --- a/src/components/agent/chat/components/MarkdownRenderer.test.tsx +++ b/src/components/agent/chat/components/MarkdownRenderer.test.tsx @@ -57,6 +57,7 @@ afterEach(() => { }); mounted.container.remove(); } + vi.useRealTimers(); vi.clearAllMocks(); }); @@ -75,6 +76,28 @@ function render(content: string, isStreaming = false): HTMLDivElement { return container; } +function renderHarness(content: string, isStreaming = false) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const rerender = (nextContent: string, nextIsStreaming = isStreaming) => { + act(() => { + root.render( + , + ); + }); + }; + + rerender(content, isStreaming); + + mountedRoots.push({ container, root }); + return { container, rerender }; +} + describe("MarkdownRenderer", () => { it("非流式时应保留 raw html 渲染能力", () => { const content = [ @@ -105,4 +128,21 @@ describe("MarkdownRenderer", () => { expect(container.querySelector(".rendered-html")).toBeNull(); expect(container.textContent).toContain("结尾文本"); }); + + it("流式结束后应立即恢复完整 raw html 渲染", () => { + vi.useFakeTimers(); + const content = [ + "A".repeat(2_200), + "", + '
原始 HTML
', + "", + "结尾文本", + ].join("\n"); + + const { container, rerender } = renderHarness(content, true); + expect(container.querySelector(".rendered-html")).toBeNull(); + + rerender(content, false); + expect(container.querySelector(".rendered-html")).not.toBeNull(); + }); }); diff --git a/src/components/agent/chat/components/MarkdownRenderer.tsx b/src/components/agent/chat/components/MarkdownRenderer.tsx index d57cb7c2a..4b749c721 100644 --- a/src/components/agent/chat/components/MarkdownRenderer.tsx +++ b/src/components/agent/chat/components/MarkdownRenderer.tsx @@ -11,10 +11,13 @@ import { Copy, Check } from "lucide-react"; import { parseA2UIJson } from "@/components/content-creator/a2ui/parser"; import type { A2UIFormData } from "@/components/content-creator/a2ui/types"; import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets"; +import { useDebouncedValue } from "@/lib/artifact/hooks/useDebouncedValue"; import { ArtifactPlaceholder } from "./ArtifactPlaceholder"; import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard"; const STREAMING_LIGHT_RENDER_THRESHOLD = 2_000; +const STREAMING_LIGHT_RENDER_DEBOUNCE_MS = 48; +const STREAMING_STANDARD_RENDER_DEBOUNCE_MS = 24; // Custom styles for markdown content to match Cherry Studio const MarkdownContainer = styled.div` @@ -249,6 +252,13 @@ export const MarkdownRenderer: React.FC = memo( const [copied, setCopied] = React.useState(null); const useLightweightStreamingRender = isStreaming && content.length >= STREAMING_LIGHT_RENDER_THRESHOLD; + const debouncedStreamingContent = useDebouncedValue( + content, + useLightweightStreamingRender + ? STREAMING_LIGHT_RENDER_DEBOUNCE_MS + : STREAMING_STANDARD_RENDER_DEBOUNCE_MS, + ); + const renderContent = isStreaming ? debouncedStreamingContent : content; const remarkPlugins = React.useMemo( () => @@ -272,12 +282,12 @@ export const MarkdownRenderer: React.FC = memo( // 匹配 markdown 图片语法中的 base64 data URL const base64ImageRegex = /!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)/g; - let result = content; + let result = renderContent; const images: { alt: string; src: string; placeholder: string }[] = []; let match; let index = 0; - while ((match = base64ImageRegex.exec(content)) !== null) { + while ((match = base64ImageRegex.exec(renderContent)) !== null) { const placeholder = `__BASE64_IMAGE_${index}__`; images.push({ alt: match[1] || "Generated Image", @@ -289,7 +299,7 @@ export const MarkdownRenderer: React.FC = memo( } return { text: result, images }; - }, [content]); + }, [renderContent]); // 渲染 base64 图片 const renderBase64Images = () => { diff --git a/src/components/agent/chat/components/StreamingRenderer.test.tsx b/src/components/agent/chat/components/StreamingRenderer.test.tsx new file mode 100644 index 000000000..54e08b8cf --- /dev/null +++ b/src/components/agent/chat/components/StreamingRenderer.test.tsx @@ -0,0 +1,150 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StreamingRenderer } from "./StreamingRenderer"; +import type { ContentPart } from "../types"; + +const parseAIResponseMock = vi.fn(); + +vi.mock("@/components/content-creator/a2ui/parser", () => ({ + parseAIResponse: (...args: unknown[]) => parseAIResponseMock(...args), +})); + +vi.mock("@/lib/artifact/hooks/useDebouncedValue", () => ({ + useDebouncedValue: (value: T) => value, +})); + +vi.mock("./MarkdownRenderer", () => ({ + MarkdownRenderer: ({ content }: { content: string }) => ( +
{content}
+ ), +})); + +vi.mock("./A2UITaskCard", () => ({ + A2UITaskCard: () =>
, + A2UITaskLoadingCard: () =>
, +})); + +vi.mock("./ToolCallDisplay", () => ({ + ToolCallList: () =>
, + ToolCallItem: () =>
, +})); + +vi.mock("./DecisionPanel", () => ({ + DecisionPanel: () =>
, +})); + +interface MountedHarness { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: MountedHarness[] = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + parseAIResponseMock.mockImplementation((content: string) => ({ + parts: content.trim() ? [{ type: "text", content: content.trim() }] : [], + hasA2UI: false, + hasWriteFile: false, + hasPending: false, + })); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +function renderHarness(props: { + content: string; + isStreaming?: boolean; + contentParts?: ContentPart[]; +}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const rerender = (nextProps: typeof props) => { + act(() => { + root.render(); + }); + }; + + rerender(props); + mountedRoots.push({ container, root }); + + return { container, rerender }; +} + +describe("StreamingRenderer", () => { + it("纯文本内容应短路跳过结构化解析", () => { + renderHarness({ + content: "这是普通文本输出,不包含结构化标签。", + isStreaming: true, + }); + + expect(parseAIResponseMock).not.toHaveBeenCalled(); + }); + + it("交错内容重复渲染时应复用已缓存解析结果", () => { + const structuredText = 'hello'; + parseAIResponseMock.mockImplementation((content: string) => { + if (content === structuredText) { + return { + parts: [ + { + type: "write_file", + content: "hello", + filePath: "demo.md", + }, + ], + hasA2UI: false, + hasWriteFile: true, + hasPending: false, + }; + } + + return { + parts: content.trim() + ? [{ type: "text", content: content.trim() }] + : [], + hasA2UI: false, + hasWriteFile: false, + hasPending: false, + }; + }); + const contentParts: ContentPart[] = [ + { type: "text", text: structuredText }, + { type: "text", text: "普通文本" }, + ]; + + const { rerender } = renderHarness({ + content: structuredText, + contentParts, + isStreaming: true, + }); + + expect(parseAIResponseMock).toHaveBeenCalledTimes(1); + + rerender({ + content: structuredText, + contentParts: [...contentParts], + isStreaming: true, + }); + + expect(parseAIResponseMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/agent/chat/components/StreamingRenderer.tsx b/src/components/agent/chat/components/StreamingRenderer.tsx index 80ff36ea5..1d9d41f13 100644 --- a/src/components/agent/chat/components/StreamingRenderer.tsx +++ b/src/components/agent/chat/components/StreamingRenderer.tsx @@ -8,16 +8,25 @@ import React, { memo, useMemo, useState, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { ChevronDown, Lightbulb, FileText } from "lucide-react"; +import { useDebouncedValue } from "@/lib/artifact/hooks/useDebouncedValue"; import { MarkdownRenderer } from "./MarkdownRenderer"; import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard"; import { ToolCallList, ToolCallItem } from "./ToolCallDisplay"; import { DecisionPanel } from "./DecisionPanel"; import { parseAIResponse } from "@/components/content-creator/a2ui/parser"; -import type { A2UIFormData } from "@/components/content-creator/a2ui/types"; +import type { + A2UIFormData, + ParseResult, + ParsedMessageContent, +} from "@/components/content-creator/a2ui/types"; import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets"; import type { ToolCallState } from "@/lib/api/agent"; import type { ContentPart, ActionRequired, ConfirmResponse } from "../types"; +const STRUCTURED_CONTENT_HINT_RE = / ( /> ); +const EMPTY_PARSE_RESULT: ParseResult = { + parts: [], + hasA2UI: false, + hasWriteFile: false, + hasPending: false, +}; + +function hasStructuredContentHint(text: string): boolean { + return STRUCTURED_CONTENT_HINT_RE.test(text); +} + +function createPlainTextParts(text: string): ParsedMessageContent[] { + const trimmed = text.trim(); + return trimmed ? [{ type: "text", content: trimmed }] : []; +} + +function parseStructuredContent( + text: string, + isStreaming: boolean, +): ParseResult { + if (!text.trim()) { + return EMPTY_PARSE_RESULT; + } + + if (!hasStructuredContentHint(text)) { + return { + parts: createPlainTextParts(text), + hasA2UI: false, + hasWriteFile: false, + hasPending: false, + }; + } + + return parseAIResponse(text, isStreaming); +} + +function getCachedStructuredParse( + cacheRef: React.MutableRefObject>, + text: string, + isStreaming: boolean, +): ParseResult { + const key = `${isStreaming ? "stream" : "static"}:${text}`; + const cached = cacheRef.current.get(key); + if (cached) { + return cached; + } + + const parsed = parseStructuredContent(text, isStreaming); + if (cacheRef.current.size >= STRUCTURED_PARSE_CACHE_LIMIT) { + const oldestKey = cacheRef.current.keys().next().value; + if (oldestKey) { + cacheRef.current.delete(oldestKey); + } + } + cacheRef.current.set(key, parsed); + return parsed; +} + // ============ 流式文本组件(逐字符动画) ============ interface StreamingTextProps { @@ -113,6 +180,7 @@ const StreamingText: React.FC = memo( const displayIndexRef = useRef(0); const animationRef = useRef(null); const prevTextRef = useRef(""); + const parseCacheRef = useRef>(new Map()); useEffect(() => { // 如果不是流式输出,直接显示完整文本 @@ -198,12 +266,27 @@ const StreamingText: React.FC = memo( const shouldShowCursor = isStreaming && showCursor && displayIndexRef.current < text.length; + const containsStructuredContent = useMemo( + () => hasStructuredContentHint(displayText), + [displayText], + ); + const debouncedStructuredText = useDebouncedValue( + displayText, + isStreaming && containsStructuredContent + ? STREAMING_STRUCTURED_PARSE_DEBOUNCE_MS + : 0, + ); + const parsedSourceText = + isStreaming && containsStructuredContent + ? debouncedStructuredText + : displayText; // 使用 parseAIResponse 解析内容,以正确处理 a2ui 代码块 // 这比依赖 MarkdownRenderer 的 pre 组件更可靠 const parsedContent = useMemo( - () => parseAIResponse(displayText, isStreaming), - [displayText, isStreaming], + () => + getCachedStructuredParse(parseCacheRef, parsedSourceText, isStreaming), + [parsedSourceText, isStreaming], ); // 渲染解析后的内容 @@ -390,16 +473,38 @@ export const StreamingRenderer: React.FC = memo( }) => { // 判断是否使用交错显示模式 const useInterleavedMode = contentParts && contentParts.length > 0; + const parseCacheRef = useRef>(new Map()); // 解析思考内容(仅在非交错模式下使用) const { visibleText, thinkingText } = useMemo( () => parseThinkingContent(content), [content], ); + const containsStructuredContent = useMemo( + () => hasStructuredContentHint(visibleText), + [visibleText], + ); + const debouncedVisibleText = useDebouncedValue( + visibleText, + isStreaming && containsStructuredContent + ? STREAMING_STRUCTURED_PARSE_DEBOUNCE_MS + : 0, + ); + const parsedVisibleText = + isStreaming && containsStructuredContent + ? debouncedVisibleText + : visibleText; // 解析 A2UI 和 write_file 内容 const parsedContent = useMemo(() => { - const result = parseAIResponse(visibleText, isStreaming); + if (useInterleavedMode) { + return EMPTY_PARSE_RESULT; + } + const result = getCachedStructuredParse( + parseCacheRef, + parsedVisibleText, + isStreaming, + ); // 添加调试日志 if (result.hasWriteFile) { console.log( @@ -410,7 +515,7 @@ export const StreamingRenderer: React.FC = memo( ); } return result; - }, [visibleText, isStreaming]); + }, [parsedVisibleText, isStreaming, useInterleavedMode]); // 处理文件写入 - 使用 ref 来追踪已处理的内容 const processedWriteFilesRef = useRef>(new Set()); @@ -475,7 +580,11 @@ export const StreamingRenderer: React.FC = memo( if (!partText) return null; // 解析 write_file 标签 - const partParsed = parseAIResponse(partText, isStreaming); + const partParsed = getCachedStructuredParse( + parseCacheRef, + partText, + isStreaming, + ); const isLastPart = index === contentParts.length - 1; // 添加调试日志 diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index 3e03b4282..4c6cdc5b9 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -44,6 +44,7 @@ import { isValidSessionId, resolveRestorableSessionId, } from "../utils/sessionRecovery"; +import { createStreamDiagnosticsReporter } from "../utils/streamDiagnostics"; /** 话题(会话)信息 */ export interface Topic { @@ -269,6 +270,9 @@ interface UseAgentChatOptions { export function useAgentChat(options: UseAgentChatOptions) { const { systemPrompt, onWriteFile, workspaceId } = options; + const streamDiagnosticsRef = useRef( + createStreamDiagnosticsReporter("useAgentChat"), + ); const getRequiredWorkspaceId = (): string => { const resolvedWorkspaceId = workspaceId?.trim(); @@ -686,7 +690,12 @@ export function useAgentChat(options: UseAgentChatOptions) { allProxycastSkills .filter((skill) => skill.installed) .forEach((skill) => { - const skillName = (skill.directory || skill.key || skill.name || "").trim(); + const skillName = ( + skill.directory || + skill.key || + skill.name || + "" + ).trim(); if (!skillName) return; detailsByName.set(skillName, { name: skillName, @@ -984,13 +993,21 @@ export function useAgentChat(options: UseAgentChatOptions) { console.log( `[AgentChat] 设置事件监听器: ${eventName}, sessionId: ${activeSessionId}`, ); + streamDiagnosticsRef.current.start({ + sessionId: activeSessionId, + eventName, + assistantMessageId: assistantMsgId, + source: "sendMessage", + }); unlisten = await safeListen(eventName, (event) => { console.log("[AgentChat] 收到事件:", eventName, event.payload); const data = parseStreamEvent(event.payload); if (!data) { console.warn("[AgentChat] 解析事件失败:", event.payload); + streamDiagnosticsRef.current.recordInvalidEvent(event.payload); return; } + streamDiagnosticsRef.current.record(data); console.log("[AgentChat] 解析后数据:", data); switch (data.type) { @@ -1085,6 +1102,7 @@ export function useAgentChat(options: UseAgentChatOptions) { unlistenRef.current = null; currentAssistantMsgIdRef.current = null; currentStreamingSessionIdRef.current = null; + streamDiagnosticsRef.current.markDone(); if (unlisten) { unlisten(); unlisten = null; @@ -1133,6 +1151,7 @@ export function useAgentChat(options: UseAgentChatOptions) { unlistenRef.current = null; currentAssistantMsgIdRef.current = null; currentStreamingSessionIdRef.current = null; + streamDiagnosticsRef.current.markError(data.message); if (unlisten) { unlisten(); unlisten = null; @@ -1354,6 +1373,7 @@ export function useAgentChat(options: UseAgentChatOptions) { ); } catch (error) { console.error("[AgentChat] Send failed:", error); + streamDiagnosticsRef.current.markError(String(error)); toast.error(`发送失败: ${error}`, { id: `send-error-${Date.now()}`, duration: 8000, @@ -1505,7 +1525,8 @@ export function useAgentChat(options: UseAgentChatOptions) { }) // 过滤仅包含工具协议的空白 assistant 消息,避免历史里出现无意义气泡 .filter( - (msg) => !(msg.role === "assistant" && msg.content.trim().length === 0), + (msg) => + !(msg.role === "assistant" && msg.content.trim().length === 0), ); if (restoreRequestVersion !== sessionResetVersionRef.current) { @@ -1773,6 +1794,12 @@ export function useAgentChat(options: UseAgentChatOptions) { console.log( `[AgentChat] triggerAIGuide 设置事件监听器: ${eventName}, sessionId: ${activeSessionId}`, ); + streamDiagnosticsRef.current.start({ + sessionId: activeSessionId, + eventName, + assistantMessageId: assistantMsgId, + source: "triggerAIGuide", + }); unlisten = await safeListen(eventName, (event) => { console.log( "[AgentChat] triggerAIGuide 收到事件:", @@ -1785,8 +1812,10 @@ export function useAgentChat(options: UseAgentChatOptions) { "[AgentChat] triggerAIGuide 解析事件失败:", event.payload, ); + streamDiagnosticsRef.current.recordInvalidEvent(event.payload); return; } + streamDiagnosticsRef.current.record(data); switch (data.type) { case "text_delta": @@ -1858,6 +1887,7 @@ export function useAgentChat(options: UseAgentChatOptions) { unlistenRef.current = null; currentAssistantMsgIdRef.current = null; currentStreamingSessionIdRef.current = null; + streamDiagnosticsRef.current.markDone(); if (unlisten) { unlisten(); unlisten = null; @@ -1888,6 +1918,7 @@ export function useAgentChat(options: UseAgentChatOptions) { unlistenRef.current = null; currentAssistantMsgIdRef.current = null; currentStreamingSessionIdRef.current = null; + streamDiagnosticsRef.current.markError(data.message); if (unlisten) { unlisten(); unlisten = null; @@ -2051,6 +2082,7 @@ export function useAgentChat(options: UseAgentChatOptions) { ); } catch (error) { console.error("[AgentChat] triggerAIGuide failed:", error); + streamDiagnosticsRef.current.markError(String(error)); toast.error(`启动引导失败: ${error}`, { id: `guide-error-${Date.now()}`, duration: 8000, diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index bf77e8db0..eec593ed6 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -1972,6 +1972,44 @@ describe("AgentChatPage 自动引导", () => { "exec-map-1", ); }); + + it("主题工作台不应把聊天命令 source_ref 当成 Skill 详情去加载", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockExecutionRunGetThemeWorkbenchState.mockResolvedValue({ + run_state: "auto_running", + current_gate_key: "write_mode", + queue_items: [ + { + run_id: "run-chat-command", + title: "执行主题工作台编排", + gate_key: "write_mode", + status: "running", + source: "chat", + source_ref: "aster_agent_chat_stream", + started_at: "2026-03-06T04:00:00.000Z", + }, + ], + latest_terminal: null, + updated_at: "2026-03-06T04:00:02.000Z", + }); + + renderPage({ + projectId: "project-theme-chat-command", + contentId: "content-theme-chat-command", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(12); + + expect(mockSkillExecutionGetDetail).not.toHaveBeenCalledWith( + "aster_agent_chat_stream", + ); + }); }); describe("AgentChatPage 视频主题工作台", () => { diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index bb1925a27..77c2e5256 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -108,17 +108,19 @@ import type { Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; import { skillsApi, type Skill } from "@/lib/api/skills"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; +import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; import { LatestRunStatusBadge } from "@/components/execution/LatestRunStatusBadge"; import { executionRunGet, executionRunGetThemeWorkbenchState, type AgentRun, type ThemeWorkbenchRunTodoItem, + type ThemeWorkbenchRunTerminalItem, type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState, } from "@/lib/api/executionRun"; import { setActiveContentTarget } from "@/lib/activeContentTarget"; import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; -import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { resolveProviderModelCompatibility } from "./utils/providerModelCompatibility"; import { useProviderModels } from "@/hooks/useProviderModels"; import { isReasoningModel, @@ -812,7 +814,7 @@ function resolveThemeWorkbenchQueueItemTitle( item: ThemeWorkbenchRunTodoItem, skillDetailMap: Record, ): string { - const sourceRef = item.source_ref?.trim(); + const sourceRef = resolveThemeWorkbenchSkillSourceRef(item); if (sourceRef) { return resolveThemeWorkbenchPrimaryTaskTitle( sourceRef, @@ -823,6 +825,19 @@ function resolveThemeWorkbenchQueueItemTitle( } const THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS = 45 * 1000; +function resolveThemeWorkbenchSkillSourceRef( + item: + | ThemeWorkbenchRunTodoItem + | ThemeWorkbenchRunTerminalItem + | { source?: string | null; source_ref?: string | null }, +): string | null { + if ((item.source || "").trim() !== "skill") { + return null; + } + const sourceRef = item.source_ref?.trim(); + return sourceRef || null; +} + interface PersistedThemeWorkbenchDocument { versions: DocumentVersion[]; currentVersionId: string; @@ -1361,7 +1376,7 @@ function buildThemeWorkbenchWorkflowSteps( if (queueItems.length > 0) { if (queueItems.length === 1) { const item = queueItems[0]; - const sourceRef = item.source_ref?.trim(); + const sourceRef = resolveThemeWorkbenchSkillSourceRef(item); const workflowSteps = sourceRef ? skillDetailMap[sourceRef]?.workflow_steps || [] : []; @@ -2582,13 +2597,14 @@ export function AgentChatPage({ } }); (themeWorkbenchBackendRunState?.queue_items || []).forEach((item) => { - const sourceRef = item.source_ref?.trim(); + const sourceRef = resolveThemeWorkbenchSkillSourceRef(item); if (sourceRef) { requiredSkillNames.add(sourceRef); } }); - const terminalSourceRef = - themeWorkbenchBackendRunState?.latest_terminal?.source_ref?.trim(); + const terminalSourceRef = resolveThemeWorkbenchSkillSourceRef( + themeWorkbenchBackendRunState?.latest_terminal || {}, + ); if (terminalSourceRef) { requiredSkillNames.add(terminalSourceRef); } @@ -2597,7 +2613,7 @@ export function AgentChatPage({ }, [ isThemeWorkbench, messages, - themeWorkbenchBackendRunState?.latest_terminal?.source_ref, + themeWorkbenchBackendRunState?.latest_terminal, themeWorkbenchBackendRunState?.queue_items, ]); @@ -3625,6 +3641,21 @@ export function AgentChatPage({ } } + const compatibilityResult = resolveProviderModelCompatibility({ + providerType, + configuredProviderType: selectedProvider?.type, + model: effectiveModel, + }); + if (compatibilityResult.changed) { + effectiveModel = compatibilityResult.model; + if (model !== compatibilityResult.model) { + setModel(compatibilityResult.model); + } + if (compatibilityResult.reason) { + toast.warning(compatibilityResult.reason); + } + } + if (autoContinuePayload) { await sendMessage( text, @@ -3672,6 +3703,7 @@ export function AgentChatPage({ providerModels, providerType, runtimeStyleMessagePrompt, + selectedProvider?.type, sendMessage, sessionId, setModel, @@ -4026,6 +4058,11 @@ export function AgentChatPage({ const toastId = initialSessionName ? "openclaw-agent-handoff" : "agent-new-chat"; + const canCreateFreshSession = Boolean(projectId?.trim()); + + if (!canCreateFreshSession) { + return; + } void (async () => { const newSessionId = await createFreshSession(initialSessionName); @@ -4048,6 +4085,7 @@ export function AgentChatPage({ externalProjectId, initialTheme, initialCreationMode, + projectId, ]); const handleBackHome = useCallback(() => { diff --git a/src/components/agent/chat/utils/providerModelCompatibility.test.ts b/src/components/agent/chat/utils/providerModelCompatibility.test.ts new file mode 100644 index 000000000..189d42b9f --- /dev/null +++ b/src/components/agent/chat/utils/providerModelCompatibility.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + filterProviderModelsByCompatibility, + getProviderModelCompatibilityIssue, + resolveProviderModelCompatibility, +} from "./providerModelCompatibility"; + +describe("resolveProviderModelCompatibility", () => { + it("应将 codex provider 的 gpt-5.3-codex 自动降级到 gpt-5.2-codex", () => { + const result = resolveProviderModelCompatibility({ + providerType: "custom-123", + configuredProviderType: "codex", + model: "gpt-5.3-codex", + }); + + expect(result.changed).toBe(true); + expect(result.model).toBe("gpt-5.2-codex"); + expect(result.reason).toContain("gpt-5.2-codex"); + }); + + it("非 codex provider 不应调整模型", () => { + const result = resolveProviderModelCompatibility({ + providerType: "anthropic", + configuredProviderType: "anthropic", + model: "gpt-5.3-codex", + }); + + expect(result.changed).toBe(false); + expect(result.model).toBe("gpt-5.3-codex"); + }); + + it("codex provider 的其他模型不应调整", () => { + const result = resolveProviderModelCompatibility({ + providerType: "codex", + configuredProviderType: "codex", + model: "gpt-5.2-codex", + }); + + expect(result.changed).toBe(false); + expect(result.model).toBe("gpt-5.2-codex"); + }); + + it("应返回不兼容模型的 UI 提示信息", () => { + const issue = getProviderModelCompatibilityIssue({ + providerType: "custom-123", + configuredProviderType: "codex", + model: "gpt-5.3-codex", + }); + + expect(issue).toEqual({ + code: "codex_chatgpt_account_unsupported", + message: "当前 Codex 登录态不支持该模型", + suggestedModel: "gpt-5.2-codex", + }); + }); + + it("应过滤不兼容模型并保留兼容模型", () => { + const result = filterProviderModelsByCompatibility( + { + providerType: "custom-123", + configuredProviderType: "codex", + }, + ["gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.2"], + ); + + expect(result.compatibleModels).toEqual(["gpt-5.2-codex", "gpt-5.2"]); + expect(result.incompatibleModels).toEqual([ + { + model: "gpt-5.3-codex", + issue: { + code: "codex_chatgpt_account_unsupported", + message: "当前 Codex 登录态不支持该模型", + suggestedModel: "gpt-5.2-codex", + }, + }, + ]); + }); +}); diff --git a/src/components/agent/chat/utils/providerModelCompatibility.ts b/src/components/agent/chat/utils/providerModelCompatibility.ts new file mode 100644 index 000000000..2540ac29b --- /dev/null +++ b/src/components/agent/chat/utils/providerModelCompatibility.ts @@ -0,0 +1,105 @@ +export interface ProviderModelCompatibilityInput { + providerType: string; + configuredProviderType?: string | null; + model: string; +} + +export interface ProviderModelCompatibilityResult { + model: string; + changed: boolean; + reason?: string; +} + +export interface ProviderModelCompatibilityIssue { + code: "codex_chatgpt_account_unsupported"; + message: string; + suggestedModel?: string; +} + +function normalize(value?: string | null): string { + return (value || "").trim().toLowerCase(); +} + +export function getProviderModelCompatibilityIssue({ + providerType, + configuredProviderType, + model, +}: ProviderModelCompatibilityInput): ProviderModelCompatibilityIssue | null { + const normalizedProviderType = normalize(providerType); + const normalizedConfiguredType = normalize(configuredProviderType); + const normalizedModel = normalize(model); + + const isCodexProvider = + normalizedProviderType === "codex" || normalizedConfiguredType === "codex"; + + if (isCodexProvider && normalizedModel === "gpt-5.3-codex") { + return { + code: "codex_chatgpt_account_unsupported", + message: "当前 Codex 登录态不支持该模型", + suggestedModel: "gpt-5.2-codex", + }; + } + + return null; +} + +export function resolveProviderModelCompatibility({ + providerType, + configuredProviderType, + model, +}: ProviderModelCompatibilityInput): ProviderModelCompatibilityResult { + const issue = getProviderModelCompatibilityIssue({ + providerType, + configuredProviderType, + model, + }); + if (issue?.suggestedModel) { + return { + model: issue.suggestedModel, + changed: true, + reason: `当前 Codex 登录态与 ${model} 兼容性不足,已自动切换到 ${issue.suggestedModel}。`, + }; + } + + return { + model, + changed: false, + }; +} + +export function filterProviderModelsByCompatibility( + input: Pick< + ProviderModelCompatibilityInput, + "providerType" | "configuredProviderType" + >, + models: string[], +): { + compatibleModels: string[]; + incompatibleModels: Array<{ + model: string; + issue: ProviderModelCompatibilityIssue; + }>; +} { + const compatibleModels: string[] = []; + const incompatibleModels: Array<{ + model: string; + issue: ProviderModelCompatibilityIssue; + }> = []; + + models.forEach((model) => { + const issue = getProviderModelCompatibilityIssue({ + ...input, + model, + }); + if (issue) { + incompatibleModels.push({ model, issue }); + } else { + compatibleModels.push(model); + } + }); + + return { + compatibleModels, + incompatibleModels, + }; +} diff --git a/src/components/agent/chat/utils/streamDiagnostics.test.ts b/src/components/agent/chat/utils/streamDiagnostics.test.ts new file mode 100644 index 000000000..c82f4f59e --- /dev/null +++ b/src/components/agent/chat/utils/streamDiagnostics.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createStreamDiagnosticsReporter } from "./streamDiagnostics"; + +const updateCrashContextMock = vi.fn(); + +vi.mock("@/lib/crashReporting", () => ({ + updateCrashContext: (context: unknown) => updateCrashContextMock(context), +})); + +describe("streamDiagnostics", () => { + beforeEach(() => { + updateCrashContextMock.mockClear(); + }); + + it("开始流后应写入基础上下文", () => { + const reporter = createStreamDiagnosticsReporter("useAgentChat"); + reporter.start({ + sessionId: "session-1", + eventName: "agent_stream_1", + assistantMessageId: "assistant-1", + source: "sendMessage", + }); + + expect(updateCrashContextMock).toHaveBeenCalledTimes(1); + expect(updateCrashContextMock.mock.calls[0]?.[0]).toMatchObject({ + agent_stream_diag: expect.objectContaining({ + component: "useAgentChat", + sessionId: "session-1", + eventName: "agent_stream_1", + assistantMessageId: "assistant-1", + state: "streaming", + }), + }); + }); + + it("遇到关键事件应立即刷新上下文", () => { + const reporter = createStreamDiagnosticsReporter("useAgentChat"); + reporter.start({ + sessionId: "session-1", + eventName: "agent_stream_1", + assistantMessageId: "assistant-1", + source: "sendMessage", + }); + + reporter.record({ + type: "tool_start", + tool_id: "tool-1", + tool_name: "WebSearch", + }); + reporter.record({ + type: "tool_end", + tool_id: "tool-1", + result: { + success: true, + output: "ok", + }, + }); + reporter.record({ + type: "final_done", + }); + + const lastCall = updateCrashContextMock.mock.calls.at(-1)?.[0] as { + agent_stream_diag: Record; + }; + expect(lastCall.agent_stream_diag).toMatchObject({ + state: "done", + toolStartCount: 1, + toolEndCount: 1, + finalDoneCount: 1, + lastToolId: "tool-1", + }); + }); + + it("解析失败时应记录 invalid 事件", () => { + const reporter = createStreamDiagnosticsReporter("useAgentChat"); + reporter.start({ + sessionId: "session-1", + eventName: "agent_stream_1", + assistantMessageId: "assistant-1", + source: "sendMessage", + }); + + reporter.recordInvalidEvent({ foo: "bar" }); + + const snapshot = reporter.getSnapshot(); + expect(snapshot).toMatchObject({ + invalidEventCount: 1, + lastEventType: "invalid", + state: "streaming", + }); + }); +}); diff --git a/src/components/agent/chat/utils/streamDiagnostics.ts b/src/components/agent/chat/utils/streamDiagnostics.ts new file mode 100644 index 000000000..50359377d --- /dev/null +++ b/src/components/agent/chat/utils/streamDiagnostics.ts @@ -0,0 +1,218 @@ +import type { StreamEvent } from "@/lib/api/agent"; +import { updateCrashContext } from "@/lib/crashReporting"; + +const EVENT_PUBLISH_INTERVAL = 20; +const PREVIEW_MAX_CHARS = 240; + +export interface StreamDiagnosticsStartPayload { + sessionId: string; + eventName: string; + assistantMessageId: string; + source: string; +} + +export interface StreamDiagnosticsSnapshot { + source: string; + sessionId: string; + eventName: string; + assistantMessageId: string; + state: "streaming" | "done" | "error"; + startedAt: string; + lastEventAt: string; + totalEvents: number; + invalidEventCount: number; + textDeltaCount: number; + thinkingDeltaCount: number; + toolStartCount: number; + toolEndCount: number; + actionRequiredCount: number; + contextTraceCount: number; + warningCount: number; + doneCount: number; + finalDoneCount: number; + errorCount: number; + maxTextDeltaChars: number; + maxToolOutputChars: number; + maxContextTraceSteps: number; + lastEventType?: string; + lastToolName?: string; + lastToolId?: string; + lastWarningCode?: string; + lastErrorMessage?: string; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function truncatePreview(value: string | undefined): string | undefined { + if (!value) return value; + if (value.length <= PREVIEW_MAX_CHARS) { + return value; + } + return `${value.slice(0, PREVIEW_MAX_CHARS)}...`; +} + +export function createStreamDiagnosticsReporter(componentName: string) { + let snapshot: StreamDiagnosticsSnapshot | null = null; + let lastPublishedTotalEvents = 0; + + const publish = (force = false) => { + if (!snapshot) return; + if ( + !force && + snapshot.totalEvents - lastPublishedTotalEvents < EVENT_PUBLISH_INTERVAL + ) { + return; + } + lastPublishedTotalEvents = snapshot.totalEvents; + updateCrashContext({ + agent_stream_diag: { + component: componentName, + ...snapshot, + }, + }); + }; + + return { + start(payload: StreamDiagnosticsStartPayload) { + const startedAt = nowIso(); + snapshot = { + source: payload.source, + sessionId: payload.sessionId, + eventName: payload.eventName, + assistantMessageId: payload.assistantMessageId, + state: "streaming", + startedAt, + lastEventAt: startedAt, + totalEvents: 0, + invalidEventCount: 0, + textDeltaCount: 0, + thinkingDeltaCount: 0, + toolStartCount: 0, + toolEndCount: 0, + actionRequiredCount: 0, + contextTraceCount: 0, + warningCount: 0, + doneCount: 0, + finalDoneCount: 0, + errorCount: 0, + maxTextDeltaChars: 0, + maxToolOutputChars: 0, + maxContextTraceSteps: 0, + }; + lastPublishedTotalEvents = 0; + publish(true); + }, + + recordInvalidEvent(payload: unknown) { + if (!snapshot) return; + snapshot.totalEvents += 1; + snapshot.invalidEventCount += 1; + snapshot.lastEventAt = nowIso(); + snapshot.lastEventType = "invalid"; + try { + snapshot.lastErrorMessage = truncatePreview(JSON.stringify(payload)); + } catch { + snapshot.lastErrorMessage = "[unserializable_payload]"; + } + publish(true); + }, + + record(event: StreamEvent) { + if (!snapshot) return; + snapshot.totalEvents += 1; + snapshot.lastEventAt = nowIso(); + snapshot.lastEventType = event.type; + + switch (event.type) { + case "text_delta": { + snapshot.textDeltaCount += 1; + snapshot.maxTextDeltaChars = Math.max( + snapshot.maxTextDeltaChars, + event.text.length, + ); + break; + } + case "thinking_delta": { + snapshot.thinkingDeltaCount += 1; + break; + } + case "tool_start": { + snapshot.toolStartCount += 1; + snapshot.lastToolId = event.tool_id; + snapshot.lastToolName = truncatePreview(event.tool_name); + break; + } + case "tool_end": { + snapshot.toolEndCount += 1; + snapshot.lastToolId = event.tool_id; + snapshot.maxToolOutputChars = Math.max( + snapshot.maxToolOutputChars, + event.result.output.length, + ); + break; + } + case "action_required": { + snapshot.actionRequiredCount += 1; + break; + } + case "context_trace": { + snapshot.contextTraceCount += 1; + snapshot.maxContextTraceSteps = Math.max( + snapshot.maxContextTraceSteps, + event.steps.length, + ); + break; + } + case "warning": { + snapshot.warningCount += 1; + snapshot.lastWarningCode = event.code; + snapshot.lastErrorMessage = truncatePreview(event.message); + publish(true); + return; + } + case "done": { + snapshot.doneCount += 1; + publish(true); + return; + } + case "final_done": { + snapshot.finalDoneCount += 1; + snapshot.state = "done"; + publish(true); + return; + } + case "error": { + snapshot.errorCount += 1; + snapshot.state = "error"; + snapshot.lastErrorMessage = truncatePreview(event.message); + publish(true); + return; + } + } + + publish(false); + }, + + markError(message: string) { + if (!snapshot) return; + snapshot.state = "error"; + snapshot.lastEventAt = nowIso(); + snapshot.lastEventType = "error"; + snapshot.lastErrorMessage = truncatePreview(message); + publish(true); + }, + + markDone() { + if (!snapshot) return; + snapshot.state = "done"; + snapshot.lastEventAt = nowIso(); + publish(true); + }, + + getSnapshot(): StreamDiagnosticsSnapshot | null { + return snapshot ? { ...snapshot } : null; + }, + }; +} diff --git a/src/components/general-chat/hooks/useProvider.ts b/src/components/general-chat/hooks/useProvider.ts index 1f58d262f..38be21a81 100644 --- a/src/components/general-chat/hooks/useProvider.ts +++ b/src/components/general-chat/hooks/useProvider.ts @@ -17,6 +17,7 @@ import { import { useProviderModels } from "@/hooks/useProviderModels"; import { useGeneralChatStore } from "../store/useGeneralChatStore"; import type { ProviderConfig } from "../types"; +import { filterProviderModelsByCompatibility } from "@/components/agent/chat/utils/providerModelCompatibility"; // ============================================================================ // 类型定义 @@ -115,6 +116,19 @@ export function useProvider(): UseProviderResult { error: modelsError, } = useProviderModels(selectedProvider); + const compatibleModelIds = useMemo(() => { + if (!selectedProvider) { + return availableModelIds; + } + return filterProviderModelsByCompatibility( + { + providerType: selectedProvider.key, + configuredProviderType: selectedProvider.type, + }, + availableModelIds, + ).compatibleModels; + }, [availableModelIds, selectedProvider]); + // 计算加载状态 // 注意:只有在加载 Provider 列表时才显示加载状态 // 模型加载是次要的,不应该阻塞整个界面 @@ -178,23 +192,23 @@ export function useProvider(): UseProviderResult { // 如果没有选中的模型,且有可用的模型,自动选择第一个 if ( !selectedModelId && - availableModelIds.length > 0 && + compatibleModelIds.length > 0 && !modelInitializedRef.current ) { modelInitializedRef.current = true; - setSelectedModel(availableModelIds[0]); + setSelectedModel(compatibleModelIds[0]); return; } // 如果选中的模型不在列表中,重新选择 - if (selectedModelId && !availableModelIds.includes(selectedModelId)) { - if (availableModelIds.length > 0) { - setSelectedModel(availableModelIds[0]); + if (selectedModelId && !compatibleModelIds.includes(selectedModelId)) { + if (compatibleModelIds.length > 0) { + setSelectedModel(compatibleModelIds[0]); } else { setSelectedModel(null); } } - }, [modelsLoading, availableModelIds, selectedModelId, setSelectedModel]); + }, [modelsLoading, compatibleModelIds, selectedModelId, setSelectedModel]); // ========== 操作方法 ========== @@ -218,11 +232,11 @@ export function useProvider(): UseProviderResult { */ const selectModel = useCallback( (modelId: string) => { - if (availableModelIds.includes(modelId)) { + if (compatibleModelIds.includes(modelId)) { setSelectedModel(modelId); } }, - [availableModelIds, setSelectedModel], + [compatibleModelIds, setSelectedModel], ); /** @@ -278,7 +292,7 @@ export function useProvider(): UseProviderResult { providers, selectedProvider, selectedModelId, - availableModelIds, + availableModelIds: compatibleModelIds, isLoading, error, hasAvailableProvider, diff --git a/src/components/input-kit/ModelSelector.test.tsx b/src/components/input-kit/ModelSelector.test.tsx new file mode 100644 index 000000000..6efaa608e --- /dev/null +++ b/src/components/input-kit/ModelSelector.test.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockUseConfiguredProviders, + mockUseProviderModels, + mockFilterModelsByTheme, +} = vi.hoisted(() => ({ + mockUseConfiguredProviders: vi.fn(), + mockUseProviderModels: vi.fn(), + mockFilterModelsByTheme: vi.fn(), +})); + +vi.mock("@/hooks/useConfiguredProviders", () => ({ + useConfiguredProviders: () => mockUseConfiguredProviders(), +})); + +vi.mock("@/hooks/useProviderModels", () => ({ + useProviderModels: (...args: unknown[]) => mockUseProviderModels(...args), +})); + +vi.mock("@/components/agent/chat/utils/modelThemePolicy", () => ({ + filterModelsByTheme: (...args: unknown[]) => mockFilterModelsByTheme(...args), +})); + +import { ModelSelector } from "./ModelSelector"; + +interface MountedRoot { + root: Root; + container: HTMLDivElement; +} + +const mountedRoots: MountedRoot[] = []; + +function renderModelSelector( + props: Partial> = {}, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const mergedProps: React.ComponentProps = { + providerType: "custom-codex", + setProviderType: vi.fn(), + model: "gpt-5.3-codex", + setModel: vi.fn(), + activeTheme: "general", + ...props, + }; + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return { container, props: mergedProps }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + vi.clearAllMocks(); + + mockUseConfiguredProviders.mockReturnValue({ + providers: [ + { + key: "custom-codex", + label: "Codex Custom", + registryId: "custom-codex", + fallbackRegistryId: "codex", + type: "codex", + }, + ], + loading: false, + }); + + mockUseProviderModels.mockReturnValue({ + modelIds: ["gpt-5.3-codex", "gpt-5.2-codex"], + models: [ + { id: "gpt-5.3-codex" }, + { id: "gpt-5.2-codex" }, + ], + loading: false, + error: null, + }); + + mockFilterModelsByTheme.mockImplementation((_theme, models) => ({ + models, + usedFallback: false, + filteredOutCount: 0, + policyName: "none", + })); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("ModelSelector", () => { + it("应在 codex 不兼容模型被选中时自动回退到兼容模型", () => { + const setModel = vi.fn(); + + renderModelSelector({ + model: "gpt-5.3-codex", + setModel, + }); + + expect(setModel).toHaveBeenCalledWith("gpt-5.2-codex"); + }); +}); diff --git a/src/components/input-kit/ModelSelector.tsx b/src/components/input-kit/ModelSelector.tsx index 6a9e54b1b..02f6f9554 100644 --- a/src/components/input-kit/ModelSelector.tsx +++ b/src/components/input-kit/ModelSelector.tsx @@ -19,6 +19,7 @@ import { ProviderIcon } from "@/icons/providers"; import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; import { useProviderModels } from "@/hooks/useProviderModels"; import { filterModelsByTheme } from "@/components/agent/chat/utils/modelThemePolicy"; +import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility"; const THEME_LABEL_MAP: Record = { general: "通用对话", @@ -80,9 +81,34 @@ export const ModelSelector: React.FC = ({ return filterModelsByTheme(activeTheme, providerModels); }, [activeTheme, providerModels]); - const currentModels = useMemo(() => { - return filteredResult.models.map((item) => item.id); - }, [filteredResult.models]); + const modelOptions = useMemo( + () => + filteredResult.models.map((item) => { + const compatibilityIssue = getProviderModelCompatibilityIssue({ + providerType, + configuredProviderType: selectedProvider?.type, + model: item.id, + }); + return { + id: item.id, + compatibilityIssue, + }; + }), + [filteredResult.models, providerType, selectedProvider?.type], + ); + + const currentModels = useMemo( + () => + modelOptions + .filter((item) => !item.compatibilityIssue) + .map((item) => item.id), + [modelOptions], + ); + + const incompatibleModelCount = useMemo( + () => modelOptions.filter((item) => item.compatibilityIssue).length, + [modelOptions], + ); useEffect(() => { if (hasInitialized.current) return; @@ -309,27 +335,39 @@ export const ModelSelector: React.FC = ({ {activeThemeLabel} 未匹配到主题模型,已展示全部模型
)} + {incompatibleModelCount > 0 && ( +
+ 已隐藏 {incompatibleModelCount} 个当前登录态不兼容的模型 +
+ )}
- {currentModels.length === 0 ? ( + {modelOptions.length === 0 ? (
暂无可用模型
) : ( - currentModels.map((currentModelItem) => ( + modelOptions.map((currentModelItem) => ( )) )} diff --git a/src/components/model-selector/ProviderModelSelector.test.tsx b/src/components/model-selector/ProviderModelSelector.test.tsx new file mode 100644 index 000000000..b3a7b9ee6 --- /dev/null +++ b/src/components/model-selector/ProviderModelSelector.test.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockUseConfiguredProviders, + mockUseProviderModels, +} = vi.hoisted(() => ({ + mockUseConfiguredProviders: vi.fn(), + mockUseProviderModels: vi.fn(), +})); + +vi.mock("@/hooks/useConfiguredProviders", () => ({ + useConfiguredProviders: () => mockUseConfiguredProviders(), +})); + +vi.mock("@/hooks/useProviderModels", () => ({ + useProviderModels: (...args: unknown[]) => mockUseProviderModels(...args), +})); + +import { ProviderModelSelector } from "./ProviderModelSelector"; + +interface MountedRoot { + root: Root; + container: HTMLDivElement; +} + +const mountedRoots: MountedRoot[] = []; + +function renderSelector( + props: Partial> = {}, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const mergedProps: React.ComponentProps = { + onSelect: vi.fn(), + initialProviderId: "custom-codex", + ...props, + }; + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return { container }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + vi.clearAllMocks(); + + mockUseConfiguredProviders.mockReturnValue({ + providers: [ + { + key: "custom-codex", + label: "Codex Custom", + registryId: "custom-codex", + fallbackRegistryId: "codex", + type: "codex", + }, + ], + loading: false, + }); + + mockUseProviderModels.mockReturnValue({ + models: [ + { + id: "gpt-5.3-codex", + display_name: "GPT-5.3 Codex", + capabilities: { + vision: true, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: true, + }, + is_latest: true, + }, + { + id: "gpt-5.2-codex", + display_name: "GPT-5.2 Codex", + capabilities: { + vision: true, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: true, + }, + is_latest: false, + }, + ], + loading: false, + error: null, + }); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("ProviderModelSelector", () => { + it("应隐藏 codex 不兼容模型并展示兼容提示", () => { + const { container } = renderSelector(); + + expect(container.textContent).toContain( + "已隐藏 1 个当前登录态不兼容的模型", + ); + expect(container.textContent).not.toContain("GPT-5.3 Codex"); + expect(container.textContent).toContain("GPT-5.2 Codex"); + }); +}); diff --git a/src/components/model-selector/ProviderModelSelector.tsx b/src/components/model-selector/ProviderModelSelector.tsx index 5860645f4..9636ece02 100644 --- a/src/components/model-selector/ProviderModelSelector.tsx +++ b/src/components/model-selector/ProviderModelSelector.tsx @@ -22,6 +22,7 @@ import { import { useProviderModels } from "@/hooks/useProviderModels"; import { getProviderLabel } from "@/lib/constants/providerMappings"; import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry"; +import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility"; // ============================================================================ // 类型定义 @@ -187,6 +188,24 @@ export const ProviderModelSelector: React.FC = ({ error: modelsError, } = useProviderModels(selectedProvider, { returnFullMetadata: true }); + const compatibleModels = useMemo( + () => + filteredModels.filter( + (model) => + !getProviderModelCompatibilityIssue({ + providerType: selectedProvider?.key || "", + configuredProviderType: selectedProvider?.type, + model: model.id, + }), + ), + [filteredModels, selectedProvider?.key, selectedProvider?.type], + ); + + const incompatibleModelCount = useMemo( + () => filteredModels.length - compatibleModels.length, + [compatibleModels.length, filteredModels.length], + ); + // 默认选中第一个 Provider useEffect(() => { if (!selectedProviderId && configuredProviders.length > 0) { @@ -279,19 +298,26 @@ export const ProviderModelSelector: React.FC = ({

{modelsError}

- ) : filteredModels.length === 0 ? ( + ) : compatibleModels.length === 0 ? (

暂无模型数据

) : ( - filteredModels.map((model) => ( + <> + {incompatibleModelCount > 0 ? ( +
+ 已隐藏 {incompatibleModelCount} 个当前登录态不兼容的模型 +
+ ) : null} + {compatibleModels.map((model) => ( handleSelectModel(model)} /> - )) + ))} + )}
diff --git a/src/components/openclaw/OpenClawPage.tsx b/src/components/openclaw/OpenClawPage.tsx index 1834feff5..5deee52ae 100644 --- a/src/components/openclaw/OpenClawPage.tsx +++ b/src/components/openclaw/OpenClawPage.tsx @@ -269,9 +269,14 @@ function resolveOpenClawSubpage( interface OpenClawPageProps { pageParams?: OpenClawPageParams; onNavigate?: (page: Page, params?: PageParams) => void; + isActive?: boolean; } -export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { +export function OpenClawPage({ + pageParams, + onNavigate, + isActive = false, +}: OpenClawPageProps) { const { providers, loading: providersLoading, @@ -562,8 +567,12 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { }, [refreshDashboardWindowState, refreshGatewayRuntime]); useEffect(() => { + if (!isActive) { + return; + } + void refreshAll(); - }, [refreshAll]); + }, [isActive, refreshAll]); useEffect(() => { if (!statusResolved || requestedSubpage || operationState.running) { @@ -587,6 +596,10 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { ]); useEffect(() => { + if (!isActive) { + return; + } + if (gatewayStatus !== "running" && gatewayStatus !== "starting") { return; } @@ -598,13 +611,23 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { }, 5000); return () => window.clearInterval(timer); - }, [gatewayStatus, refreshGatewayRuntime]); + }, [gatewayStatus, isActive, refreshGatewayRuntime]); useEffect(() => { + if (!isActive) { + return; + } + if (currentSubpage === "dashboard" && gatewayRunning && !dashboardUrl) { void refreshDashboardUrl({ silent: true, showLoading: true }); } - }, [currentSubpage, dashboardUrl, gatewayRunning, refreshDashboardUrl]); + }, [ + currentSubpage, + dashboardUrl, + gatewayRunning, + isActive, + refreshDashboardUrl, + ]); const syncProviderConfig = useCallback( async ({ showSuccessToast = true, trackLoading = true } = {}) => { diff --git a/src/components/settings-v2/system/channels/index.tsx b/src/components/settings-v2/system/channels/index.tsx index 5747d8566..78709c121 100644 --- a/src/components/settings-v2/system/channels/index.tsx +++ b/src/components/settings-v2/system/channels/index.tsx @@ -46,6 +46,7 @@ import { telegramChannelProbe, } from "@/hooks/useTauri"; import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { filterProviderModelsByCompatibility, getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility"; import { ChannelLogTailPanel } from "./ChannelLogTailPanel"; // ============================================================================ @@ -108,6 +109,25 @@ function DefaultModelSelect({ }) { const { providers, loading: providersLoading } = useConfiguredProviders(); + // 已保存的值如果不兼容,自动回退到 "未指定" + useEffect(() => { + if (!value || providersLoading) return; + const slashIdx = value.indexOf("/"); + if (slashIdx < 0) return; + const providerKey = value.slice(0, slashIdx); + const modelName = value.slice(slashIdx + 1); + const provider = providers.find((p) => p.key === providerKey); + if (!provider) return; + const issue = getProviderModelCompatibilityIssue({ + providerType: provider.type, + configuredProviderType: provider.type, + model: modelName, + }); + if (issue) { + onChange(undefined); + } + }, [value, providers, providersLoading, onChange]); + return (
@@ -118,15 +138,23 @@ function DefaultModelSelect({ > {providersLoading && } - {providers.map((p) => ( - - {p.customModels?.map((m) => ( - - ))} - - ))} + {providers.map((p) => { + const models = p.customModels ?? []; + const { compatibleModels } = filterProviderModelsByCompatibility( + { providerType: p.type, configuredProviderType: p.type }, + models, + ); + if (compatibleModels.length === 0) return null; + return ( + + {compatibleModels.map((m) => ( + + ))} + + ); + })}

为此渠道指定默认使用的 AI 模型 diff --git a/src/components/terminal/TerminalWorkspace.tsx b/src/components/terminal/TerminalWorkspace.tsx index d3d931a25..608ee422b 100644 --- a/src/components/terminal/TerminalWorkspace.tsx +++ b/src/components/terminal/TerminalWorkspace.tsx @@ -16,7 +16,7 @@ * - AI 面板可控制活动终端 */ -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useRef, useEffect } from "react"; import styled from "styled-components"; import { TerminalPanel } from "./TerminalPanel"; import { @@ -210,12 +210,17 @@ const CloseIcon = () => ( interface TerminalWorkspaceProps { /** 页面导航回调 */ onNavigate: (page: Page) => void; + /** 当前页面是否已激活 */ + isActive: boolean; } /** * 终端工作区组件 */ -export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) { +export function TerminalWorkspace({ + onNavigate, + isActive, +}: TerminalWorkspaceProps) { // 面板状态管理 - 初始包含主终端 const [panels, setPanels] = useState([ { id: "main-terminal", type: "terminal", title: "Terminal" }, @@ -223,6 +228,7 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) { // AI 面板状态 const [showAIPanel, setShowAIPanel] = useState(false); + const [hasActivated, setHasActivated] = useState(isActive); // 活动终端面板 ID(用于 AI 控制) const [activeTerminalPanelId, setActiveTerminalPanelId] = @@ -234,6 +240,12 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) { // 连接编辑器模态窗口状态 const [isConnectionsEditorOpen, setIsConnectionsEditorOpen] = useState(false); + useEffect(() => { + if (isActive) { + setHasActivated(true); + } + }, [isActive]); + // 获取活动终端的会话 ID const getActiveTerminalSessionId = useCallback((): string | null => { const activePanel = panels.find( @@ -346,6 +358,14 @@ export function TerminalWorkspace({ onNavigate }: TerminalWorkspaceProps) { const renderPanelContent = (panel: SidePanel) => { switch (panel.type) { case "terminal": + if (!hasActivated) { + return ( +

+ 终端将在首次打开该页面时初始化 +
+ ); + } + return ( ({ + mockUseConfiguredProviders: vi.fn(), + mockUseProviderModels: vi.fn(), +})); + +vi.mock("@/hooks/useConfiguredProviders", () => ({ + useConfiguredProviders: () => mockUseConfiguredProviders(), +})); + +vi.mock("@/hooks/useProviderModels", () => ({ + useProviderModels: (...args: unknown[]) => mockUseProviderModels(...args), +})); + +import { TerminalAIModeSelector } from "./TerminalAIModeSelector"; + +interface MountedRoot { + root: Root; + container: HTMLDivElement; +} + +const mountedRoots: MountedRoot[] = []; + +function renderSelector( + props: Partial> = {}, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const mergedProps: React.ComponentProps = { + providerId: "custom-codex", + onProviderChange: vi.fn(), + modelId: "gpt-5.3-codex", + onModelChange: vi.fn(), + ...props, + }; + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return { container, props: mergedProps }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + vi.clearAllMocks(); + + mockUseConfiguredProviders.mockReturnValue({ + providers: [ + { + key: "custom-codex", + label: "Codex Custom", + registryId: "custom-codex", + fallbackRegistryId: "codex", + type: "codex", + credentialType: "codex_oauth", + }, + ], + loading: false, + }); + + mockUseProviderModels.mockReturnValue({ + modelIds: ["gpt-5.3-codex", "gpt-5.2-codex"], + models: [], + loading: false, + error: null, + }); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("TerminalAIModeSelector", () => { + it("应在 codex 不兼容模型被选中时自动回退到兼容模型", () => { + const onModelChange = vi.fn(); + + renderSelector({ + modelId: "gpt-5.3-codex", + onModelChange, + }); + + expect(onModelChange).toHaveBeenCalledWith("gpt-5.2-codex"); + }); +}); diff --git a/src/components/terminal/ai/TerminalAIModeSelector.tsx b/src/components/terminal/ai/TerminalAIModeSelector.tsx index 8918f88d9..6d2482688 100644 --- a/src/components/terminal/ai/TerminalAIModeSelector.tsx +++ b/src/components/terminal/ai/TerminalAIModeSelector.tsx @@ -18,6 +18,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; import { useProviderModels } from "@/hooks/useProviderModels"; import { isAliasProvider } from "@/lib/constants/providerMappings"; +import { filterProviderModelsByCompatibility } from "@/components/agent/chat/utils/providerModelCompatibility"; // ============================================================================ // 常量 @@ -229,6 +230,20 @@ export const TerminalAIModeSelector: React.FC = ({ return hookModels; }, [selectedProvider, hookModels]); + const compatibilityResult = useMemo(() => { + if (!selectedProvider) { + return { compatibleModels: [], incompatibleModels: [] }; + } + + return filterProviderModelsByCompatibility( + { + providerType: selectedProvider.key, + configuredProviderType: selectedProvider.type, + }, + currentModels, + ); + }, [currentModels, selectedProvider]); + // 自动选择第一个模型 useEffect(() => { // 等待模型加载完成 @@ -240,10 +255,19 @@ export const TerminalAIModeSelector: React.FC = ({ return; } - if (currentModels.length > 0 && !currentModels.includes(modelId)) { - onModelChange(currentModels[0]); + if ( + compatibilityResult.compatibleModels.length > 0 && + !compatibilityResult.compatibleModels.includes(modelId) + ) { + onModelChange(compatibilityResult.compatibleModels[0]); } - }, [currentModels, modelId, onModelChange, selectedProvider, modelsLoading]); + }, [ + compatibilityResult.compatibleModels, + modelId, + onModelChange, + selectedProvider, + modelsLoading, + ]); // 初始化 Provider useEffect(() => { @@ -308,12 +332,17 @@ export const TerminalAIModeSelector: React.FC = ({
Models
+ {compatibilityResult.incompatibleModels.length > 0 ? ( +
+ 已隐藏 {compatibilityResult.incompatibleModels.length} 个当前登录态不兼容的模型 +
+ ) : null}
- {currentModels.length === 0 ? ( + {compatibilityResult.compatibleModels.length === 0 ? (
暂无可用模型
) : ( - currentModels.map((m) => ( + compatibilityResult.compatibleModels.map((m) => (