From 2557f22bb2a8679666ef8ea38270f6eef72f955d Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 4 Mar 2026 18:23:59 +0800 Subject: [PATCH] chore(release): v0.79.0 --- .tmp_perm_test | 1 + package.json | 7 +- scripts/app-version.mjs | 71 +++ scripts/check-app-version-consistency.mjs | 56 ++ src-tauri/Cargo.lock | 32 +- src-tauri/Cargo.toml | 4 +- src-tauri/crates/agent/Cargo.toml | 1 + .../crates/agent/src/credential_bridge.rs | 153 ++++- src-tauri/crates/agent/src/hooks.rs | 87 ++- .../agent/tests/real_codex_tool_events.rs | 167 ++++++ src-tauri/crates/core/src/agent/types.rs | 30 +- src-tauri/crates/mcp/Cargo.toml | 1 + src-tauri/crates/mcp/src/manager.rs | 4 +- src-tauri/crates/mcp/src/types.rs | 53 ++ .../crates/providers/src/providers/codex.rs | 253 +++----- .../services/src/api_key_provider_service.rs | 64 ++ src-tauri/crates/services/src/live_sync.rs | 178 ++++-- .../crates/services/src/live_sync_tests.rs | 34 +- .../terminal/src/block_controller/traits.rs | 22 + .../terminal/src/connections/local_pty.rs | 150 ++++- .../src/connections/ssh_shell_proc.rs | 4 +- .../src/connections/wsl_connection.rs | 2 +- src-tauri/crates/terminal/src/pty_session.rs | 143 ++++- src-tauri/src/commands/aster_agent_cmd.rs | 166 ++++- src-tauri/src/commands/music_cmd.rs | 100 +++- src-tauri/src/commands/unified_chat_cmd.rs | 220 +++++-- src-tauri/src/services/mod.rs | 1 + .../request_tool_policy_prompt_service.rs | 566 ++++++++++++++++++ src-tauri/tauri.conf.json | 2 +- src-tauri/tests/real_web_search_policy.rs | 268 +++++++++ .../real_web_search_preflight_short_input.rs | 134 +++++ src/components/AppSidebar.tsx | 70 ++- .../agent/chat/components/EmptyState.test.tsx | 68 ++- .../agent/chat/components/EmptyState.tsx | 116 +++- .../Inputbar/components/InputbarTools.tsx | 14 +- .../chat/components/Inputbar/index.test.tsx | 62 +- .../agent/chat/components/Inputbar/index.tsx | 107 +++- .../chat/hooks/useAsterAgentChat.test.tsx | 28 + .../agent/chat/hooks/useAsterAgentChat.ts | 161 ++++- src/components/agent/chat/index.test.tsx | 2 + src/components/agent/chat/index.tsx | 139 ++++- .../agent/chat/utils/chatToolPreferences.ts | 48 ++ .../content-creator/agents/AgentChatPanel.tsx | 43 +- .../content-creator/agents/AgentScheduler.ts | 18 + .../content-creator/agents/base/BaseAgent.ts | 30 + .../agents/poster/ContentAgent.ts | 125 +++- .../agents/poster/LayoutAgent.ts | 115 +++- .../poster/ProfessionalLayoutMethods.ts | 450 ++++++++++++++ .../agents/poster/RequirementAgent.ts | 145 ++++- .../canvas/document/DocumentCanvas.tsx | 68 ++- .../canvas/document/DocumentToolbar.tsx | 7 +- .../document/hooks/useDocumentCanvas.ts | 22 +- .../canvas/document/types.test.ts | 17 + .../content-creator/canvas/document/types.ts | 4 +- .../canvas/poster/platforms/juejin.ts | 72 +++ .../canvas/poster/platforms/zhihu.ts | 71 +++ .../ActivityLog/ActivityLogList.tsx | 147 +++++ .../components/ActivityLog/index.ts | 8 + .../content-creator/hooks/useActivityLog.ts | 58 ++ .../templates/social-media/index.ts | 39 ++ .../social-media/industry-analysis.ts | 139 +++++ .../templates/social-media/product-launch.ts | 139 +++++ .../templates/social-media/tech-sharing.ts | 154 +++++ .../templates/social-media/trending-topic.ts | 156 +++++ .../templates/social-media/visual-content.ts | 139 +++++ .../utils/__tests__/activityLogger.test.ts | 151 +++++ .../content-creator/utils/activityLogger.ts | 182 ++++++ .../workflows/poster/PosterWorkflowPanel.tsx | 121 ++-- .../workflows/poster/social-media.ts | 27 +- .../general-chat/chat/ChatPanel.tsx | 95 ++- .../general-chat/store/useGeneralChatStore.ts | 14 +- .../general/chat-appearance/index.tsx | 44 +- .../workspace/WorkbenchPage.test.tsx | 10 - src/components/workspace/WorkbenchPage.tsx | 31 +- .../workspace/hooks/useWorkbenchController.ts | 13 +- .../hooks/useWorkbenchNavigation.test.tsx | 46 +- .../workspace/hooks/useWorkbenchNavigation.ts | 6 +- .../workspace/panels/WorkbenchMainContent.tsx | 3 + .../workspace/panels/WorkbenchRightRail.tsx | 213 +------ src/hooks/usePosterWorkflow.ts | 39 ++ src/hooks/useUnifiedChat.ts | 21 +- src/lib/api/agent.ts | 4 + src/lib/appVersion.test.ts | 10 +- src/lib/appVersion.ts | 10 - src/lib/model/thinkingBaseModelMemory.ts | 101 ++++ src/lib/model/thinkingModelResolver.test.ts | 137 +++++ src/lib/model/thinkingModelResolver.ts | 245 ++++++++ src/types/chat.ts | 7 +- vite.config.ts | 12 +- 89 files changed, 6640 insertions(+), 857 deletions(-) create mode 100644 .tmp_perm_test create mode 100644 scripts/app-version.mjs create mode 100644 scripts/check-app-version-consistency.mjs create mode 100644 src-tauri/crates/agent/tests/real_codex_tool_events.rs create mode 100644 src-tauri/src/services/request_tool_policy_prompt_service.rs create mode 100644 src-tauri/tests/real_web_search_policy.rs create mode 100644 src-tauri/tests/real_web_search_preflight_short_input.rs create mode 100644 src/components/agent/chat/utils/chatToolPreferences.ts create mode 100644 src/components/content-creator/agents/poster/ProfessionalLayoutMethods.ts create mode 100644 src/components/content-creator/canvas/document/types.test.ts create mode 100644 src/components/content-creator/canvas/poster/platforms/juejin.ts create mode 100644 src/components/content-creator/canvas/poster/platforms/zhihu.ts create mode 100644 src/components/content-creator/components/ActivityLog/ActivityLogList.tsx create mode 100644 src/components/content-creator/components/ActivityLog/index.ts create mode 100644 src/components/content-creator/hooks/useActivityLog.ts create mode 100644 src/components/content-creator/templates/social-media/index.ts create mode 100644 src/components/content-creator/templates/social-media/industry-analysis.ts create mode 100644 src/components/content-creator/templates/social-media/product-launch.ts create mode 100644 src/components/content-creator/templates/social-media/tech-sharing.ts create mode 100644 src/components/content-creator/templates/social-media/trending-topic.ts create mode 100644 src/components/content-creator/templates/social-media/visual-content.ts create mode 100644 src/components/content-creator/utils/__tests__/activityLogger.test.ts create mode 100644 src/components/content-creator/utils/activityLogger.ts create mode 100644 src/lib/model/thinkingBaseModelMemory.ts create mode 100644 src/lib/model/thinkingModelResolver.test.ts create mode 100644 src/lib/model/thinkingModelResolver.ts diff --git a/.tmp_perm_test b/.tmp_perm_test new file mode 100644 index 000000000..9daeafb98 --- /dev/null +++ b/.tmp_perm_test @@ -0,0 +1 @@ +test diff --git a/package.json b/package.json index 0333ad3bf..e558b8e98 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.78.0", + "version": "0.79.0", "type": "module", "repository": { "type": "git", @@ -9,9 +9,9 @@ }, "homepage": "https://github.com/aiclientproxy/proxycast", "scripts": { - "predev": "node scripts/ensure-dev-port.mjs", + "predev": "npm run verify:app-version && node scripts/ensure-dev-port.mjs", "dev": "npx vite", - "build": "tsc && vite build", + "build": "npm run verify:app-version && tsc && vite build", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "CARGO_TARGET_DIR=target tauri dev", @@ -25,6 +25,7 @@ "detect-translations": "tsx scripts/detect-missing-translations.ts", "detect-translations:fix": "tsx scripts/detect-missing-translations.ts --fix", "detect-translations:verbose": "tsx scripts/detect-missing-translations.ts --verbose", + "verify:app-version": "node scripts/check-app-version-consistency.mjs", "ai-verify": "tsx scripts/ai-code-verify.ts", "ai-verify:level1": "tsx scripts/ai-code-verify.ts --level 1", "ai-verify:level2": "tsx scripts/ai-code-verify.ts --level 2", diff --git a/scripts/app-version.mjs b/scripts/app-version.mjs new file mode 100644 index 000000000..1a74c659f --- /dev/null +++ b/scripts/app-version.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function extractSection(content, sectionName) { + const pattern = new RegExp( + `^\\[${escapeRegExp(sectionName)}\\]\\s*([\\s\\S]*?)(?=^\\[|\\Z)`, + "m", + ); + const match = content.match(pattern); + return match?.[1] ?? ""; +} + +function parseStringField(section, fieldName) { + const pattern = new RegExp( + `^\\s*${escapeRegExp(fieldName)}\\s*=\\s*\"([^\"]+)\"\\s*$`, + "m", + ); + return section.match(pattern)?.[1] ?? null; +} + +function parseWorkspaceField(section, fieldName) { + const pattern = new RegExp( + `^\\s*${escapeRegExp(fieldName)}\\.workspace\\s*=\\s*true\\s*$`, + "m", + ); + return pattern.test(section); +} + +export function readCargoVersions(cargoTomlPath) { + const content = fs.readFileSync(cargoTomlPath, "utf8"); + const workspacePackageSection = extractSection(content, "workspace.package"); + const packageSection = extractSection(content, "package"); + + const workspaceVersion = parseStringField(workspacePackageSection, "version"); + const packageVersion = parseStringField(packageSection, "version"); + const packageVersionIsWorkspace = parseWorkspaceField(packageSection, "version"); + + return { + workspaceVersion, + packageVersion, + packageVersionIsWorkspace, + }; +} + +export function readWorkspaceAppVersion(repoRoot = process.cwd()) { + const cargoTomlPath = path.join(repoRoot, "src-tauri", "Cargo.toml"); + const { workspaceVersion } = readCargoVersions(cargoTomlPath); + return workspaceVersion; +} + +const currentFilePath = fileURLToPath(import.meta.url); +const entryFilePath = process.argv[1] ? path.resolve(process.argv[1]) : null; + +if (entryFilePath && currentFilePath === entryFilePath) { + const repoRoot = path.resolve(path.dirname(currentFilePath), ".."); + const version = readWorkspaceAppVersion(repoRoot); + if (!version) { + console.error("[proxycast] 无法从 src-tauri/Cargo.toml 读取 workspace 版本"); + process.exit(1); + } + process.stdout.write(version); +} + diff --git a/scripts/check-app-version-consistency.mjs b/scripts/check-app-version-consistency.mjs new file mode 100644 index 000000000..e867b655a --- /dev/null +++ b/scripts/check-app-version-consistency.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { readCargoVersions } from "./app-version.mjs"; + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +const repoRoot = path.resolve(process.cwd()); +const cargoTomlPath = path.join(repoRoot, "src-tauri", "Cargo.toml"); +const tauriConfigPath = path.join(repoRoot, "src-tauri", "tauri.conf.json"); +const packageJsonPath = path.join(repoRoot, "package.json"); + +const cargo = readCargoVersions(cargoTomlPath); +const tauriConfig = readJson(tauriConfigPath); +const packageJson = readJson(packageJsonPath); + +const sourceVersion = cargo.workspaceVersion; +const issues = []; + +if (!sourceVersion) { + issues.push("src-tauri/Cargo.toml [workspace.package].version 缺失"); +} + +if (!cargo.packageVersionIsWorkspace && cargo.packageVersion !== sourceVersion) { + issues.push( + `src-tauri/Cargo.toml [package].version (${cargo.packageVersion ?? "missing"}) 与 workspace.version (${sourceVersion ?? "missing"}) 不一致`, + ); +} + +if ((packageJson.version ?? null) !== sourceVersion) { + issues.push( + `package.json version (${packageJson.version ?? "missing"}) 与 workspace.version (${sourceVersion ?? "missing"}) 不一致`, + ); +} + +if ((tauriConfig.version ?? null) !== sourceVersion) { + issues.push( + `src-tauri/tauri.conf.json version (${tauriConfig.version ?? "missing"}) 与 workspace.version (${sourceVersion ?? "missing"}) 不一致`, + ); +} + +if (issues.length > 0) { + console.error("[proxycast] 应用版本一致性检查失败:"); + for (const issue of issues) { + console.error(`- ${issue}`); + } + process.exit(1); +} + +console.log(`[proxycast] 版本一致性检查通过: ${sourceVersion}`); + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ce2b7f430..0f2a35232 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6996,7 +6996,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.77.0" +version = "0.79.0" dependencies = [ "anyhow", "arboard", @@ -7097,12 +7097,13 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.78.0" +version = "0.79.0" dependencies = [ "aster-core", "async-trait", "chrono", "dirs 5.0.1", + "futures", "proxycast-core", "proxycast-mcp", "proxycast-providers", @@ -7121,7 +7122,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.78.0" +version = "0.79.0" dependencies = [ "async-trait", "parking_lot", @@ -7137,7 +7138,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.78.0" +version = "0.79.0" dependencies = [ "aster-models", "async-trait", @@ -7177,7 +7178,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.78.0" +version = "0.79.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -7212,7 +7213,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.78.0" +version = "0.79.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -7232,9 +7233,10 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.78.0" +version = "0.79.0" dependencies = [ "async-trait", + "dirs 5.0.1", "glob", "proxycast-core", "rmcp", @@ -7263,7 +7265,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.78.0" +version = "0.79.0" dependencies = [ "async-trait", "parking_lot", @@ -7282,7 +7284,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.78.0" +version = "0.79.0" dependencies = [ "anyhow", "async-stream", @@ -7334,7 +7336,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.78.0" +version = "0.79.0" dependencies = [ "aster-core", "async-stream", @@ -7379,7 +7381,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.78.0" +version = "0.79.0" dependencies = [ "axum 0.7.9", "futures", @@ -7394,7 +7396,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.78.0" +version = "0.79.0" dependencies = [ "anyhow", "aster-core", @@ -7435,7 +7437,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.78.0" +version = "0.79.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7451,7 +7453,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.78.0" +version = "0.79.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7478,7 +7480,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.78.0" +version = "0.79.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bc4ff2209..b248c7f5e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.78.0" +version = "0.79.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/proxycast" @@ -190,7 +190,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.77.0" +version.workspace = true description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/Cargo.toml b/src-tauri/crates/agent/Cargo.toml index 30587c909..483813ef8 100644 --- a/src-tauri/crates/agent/Cargo.toml +++ b/src-tauri/crates/agent/Cargo.toml @@ -26,3 +26,4 @@ regex.workspace = true [dev-dependencies] tempfile.workspace = true +futures.workspace = true diff --git a/src-tauri/crates/agent/src/credential_bridge.rs b/src-tauri/crates/agent/src/credential_bridge.rs index 7dbaaf365..b407318f0 100644 --- a/src-tauri/crates/agent/src/credential_bridge.rs +++ b/src-tauri/crates/agent/src/credential_bridge.rs @@ -17,6 +17,7 @@ use proxycast_core::database::DbConnection; use proxycast_core::models::provider_pool_model::{ CredentialData, PoolProviderType, ProviderCredential, }; +use proxycast_core::models::provider_type::is_custom_provider_id; use proxycast_services::api_key_provider_service::ApiKeyProviderService; use proxycast_services::provider_pool_service::ProviderPoolService; use std::sync::Arc; @@ -63,6 +64,8 @@ pub struct AsterProviderConfig { pub base_url: Option, /// 凭证 UUID(用于记录使用和健康状态) pub credential_uuid: String, + /// 是否强制 OpenAI provider 使用 Responses API(用于 Codex 等兼容链路) + pub force_responses_api: bool, } /// 凭证池桥接器 @@ -128,6 +131,39 @@ impl CredentialBridge { .await } + fn resolve_api_provider_type_hint( + &self, + db: &DbConnection, + provider_type_hint: &str, + ) -> Option { + if let Ok(api_type) = provider_type_hint.parse::() { + return Some(api_type); + } + + if !is_custom_provider_id(provider_type_hint) { + return None; + } + + match self.api_key_service.get_provider(db, provider_type_hint) { + Ok(Some(provider_with_keys)) => Some(provider_with_keys.provider.provider_type), + Ok(None) => { + tracing::warn!( + "[CredentialBridge] custom provider 不存在: {}, 使用默认映射", + provider_type_hint + ); + None + } + Err(error) => { + tracing::warn!( + "[CredentialBridge] 读取 custom provider 失败: {} ({}),使用默认映射", + provider_type_hint, + error + ); + None + } + } + } + /// 将 ProxyCast 凭证转换为 Aster Provider 配置 async fn credential_to_config( &self, @@ -142,20 +178,23 @@ impl CredentialBridge { credential.provider_type ); - let (provider_name, api_key, base_url) = match &credential.credential { + let (provider_name, api_key, base_url, force_responses_api) = match &credential.credential { // OpenAI API Key - 根据 provider_type_hint 确定实际的 Provider CredentialData::OpenAIKey { api_key, base_url } => { - // 使用 provider_type_hint 来确定 aster provider 名称 - let provider = map_provider_type_to_aster(provider_type_hint); + let resolved_api_type = self.resolve_api_provider_type_hint(db, provider_type_hint); + let provider = + map_provider_type_to_aster_with_api_type(provider_type_hint, resolved_api_type); tracing::info!( - "[CredentialBridge] OpenAIKey: provider_type_hint={} -> aster_provider={}", + "[CredentialBridge] OpenAIKey: provider_type_hint={}, resolved_api_type={:?} -> aster_provider={}", provider_type_hint, + resolved_api_type, provider ); ( provider.to_string(), Some(api_key.clone()), base_url.clone(), + resolved_api_type == Some(ApiProviderType::Codex), ) } @@ -165,6 +204,7 @@ impl CredentialBridge { "anthropic".to_string(), Some(api_key.clone()), base_url.clone(), + false, ), // Kiro OAuth - 需要获取 access_token @@ -173,7 +213,7 @@ impl CredentialBridge { .get_kiro_token(creds_file_path, db, &credential.uuid) .await?; // Kiro 使用 CodeWhisperer API,映射到 bedrock provider - ("bedrock".to_string(), Some(token), None) + ("bedrock".to_string(), Some(token), None, false) } // Gemini OAuth @@ -181,7 +221,7 @@ impl CredentialBridge { creds_file_path, .. } => { let token = self.get_oauth_token(creds_file_path).await?; - ("google".to_string(), Some(token), None) + ("google".to_string(), Some(token), None, false) } // Gemini API Key @@ -191,6 +231,7 @@ impl CredentialBridge { "google".to_string(), Some(api_key.clone()), base_url.clone(), + false, ), // Vertex AI @@ -200,6 +241,7 @@ impl CredentialBridge { "gcpvertexai".to_string(), Some(api_key.clone()), base_url.clone(), + false, ), // Codex OAuth @@ -208,13 +250,19 @@ impl CredentialBridge { api_base_url, } => { let token = self.get_codex_token(creds_file_path).await?; - ("codex".to_string(), Some(token), api_base_url.clone()) + ( + // 统一走 OpenAI provider,保证 tools/stream 事件链路一致 + "openai".to_string(), + Some(token), + api_base_url.clone(), + true, + ) } // Claude OAuth CredentialData::ClaudeOAuth { creds_file_path } => { let token = self.get_oauth_token(creds_file_path).await?; - ("anthropic".to_string(), Some(token), None) + ("anthropic".to_string(), Some(token), None, false) } // Antigravity OAuth @@ -222,7 +270,7 @@ impl CredentialBridge { creds_file_path, .. } => { let token = self.get_oauth_token(creds_file_path).await?; - ("google".to_string(), Some(token), None) + ("google".to_string(), Some(token), None, false) } }; @@ -232,6 +280,7 @@ impl CredentialBridge { api_key, base_url, credential_uuid: credential.uuid.clone(), + force_responses_api, }) } @@ -418,12 +467,32 @@ fn set_provider_env_vars(config: &AsterProviderConfig) { std::env::set_var(env_key, api_key); } + if config.provider_name == "openai" { + if config.force_responses_api { + std::env::set_var("OPENAI_FORCE_RESPONSES_API", "1"); + } else { + std::env::remove_var("OPENAI_FORCE_RESPONSES_API"); + } + } + // 设置 base_url // Aster 的 OpenAI Provider 使用 OPENAI_HOST(仅 scheme+host+port)和 // OPENAI_BASE_PATH(路径部分 + /chat/completions)环境变量 if let Some(base_url) = &config.base_url { match config.provider_name.as_str() { "openai" => { + // 当显式强制 responses 模式时,需要将路径前缀保留在 OPENAI_HOST 中, + // 因为 Aster OpenAI provider 在 responses 模式下固定请求 v1/responses, + // 不会读取 OPENAI_BASE_PATH。 + if config.force_responses_api { + std::env::set_var("OPENAI_HOST", base_url); + std::env::remove_var("OPENAI_BASE_PATH"); + tracing::info!( + "[CredentialBridge] 强制 Responses 模式: 设置 OPENAI_HOST={}, 清理 OPENAI_BASE_PATH", + base_url + ); + return; + } // 解析 base_url,将路径部分拆分到 OPENAI_BASE_PATH // 例如 https://open.bigmodel.cn/api/paas/v4 // -> OPENAI_HOST = https://open.bigmodel.cn @@ -525,6 +594,21 @@ fn map_provider_type_to_aster(provider_type: &str) -> &'static str { } } +fn map_provider_type_to_aster_with_api_type( + provider_type: &str, + resolved_api_type: Option, +) -> &'static str { + if let Some(api_type) = resolved_api_type { + // Codex API Key 在 Aster 中应走 OpenAI provider(支持标准 tools + responses 转换逻辑), + // 避免误走 codex CLI provider 导致工具事件丢失。 + if api_type == ApiProviderType::Codex { + return "openai"; + } + return api_type.runtime_spec().aster_provider_name; + } + map_provider_type_to_aster(provider_type) +} + #[cfg(test)] mod tests { use super::*; @@ -540,6 +624,56 @@ mod tests { assert_eq!(map_pool_type_to_aster(&PoolProviderType::Kiro), "bedrock"); } + #[test] + fn test_map_provider_type_to_aster_with_api_type() { + assert_eq!( + map_provider_type_to_aster_with_api_type( + "custom-a32774c6-6fd0-433b-8b81-e95340e08793", + Some(ApiProviderType::Codex), + ), + "openai" + ); + assert_eq!( + map_provider_type_to_aster_with_api_type( + "custom-a32774c6-6fd0-433b-8b81-e95340e08793", + Some(ApiProviderType::AnthropicCompatible), + ), + "anthropic" + ); + assert_eq!( + map_provider_type_to_aster_with_api_type("deepseek", None), + "openai" + ); + } + + #[test] + fn test_set_provider_env_vars_openai_codex_responses_keeps_full_base_url() { + std::env::remove_var("OPENAI_HOST"); + std::env::remove_var("OPENAI_BASE_PATH"); + std::env::remove_var("OPENAI_FORCE_RESPONSES_API"); + + let config = AsterProviderConfig { + provider_name: "openai".to_string(), + model_name: "gpt-5.3-codex".to_string(), + api_key: Some("test-key".to_string()), + base_url: Some("https://example.com/openai".to_string()), + credential_uuid: "test-uuid".to_string(), + force_responses_api: true, + }; + + set_provider_env_vars(&config); + + assert_eq!( + std::env::var("OPENAI_HOST").ok(), + Some("https://example.com/openai".to_string()) + ); + assert!(std::env::var("OPENAI_BASE_PATH").is_err()); + assert_eq!( + std::env::var("OPENAI_FORCE_RESPONSES_API").ok().as_deref(), + Some("1") + ); + } + #[test] fn test_credential_bridge_error_display() { let err = CredentialBridgeError::NoCredentials("test".to_string()); @@ -582,6 +716,7 @@ mod tests { api_key: Some("test-key".to_string()), base_url: Some("https://open.bigmodel.cn/api/anthropic".to_string()), credential_uuid: "test-uuid".to_string(), + force_responses_api: false, }; set_provider_env_vars(&config); diff --git a/src-tauri/crates/agent/src/hooks.rs b/src-tauri/crates/agent/src/hooks.rs index 9abdd6134..a78c092f1 100644 --- a/src-tauri/crates/agent/src/hooks.rs +++ b/src-tauri/crates/agent/src/hooks.rs @@ -56,6 +56,74 @@ fn default_timeout() -> u64 { 10 } +fn shell_command_flag(shell: &str) -> &'static str { + let executable = std::path::Path::new(shell) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(shell) + .to_ascii_lowercase(); + + if executable == "cmd" || executable == "cmd.exe" { + "/C" + } else if executable.contains("powershell") || executable == "pwsh" || executable == "pwsh.exe" + { + "-Command" + } else { + "-c" + } +} + +fn resolve_command_shell() -> String { + let shell_from_env = std::env::var("SHELL").ok().and_then(|value| { + let cleaned = value + .split('\0') + .next() + .unwrap_or_default() + .trim() + .to_string(); + (!cleaned.is_empty()).then_some(cleaned) + }); + + #[cfg(target_os = "windows")] + { + if let Some(shell) = shell_from_env { + let path = std::path::Path::new(&shell); + if path.is_absolute() && path.exists() { + return shell; + } + } + + if let Ok(comspec) = std::env::var("COMSPEC") { + let cleaned = comspec + .split('\0') + .next() + .unwrap_or_default() + .trim() + .to_string(); + if !cleaned.is_empty() && std::path::Path::new(&cleaned).exists() { + return cleaned; + } + } + + "cmd.exe".to_string() + } + + #[cfg(not(target_os = "windows"))] + { + if let Some(shell) = shell_from_env { + if std::path::Path::new(&shell).exists() { + return shell; + } + } + + if std::path::Path::new("/bin/sh").exists() { + "/bin/sh".to_string() + } else { + "sh".to_string() + } + } +} + /// Hook 执行结果 #[derive(Debug)] pub struct HookResult { @@ -209,9 +277,11 @@ impl HookManager { async fn execute_hook(hook: &HookDefinition, context: &HookContext) -> HookResult { let context_json = serde_json::to_string(context).unwrap_or_default(); + let shell = resolve_command_shell(); + let shell_flag = shell_command_flag(&shell); - let child = Command::new("sh") - .arg("-c") + let child = Command::new(&shell) + .arg(shell_flag) .arg(&hook.command) .env( "HOOK_EVENT", @@ -411,6 +481,19 @@ mod tests { assert!(!HookManager::is_blocked(&results_ok)); } + #[test] + fn test_shell_command_flag_for_common_shells() { + assert_eq!(shell_command_flag("cmd.exe"), "/C"); + assert_eq!(shell_command_flag("pwsh"), "-Command"); + assert_eq!(shell_command_flag("/bin/sh"), "-c"); + } + + #[test] + fn test_resolve_command_shell_not_empty() { + let shell = resolve_command_shell(); + assert!(!shell.trim().is_empty()); + } + #[tokio::test] async fn test_trigger_executes_matching_hooks() { let mut mgr = HookManager::new(); diff --git a/src-tauri/crates/agent/tests/real_codex_tool_events.rs b/src-tauri/crates/agent/tests/real_codex_tool_events.rs new file mode 100644 index 000000000..941a62843 --- /dev/null +++ b/src-tauri/crates/agent/tests/real_codex_tool_events.rs @@ -0,0 +1,167 @@ +use futures::StreamExt; +use proxycast_agent::{ + convert_agent_event, AsterAgentState, SessionConfigBuilder, TauriAgentEvent, +}; +use proxycast_core::database::dao::api_key_provider::ApiProviderType; +use proxycast_core::database::init_database; +use proxycast_services::api_key_provider_service::ApiKeyProviderService; +use uuid::Uuid; + +fn should_run_real_test() -> bool { + std::env::var("PROXYCAST_REAL_API_TEST").ok().as_deref() == Some("1") +} + +fn resolve_model_name( + explicit: Option, + provider_models: &[String], +) -> Result { + if let Some(model) = explicit + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(model.to_string()); + } + + if let Some(model) = provider_models + .iter() + .map(|value| value.trim()) + .find(|value| !value.is_empty()) + { + return Ok(model.to_string()); + } + + Err( + "未找到可用模型:请设置 PROXYCAST_REAL_MODEL,或在 Provider custom_models 中配置模型。" + .to_string(), + ) +} + +fn resolve_codex_provider_and_model( + db: &proxycast_core::database::DbConnection, +) -> Result<(String, String), String> { + let explicit_model = std::env::var("PROXYCAST_REAL_MODEL").ok(); + + if let Ok(explicit) = std::env::var("PROXYCAST_REAL_PROVIDER_ID") { + let trimmed = explicit.trim(); + if !trimmed.is_empty() { + let service = ApiKeyProviderService::new(); + let provider = service + .get_provider(db, trimmed)? + .ok_or_else(|| format!("未找到指定 Provider: {trimmed}"))?; + let model = resolve_model_name(explicit_model, &provider.provider.custom_models)?; + return Ok((trimmed.to_string(), model)); + } + } + + let service = ApiKeyProviderService::new(); + let providers = service.get_all_providers(db)?; + providers + .into_iter() + .find(|item| { + item.provider.enabled + && item.provider.provider_type == ApiProviderType::Codex + && item.api_keys.iter().any(|key| key.enabled) + }) + .map(|item| { + let model = resolve_model_name(explicit_model, &item.provider.custom_models)?; + Ok((item.provider.id, model)) + }) + .transpose()? + .ok_or_else(|| "未找到启用且含可用 Key 的 Codex Provider".to_string()) +} + +#[tokio::test] +#[ignore = "真实联网测试:设置 PROXYCAST_REAL_API_TEST=1 后执行"] +async fn test_real_codex_stream_emits_tool_events() { + if !should_run_real_test() { + return; + } + + let db = init_database().expect("初始化数据库失败"); + let (provider_id, model_name) = + resolve_codex_provider_and_model(&db).expect("解析 Codex Provider/模型失败"); + let session_id = format!("real-codex-tool-{}", Uuid::new_v4()); + + let state = AsterAgentState::new(); + state + .configure_provider_from_pool(&db, &provider_id, &model_name, &session_id) + .await + .expect("配置 Provider 失败"); + + let agent_arc = state.get_agent_arc(); + let agent_guard = agent_arc.read().await; + let agent = agent_guard.as_ref().expect("Agent 未初始化"); + + let tools = agent.list_tools(None).await; + assert!( + !tools.is_empty(), + "工具列表为空,无法验证 tool_start/tool_end" + ); + + let preferred_tool = tools + .iter() + .map(|tool| tool.name.to_string()) + .find(|name| name.contains("list_tools")) + .unwrap_or_else(|| "bash".to_string()); + + let prompt = if preferred_tool == "bash" { + "请严格执行以下步骤:\ +1) 必须调用工具 bash,执行命令 `echo PROXYCAST_REAL_TOOL_EVENT`; \ +2) 然后只回复 `REAL_TOOL_OK`。" + .to_string() + } else { + format!( + "请严格执行以下步骤:\ +1) 必须调用工具 `{}` 一次;\ +2) 如果需要参数请传空对象;\ +3) 然后只回复 `REAL_TOOL_OK`。", + preferred_tool + ) + }; + + let user_message = aster::conversation::message::Message::user().with_text(prompt); + let session_config = SessionConfigBuilder::new(&session_id).build(); + let mut stream = agent + .reply(user_message, session_config, None) + .await + .expect("创建流式回复失败"); + + let mut tool_start_count = 0usize; + let mut tool_end_count = 0usize; + let mut error_messages: Vec = Vec::new(); + let mut text_buffer = String::new(); + + while let Some(event_result) = stream.next().await { + match event_result { + Ok(agent_event) => { + for event in convert_agent_event(agent_event) { + match event { + TauriAgentEvent::ToolStart { .. } => tool_start_count += 1, + TauriAgentEvent::ToolEnd { .. } => tool_end_count += 1, + TauriAgentEvent::TextDelta { text } => text_buffer.push_str(&text), + TauriAgentEvent::Error { message } => error_messages.push(message), + _ => {} + } + } + } + Err(err) => error_messages.push(format!("stream_error: {err}")), + } + } + + assert!( + error_messages.is_empty(), + "流式过程中出现错误: {:?}", + error_messages + ); + assert!( + tool_start_count > 0, + "未收到 tool_start 事件,文本输出: {}", + text_buffer + ); + assert!( + tool_end_count > 0, + "未收到 tool_end 事件,文本输出: {}", + text_buffer + ); +} diff --git a/src-tauri/crates/core/src/agent/types.rs b/src-tauri/crates/core/src/agent/types.rs index 23576b7b1..b37ec5d25 100644 --- a/src-tauri/crates/core/src/agent/types.rs +++ b/src-tauri/crates/core/src/agent/types.rs @@ -123,7 +123,7 @@ impl ProviderType { #[cfg(test)] mod tests { - use super::ProviderType; + use super::{ProviderType, DEFAULT_SYSTEM_PROMPT}; #[test] fn test_custom_provider_does_not_force_anthropic_protocol() { @@ -147,6 +147,18 @@ mod tests { ProviderType::Gemini ); } + + #[test] + fn test_default_system_prompt_mentions_web_tools() { + assert!(DEFAULT_SYSTEM_PROMPT.contains("WebSearch")); + assert!(DEFAULT_SYSTEM_PROMPT.contains("WebFetch")); + } + + #[test] + fn test_default_system_prompt_requires_authorization_for_local_ops_only() { + assert!(DEFAULT_SYSTEM_PROMPT.contains("本地操作需授权")); + assert!(DEFAULT_SYSTEM_PROMPT.contains("实时信息")); + } } /// Agent 会话状态 @@ -331,14 +343,16 @@ pub const DEFAULT_SYSTEM_PROMPT: &str = r#"你是 ProxyCast 内置的 AI 助手 1. **自然交流优先**:问候、闲聊、问答类对话,直接用文字回复 -2. **显式授权操作**:只有当用户明确提供路径或命令时,才能执行工具 -3. **不主动探索**:不要未经请求就读取文件或执行命令 +2. **按任务选择工具**:当工具能显著提升准确性或完成度时,应主动调用工具 +3. **本地操作需授权**:读取/修改本地文件、执行本地命令前,需有用户明确意图 +4. **不无依据臆测**:涉及实时信息或外部事实时,优先通过工具检索再回答 ## 何时使用工具 ✅ **使用工具的情况**: +- 用户需要实时信息、新闻、外部来源或网页事实核验 - 用户明确提供了文件路径(如 "读取 /path/to/file") - 用户明确要求执行命令(如 "运行 npm install") - 用户要求创建或修改文件 @@ -347,14 +361,16 @@ pub const DEFAULT_SYSTEM_PROMPT: &str = r#"你是 ProxyCast 内置的 AI 助手 - 用户说 "你好"、"嗨"、"hello" 等问候语 - 用户进行闲聊或一般性提问 - 用户没有提供具体路径时猜测路径 -- 为了 "了解环境" 或 "打招呼" 而读取文件 +- 为了 "了解环境" 或 "打招呼" 而读取本地文件/执行本地命令 ## 可用工具 -- **read_file**:读取用户指定的文件或目录内容 -- **write_file**:创建或覆盖用户指定的文件 -- **edit_file**:修改用户指定文件的特定内容 +- **read**:读取用户指定的文件或目录内容 +- **write**:创建或覆盖用户指定的文件 +- **edit**:修改用户指定文件的特定内容 - **bash**:执行用户要求的 shell 命令 +- **WebSearch**:联网搜索公开网页信息 +- **WebFetch**:抓取并提取指定网页内容 diff --git a/src-tauri/crates/mcp/Cargo.toml b/src-tauri/crates/mcp/Cargo.toml index 45ee289c0..f63c654ee 100644 --- a/src-tauri/crates/mcp/Cargo.toml +++ b/src-tauri/crates/mcp/Cargo.toml @@ -15,3 +15,4 @@ tracing.workspace = true thiserror.workspace = true glob.workspace = true rmcp.workspace = true +dirs.workspace = true diff --git a/src-tauri/crates/mcp/src/manager.rs b/src-tauri/crates/mcp/src/manager.rs index cab3efda3..bf1c88aff 100644 --- a/src-tauri/crates/mcp/src/manager.rs +++ b/src-tauri/crates/mcp/src/manager.rs @@ -445,8 +445,8 @@ impl McpClientManager { } } - // 设置工作目录 - if let Some(ref cwd) = config.cwd { + // 设置工作目录(清洗 `\0` 和无效空白) + if let Some(cwd) = config.sanitized_cwd() { command.current_dir(cwd); } diff --git a/src-tauri/crates/mcp/src/types.rs b/src-tauri/crates/mcp/src/types.rs index 5c6563bda..ab459a8c8 100644 --- a/src-tauri/crates/mcp/src/types.rs +++ b/src-tauri/crates/mcp/src/types.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::path::PathBuf; // ============================================================================ // 服务器配置和状态 @@ -37,6 +38,29 @@ fn default_timeout() -> u64 { 30 } +impl McpServerConfig { + /// 获取清洗后的工作目录(去除 `\0`、首尾空白,并展开 `~`) + pub fn sanitized_cwd(&self) -> Option { + let cwd = self.cwd.as_deref()?; + let cleaned = cwd.split('\0').next().unwrap_or_default().trim(); + if cleaned.is_empty() { + return None; + } + + if cleaned == "~" { + return Some(dirs::home_dir().unwrap_or_else(|| PathBuf::from(cleaned))); + } + + if cleaned.starts_with("~/") || cleaned.starts_with("~\\") { + if let Some(home) = dirs::home_dir() { + return Some(home.join(&cleaned[2..])); + } + } + + Some(PathBuf::from(cleaned)) + } +} + /// MCP 服务器信息(包含运行状态) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpServerInfo { @@ -257,3 +281,32 @@ use tokio::sync::Mutex; /// /// 使用 Arc> 包装,支持跨线程共享和异步访问。 pub type McpManagerState = Arc>; + +#[cfg(test)] +mod tests { + use super::McpServerConfig; + use std::collections::HashMap; + use std::path::PathBuf; + + fn sample_config(cwd: Option) -> McpServerConfig { + McpServerConfig { + command: "npx".to_string(), + args: vec!["-y".to_string(), "some-server".to_string()], + env: HashMap::new(), + cwd, + timeout: 30, + } + } + + #[test] + fn sanitized_cwd_should_strip_nul_suffix() { + let config = sample_config(Some(" /tmp/demo\0ignored ".to_string())); + assert_eq!(config.sanitized_cwd(), Some(PathBuf::from("/tmp/demo"))); + } + + #[test] + fn sanitized_cwd_should_reject_empty_value() { + let config = sample_config(Some(" \0 ".to_string())); + assert!(config.sanitized_cwd().is_none()); + } +} diff --git a/src-tauri/crates/providers/src/providers/codex.rs b/src-tauri/crates/providers/src/providers/codex.rs index f7ae12831..6f012feea 100644 --- a/src-tauri/crates/providers/src/providers/codex.rs +++ b/src-tauri/crates/providers/src/providers/codex.rs @@ -1229,155 +1229,58 @@ fn parse_jwt_claims(token: &str) -> (Option, Option) { (account_id, email) } -/// 根据模型名称获取对应的 Codex instructions -/// 参考 CLIProxyAPI: internal/misc/codex_instructions.go -fn get_codex_instructions_for_model(model_name: &str) -> &'static str { - let model_lower = model_name.to_lowercase(); - - if model_lower.contains("codex-max") { - // GPT-5.1 Codex Max 专用 prompt - CODEX_MAX_INSTRUCTIONS - } else if model_lower.contains("5.2-codex") { - // GPT-5.2 Codex 专用 prompt - CODEX_52_INSTRUCTIONS - } else if model_lower.contains("codex") { - // GPT-5 Codex 通用 prompt - CODEX_INSTRUCTIONS - } else if model_lower.contains("5.1") { - // GPT-5.1 通用 prompt - GPT_51_INSTRUCTIONS - } else if model_lower.contains("5.2") { - // GPT-5.2 通用 prompt - GPT_52_INSTRUCTIONS - } else { - // 默认使用 Codex prompt - CODEX_INSTRUCTIONS +fn extract_text_fragments(content: &serde_json::Value) -> Vec { + if let Some(text) = content + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + { + return vec![text.to_string()]; } + + let mut fragments = Vec::new(); + if let Some(parts) = content.as_array() { + for part in parts { + let text = if let Some(kind) = part.get("type").and_then(|v| v.as_str()) { + match kind { + "text" | "input_text" | "output_text" => { + part.get("text").and_then(|v| v.as_str()) + } + _ => part.get("text").and_then(|v| v.as_str()), + } + } else { + part.get("text").and_then(|v| v.as_str()) + }; + if let Some(text) = text.map(str::trim).filter(|text| !text.is_empty()) { + fragments.push(text.to_string()); + } + } + } + fragments } -// GPT-5 Codex 通用 prompt(最新版本) -// 来源: CLIProxyAPI/internal/misc/codex_instructions/gpt_5_codex_prompt.md-009 -const CODEX_INSTRUCTIONS: &str = r#"You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. +fn resolve_codex_instructions( + request: &serde_json::Value, + system_instructions: &[String], +) -> Option { + if let Some(request_instructions) = request + .get("instructions") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(request_instructions.to_string()); + } -## General + if !system_instructions.is_empty() { + return Some(system_instructions.join("\n\n")); + } -- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.) - -## Editing constraints - -- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. -- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. -- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). -- You may be in a dirty git worktree. - * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user. - * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. - * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them. - * If the changes are in unrelated files, just ignore them and don't revert them. -- Do not amend a commit unless explicitly requested to do so. -- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed. -- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user. - -## Plan tool - -When using the planning tool: -- Skip using the planning tool for straightforward tasks (roughly the easiest 25%). -- Do not make single-step plans. -- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. - -## Codex CLI harness, sandboxing, and approvals - -The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. - -Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are: -- **read-only**: The sandbox only permits reading files. -- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. -- **danger-full-access**: No filesystem sandboxing - all commands are permitted. - -Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are: -- **restricted**: Requires approval -- **enabled**: No approval needed - -Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are -- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. -- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. -- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.) -- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. - -When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: -- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) -- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. -- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) -- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. -- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for -- (for all of these, you should weigh alternative paths that do not require approval) - -When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read. - -You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. - -Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. - -When requesting approval to execute a command that will require escalated privileges: - - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` - - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter - -## Special user requests - -- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so. -- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps. - -## Presenting your work and final message - -You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. - -- Default: be very concise; friendly coding teammate tone. -- Ask only when needed; suggest ideas; mirror the user's style. -- For substantial work, summarize clearly; follow final-answer formatting. -- Skip heavy formatting for simple confirmations. -- Don't dump large files you've written; reference paths only. -- No "save/copy this file" - User is on the same machine. -- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something. -- For code changes: - * Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in. - * If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. - * When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number. -- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result. - -### Final answer structure and style guidelines - -- Plain text; CLI handles styling. Use structure only when it helps scanability. -- Headers: optional; short Title Case (1-3 words) wrapped in **...**; no blank line before the first bullet; add only if they truly help. -- Bullets: use - ; merge related points; keep to one line when possible; 4-6 per list ordered by importance; keep phrasing consistent. -- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **. -- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible. -- Structure: group related bullets; order sections general -> specific -> supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task. -- Tone: collaborative, concise, factual; present tense, active voice; self-contained; no "above/below"; parallel wording. -- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short-wrap/reformat if long; avoid naming formatting styles in answers. -- Adaptation: code explanations -> precise, structured with code refs; simple tasks -> lead with outcome; big changes -> logical walkthrough + rationale + next actions; casual one-offs -> plain sentences, no headers/bullets. -- File References: When referencing files in your response, make sure to include the relevant start line and always follow the below rules: - * Use inline code to make file paths clickable. - * Each reference should have a stand alone path. Even if it's the same file. - * Accepted: absolute, workspace-relative, a/ or b/ diff prefixes, or bare filename/suffix. - * Line/column (1-based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1). - * Do not use URIs like file://, vscode://, or https://. - * Do not provide range of lines - * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5"#; - -// GPT-5.1 Codex Max 专用 prompt -// 来源: CLIProxyAPI/internal/misc/codex_instructions/gpt-5.1-codex-max_prompt.md-002 -const CODEX_MAX_INSTRUCTIONS: &str = CODEX_INSTRUCTIONS; - -// GPT-5.2 Codex 专用 prompt -// 来源: CLIProxyAPI/internal/misc/codex_instructions/gpt-5.2-codex_prompt.md-001 -const CODEX_52_INSTRUCTIONS: &str = CODEX_INSTRUCTIONS; - -// GPT-5.1 通用 prompt -// 来源: CLIProxyAPI/internal/misc/codex_instructions/gpt_5_1_prompt.md-004 -const GPT_51_INSTRUCTIONS: &str = CODEX_INSTRUCTIONS; - -// GPT-5.2 通用 prompt -// 来源: CLIProxyAPI/internal/misc/codex_instructions/gpt_5_2_prompt.md-001 -const GPT_52_INSTRUCTIONS: &str = CODEX_INSTRUCTIONS; + std::env::var("PROXYCAST_CODEX_DEFAULT_INSTRUCTIONS") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} /// Transform OpenAI chat completion request to Codex format /// 参考 CLIProxyAPI: internal/translator/codex/openai/chat-completions/codex_openai_request.go @@ -1390,6 +1293,7 @@ fn transform_to_codex_format( // Build input array from messages let mut input = Vec::new(); + let mut system_instructions: Vec = Vec::new(); if let Some(msgs) = messages { for msg in msgs { @@ -1398,16 +1302,8 @@ fn transform_to_codex_format( match role { "system" => { - // System messages 转换为 user message(Codex 使用 instructions 而不是 system role) - if let Some(text) = content.as_str() { - if !text.is_empty() { - input.push(serde_json::json!({ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": text}] - })); - } - } + // system 消息统一转换到 instructions,避免污染用户输入。 + system_instructions.extend(extract_text_fragments(content)); } "user" => { let content_parts = if let Some(text) = content.as_str() { @@ -1510,10 +1406,9 @@ fn transform_to_codex_format( "include": ["reasoning.encrypted_content"] }); - // 根据模型名称选择正确的 instructions - // 参考 CLIProxyAPI: internal/misc/codex_instructions.go - let instructions = get_codex_instructions_for_model(model); - codex_request["instructions"] = serde_json::json!(instructions); + if let Some(instructions) = resolve_codex_instructions(request, &system_instructions) { + codex_request["instructions"] = serde_json::json!(instructions); + } // 处理可选参数:temperature, max_tokens (-> max_output_tokens), top_p if let Some(temp) = request.get("temperature") { @@ -1908,17 +1803,45 @@ mod tests { assert_eq!(result["model"], "gpt-4o"); assert_eq!(result["stream"], true); - // instructions 字段存在,使用 Codex 默认 prompt + // system 消息应映射到 instructions assert!(result.get("instructions").is_some()); - // 验证 instructions 以正确的前缀开始 let instructions = result["instructions"].as_str().unwrap(); - assert!(instructions.starts_with("You are Codex, based on GPT-5.")); + assert_eq!(instructions, "You are a helpful assistant."); let input = result["input"].as_array().unwrap(); - // system message 被转换为 user message,所以有 2 条消息 - assert_eq!(input.len(), 2); - assert_eq!(input[0]["role"], "user"); // system -> user - assert_eq!(input[1]["role"], "user"); // original user + // system message 不应污染输入,保留 user 消息即可 + assert_eq!(input.len(), 1); + assert_eq!(input[0]["role"], "user"); + } + + #[test] + fn test_transform_to_codex_format_without_system_does_not_inject_instructions() { + let request = serde_json::json!({ + "model": "gpt-4o", + "messages": [ + {"role": "user", "content": "Hello!"} + ] + }); + + let result = transform_to_codex_format(&request).unwrap(); + assert!(result.get("instructions").is_none()); + } + + #[test] + fn test_transform_to_codex_format_uses_explicit_instructions() { + let request = serde_json::json!({ + "model": "gpt-4o", + "instructions": "You are a general assistant.", + "messages": [ + {"role": "user", "content": "Hello!"} + ] + }); + + let result = transform_to_codex_format(&request).unwrap(); + assert_eq!( + result["instructions"].as_str(), + Some("You are a general assistant.") + ); } #[test] diff --git a/src-tauri/crates/services/src/api_key_provider_service.rs b/src-tauri/crates/services/src/api_key_provider_service.rs index 8f4e8e0c0..76d06a2fd 100644 --- a/src-tauri/crates/services/src/api_key_provider_service.rs +++ b/src-tauri/crates/services/src/api_key_provider_service.rs @@ -44,6 +44,38 @@ pub struct ConnectionTestResult { mod tests { use super::ApiKeyProviderService; use proxycast_core::database::dao::api_key_provider::ApiProviderType; + use proxycast_core::database::init_database; + use rusqlite::OptionalExtension; + + fn resolve_real_codex_provider_id( + db: &proxycast_core::database::DbConnection, + ) -> Result { + if let Ok(explicit) = std::env::var("PROXYCAST_REAL_PROVIDER_ID") { + let trimmed = explicit.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_string()); + } + } + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + conn.query_row( + r#" + SELECT p.id + FROM api_key_providers p + JOIN api_keys k ON k.provider_id = p.id + WHERE p.enabled = 1 + AND k.enabled = 1 + AND p.type = 'codex' + ORDER BY p.updated_at DESC + LIMIT 1 + "#, + [], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| format!("查询 Codex Provider 失败: {e}"))? + .ok_or_else(|| "未找到可用的 Codex Provider,请先在设置中配置并启用".to_string()) + } #[test] fn test_build_codex_responses_request_input_list() { @@ -101,6 +133,38 @@ data: [DONE]\n"; let none = ApiKeyProviderService::pick_test_model(None, &[], &[]); assert!(none.is_none()); } + + #[tokio::test] + #[ignore = "真实联网测试:设置 PROXYCAST_REAL_API_TEST=1 后执行"] + async fn test_real_codex_provider_chat_gpt_5_3_codex() { + if std::env::var("PROXYCAST_REAL_API_TEST").ok().as_deref() != Some("1") { + return; + } + + let db = init_database().expect("初始化数据库失败"); + let service = ApiKeyProviderService::new(); + let provider_id = resolve_real_codex_provider_id(&db).expect("解析 Codex Provider 失败"); + let model = + std::env::var("PROXYCAST_REAL_MODEL").unwrap_or_else(|_| "gpt-5.3-codex".to_string()); + let prompt = std::env::var("PROXYCAST_REAL_PROMPT") + .unwrap_or_else(|_| "请仅回复 REAL_OK".to_string()); + + let result = service + .test_chat(&db, &provider_id, Some(model.clone()), prompt) + .await + .expect("真实调用失败"); + + assert!( + result.success, + "真实调用未成功: provider_id={provider_id}, model={model}, error={:?}, raw={:?}", + result.error, result.raw + ); + assert!( + result.error.is_none(), + "真实调用返回错误: {:?}", + result.error + ); + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/crates/services/src/live_sync.rs b/src-tauri/crates/services/src/live_sync.rs index abf3c3616..74511a6ee 100644 --- a/src-tauri/crates/services/src/live_sync.rs +++ b/src-tauri/crates/services/src/live_sync.rs @@ -86,35 +86,136 @@ fn should_create_backup() -> bool { true } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +enum ShellConfigSyntax { + Posix, + PowerShell, +} + /// 获取当前 shell 配置文件路径 /// 优先级:zsh > bash -fn get_shell_config_path() -> Result> { +fn get_shell_config_target( +) -> Result<(PathBuf, ShellConfigSyntax), Box> { let home = dirs::home_dir().ok_or("Cannot find home directory")?; - // 检查 SHELL 环境变量 - if let Ok(shell) = std::env::var("SHELL") { - if shell.contains("zsh") { - let zshrc = home.join(".zshrc"); - return Ok(zshrc); - } else if shell.contains("bash") { - let bashrc = home.join(".bashrc"); - return Ok(bashrc); + #[cfg(target_os = "windows")] + { + let documents = dirs::document_dir().unwrap_or_else(|| home.join("Documents")); + let ps7_profile = documents + .join("PowerShell") + .join("Microsoft.PowerShell_profile.ps1"); + let winps_profile = documents + .join("WindowsPowerShell") + .join("Microsoft.PowerShell_profile.ps1"); + + if ps7_profile.exists() { + return Ok((ps7_profile, ShellConfigSyntax::PowerShell)); } + if winps_profile.exists() { + return Ok((winps_profile, ShellConfigSyntax::PowerShell)); + } + + return Ok((ps7_profile, ShellConfigSyntax::PowerShell)); } - // 默认检查文件是否存在 - let zshrc = home.join(".zshrc"); - if zshrc.exists() { - return Ok(zshrc); + #[cfg(not(target_os = "windows"))] + { + // 检查 SHELL 环境变量 + if let Ok(shell) = std::env::var("SHELL") { + if shell.contains("zsh") { + let zshrc = home.join(".zshrc"); + return Ok((zshrc, ShellConfigSyntax::Posix)); + } else if shell.contains("bash") { + let bashrc = home.join(".bashrc"); + return Ok((bashrc, ShellConfigSyntax::Posix)); + } + } + + // 默认检查文件是否存在 + let zshrc = home.join(".zshrc"); + if zshrc.exists() { + return Ok((zshrc, ShellConfigSyntax::Posix)); + } + + let bashrc = home.join(".bashrc"); + if bashrc.exists() { + return Ok((bashrc, ShellConfigSyntax::Posix)); + } + + // 如果都不存在,默认使用 .zshrc(macOS 默认) + Ok((zshrc, ShellConfigSyntax::Posix)) + } +} + +fn get_shell_config_path() -> Result> { + Ok(get_shell_config_target()?.0) +} + +fn escape_shell_env_value(value: &str, syntax: ShellConfigSyntax) -> String { + match syntax { + ShellConfigSyntax::Posix => value.replace('\\', "\\\\").replace('"', "\\\""), + ShellConfigSyntax::PowerShell => value.replace('`', "``").replace('"', "`\""), + } +} + +fn format_shell_env_line(key: &str, value: &str, syntax: ShellConfigSyntax) -> String { + let escaped_value = escape_shell_env_value(value, syntax); + match syntax { + ShellConfigSyntax::Posix => format!("export {key}=\"{escaped_value}\""), + ShellConfigSyntax::PowerShell => format!("$env:{key} = \"{escaped_value}\""), + } +} + +fn parse_key_value(expr: &str) -> Option<(String, String)> { + let eq_pos = expr.find('=')?; + let key = expr[..eq_pos].trim(); + if key.is_empty() { + return None; + } + let value = expr[eq_pos + 1..].trim().to_string(); + Some((key.to_string(), value)) +} + +fn unquote_shell_value(raw: &str) -> String { + let value = if (raw.starts_with('"') && raw.ends_with('"')) + || (raw.starts_with('\'') && raw.ends_with('\'')) + { + raw[1..raw.len() - 1].to_string() + } else { + raw.to_string() + }; + value.replace("\\\"", "\"").replace("\\\\", "\\") +} + +fn unquote_powershell_value(raw: &str) -> String { + let value = if (raw.starts_with('"') && raw.ends_with('"')) + || (raw.starts_with('\'') && raw.ends_with('\'')) + { + raw[1..raw.len() - 1].to_string() + } else { + raw.to_string() + }; + value.replace("`\"", "\"").replace("``", "`") +} + +fn parse_shell_env_line(line: &str) -> Option<(String, String)> { + let trimmed = line.trim(); + + if let Some(export_line) = trimmed.strip_prefix("export ") { + let (key, value) = parse_key_value(export_line)?; + return Some((key, unquote_shell_value(&value))); } - let bashrc = home.join(".bashrc"); - if bashrc.exists() { - return Ok(bashrc); + if let Some(env_line) = trimmed + .strip_prefix("$env:") + .or_else(|| trimmed.strip_prefix("$Env:")) + { + let (key, value) = parse_key_value(env_line)?; + return Some((key, unquote_powershell_value(&value))); } - // 如果都不存在,默认使用 .zshrc(macOS 默认) - Ok(zshrc) + None } /// 将环境变量写入 shell 配置文件 @@ -124,13 +225,17 @@ fn get_shell_config_path() -> Result Result<(), Box> { - let config_path = get_shell_config_path()?; + let (config_path, syntax) = get_shell_config_target()?; tracing::info!( "Writing environment variables to: {}", config_path.display() ); + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + // 读取现有配置 let existing_content = if config_path.exists() { fs::read_to_string(&config_path)? @@ -170,9 +275,9 @@ pub fn write_env_to_shell_config( new_content.push_str("# Do not edit this block manually\n"); for (key, value) in env_vars { - // 转义值中的特殊字符 - let escaped_value = value.replace('\\', "\\\\").replace('"', "\\\""); - new_content.push_str(&format!("export {key}=\"{escaped_value}\"\n")); + let line = format_shell_env_line(key, value, syntax); + new_content.push_str(&line); + new_content.push('\n'); } new_content.push_str(ENV_BLOCK_END); @@ -222,22 +327,8 @@ fn read_env_from_shell_config( continue; } - if in_proxycast_block && trimmed.starts_with("export ") { - // 解析 export KEY="VALUE" 格式 - let export_line = trimmed.strip_prefix("export ").unwrap_or(trimmed); - if let Some(eq_pos) = export_line.find('=') { - let key = export_line[..eq_pos].trim().to_string(); - let value_part = export_line[eq_pos + 1..].trim(); - - // 移除引号 - let value = if (value_part.starts_with('"') && value_part.ends_with('"')) - || (value_part.starts_with('\'') && value_part.ends_with('\'')) - { - value_part[1..value_part.len() - 1].to_string() - } else { - value_part.to_string() - }; - + if in_proxycast_block { + if let Some((key, value)) = parse_shell_env_line(trimmed) { env_vars.push((key, value)); } } @@ -758,7 +849,16 @@ pub fn read_live_settings_for_display( // 获取 shell 配置文件路径 let shell_config_path = get_shell_config_path() .map(|p| p.display().to_string()) - .unwrap_or_else(|_| "~/.zshrc or ~/.bashrc".to_string()); + .unwrap_or_else(|_| { + #[cfg(target_os = "windows")] + { + "Documents/PowerShell/Microsoft.PowerShell_profile.ps1".to_string() + } + #[cfg(not(target_os = "windows"))] + { + "~/.zshrc or ~/.bashrc".to_string() + } + }); // 返回包含两部分的结构 Ok(json!({ diff --git a/src-tauri/crates/services/src/live_sync_tests.rs b/src-tauri/crates/services/src/live_sync_tests.rs index c080f0181..f4362dc0c 100644 --- a/src-tauri/crates/services/src/live_sync_tests.rs +++ b/src-tauri/crates/services/src/live_sync_tests.rs @@ -288,6 +288,7 @@ mod tests { #[cfg(test)] mod shell_config_write_tests { + use super::*; /// **Feature: shell-write, Property 1: 特殊字符转义** #[test] @@ -314,13 +315,39 @@ mod tests { ); } } + + #[test] + fn test_parse_shell_env_line_supports_posix_export() { + let parsed = parse_shell_env_line(r#"export OPENAI_API_KEY="abc123""#) + .expect("Should parse posix export line"); + assert_eq!(parsed.0, "OPENAI_API_KEY"); + assert_eq!(parsed.1, "abc123"); + } + + #[test] + fn test_parse_shell_env_line_supports_powershell_env() { + let parsed = parse_shell_env_line(r#"$env:OPENAI_BASE_URL = "https://example.com""#) + .expect("Should parse PowerShell env line"); + assert_eq!(parsed.0, "OPENAI_BASE_URL"); + assert_eq!(parsed.1, "https://example.com"); + } + + #[test] + fn test_format_shell_env_line_powershell_style() { + let line = format_shell_env_line( + "TEST_KEY", + r#"value with "quotes""#, + ShellConfigSyntax::PowerShell, + ); + assert_eq!(line, "$env:TEST_KEY = \"value with `\"quotes`\"\""); + } } // ============================================================================ // 总结 // ============================================================================ // - // 本测试模块包含 3 个子模块,共 10 个单元测试: + // 本测试模块包含 3 个子模块,共 13 个单元测试: // // 1. **原子写入测试** (3 个测试) // - 正常写入、备份创建、JSON 往返 @@ -328,8 +355,11 @@ mod tests { // 2. **认证冲突清理测试** (5 个测试) // - 单独 TOKEN、单独 KEY、冲突处理、都为空、空值处理 // - // 3. **Shell 配置写入测试** (1 个测试) + // 3. **Shell 配置写入测试** (4 个测试) // - 特殊字符转义验证 + // - POSIX export 解析 + // - PowerShell 环境变量解析 + // - PowerShell 写入格式验证 // // **注意**:由于 `sync_claude_settings`、`write_env_to_shell_config` 等函数 // 依赖于真实的文件系统路径(如 ~/.claude、~/.zshrc),完整的集成测试 diff --git a/src-tauri/crates/terminal/src/block_controller/traits.rs b/src-tauri/crates/terminal/src/block_controller/traits.rs index bb42ed3a5..547c0940a 100644 --- a/src-tauri/crates/terminal/src/block_controller/traits.rs +++ b/src-tauri/crates/terminal/src/block_controller/traits.rs @@ -167,6 +167,13 @@ impl BlockMeta { _ => String::new(), } } + + /// 获取清洗后的工作目录(去除 `\0`、首尾空白) + pub fn sanitized_cmd_cwd(&self) -> Option { + let cwd = self.cmd_cwd.as_deref()?; + let cleaned = cwd.split('\0').next().unwrap_or_default().trim(); + (!cleaned.is_empty()).then_some(cleaned.to_string()) + } } /// 运行时选项 @@ -294,4 +301,19 @@ mod tests { assert_eq!(meta.get_string("cmd"), ""); assert_eq!(meta.get_string("term_mode"), "term"); } + + #[test] + fn test_block_meta_sanitized_cmd_cwd() { + let meta = BlockMeta { + cmd_cwd: Some(" /tmp/demo\0ignored ".to_string()), + ..Default::default() + }; + assert_eq!(meta.sanitized_cmd_cwd(), Some("/tmp/demo".to_string())); + + let empty = BlockMeta { + cmd_cwd: Some(" \0 ".to_string()), + ..Default::default() + }; + assert!(empty.sanitized_cmd_cwd().is_none()); + } } diff --git a/src-tauri/crates/terminal/src/connections/local_pty.rs b/src-tauri/crates/terminal/src/connections/local_pty.rs index 7491d3bcb..6c6b02579 100644 --- a/src-tauri/crates/terminal/src/connections/local_pty.rs +++ b/src-tauri/crates/terminal/src/connections/local_pty.rs @@ -21,6 +21,7 @@ //! - 17.10: fish 使用 -C 参数 source 集成脚本 use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; use std::sync::Arc; @@ -38,6 +39,102 @@ use crate::events::{event_names, SessionStatus, TerminalOutputEvent, TerminalSta use crate::integration::{ShellLaunchBuilder, ShellType}; use crate::persistence::BlockFile; +fn resolve_default_shell() -> String { + let shell_from_env = std::env::var("SHELL").ok().and_then(|value| { + let cleaned = value + .split('\0') + .next() + .unwrap_or_default() + .trim() + .to_string(); + (!cleaned.is_empty()).then_some(cleaned) + }); + + #[cfg(target_os = "windows")] + { + if let Some(shell) = shell_from_env { + let path = Path::new(&shell); + if path.is_absolute() && path.exists() { + return shell; + } + } + + if let Ok(comspec) = std::env::var("COMSPEC") { + let cleaned = comspec + .split('\0') + .next() + .unwrap_or_default() + .trim() + .to_string(); + if !cleaned.is_empty() && Path::new(&cleaned).exists() { + return cleaned; + } + } + + "cmd.exe".to_string() + } + + #[cfg(not(target_os = "windows"))] + { + if let Some(shell) = shell_from_env { + let path = Path::new(&shell); + if path.exists() { + return shell; + } + } + + if Path::new("/bin/bash").exists() { + "/bin/bash".to_string() + } else { + "/bin/sh".to_string() + } + } +} + +fn resolve_working_dir(cwd: Option<&str>) -> Option { + let dir = cwd?; + let cleaned = dir + .split('\0') + .next() + .unwrap_or_default() + .trim() + .to_string(); + if cleaned.is_empty() { + return None; + } + + let expanded = if cleaned.starts_with("~/") { + if let Some(home) = dirs::home_dir() { + home.join(&cleaned[2..]) + } else { + PathBuf::from(&cleaned) + } + } else if cleaned == "~" { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(&cleaned)) + } else { + PathBuf::from(&cleaned) + }; + + (expanded.exists() && expanded.is_dir()).then_some(expanded) +} + +fn shell_command_flag(shell: &str) -> &'static str { + let executable = Path::new(shell) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(shell) + .to_ascii_lowercase(); + + if executable == "cmd" || executable == "cmd.exe" { + "/C" + } else if executable.contains("powershell") || executable == "pwsh" || executable == "pwsh.exe" + { + "-Command" + } else { + "-c" + } +} + /// Shell 进程封装 /// /// 封装 PTY 进程,提供输入输出和生命周期管理。 @@ -199,8 +296,17 @@ impl ShellProc { }; // 设置工作目录 - if let Some(cwd) = &block_meta.cmd_cwd { - cmd.cwd(cwd); + let sanitized_cwd = block_meta.sanitized_cmd_cwd(); + if let Some(resolved_cwd) = resolve_working_dir(sanitized_cwd.as_deref()) { + cmd.cwd(resolved_cwd); + } else if let Some(raw_cwd) = block_meta.cmd_cwd.as_deref() { + tracing::warn!( + "[ShellProc] 工作目录无效或不存在: {:?}, 使用主目录", + raw_cwd + ); + if let Some(home) = dirs::home_dir() { + cmd.cwd(home); + } } else if let Some(home) = dirs::home_dir() { cmd.cwd(home); } @@ -219,7 +325,7 @@ impl ShellProc { block_id: &str, ) -> Result { // 获取用户默认 shell - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + let shell = resolve_default_shell(); tracing::info!("[ShellProc] 使用 shell: {}", shell); // 获取应用数据目录 @@ -267,9 +373,9 @@ impl ShellProc { tracing::info!("[ShellProc] 执行命令: {}", cmd_str); // 使用 shell 执行命令 - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + let shell = resolve_default_shell(); let mut cmd = CommandBuilder::new(&shell); - cmd.arg("-c"); + cmd.arg(shell_command_flag(&shell)); // 构建完整命令字符串 let full_cmd = if let Some(args) = &block_meta.cmd_args { @@ -556,3 +662,37 @@ impl Drop for ShellProc { tracing::debug!("[ShellProc] 进程已销毁: block_id={}", self.block_id); } } + +#[cfg(test)] +mod tests { + use super::{resolve_default_shell, resolve_working_dir, shell_command_flag}; + + #[test] + fn resolve_working_dir_should_strip_nul_suffix() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let raw = format!("{}\0", temp_dir.path().to_string_lossy()); + + let resolved = resolve_working_dir(Some(&raw)); + + assert_eq!(resolved.as_deref(), Some(temp_dir.path())); + } + + #[test] + fn resolve_working_dir_should_reject_invalid_path() { + let resolved = resolve_working_dir(Some("/path/not-exists\0")); + assert!(resolved.is_none()); + } + + #[test] + fn resolve_default_shell_should_not_be_empty() { + let shell = resolve_default_shell(); + assert!(!shell.trim().is_empty()); + } + + #[test] + fn shell_command_flag_should_match_common_shells() { + assert_eq!(shell_command_flag("cmd.exe"), "/C"); + assert_eq!(shell_command_flag("pwsh"), "-Command"); + assert_eq!(shell_command_flag("/bin/bash"), "-c"); + } +} diff --git a/src-tauri/crates/terminal/src/connections/ssh_shell_proc.rs b/src-tauri/crates/terminal/src/connections/ssh_shell_proc.rs index 49fc872d6..e03ff709c 100644 --- a/src-tauri/crates/terminal/src/connections/ssh_shell_proc.rs +++ b/src-tauri/crates/terminal/src/connections/ssh_shell_proc.rs @@ -227,8 +227,8 @@ impl SSHShellProc { let mut full_cmd = String::new(); // 如果指定了工作目录,先 cd 到该目录 - if let Some(cwd) = &block_meta.cmd_cwd { - full_cmd.push_str(&format!("cd {} && ", shell_escape(cwd))); + if let Some(cwd) = block_meta.sanitized_cmd_cwd() { + full_cmd.push_str(&format!("cd {} && ", shell_escape(&cwd))); } // 设置环境变量 diff --git a/src-tauri/crates/terminal/src/connections/wsl_connection.rs b/src-tauri/crates/terminal/src/connections/wsl_connection.rs index 3d04dc994..743434674 100644 --- a/src-tauri/crates/terminal/src/connections/wsl_connection.rs +++ b/src-tauri/crates/terminal/src/connections/wsl_connection.rs @@ -757,7 +757,7 @@ impl WSLShellProc { if let Some(ref path) = opts.initial_path { cmd.arg("--cd"); cmd.arg(path); - } else if let Some(ref cwd) = block_meta.cmd_cwd { + } else if let Some(cwd) = block_meta.sanitized_cmd_cwd() { cmd.arg("--cd"); cmd.arg(cwd); } diff --git a/src-tauri/crates/terminal/src/pty_session.rs b/src-tauri/crates/terminal/src/pty_session.rs index cf1a73c8a..0b398cb30 100644 --- a/src-tauri/crates/terminal/src/pty_session.rs +++ b/src-tauri/crates/terminal/src/pty_session.rs @@ -14,6 +14,7 @@ //! 输出历史保存在循环缓冲区中,前端连接时可以获取历史数据。 use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -89,6 +90,85 @@ pub struct PtySession { } impl PtySession { + fn resolve_default_shell() -> String { + let shell_from_env = std::env::var("SHELL").ok().and_then(|value| { + let cleaned = value + .split('\0') + .next() + .unwrap_or_default() + .trim() + .to_string(); + (!cleaned.is_empty()).then_some(cleaned) + }); + + #[cfg(target_os = "windows")] + { + if let Some(shell) = shell_from_env { + let path = Path::new(&shell); + if path.is_absolute() && path.exists() { + return shell; + } + } + + if let Ok(comspec) = std::env::var("COMSPEC") { + let cleaned = comspec + .split('\0') + .next() + .unwrap_or_default() + .trim() + .to_string(); + if !cleaned.is_empty() && Path::new(&cleaned).exists() { + return cleaned; + } + } + + "cmd.exe".to_string() + } + + #[cfg(not(target_os = "windows"))] + { + if let Some(shell) = shell_from_env { + let path = Path::new(&shell); + if path.exists() { + return shell; + } + } + + if Path::new("/bin/bash").exists() { + "/bin/bash".to_string() + } else { + "/bin/sh".to_string() + } + } + } + + fn resolve_working_dir(cwd: Option) -> Option { + let dir = cwd?; + let cleaned = dir + .split('\0') + .next() + .unwrap_or_default() + .trim() + .to_string(); + if cleaned.is_empty() { + return None; + } + + let expanded = if cleaned.starts_with("~/") { + if let Some(home) = dirs::home_dir() { + home.join(&cleaned[2..]) + } else { + PathBuf::from(&cleaned) + } + } else if cleaned == "~" { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(&cleaned)) + } else { + PathBuf::from(&cleaned) + }; + + (expanded.exists() && expanded.is_dir()).then_some(expanded) + } + /// 创建新的 PTY 会话(使用默认大小) /// /// PTY 使用默认大小 (24x80) 预创建,前端连接后通过 resize 同步实际大小。 @@ -163,8 +243,8 @@ impl PtySession { }) .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - // 获取用户默认 shell - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + // 获取用户默认 shell(Windows 优先使用 COMSPEC/cmd.exe) + let shell = Self::resolve_default_shell(); tracing::info!("[终端] 使用 shell: {}", shell); // 构建命令 @@ -172,31 +252,13 @@ impl PtySession { cmd.env("TERM", "xterm-256color"); // 设置工作目录 - if let Some(dir) = cwd { - // 展开 ~ 为用户主目录 - let expanded_dir = if dir.starts_with("~/") { - if let Some(home) = dirs::home_dir() { - home.join(&dir[2..]) - } else { - std::path::PathBuf::from(&dir) - } - } else if dir == "~" { - dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(&dir)) - } else { - std::path::PathBuf::from(&dir) - }; - - if expanded_dir.exists() && expanded_dir.is_dir() { - tracing::info!("[终端] 设置工作目录: {:?}", expanded_dir); - cmd.cwd(expanded_dir); - } else { - tracing::warn!( - "[终端] 工作目录不存在或不是目录: {:?}, 使用主目录", - expanded_dir - ); - if let Some(home) = dirs::home_dir() { - cmd.cwd(home); - } + if let Some(expanded_dir) = Self::resolve_working_dir(cwd.clone()) { + tracing::info!("[终端] 设置工作目录: {:?}", expanded_dir); + cmd.cwd(expanded_dir); + } else if let Some(raw) = cwd { + tracing::warn!("[终端] 工作目录无效或不存在: {:?}, 使用主目录", raw); + if let Some(home) = dirs::home_dir() { + cmd.cwd(home); } } else if let Some(home) = dirs::home_dir() { cmd.cwd(home); @@ -380,3 +442,30 @@ impl PtySession { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::PtySession; + + #[test] + fn resolve_working_dir_should_strip_nul_suffix() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let raw = format!("{}\0", temp_dir.path().to_string_lossy()); + + let resolved = PtySession::resolve_working_dir(Some(raw)); + + assert_eq!(resolved.as_deref(), Some(temp_dir.path())); + } + + #[test] + fn resolve_working_dir_should_reject_invalid_path() { + let resolved = PtySession::resolve_working_dir(Some("/path/not-exists\0".to_string())); + assert!(resolved.is_none()); + } + + #[test] + fn resolve_default_shell_should_not_be_empty() { + let shell = PtySession::resolve_default_shell(); + assert!(!shell.trim().is_empty()); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index 8ad4dd87b..4c05021f9 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -19,6 +19,12 @@ use crate::mcp::{McpManagerState, McpServerConfig}; use crate::services::execution_tracker_service::{ExecutionTracker, RunFinalizeOptions, RunSource}; use crate::services::heartbeat_service::HeartbeatServiceState; use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile; +#[cfg(test)] +use crate::services::request_tool_policy_prompt_service::REQUEST_TOOL_POLICY_MARKER; +use crate::services::request_tool_policy_prompt_service::{ + execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy, + resolve_request_tool_policy, RequestToolPolicy, WebSearchExecutionTracker, +}; use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search; use crate::services::web_search_runtime_service::apply_web_search_runtime_env; use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate; @@ -46,7 +52,7 @@ use proxycast_agent::event_converter::convert_agent_event; use proxycast_services::mcp_service::McpService; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use std::time::Duration; use tauri::{AppHandle, Emitter, State}; @@ -158,6 +164,8 @@ pub struct AsterAgentStatus { /// Provider 配置请求 #[derive(Debug, Deserialize)] pub struct ConfigureProviderRequest { + #[serde(default)] + pub provider_id: Option, pub provider_name: String, pub model_name: String, #[serde(default)] @@ -310,21 +318,27 @@ pub async fn aster_agent_reset( #[derive(Debug, Deserialize)] pub struct AsterChatRequest { pub message: String, + #[serde(alias = "sessionId")] pub session_id: String, + #[serde(alias = "eventName")] pub event_name: String, #[serde(default)] #[allow(dead_code)] pub images: Option>, /// Provider 配置(可选,如果未配置则使用当前配置) - #[serde(default)] + #[serde(default, alias = "providerConfig")] pub provider_config: Option, /// 项目 ID(可选,用于注入项目上下文到 System Prompt) - #[serde(default)] + #[serde(default, alias = "projectId")] pub project_id: Option, /// Workspace ID(必填,用于校验会话与工作区一致性) + #[serde(alias = "workspaceId")] pub workspace_id: String, + /// 是否强制开启联网搜索工具策略 + #[serde(default, alias = "webSearch")] + pub web_search: Option, /// 执行策略(react / code_orchestrated / auto) - #[serde(default)] + #[serde(default, alias = "executionStrategy")] pub execution_strategy: Option, } @@ -395,14 +409,6 @@ fn should_force_react_for_message(message: &str) -> bool { "webfetch", "web fetch", "web_fetch", - "联网搜索", - "网络搜索", - "实时新闻", - "最新新闻", - "今日要闻", - "时事新闻", - "breaking news", - "news today", ]; resolve_intent_hints("PROXYCAST_FORCE_REACT_HINTS", &default_hints) .iter() @@ -499,9 +505,41 @@ async fn stream_reply_once( app: &AppHandle, event_name: &str, message_text: &str, + working_directory: Option<&Path>, session_config: aster::agents::SessionConfig, cancel_token: CancellationToken, + request_tool_policy: &RequestToolPolicy, ) -> Result<(), ReplyAttemptError> { + let mut web_search_tracker = WebSearchExecutionTracker::default(); + let preflight = execute_web_search_preflight_if_needed( + agent, + &session_config.id, + message_text, + working_directory, + Some(cancel_token.clone()), + request_tool_policy, + &mut web_search_tracker, + ) + .await; + match preflight { + Ok(preflight_execution) => { + for event in preflight_execution.events { + if let Err(error) = app.emit(event_name, &event) { + tracing::error!("[AsterAgent] 发送预调用事件失败: {}", error); + } + } + } + Err(error) => { + return Err(ReplyAttemptError { + message: format!( + "{error}\n尝试记录: {}", + web_search_tracker.format_attempts() + ), + emitted_any: false, + }); + } + } + let user_message = Message::user().with_text(message_text); let mut stream = agent .reply(user_message, session_config, Some(cancel_token)) @@ -522,6 +560,23 @@ async fn stream_reply_once( }; let tauri_events = convert_agent_event(agent_event); for tauri_event in tauri_events { + match &tauri_event { + TauriAgentEvent::ToolStart { + tool_name, tool_id, .. + } => web_search_tracker.record_tool_start( + request_tool_policy, + tool_id, + tool_name, + ), + TauriAgentEvent::ToolEnd { tool_id, result } => web_search_tracker + .record_tool_end( + request_tool_policy, + tool_id, + result.success, + result.error.as_deref(), + ), + _ => {} + } if let Err(e) = app.emit(event_name, &tauri_event) { tracing::error!("[AsterAgent] 发送事件失败: {}", e); } @@ -543,6 +598,15 @@ async fn stream_reply_once( } } + if let Err(validation_error) = + web_search_tracker.validate_web_search_requirement(request_tool_policy) + { + return Err(ReplyAttemptError { + message: validation_error, + emitted_any, + }); + } + Ok(()) } @@ -2082,6 +2146,15 @@ pub async fn aster_agent_chat_stream( ); } + // 构建请求级工具策略:effective_web_search = request.web_search ?? mode_default(false) + let request_tool_policy = resolve_request_tool_policy(request.web_search, false); + tracing::info!( + "[AsterAgent][WebSearchGuard] session={}, request_web_search={:?}, mode_default_web_search=false, effective_web_search={}", + session_id, + request.web_search, + request_tool_policy.effective_web_search + ); + // 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt // 同时读取会话已持久化的 execution_strategy let (system_prompt, persisted_strategy) = { @@ -2138,9 +2211,12 @@ pub async fn aster_agent_chat_stream( } }; - let merged_prompt = merge_system_prompt_with_web_search( - merge_system_prompt_with_memory_profile(resolved_prompt, &runtime_config), - &runtime_config, + let merged_prompt = merge_system_prompt_with_request_tool_policy( + merge_system_prompt_with_web_search( + merge_system_prompt_with_memory_profile(resolved_prompt, &runtime_config), + &runtime_config, + ), + &request_tool_policy, ); (merged_prompt, persisted) @@ -2176,7 +2252,8 @@ pub async fn aster_agent_chat_stream( // 如果提供了 Provider 配置,则配置 Provider if let Some(provider_config) = &request.provider_config { tracing::info!( - "[AsterAgent] 收到 provider_config: provider_name={}, model_name={}, has_api_key={}, base_url={:?}", + "[AsterAgent] 收到 provider_config: provider_id={:?}, provider_name={}, model_name={}, has_api_key={}, base_url={:?}", + provider_config.provider_id, provider_config.provider_name, provider_config.model_name, provider_config.api_key.is_some(), @@ -2193,11 +2270,15 @@ pub async fn aster_agent_chat_stream( if provider_config.api_key.is_some() { state.configure_provider(config, session_id, &db).await?; } else { - // 没有 api_key,使用凭证池(provider_name 作为 provider_type) + // 没有 api_key,使用凭证池(优先 provider_id,其次 provider_name) + let provider_selector = provider_config + .provider_id + .as_deref() + .unwrap_or(&provider_config.provider_name); state .configure_provider_from_pool( &db, - &provider_config.provider_name, + provider_selector, &provider_config.model_name, session_id, ) @@ -2287,16 +2368,19 @@ pub async fn aster_agent_chat_stream( "event_name": request.event_name.clone(), "execution_strategy": format!("{:?}", effective_strategy).to_lowercase(), "message_length": request.message.chars().count(), + "web_search_enabled": request_tool_policy.effective_web_search, })), RunFinalizeOptions { success_metadata: Some(serde_json::json!({ "execution_strategy": format!("{:?}", effective_strategy).to_lowercase(), "workspace_id": workspace_id.clone(), + "web_search_enabled": request_tool_policy.effective_web_search, })), error_code: Some("chat_stream_failed".to_string()), error_metadata: Some(serde_json::json!({ "execution_strategy": format!("{:?}", effective_strategy).to_lowercase(), "workspace_id": workspace_id.clone(), + "web_search_enabled": request_tool_policy.effective_web_search, })), }, async { @@ -2310,8 +2394,10 @@ pub async fn aster_agent_chat_stream( &app, &request.event_name, &request.message, + Some(Path::new(&workspace_root)), build_session_config(), cancel_token.clone(), + &request_tool_policy, ) .await; @@ -2341,8 +2427,10 @@ pub async fn aster_agent_chat_stream( &app, &request.event_name, &request.message, + Some(Path::new(&workspace_root)), build_session_config(), cancel_token.clone(), + &request_tool_policy, ) .await .map_err(|fallback_err| fallback_err.message) @@ -2699,6 +2787,20 @@ mod tests { ); } + #[test] + fn test_aster_chat_request_deserialize_with_web_search_flag() { + let json = r#"{ + "message": "Hello", + "session_id": "test-session", + "event_name": "agent_stream", + "workspace_id": "workspace-test", + "web_search": true + }"#; + + let request: AsterChatRequest = serde_json::from_str(json).unwrap(); + assert_eq!(request.web_search, Some(true)); + } + #[test] fn test_aster_execution_strategy_default_is_auto() { assert_eq!( @@ -2747,7 +2849,7 @@ mod tests { #[test] fn test_aster_execution_strategy_code_orchestrated_still_prefers_react_for_web_search() { let strategy = AsterExecutionStrategy::CodeOrchestrated - .effective_for_message("请联网搜索今天的 AI 新闻并给出来源"); + .effective_for_message("请使用 WebSearch 工具检索并给出来源"); assert_eq!(strategy, AsterExecutionStrategy::React); } @@ -2758,6 +2860,32 @@ mod tests { assert_eq!(strategy, AsterExecutionStrategy::React); } + #[test] + fn test_merge_system_prompt_with_request_tool_policy_adds_policy_when_enabled() { + let policy = resolve_request_tool_policy(Some(true), false); + let merged = + merge_system_prompt_with_request_tool_policy(Some("你是助手".to_string()), &policy) + .expect("should have merged prompt"); + assert!(merged.contains(REQUEST_TOOL_POLICY_MARKER)); + assert!(merged.contains("WebSearch")); + } + + #[test] + fn test_merge_system_prompt_with_request_tool_policy_keeps_original_when_disabled() { + let base = Some("你好".to_string()); + let policy = resolve_request_tool_policy(Some(false), false); + let merged = merge_system_prompt_with_request_tool_policy(base.clone(), &policy); + assert_eq!(merged, base); + } + + #[test] + fn test_merge_system_prompt_with_request_tool_policy_no_duplicate_marker() { + let base = Some(format!("{REQUEST_TOOL_POLICY_MARKER}\n已有策略")); + let policy = resolve_request_tool_policy(Some(true), false); + let merged = merge_system_prompt_with_request_tool_policy(base.clone(), &policy); + assert_eq!(merged, base); + } + #[test] fn test_should_fallback_to_react_from_code_orchestrated_when_no_event_emitted() { let error = ReplyAttemptError { diff --git a/src-tauri/src/commands/music_cmd.rs b/src-tauri/src/commands/music_cmd.rs index bb1941e2d..2ebea1b81 100644 --- a/src-tauri/src/commands/music_cmd.rs +++ b/src-tauri/src/commands/music_cmd.rs @@ -56,34 +56,64 @@ pub struct PythonEnvInfo { pub missing_packages: Vec, } +fn python_candidates() -> &'static [&'static str] { + #[cfg(target_os = "windows")] + { + &["python", "py", "python3"] + } + + #[cfg(not(target_os = "windows"))] + { + &["python3", "python"] + } +} + +fn detect_python_command() -> Option { + for candidate in python_candidates() { + if let Ok(output) = Command::new(candidate).arg("--version").output() { + if output.status.success() { + return Some((*candidate).to_string()); + } + } + } + None +} + +fn extract_python_version(output: &std::process::Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !stdout.is_empty() { + return stdout; + } + String::from_utf8_lossy(&output.stderr).trim().to_string() +} + /// 检查 Python 环境 #[tauri::command] pub async fn check_python_env() -> Result { - // 检查 Python 是否安装 - let python_check = Command::new("python3").arg("--version").output(); - - let (python_installed, python_version) = match python_check { - Ok(output) => { - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - (true, Some(version)) + let python_command = detect_python_command(); + let (python_installed, python_version, python_cmd) = match python_command { + Some(cmd) => { + let output = Command::new(&cmd).arg("--version").output(); + match output { + Ok(output) => (true, Some(extract_python_version(&output)), cmd), + Err(_) => (true, None, cmd), + } + } + None => { + return Ok(PythonEnvInfo { + python_installed: false, + python_version: None, + missing_packages: vec![], + }); } - Err(_) => (false, None), }; - if !python_installed { - return Ok(PythonEnvInfo { - python_installed: false, - python_version: None, - missing_packages: vec![], - }); - } - // 检查必需的 Python 包 let required_packages = vec!["mido", "music21", "numpy", "demucs", "basic-pitch"]; let mut missing_packages = Vec::new(); for package in required_packages { - let check = Command::new("python3") + let check = Command::new(&python_cmd) .arg("-c") .arg(format!("import {}", package.replace("-", "_"))) .output(); @@ -105,9 +135,11 @@ pub async fn check_python_env() -> Result { pub async fn analyze_midi(midi_path: String) -> Result { // 获取 Python 脚本路径 let script_path = get_resource_path("scripts/midi_analyzer.py")?; + let python_cmd = detect_python_command() + .ok_or_else(|| "Python is not installed or not found in PATH".to_string())?; // 调用 Python 脚本 - let output = Command::new("python3") + let output = Command::new(&python_cmd) .arg(&script_path) .arg(&midi_path) .output() @@ -128,9 +160,11 @@ pub async fn analyze_midi(midi_path: String) -> Result Result { // 获取 Python 脚本路径 let script_path = get_resource_path("scripts/audio_to_midi.py")?; + let python_cmd = detect_python_command() + .ok_or_else(|| "Python is not installed or not found in PATH".to_string())?; // 调用 Python 脚本 - let output = Command::new("python3") + let output = Command::new(&python_cmd) .arg(&script_path) .arg(&mp3_path) .arg(&output_path) @@ -194,8 +228,12 @@ fn get_resource_path(relative_path: &str) -> Result { #[tauri::command] pub async fn install_python_dependencies() -> Result { let packages = vec!["mido", "music21", "numpy", "demucs", "basic-pitch"]; + let python_cmd = detect_python_command() + .ok_or_else(|| "Python is not installed or not found in PATH".to_string())?; - let output = Command::new("pip3") + let output = Command::new(&python_cmd) + .arg("-m") + .arg("pip") .arg("install") .args(&packages) .output() @@ -208,3 +246,25 @@ pub async fn install_python_dependencies() -> Result { Ok("Dependencies installed successfully".to_string()) } + +#[cfg(test)] +mod tests { + use super::python_candidates; + + #[test] + fn python_candidates_should_not_be_empty() { + assert!(!python_candidates().is_empty()); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_python_candidates_should_prioritize_python() { + assert_eq!(python_candidates().first().copied(), Some("python")); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn unix_python_candidates_should_prioritize_python3() { + assert_eq!(python_candidates().first().copied(), Some("python3")); + } +} diff --git a/src-tauri/src/commands/unified_chat_cmd.rs b/src-tauri/src/commands/unified_chat_cmd.rs index 814bdd6bc..115c830c0 100644 --- a/src-tauri/src/commands/unified_chat_cmd.rs +++ b/src-tauri/src/commands/unified_chat_cmd.rs @@ -20,6 +20,10 @@ use crate::config::GlobalConfigManagerState; use crate::database::dao::chat::{ChatDao, ChatMessage, ChatMode, ChatSession}; use crate::database::DbConnection; use crate::services::memory_profile_prompt_service::merge_system_prompt_with_memory_profile; +use crate::services::request_tool_policy_prompt_service::{ + execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy, + resolve_request_tool_policy, RequestToolPolicy, WebSearchExecutionTracker, +}; use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search; use crate::services::web_search_runtime_service::apply_web_search_runtime_env; use aster::agents::extension::ExtensionConfig; @@ -56,14 +60,19 @@ pub struct CreateSessionRequest { #[derive(Debug, Deserialize)] pub struct SendMessageRequest { /// 会话 ID + #[serde(alias = "sessionId")] pub session_id: String, /// 消息内容 pub message: String, /// 事件名称(用于前端监听) + #[serde(alias = "eventName")] pub event_name: String, /// 图片输入(可选,用于多模态对话) /// TODO: 实现图片处理逻辑,将图片转换为 Aster Message 的 ImageContent pub images: Option>, + /// 请求级联网搜索开关 + #[serde(default, alias = "webSearch")] + pub web_search: Option, } /// 图片输入 @@ -361,46 +370,30 @@ pub async fn chat_send_message( &config, ); - let prefer_web_search_tools = matches!(session.mode, ChatMode::General); + let mode_default_web_search = matches!(session.mode, ChatMode::General); + let request_tool_policy = + resolve_request_tool_policy(request.web_search, mode_default_web_search); tracing::info!( - "[UnifiedChat][WebSearchGuard] session={}, mode={:?}, prefer_web_search_tools={}", + "[UnifiedChat][WebSearchGuard] session={}, mode={:?}, request_web_search={:?}, mode_default_web_search={}, effective_web_search={}", request.session_id, session.mode, - prefer_web_search_tools + request.web_search, + mode_default_web_search, + request_tool_policy.effective_web_search ); - let result = match session.mode { - ChatMode::Agent | ChatMode::Creator => { - // 使用 Aster Agent 处理 - send_message_with_aster( - &app, - &db, - &agent_state, - &request.session_id, - &request.message, - &request.event_name, - merged_system_prompt.as_deref(), - config.memory.enabled, - false, - ) - .await - } - ChatMode::General => { - // 通用模式:也使用 Aster Agent,但不启用工具 - send_message_with_aster( - &app, - &db, - &agent_state, - &request.session_id, - &request.message, - &request.event_name, - merged_system_prompt.as_deref(), - config.memory.enabled, - true, - ) - .await - } - }; + let result = send_message_with_aster( + &app, + &db, + &agent_state, + &request.session_id, + &request.message, + &request.event_name, + merged_system_prompt.as_deref(), + config.memory.enabled, + &request_tool_policy, + ) + .await; let total_elapsed = start_time.elapsed(); tracing::info!( @@ -422,13 +415,13 @@ async fn send_message_with_aster( event_name: &str, system_prompt: Option<&str>, include_context_trace: bool, - prefer_web_search_tools: bool, + request_tool_policy: &RequestToolPolicy, ) -> Result<(), String> { let start_time = std::time::Instant::now(); tracing::info!( - "[UnifiedChat][WebSearchGuard] session={}, prefer_web_search_tools={}", + "[UnifiedChat][WebSearchGuard] session={}, effective_web_search={}", session_id, - prefer_web_search_tools + request_tool_policy.effective_web_search ); // 确保 Agent 已初始化 @@ -454,26 +447,17 @@ async fn send_message_with_aster( // 创建取消令牌 let cancel_token = agent_state.create_cancel_token(session_id).await; - let guarded_user_message = if prefer_web_search_tools { - format!( - "[执行约束]\n\ -本次请求必须优先使用 WebSearch / WebFetch 工具获取联网结果。\n\ -不要调用 code_execution_execute_code / code_execution_read_module / code_execution_search_modules 这类代码执行模块来替代联网搜索。\n\n{}", - message - ) - } else { - message.to_string() - }; + let effective_system_prompt = merge_system_prompt_with_request_tool_policy( + system_prompt.map(|prompt| prompt.to_string()), + request_tool_policy, + ); - // 构建消息(如果有 system_prompt 且是第一条消息,注入到消息前面) - let final_message = if let Some(prompt) = system_prompt { - format!("{prompt}\n\n{guarded_user_message}") - } else { - guarded_user_message - }; - - let user_message = Message::user().with_text(&final_message); - let session_config = SessionConfigBuilder::new(session_id) + let user_message = Message::user().with_text(message); + let mut session_config_builder = SessionConfigBuilder::new(session_id); + if let Some(prompt) = effective_system_prompt { + session_config_builder = session_config_builder.system_prompt(prompt); + } + let session_config = session_config_builder .include_context_trace(include_context_trace) .build(); @@ -483,7 +467,7 @@ async fn send_message_with_aster( let agent = guard.as_ref().ok_or("Agent 未初始化")?; let mut removed_extension: Option = None; - if prefer_web_search_tools { + if request_tool_policy.effective_web_search { let extension_configs = agent.get_extension_configs().await; if let Some(extension) = extension_configs .into_iter() @@ -516,6 +500,47 @@ async fn send_message_with_aster( // 调用 Agent let reply_start = std::time::Instant::now(); + let mut web_search_tracker = WebSearchExecutionTracker::default(); + let preflight = execute_web_search_preflight_if_needed( + agent, + session_id, + message, + None, + Some(cancel_token.clone()), + request_tool_policy, + &mut web_search_tracker, + ) + .await; + match preflight { + Ok(preflight_execution) => { + for event in preflight_execution.events { + if let Err(error) = app.emit(event_name, &event) { + tracing::error!("[UnifiedChat] 发送预调用事件失败: {}", error); + } + } + } + Err(error) => { + let error_event = TauriAgentEvent::Error { + message: format!( + "{error}\n尝试记录: {}", + web_search_tracker.format_attempts() + ), + }; + let _ = app.emit(event_name, &error_event); + agent_state.remove_cancel_token(session_id).await; + if let Some(extension) = removed_extension { + if let Err(restore_error) = agent.add_extension(extension).await { + tracing::warn!( + "[UnifiedChat] 预调用失败后恢复 {} 扩展失败: {}", + CODE_EXECUTION_EXTENSION_NAME, + restore_error + ); + } + } + return Err(error); + } + } + let stream_result = agent .reply(user_message, session_config, Some(cancel_token.clone())) .await; @@ -539,6 +564,24 @@ async fn send_message_with_aster( let tauri_events = convert_agent_event(agent_event); for tauri_event in tauri_events { + match &tauri_event { + TauriAgentEvent::ToolStart { + tool_name, tool_id, .. + } => web_search_tracker.record_tool_start( + request_tool_policy, + tool_id, + tool_name, + ), + TauriAgentEvent::ToolEnd { tool_id, result } => { + web_search_tracker.record_tool_end( + request_tool_policy, + tool_id, + result.success, + result.error.as_deref(), + ); + } + _ => {} + } if let Err(e) = app.emit(event_name, &tauri_event) { tracing::error!("[UnifiedChat] 发送事件失败: {}", e); } @@ -555,9 +598,23 @@ async fn send_message_with_aster( } } - // 发送完成事件 - let done_event = TauriAgentEvent::FinalDone { usage: None }; - let _ = app.emit(event_name, &done_event); + if stream_error.is_none() { + if let Err(validation_error) = + web_search_tracker.validate_web_search_requirement(request_tool_policy) + { + let error_event = TauriAgentEvent::Error { + message: validation_error.clone(), + }; + let _ = app.emit(event_name, &error_event); + stream_error = Some(validation_error); + } + } + + if stream_error.is_none() { + // 发送完成事件 + let done_event = TauriAgentEvent::FinalDone { usage: None }; + let _ = app.emit(event_name, &done_event); + } let stream_elapsed = start_time.elapsed(); tracing::info!( @@ -633,3 +690,44 @@ pub async fn chat_configure_provider( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::request_tool_policy_prompt_service::resolve_request_tool_policy; + + #[test] + fn test_send_message_request_deserialize_web_search_camel_case() { + let payload = serde_json::json!({ + "sessionId": "session-1", + "message": "hello", + "eventName": "event-1", + "webSearch": true + }); + let request: SendMessageRequest = + serde_json::from_value(payload).expect("deserialize request"); + assert_eq!(request.web_search, Some(true)); + assert_eq!(request.session_id, "session-1"); + assert_eq!(request.event_name, "event-1"); + } + + #[test] + fn test_send_message_request_deserialize_web_search_snake_case() { + let payload = serde_json::json!({ + "session_id": "session-1", + "message": "hello", + "event_name": "event-1", + "web_search": false + }); + let request: SendMessageRequest = + serde_json::from_value(payload).expect("deserialize request"); + assert_eq!(request.web_search, Some(false)); + } + + #[test] + fn test_unified_effective_web_search_uses_request_override() { + let mode_default = true; + let policy = resolve_request_tool_policy(Some(false), mode_default); + assert!(!policy.effective_web_search); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index a8a92a24b..27bdb4878 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -14,6 +14,7 @@ pub mod memory_profile_prompt_service; pub mod memory_rules_loader_service; pub mod memory_source_resolver_service; pub mod novel_service; +pub mod request_tool_policy_prompt_service; pub mod sysinfo_service; pub mod update_check_service; pub mod update_window; diff --git a/src-tauri/src/services/request_tool_policy_prompt_service.rs b/src-tauri/src/services/request_tool_policy_prompt_service.rs new file mode 100644 index 000000000..0e67876c6 --- /dev/null +++ b/src-tauri/src/services/request_tool_policy_prompt_service.rs @@ -0,0 +1,566 @@ +//! 请求级工具策略提示词服务 +//! +//! 将本次请求的工具偏好(例如“开启联网搜索”)统一转换为系统提示词附加项, +//! 避免通过改写用户原始消息来注入策略。 +//! +//! 设计目标: +//! - 单一策略入口:Aster 聊天入口与统一执行入口复用同一策略解析/校验逻辑 +//! - 请求级优先:`effective_web_search = request.web_search ?? mode_default` +//! - 配置驱动:工具白/黑名单支持环境变量覆盖,保留扩展性 + +use aster::agents::Agent; +use aster::tools::ToolContext; +use proxycast_agent::event_converter::{TauriAgentEvent, TauriToolResult}; +use std::collections::HashMap; +use std::path::Path; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +pub const REQUEST_TOOL_POLICY_MARKER: &str = "【请求级工具策略】"; + +const DEFAULT_REQUIRED_TOOLS: &[&str] = &["WebSearch"]; +const DEFAULT_ALLOWED_TOOLS: &[&str] = &["WebSearch", "WebFetch"]; +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"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestToolPolicy { + /// 本次请求是否开启联网搜索策略 + pub effective_web_search: bool, + /// 必须至少成功一次的工具(默认 WebSearch) + pub required_tools: Vec, + /// 允许的联网工具集合(默认 WebSearch/WebFetch) + pub allowed_tools: Vec, + /// 禁止工具集合(可配置) + pub disallowed_tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolAttemptRecord { + pub tool_id: String, + pub tool_name: String, + pub success: Option, + pub error: Option, +} + +#[derive(Debug, Default)] +pub struct WebSearchExecutionTracker { + ordered_tool_ids: Vec, + attempts_by_id: HashMap, +} + +impl WebSearchExecutionTracker { + pub fn record_tool_start( + &mut self, + policy: &RequestToolPolicy, + tool_id: &str, + tool_name: &str, + ) { + if !policy.effective_web_search || tool_id.trim().is_empty() || tool_name.trim().is_empty() + { + return; + } + + if !self.attempts_by_id.contains_key(tool_id) { + self.ordered_tool_ids.push(tool_id.to_string()); + self.attempts_by_id.insert( + tool_id.to_string(), + ToolAttemptRecord { + tool_id: tool_id.to_string(), + tool_name: tool_name.to_string(), + success: None, + error: None, + }, + ); + } + } + + pub fn record_tool_end( + &mut self, + policy: &RequestToolPolicy, + tool_id: &str, + success: bool, + error: Option<&str>, + ) { + if !policy.effective_web_search || tool_id.trim().is_empty() { + return; + } + if let Some(record) = self.attempts_by_id.get_mut(tool_id) { + record.success = Some(success); + record.error = error + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()); + } + } + + pub fn validate_web_search_requirement( + &self, + policy: &RequestToolPolicy, + ) -> Result<(), String> { + if !policy.effective_web_search { + return Ok(()); + } + + let disallowed_attempts: Vec<&ToolAttemptRecord> = self + .ordered_tool_ids + .iter() + .filter_map(|tool_id| self.attempts_by_id.get(tool_id)) + .filter(|record| matches_tool_list(&record.tool_name, &policy.disallowed_tools)) + .collect(); + if !disallowed_attempts.is_empty() { + let disallowed_names = disallowed_attempts + .iter() + .map(|record| record.tool_name.clone()) + .collect::>() + .join(", "); + return Err(format!( + "联网搜索策略阻止了禁止工具调用: {}。\n尝试记录: {}", + disallowed_names, + self.format_attempts() + )); + } + + let required_attempts: Vec<&ToolAttemptRecord> = self + .ordered_tool_ids + .iter() + .filter_map(|tool_id| self.attempts_by_id.get(tool_id)) + .filter(|record| policy.matches_any_required_tool(&record.tool_name)) + .collect(); + + if required_attempts.is_empty() { + return Err(format!( + "联网搜索已开启,但未检测到必需工具调用。必须先调用 {} 至少一次后再给出最终答复。\n尝试记录: {}", + policy.required_tools.join(", "), + self.format_attempts() + )); + } + + if required_attempts + .iter() + .any(|record| record.success.unwrap_or(false)) + { + return Ok(()); + } + + Err(format!( + "联网搜索已开启,但必需工具调用全部失败,无法给出符合约束的最终答复。\n失败原因与尝试记录: {}", + self.format_attempts() + )) + } + + pub fn format_attempts(&self) -> String { + if self.ordered_tool_ids.is_empty() { + return "无工具调用".to_string(); + } + + self.ordered_tool_ids + .iter() + .filter_map(|tool_id| self.attempts_by_id.get(tool_id)) + .map(|record| { + let status = match record.success { + Some(true) => "success".to_string(), + Some(false) => { + format!("failed({})", record.error.as_deref().unwrap_or("unknown")) + } + None => "pending".to_string(), + }; + format!("{}#{}:{}", record.tool_name, record.tool_id, status) + }) + .collect::>() + .join("; ") + } +} + +#[derive(Debug, Clone)] +pub struct PreflightToolExecution { + pub events: Vec, +} + +impl PreflightToolExecution { + fn none() -> Self { + Self { events: Vec::new() } + } +} + +impl RequestToolPolicy { + pub fn matches_any_required_tool(&self, tool_name: &str) -> bool { + matches_tool_list(tool_name, &self.required_tools) + } + + pub fn matches_any_allowed_tool(&self, tool_name: &str) -> bool { + matches_tool_list(tool_name, &self.allowed_tools) + } +} + +/// 解析请求级工具策略 +/// +/// 规则: +/// - `effective_web_search = request_web_search.unwrap_or(mode_default)` +/// - 白/黑名单支持环境变量覆盖: +/// - `PROXYCAST_WEB_SEARCH_REQUIRED_TOOLS` +/// - `PROXYCAST_WEB_SEARCH_ALLOWED_TOOLS` +/// - `PROXYCAST_WEB_SEARCH_DISALLOWED_TOOLS` +pub fn resolve_request_tool_policy( + request_web_search: Option, + mode_default: bool, +) -> RequestToolPolicy { + let effective_web_search = request_web_search.unwrap_or(mode_default); + let required_tools = parse_tool_list_env(WEB_SEARCH_REQUIRED_TOOLS_ENV, DEFAULT_REQUIRED_TOOLS); + let mut allowed_tools = + parse_tool_list_env(WEB_SEARCH_ALLOWED_TOOLS_ENV, DEFAULT_ALLOWED_TOOLS); + let disallowed_tools = parse_tool_list_env(WEB_SEARCH_DISALLOWED_TOOLS_ENV, &[]); + + for required in &required_tools { + if !allowed_tools + .iter() + .any(|candidate| is_same_tool(candidate, required)) + { + allowed_tools.push(required.clone()); + } + } + + RequestToolPolicy { + effective_web_search, + required_tools, + allowed_tools, + disallowed_tools, + } +} + +/// 合并请求级工具策略到系统提示词 +/// +/// - `effective_web_search=false`:保持原始 system prompt 不变 +/// - 已包含 marker 时:不重复追加 +pub fn merge_system_prompt_with_request_tool_policy( + base_prompt: Option, + policy: &RequestToolPolicy, +) -> Option { + if !policy.effective_web_search { + return base_prompt; + } + + let disallowed_line = if policy.disallowed_tools.is_empty() { + "无".to_string() + } else { + policy.disallowed_tools.join(", ") + }; + + let policy_prompt = format!( + "{REQUEST_TOOL_POLICY_MARKER}\n\ +- 用户在本次请求中已开启“联网搜索”开关。\n\ +- 必须先调用 {} 至少一次(必要时再调用 WebFetch),再输出最终答复。\n\ +- 若工具调用失败,必须返回失败原因与尝试记录;不要在未完成必需工具调用前直接给最终结论。\n\ +- 允许工具: {}\n\ +- 禁止工具: {}", + policy.required_tools.join(", "), + policy.allowed_tools.join(", "), + disallowed_line + ); + + match base_prompt { + Some(base) => { + if base.contains(REQUEST_TOOL_POLICY_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(policy_prompt) + } else { + Some(format!("{base}\n\n{policy_prompt}")) + } + } + None => Some(policy_prompt), + } +} + +fn parse_tool_list_env(key: &str, default_values: &[&str]) -> Vec { + let from_env = std::env::var(key) + .ok() + .map(|raw| parse_tool_list(&raw)) + .filter(|tools| !tools.is_empty()); + + let values = + from_env.unwrap_or_else(|| default_values.iter().map(|item| item.to_string()).collect()); + dedup_tools(values) +} + +fn parse_tool_list(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(|item| item.to_string()) + .collect() +} + +fn dedup_tools(values: Vec) -> Vec { + let mut result: Vec = Vec::new(); + for value in values { + if !result.iter().any(|existing| is_same_tool(existing, &value)) { + result.push(value); + } + } + result +} + +fn matches_tool_list(tool_name: &str, list: &[String]) -> bool { + list.iter() + .any(|candidate| is_same_tool(tool_name, candidate)) +} + +fn is_same_tool(a: &str, b: &str) -> bool { + let normalized_a = normalize_tool_name(a); + let normalized_b = normalize_tool_name(b); + if normalized_a.is_empty() || normalized_b.is_empty() { + return false; + } + normalized_a == normalized_b + || normalized_a.contains(&normalized_b) + || normalized_b.contains(&normalized_a) +} + +fn normalize_tool_name(value: &str) -> String { + value + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .flat_map(|ch| ch.to_lowercase()) + .collect::() +} + +/// 当开启联网搜索时,在正式回复前执行一次 WebSearch 预调用。 +/// +/// 目标: +/// - 通过执行层保证至少一次 WebSearch 调用(而非仅依赖提示词) +/// - 统一生成 tool_start/tool_end 事件,供前端落地 +/// - 若预调用失败,返回失败原因并由上层中断本次回答 +pub async fn execute_web_search_preflight_if_needed( + agent: &Agent, + session_id: &str, + message_text: &str, + working_directory: Option<&Path>, + cancel_token: Option, + policy: &RequestToolPolicy, + tracker: &mut WebSearchExecutionTracker, +) -> Result { + if !policy.effective_web_search || !is_web_search_preflight_enabled() { + return Ok(PreflightToolExecution::none()); + } + + let registry_arc = agent.tool_registry().clone(); + let registry = registry_arc.read().await; + let available_tools = registry.get_definitions(); + let preflight_tool = available_tools + .iter() + .find(|definition| { + policy.matches_any_required_tool(&definition.name) + && normalize_tool_name(&definition.name).contains("websearch") + }) + .ok_or_else(|| { + format!( + "联网搜索已开启,但未找到可执行的必需工具定义。required_tools={}, available_tools={}", + policy.required_tools.join(", "), + available_tools + .iter() + .map(|definition| definition.name.clone()) + .collect::>() + .join(", ") + ) + })?; + + let query = derive_preflight_query(message_text); + let params = serde_json::json!({ "query": query }); + let arguments = serde_json::to_string(¶ms).ok(); + let tool_id = format!("preflight-websearch-{}", Uuid::new_v4()); + tracker.record_tool_start(policy, &tool_id, &preflight_tool.name); + + let mut context = ToolContext::new( + working_directory + .map(Path::to_path_buf) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_default(), + ) + .with_session_id(session_id.to_string()); + if let Some(token) = cancel_token { + context = context.with_cancellation_token(token); + } + + let mut events = vec![TauriAgentEvent::ToolStart { + tool_name: preflight_tool.name.clone(), + tool_id: tool_id.clone(), + arguments, + }]; + + let result = registry + .execute(&preflight_tool.name, params, &context, None) + .await + .map_err(|error| format!("执行 WebSearch 预调用失败: {}", error.to_string())); + + match result { + Ok(tool_result) => { + tracker.record_tool_end( + policy, + &tool_id, + tool_result.success, + tool_result.error.as_deref(), + ); + let event = TauriAgentEvent::ToolEnd { + tool_id, + result: TauriToolResult { + success: tool_result.success, + output: tool_result.output.unwrap_or_default(), + error: tool_result.error, + images: None, + }, + }; + events.push(event); + + if events + .last() + .and_then(|event| match event { + TauriAgentEvent::ToolEnd { result, .. } => Some(result.success), + _ => None, + }) + .unwrap_or(false) + { + Ok(PreflightToolExecution { events }) + } else { + let failure = events.last().and_then(|event| match event { + TauriAgentEvent::ToolEnd { result, .. } => result.error.clone(), + _ => None, + }); + Err(format!( + "联网搜索预调用失败: {}", + failure.unwrap_or_else(|| "unknown".to_string()) + )) + } + } + Err(error) => { + tracker.record_tool_end(policy, &tool_id, false, Some(error.as_str())); + events.push(TauriAgentEvent::ToolEnd { + tool_id, + result: TauriToolResult { + success: false, + output: String::new(), + error: Some(error.clone()), + images: None, + }, + }); + Err(error) + } + } +} + +fn is_web_search_preflight_enabled() -> bool { + match std::env::var(WEB_SEARCH_PREFLIGHT_ENABLED_ENV) { + Ok(raw) => match raw.trim().to_ascii_lowercase().as_str() { + "0" | "false" | "no" | "off" => false, + _ => true, + }, + Err(_) => true, + } +} + +fn derive_preflight_query(message_text: &str) -> String { + let trimmed = message_text.trim(); + if trimmed.chars().count() >= 2 { + return trimmed.to_string(); + } + if trimmed.is_empty() { + return "最新信息".to_string(); + } + + // 兜底补齐最短长度,避免触发 WebSearch.query minLength 校验失败 + let mut fallback = trimmed.to_string(); + while fallback.chars().count() < 2 { + fallback.push_str(" 信息"); + } + fallback +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_effective_web_search_with_request_override() { + let policy = resolve_request_tool_policy(Some(false), true); + assert!(!policy.effective_web_search); + + let policy = resolve_request_tool_policy(Some(true), false); + assert!(policy.effective_web_search); + } + + #[test] + fn resolves_effective_web_search_with_mode_default() { + let policy = resolve_request_tool_policy(None, true); + assert!(policy.effective_web_search); + + let policy = resolve_request_tool_policy(None, false); + assert!(!policy.effective_web_search); + } + + #[test] + fn keeps_original_prompt_when_disabled() { + let base = Some("base".to_string()); + let policy = resolve_request_tool_policy(Some(false), false); + assert_eq!( + merge_system_prompt_with_request_tool_policy(base.clone(), &policy), + base + ); + } + + #[test] + fn appends_policy_prompt_when_enabled() { + let policy = resolve_request_tool_policy(Some(true), false); + let merged = + merge_system_prompt_with_request_tool_policy(Some("base".to_string()), &policy) + .expect("merged prompt should exist"); + assert!(merged.contains(REQUEST_TOOL_POLICY_MARKER)); + assert!(merged.contains("必须先调用")); + assert!(merged.contains("WebSearch")); + } + + #[test] + fn no_duplicate_when_marker_exists() { + let base = Some(format!("{REQUEST_TOOL_POLICY_MARKER}\nexists")); + let policy = resolve_request_tool_policy(Some(true), false); + assert_eq!( + merge_system_prompt_with_request_tool_policy(base.clone(), &policy), + base + ); + } + + #[test] + fn tracker_requires_websearch_when_enabled() { + let policy = resolve_request_tool_policy(Some(true), false); + let mut tracker = WebSearchExecutionTracker::default(); + tracker.record_tool_start(&policy, "tool-1", "WebFetch"); + tracker.record_tool_end(&policy, "tool-1", true, None); + let err = tracker + .validate_web_search_requirement(&policy) + .expect_err("missing web search should fail"); + assert!(err.contains("未检测到必需工具调用")); + } + + #[test] + fn tracker_accepts_successful_websearch() { + let policy = resolve_request_tool_policy(Some(true), false); + let mut tracker = WebSearchExecutionTracker::default(); + tracker.record_tool_start(&policy, "tool-1", "WebSearch"); + tracker.record_tool_end(&policy, "tool-1", true, None); + assert!(tracker.validate_web_search_requirement(&policy).is_ok()); + } + + #[test] + fn tracker_reports_failure_record() { + let policy = resolve_request_tool_policy(Some(true), false); + let mut tracker = WebSearchExecutionTracker::default(); + tracker.record_tool_start(&policy, "tool-1", "WebSearch"); + tracker.record_tool_end(&policy, "tool-1", false, Some("network timeout")); + let err = tracker + .validate_web_search_requirement(&policy) + .expect_err("failed required tool should fail"); + assert!(err.contains("network timeout")); + assert!(err.contains("尝试记录")); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ba35c198f..9c014e87b 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.78.0", + "version": "0.79.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tests/real_web_search_policy.rs b/src-tauri/tests/real_web_search_policy.rs new file mode 100644 index 000000000..dc0712d59 --- /dev/null +++ b/src-tauri/tests/real_web_search_policy.rs @@ -0,0 +1,268 @@ +use futures::StreamExt; +use proxycast_agent::{ + convert_agent_event, AsterAgentState, SessionConfigBuilder, TauriAgentEvent, +}; +use proxycast_core::database::dao::api_key_provider::ApiProviderType; +use proxycast_core::database::init_database; +use proxycast_lib::services::request_tool_policy_prompt_service::{ + merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy, + WebSearchExecutionTracker, +}; +use proxycast_services::api_key_provider_service::ApiKeyProviderService; +use uuid::Uuid; + +fn should_run_real_test() -> bool { + std::env::var("PROXYCAST_REAL_API_TEST").ok().as_deref() == Some("1") +} + +fn resolve_model_name( + explicit: Option, + provider_models: &[String], +) -> Result { + if let Some(model) = explicit + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(model.to_string()); + } + + if let Some(model) = provider_models + .iter() + .map(|value| value.trim()) + .find(|value| !value.is_empty()) + { + return Ok(model.to_string()); + } + + Err( + "未找到可用模型:请设置 PROXYCAST_REAL_MODEL,或在 Provider custom_models 中配置模型。" + .to_string(), + ) +} + +fn resolve_codex_provider_and_model( + db: &proxycast_core::database::DbConnection, +) -> Result<(String, String), String> { + let explicit_model = std::env::var("PROXYCAST_REAL_MODEL").ok(); + + if let Ok(explicit) = std::env::var("PROXYCAST_REAL_PROVIDER_ID") { + let trimmed = explicit.trim(); + if !trimmed.is_empty() { + let service = ApiKeyProviderService::new(); + let provider = service + .get_provider(db, trimmed)? + .ok_or_else(|| format!("未找到指定 Provider: {trimmed}"))?; + let model = resolve_model_name(explicit_model, &provider.provider.custom_models)?; + return Ok((trimmed.to_string(), model)); + } + } + + let service = ApiKeyProviderService::new(); + let providers = service.get_all_providers(db)?; + providers + .into_iter() + .find(|item| { + item.provider.enabled + && item.provider.provider_type == ApiProviderType::Codex + && item.api_keys.iter().any(|key| key.enabled) + }) + .map(|item| -> Result<(String, String), String> { + let model = resolve_model_name(explicit_model, &item.provider.custom_models)?; + Ok((item.provider.id, model)) + }) + .transpose()? + .ok_or_else(|| "未找到启用且含可用 Key 的 Codex Provider".to_string()) +} + +#[derive(Debug, Default)] +struct RealRunSummary { + session_id: String, + web_search: bool, + model: String, + tool_start_count: usize, + tool_end_count: usize, + web_search_tool_names: Vec, + errors: Vec, + final_text_preview: String, +} + +async fn run_real_case( + state: &AsterAgentState, + db: &proxycast_core::database::DbConnection, + provider_id: &str, + model_name: &str, + web_search: bool, + prompt: &str, +) -> Result { + let session_id = format!("real-web-policy-{}", Uuid::new_v4()); + state + .configure_provider_from_pool(db, provider_id, model_name, &session_id) + .await + .map_err(|e| format!("配置 Provider 失败: {e}"))?; + + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or_else(|| "Agent 未初始化".to_string())?; + + let policy = resolve_request_tool_policy(Some(web_search), false); + let merged_prompt = merge_system_prompt_with_request_tool_policy(None, &policy); + let mut session_config_builder = SessionConfigBuilder::new(&session_id); + if let Some(system_prompt) = merged_prompt { + session_config_builder = session_config_builder.system_prompt(system_prompt); + } + let session_config = session_config_builder.build(); + + let user_message = aster::conversation::message::Message::user().with_text(prompt); + let mut stream = agent + .reply(user_message, session_config, None) + .await + .map_err(|e| format!("创建流式回复失败: {e}"))?; + + let mut summary = RealRunSummary { + session_id, + web_search, + model: model_name.to_string(), + ..RealRunSummary::default() + }; + let mut tracker = WebSearchExecutionTracker::default(); + let mut text_buffer = String::new(); + + while let Some(event_result) = stream.next().await { + match event_result { + Ok(agent_event) => { + for event in convert_agent_event(agent_event) { + match &event { + TauriAgentEvent::ToolStart { + tool_name, tool_id, .. + } => { + summary.tool_start_count += 1; + tracker.record_tool_start(&policy, tool_id, tool_name); + if tool_name.to_ascii_lowercase().contains("websearch") { + summary.web_search_tool_names.push(tool_name.clone()); + } + } + TauriAgentEvent::ToolEnd { tool_id, result } => { + summary.tool_end_count += 1; + tracker.record_tool_end( + &policy, + tool_id, + result.success, + result.error.as_deref(), + ); + } + TauriAgentEvent::TextDelta { text } => text_buffer.push_str(text), + TauriAgentEvent::Error { message } => summary.errors.push(message.clone()), + _ => {} + } + } + } + Err(error) => summary.errors.push(format!("stream_error: {error}")), + } + } + + if let Err(error) = tracker.validate_web_search_requirement(&policy) { + summary.errors.push(error); + } + + summary.final_text_preview = text_buffer.chars().take(280).collect(); + Ok(summary) +} + +#[tokio::test] +#[ignore = "真实联网测试:设置 PROXYCAST_REAL_API_TEST=1 后执行"] +async fn test_real_gpt53_codex_web_search_scenarios() { + if !should_run_real_test() { + return; + } + + let db = init_database().expect("初始化数据库失败"); + let (provider_id, resolved_model) = + resolve_codex_provider_and_model(&db).expect("解析 Codex Provider/模型失败"); + let model_name = std::env::var("PROXYCAST_REAL_MODEL").unwrap_or_else(|_| { + if resolved_model.trim().is_empty() { + "gpt-5.3-codex".to_string() + } else { + resolved_model + } + }); + + assert_eq!( + model_name.trim(), + "gpt-5.3-codex", + "本测试仅允许使用 gpt-5.3-codex" + ); + + let state = AsterAgentState::new(); + + let scenario_a = run_real_case( + &state, + &db, + &provider_id, + &model_name, + false, + "场景A:webSearch=false。请简要解释什么是 Rust 的所有权模型。", + ) + .await + .expect("场景A调用失败"); + + println!( + "[ScenarioA] request={{model:{}, web_search:{}, session:{}}} events={{tool_start:{}, tool_end:{}, web_search_tools:{:?}}} errors={:?} final_preview={}", + scenario_a.model, + scenario_a.web_search, + scenario_a.session_id, + scenario_a.tool_start_count, + scenario_a.tool_end_count, + scenario_a.web_search_tool_names, + scenario_a.errors, + scenario_a.final_text_preview + ); + assert!( + scenario_a.errors.is_empty(), + "场景A出现错误: {:?}", + scenario_a.errors + ); + + let scenario_b = run_real_case( + &state, + &db, + &provider_id, + &model_name, + true, + "场景B:webSearch=true。请搜索并总结2026年3月4日全球重要新闻,给出来源链接。", + ) + .await + .expect("场景B调用失败"); + + println!( + "[ScenarioB] request={{model:{}, web_search:{}, session:{}}} events={{tool_start:{}, tool_end:{}, web_search_tools:{:?}}} errors={:?} final_preview={}", + scenario_b.model, + scenario_b.web_search, + scenario_b.session_id, + scenario_b.tool_start_count, + scenario_b.tool_end_count, + scenario_b.web_search_tool_names, + scenario_b.errors, + scenario_b.final_text_preview + ); + + assert!( + scenario_b.errors.is_empty(), + "场景B出现错误: {:?}", + scenario_b.errors + ); + assert!( + scenario_b + .web_search_tool_names + .iter() + .any(|name| name.to_ascii_lowercase().contains("websearch")), + "场景B必须包含 WebSearch 工具调用,实际: {:?}", + scenario_b.web_search_tool_names + ); + assert!( + scenario_b.tool_start_count > 0 && scenario_b.tool_end_count > 0, + "场景B必须出现 tool_start/tool_end 事件,实际: start={}, end={}", + scenario_b.tool_start_count, + scenario_b.tool_end_count + ); +} diff --git a/src-tauri/tests/real_web_search_preflight_short_input.rs b/src-tauri/tests/real_web_search_preflight_short_input.rs new file mode 100644 index 000000000..3d45bea23 --- /dev/null +++ b/src-tauri/tests/real_web_search_preflight_short_input.rs @@ -0,0 +1,134 @@ +use proxycast_agent::AsterAgentState; +use proxycast_core::database::dao::api_key_provider::ApiProviderType; +use proxycast_core::database::init_database; +use proxycast_lib::services::request_tool_policy_prompt_service::{ + execute_web_search_preflight_if_needed, resolve_request_tool_policy, WebSearchExecutionTracker, +}; +use proxycast_services::api_key_provider_service::ApiKeyProviderService; +use uuid::Uuid; + +fn should_run_real_test() -> bool { + std::env::var("PROXYCAST_REAL_API_TEST").ok().as_deref() == Some("1") +} + +fn resolve_model_name( + explicit: Option, + provider_models: &[String], +) -> Result { + if let Some(model) = explicit + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(model.to_string()); + } + + if let Some(model) = provider_models + .iter() + .map(|value| value.trim()) + .find(|value| !value.is_empty()) + { + return Ok(model.to_string()); + } + + Err("未找到可用模型".to_string()) +} + +fn resolve_codex_provider_and_model( + db: &proxycast_core::database::DbConnection, +) -> Result<(String, String), String> { + let explicit_model = std::env::var("PROXYCAST_REAL_MODEL").ok(); + let service = ApiKeyProviderService::new(); + let providers = service.get_all_providers(db)?; + + providers + .into_iter() + .find(|item| { + item.provider.enabled + && item.provider.provider_type == ApiProviderType::Codex + && item.api_keys.iter().any(|key| key.enabled) + }) + .map(|item| -> Result<(String, String), String> { + let model = resolve_model_name(explicit_model, &item.provider.custom_models)?; + Ok((item.provider.id, model)) + }) + .transpose()? + .ok_or_else(|| "未找到启用且含可用 Key 的 Codex Provider".to_string()) +} + +#[tokio::test] +#[ignore = "真实联网测试:设置 PROXYCAST_REAL_API_TEST=1 后执行"] +async fn test_real_web_search_preflight_short_input_continue() { + if !should_run_real_test() { + return; + } + + let db = init_database().expect("初始化数据库失败"); + let (provider_id, resolved_model) = + resolve_codex_provider_and_model(&db).expect("解析 Codex Provider/模型失败"); + let model_name = std::env::var("PROXYCAST_REAL_MODEL").unwrap_or(resolved_model); + assert_eq!( + model_name.trim(), + "gpt-5.3-codex", + "本测试仅允许使用 gpt-5.3-codex" + ); + + let state = AsterAgentState::new(); + let session_id = format!("real-web-preflight-{}", Uuid::new_v4()); + state + .configure_provider_from_pool(&db, &provider_id, &model_name, &session_id) + .await + .expect("配置 Provider 失败"); + + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard.as_ref().expect("Agent 未初始化"); + + let policy = resolve_request_tool_policy(Some(true), false); + let mut tracker = WebSearchExecutionTracker::default(); + let execution = execute_web_search_preflight_if_needed( + agent, + &session_id, + "继续", + None, + None, + &policy, + &mut tracker, + ) + .await + .expect("预调用失败"); + + let mut tool_start_count = 0usize; + let mut tool_end_count = 0usize; + let mut tool_names = Vec::new(); + for event in execution.events { + match event { + proxycast_agent::TauriAgentEvent::ToolStart { tool_name, .. } => { + tool_start_count += 1; + tool_names.push(tool_name); + } + proxycast_agent::TauriAgentEvent::ToolEnd { .. } => { + tool_end_count += 1; + } + _ => {} + } + } + + println!( + "[PreflightContinue] request={{model:{}, web_search:true, prompt:\"继续\", session:{}}} events={{tool_start:{}, tool_end:{}, tools:{:?}}}", + model_name, session_id, tool_start_count, tool_end_count, tool_names + ); + + assert!( + tool_names + .iter() + .any(|name| name.to_ascii_lowercase().contains("websearch")), + "预调用必须包含 WebSearch,实际: {:?}", + tool_names + ); + assert!(tool_start_count > 0, "必须出现 tool_start"); + assert!(tool_end_count > 0, "必须出现 tool_end"); + tracker + .validate_web_search_requirement(&policy) + .expect("预调用后应满足必需工具约束"); +} diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index c45cd660c..cead4e0af 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -27,6 +27,7 @@ import { ChevronDown, Activity, Layers, + Terminal, LucideIcon, } from "lucide-react"; import * as LucideIcons from "lucide-react"; @@ -276,6 +277,7 @@ const MAIN_MENU_ITEMS: SidebarNavItem[] = [ }, { id: "image-gen", label: "插图", icon: Image, page: "image-gen" }, { id: "batch", label: "批量任务", icon: Layers, page: "batch" }, + { id: "terminal", label: "终端", icon: Terminal, page: "terminal" }, { id: "plugins", label: "插件中心", icon: Compass, page: "plugins" }, ]; @@ -377,9 +379,52 @@ const DEFAULT_ENABLED_NAV_ITEMS = [ "home-general", "video", "image-gen", - "plugins", ]; +const ALL_NAV_ITEM_IDS = [ + ...MAIN_MENU_ITEMS.map((item) => item.id), + ...FOOTER_MENU_ITEMS.map((item) => item.id), +]; + +const LEGACY_DEFAULT_NAV_ITEM_SETS: string[][] = [ + ["home-general", "video", "image-gen", "plugins"], + ["home-general", "video", "image-gen", "terminal", "plugins"], +]; + +const normalizeEnabledNavItems = (items: string[]): string[] => { + const unique = Array.from(new Set(items)); + return unique.filter((item) => ALL_NAV_ITEM_IDS.includes(item)); +}; + +const hasSameMembers = (left: string[], right: string[]): boolean => { + if (left.length !== right.length) return false; + const rightSet = new Set(right); + return left.every((item) => rightSet.has(item)); +}; + +const isLegacyDefaultEnabledItems = (items: string[]): boolean => { + return LEGACY_DEFAULT_NAV_ITEM_SETS.some((legacyItems) => + hasSameMembers(items, legacyItems), + ); +}; + +const resolveEnabledNavItems = (savedItems?: string[]): string[] => { + if (!savedItems || savedItems.length === 0) { + return [...DEFAULT_ENABLED_NAV_ITEMS]; + } + const normalized = normalizeEnabledNavItems(savedItems); + if (isLegacyDefaultEnabledItems(normalized)) { + return [...DEFAULT_ENABLED_NAV_ITEMS]; + } + const merged = [...normalized]; + for (const item of DEFAULT_ENABLED_NAV_ITEMS) { + if (!merged.includes(item)) { + merged.push(item); + } + } + return merged; +}; + function getIconByName(iconName: string): LucideIcon { const IconComponent = ( LucideIcons as unknown as Record @@ -423,17 +468,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { try { const config = await getConfig(); const saved = config.navigation?.enabled_items; - if (saved && saved.length > 0) { - const merged = [...saved]; - for (const item of DEFAULT_ENABLED_NAV_ITEMS) { - if (!merged.includes(item)) { - merged.push(item); - } - } - setEnabledNavItems(merged); - } else { - setEnabledNavItems(DEFAULT_ENABLED_NAV_ITEMS); - } + setEnabledNavItems(resolveEnabledNavItems(saved)); const savedThemes = config.content_creator?.enabled_themes; if (savedThemes && savedThemes.length > 0) { @@ -463,6 +498,15 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { return MAIN_MENU_ITEMS.filter((item) => enabledNavItems.includes(item.id)); }, [enabledNavItems]); + const filteredFooterMenuItems = useMemo(() => { + return FOOTER_MENU_ITEMS.filter((item) => { + if (item.id === "tools") { + return enabledNavItems.includes("tools"); + } + return true; + }); + }, [enabledNavItems]); + const filteredThemeMenuItems = useMemo(() => { return THEME_MENU_ITEMS.filter((item) => { // 从 theme-xxx 提取出 xxx @@ -638,7 +682,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
- {FOOTER_MENU_ITEMS.map((item) => ( + {filteredFooterMenuItems.map((item) => ( ({ children, onClick, disabled, + ...rest }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean; + [key: string]: unknown; }) => ( - ), @@ -226,7 +228,11 @@ describe("EmptyState", () => { it("选择技能后发送应自动附加 skill 前缀,且发送后清除激活技能", async () => { const onSend = vi.fn< - (value: string, executionStrategy?: "react" | "code_orchestrated" | "auto") => void + ( + value: string, + executionStrategy?: "react" | "code_orchestrated" | "auto", + images?: unknown[], + ) => void >(); const skill: Skill = { key: "canvas-design", @@ -261,11 +267,65 @@ describe("EmptyState", () => { act(() => { sendButton?.click(); }); - expect(onSend).toHaveBeenCalledWith("/canvas-design 帮我设计封面", "react"); + expect(onSend).toHaveBeenCalledWith( + "/canvas-design 帮我设计封面", + "react", + undefined, + ); act(() => { sendButton?.click(); }); - expect(onSend).toHaveBeenCalledWith("帮我设计封面", "react"); + expect(onSend).toHaveBeenCalledWith("帮我设计封面", "react", undefined); + }); + + it("点击地球按钮应切换联网搜索开关", async () => { + const onWebSearchEnabledChange = vi.fn<(enabled: boolean) => void>(); + const container = renderEmptyState({ + webSearchEnabled: false, + onWebSearchEnabledChange, + }); + await act(async () => { + await Promise.resolve(); + }); + + const globeToggle = container.querySelector( + 'button[title="开启联网搜索"]', + ) as HTMLButtonElement | null; + expect(globeToggle).toBeTruthy(); + + act(() => { + globeToggle?.click(); + }); + + expect(onWebSearchEnabledChange).toHaveBeenCalledWith(true); + }); + + it("通用主题工具栏应包含附件和深度思考开关", async () => { + const onThinkingEnabledChange = vi.fn<(enabled: boolean) => void>(); + const container = renderEmptyState({ + activeTheme: "general", + thinkingEnabled: false, + onThinkingEnabledChange, + }); + await act(async () => { + await Promise.resolve(); + }); + + const attachButton = container.querySelector( + 'button[title="上传文件"]', + ) as HTMLButtonElement | null; + expect(attachButton).toBeTruthy(); + + const thinkingButton = container.querySelector( + 'button[title="开启深度思考"]', + ) as HTMLButtonElement | null; + expect(thinkingButton).toBeTruthy(); + + act(() => { + thinkingButton?.click(); + }); + + expect(onThinkingEnabledChange).toHaveBeenCalledWith(true); }); }); diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index e0cbe9680..d03fe7548 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -2,6 +2,8 @@ import React, { useState, useEffect, useMemo, useRef } from "react"; import styled, { keyframes, css } from "styled-components"; import { ArrowRight, + Paperclip, + Lightbulb, ImageIcon, Video, FileText, @@ -52,6 +54,7 @@ import { SkillBadge } from "./Inputbar/components/SkillBadge"; import { useActiveSkill } from "./Inputbar/hooks/useActiveSkill"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; +import type { MessageImage } from "../types"; // Import Assets import iconXhs from "@/assets/platforms/xhs.png"; @@ -364,6 +367,7 @@ interface EmptyStateProps { onSend: ( value: string, executionStrategy?: "react" | "code_orchestrated" | "auto", + images?: MessageImage[], ) => void; /** 创作模式 */ creationMode?: CreationMode; @@ -386,6 +390,10 @@ interface EmptyStateProps { strategy: "react" | "code_orchestrated" | "auto", ) => void; onManageProviders?: () => void; + webSearchEnabled?: boolean; + onWebSearchEnabledChange?: (enabled: boolean) => void; + thinkingEnabled?: boolean; + onThinkingEnabledChange?: (enabled: boolean) => void; hasCanvasContent?: boolean; hasContentId?: boolean; selectedText?: string; @@ -514,6 +522,10 @@ export const EmptyState: React.FC = ({ executionStrategy = "react", setExecutionStrategy, onManageProviders, + webSearchEnabled = false, + onWebSearchEnabledChange, + thinkingEnabled = false, + onThinkingEnabledChange, hasCanvasContent = false, hasContentId = false, selectedText = "", @@ -588,10 +600,12 @@ export const EmptyState: React.FC = ({ const [ratio, setRatio] = useState("3:4"); const [style, setStyle] = useState("minimal"); const [depth, setDepth] = useState("deep"); + const [pendingImages, setPendingImages] = useState([]); const [entryTaskType, setEntryTaskType] = useState("direct"); const [entrySlotValues, setEntrySlotValues] = useState( () => createDefaultEntrySlotValues("direct"), ); + const imageInputRef = useRef(null); // Popover 打开状态 const [ratioPopoverOpen, setRatioPopoverOpen] = useState(false); const [stylePopoverOpen, setStylePopoverOpen] = useState(false); @@ -669,8 +683,36 @@ export const EmptyState: React.FC = ({ })); }; + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + Array.from(files).forEach((file) => { + if (!file.type.startsWith("image/")) { + return; + } + + const reader = new FileReader(); + reader.onload = (event) => { + const base64 = event.target?.result as string; + const base64Data = base64.split(",")[1]; + setPendingImages((prev) => [ + ...prev, + { + data: base64Data, + mediaType: file.type, + }, + ]); + }; + reader.readAsDataURL(file); + }); + + e.target.value = ""; + }; + const handleSend = () => { - if (!input.trim() && !isEntryTheme) return; + if (!input.trim() && !isEntryTheme && pendingImages.length === 0) return; + const imagesToSend = pendingImages.length > 0 ? pendingImages : undefined; if (isEntryTheme) { const validation = validateEntryTaskSlots(entryTaskType, entrySlotValues); @@ -696,7 +738,8 @@ export const EmptyState: React.FC = ({ }, }); - onSend(wrapTextWithSkill(composedPrompt), executionStrategy); + onSend(wrapTextWithSkill(composedPrompt), executionStrategy, imagesToSend); + setPendingImages([]); clearActiveSkill(); return; } @@ -712,7 +755,8 @@ export const EmptyState: React.FC = ({ prefix = `[知识探索: ${depth === "deep" ? "深度" : "快速"}] `; if (activeTheme === "planning") prefix = `[计划规划] `; - onSend(wrapTextWithSkill(prefix + input), executionStrategy); + onSend(wrapTextWithSkill(prefix + input), executionStrategy, imagesToSend); + setPendingImages([]); clearActiveSkill(); }; @@ -727,7 +771,7 @@ export const EmptyState: React.FC = ({ executionStrategy === "auto" ? "Auto" : executionStrategy === "code_orchestrated" - ? "编排" + ? "Plan" : "ReAct"; // Dynamic Placeholder @@ -883,6 +927,19 @@ export const EmptyState: React.FC = ({ onSelectSkill={setActiveSkill} onNavigateToSettings={onNavigateToSettings} /> + + {pendingImages.length > 0 && ( +
+ 已添加图片 {pendingImages.length} 张 +
+ )} @@ -1159,10 +1216,49 @@ export const EmptyState: React.FC = ({ )} + {activeTheme === "general" && ( + <> + + + + )} + @@ -1188,19 +1284,19 @@ export const EmptyState: React.FC = ({
- ReAct · 需确认 + ReAct
- 编排 · 需确认 + Plan
- Auto · 自动确认 + Auto
@@ -1211,7 +1307,9 @@ export const EmptyState: React.FC = ({ + + {props.activeTools?.web_search ? "on" : "off"} + + + ), +); vi.mock("./components/InputbarCore", () => ({ - InputbarCore: () =>
, + InputbarCore: (props: { + onToolClick?: (tool: string) => void; + activeTools?: Record; + }) => mockInputbarCore(props), })); vi.mock("./components/CharacterMention", () => ({ @@ -162,4 +184,42 @@ describe("Inputbar", () => { expect(mockCharacterMention.mock.calls[0][0].characters).toEqual([]); expect(mockCharacterMention.mock.calls[0][0].skills).toEqual([]); }); + + it("受控模式下点击联网搜索应透传状态变更", () => { + const onToolStatesChange = vi.fn(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ root, container }); + + const toggleButton = container.querySelector( + '[data-testid="toggle-web-search"]', + ) as HTMLButtonElement | null; + expect(toggleButton).toBeTruthy(); + + act(() => { + toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onToolStatesChange).toHaveBeenCalledWith({ + webSearch: true, + thinking: false, + }); + }); }); diff --git a/src/components/agent/chat/components/Inputbar/index.tsx b/src/components/agent/chat/components/Inputbar/index.tsx index 2e5548eab..d6d17b2e0 100644 --- a/src/components/agent/chat/components/Inputbar/index.tsx +++ b/src/components/agent/chat/components/Inputbar/index.tsx @@ -131,6 +131,16 @@ interface HintRouteItem { model: string; } +export interface InputbarToolStates { + webSearch: boolean; + thinking: boolean; +} + +const DEFAULT_INPUTBAR_TOOL_STATES: InputbarToolStates = { + webSearch: false, + thinking: false, +}; + interface InputbarProps { input: string; setInput: (value: string) => void; @@ -176,6 +186,8 @@ interface InputbarProps { setExecutionStrategy?: ( strategy: "react" | "code_orchestrated" | "auto", ) => void; + toolStates?: Partial; + onToolStatesChange?: (states: InputbarToolStates) => void; activeTheme?: string; onManageProviders?: () => void; } @@ -205,10 +217,17 @@ export const Inputbar: React.FC = ({ setModel, executionStrategy, setExecutionStrategy, + toolStates, + onToolStatesChange, activeTheme, onManageProviders, }) => { - const [activeTools, setActiveTools] = useState>({}); + const [localActiveTools, setLocalActiveTools] = useState< + Record + >({}); + const [localToolStates, setLocalToolStates] = useState( + DEFAULT_INPUTBAR_TOOL_STATES, + ); const [pendingImages, setPendingImages] = useState([]); const [isFullscreen, setIsFullscreen] = useState(false); const { activeSkill, setActiveSkill, clearActiveSkill } = useActiveSkill(); @@ -220,6 +239,31 @@ export const Inputbar: React.FC = ({ const [hintRoutes, setHintRoutes] = useState([]); const [hintIndex, setHintIndex] = useState(0); + const webSearchEnabled = + toolStates?.webSearch ?? localToolStates.webSearch; + const thinkingEnabled = toolStates?.thinking ?? localToolStates.thinking; + + const activeTools = useMemo>( + () => ({ + ...localActiveTools, + web_search: webSearchEnabled, + thinking: thinkingEnabled, + }), + [localActiveTools, thinkingEnabled, webSearchEnabled], + ); + + const updateToolStates = useCallback( + (next: InputbarToolStates) => { + setLocalToolStates((prev) => ({ + webSearch: toolStates?.webSearch ?? next.webSearch ?? prev.webSearch, + thinking: toolStates?.thinking ?? next.thinking ?? prev.thinking, + })); + onToolStatesChange?.(next); + return next; + }, + [onToolStatesChange, toolStates?.thinking, toolStates?.webSearch], + ); + useEffect(() => { safeInvoke("get_hint_routes") .then((routes) => { @@ -275,16 +319,24 @@ export const Inputbar: React.FC = ({ const handleToolClick = useCallback( (tool: string) => { switch (tool) { - case "thinking": - case "web_search": - setActiveTools((prev) => { - const newState = { ...prev, [tool]: !prev[tool] }; - toast.info( - `${tool === "thinking" ? "深度思考" : "联网搜索"}${newState[tool] ? "已开启" : "已关闭"}`, - ); - return newState; + case "thinking": { + const nextThinking = !thinkingEnabled; + updateToolStates({ + webSearch: webSearchEnabled, + thinking: nextThinking, }); + toast.info(`深度思考${nextThinking ? "已开启" : "已关闭"}`); break; + } + case "web_search": { + const nextWebSearch = !webSearchEnabled; + updateToolStates({ + webSearch: nextWebSearch, + thinking: thinkingEnabled, + }); + toast.info(`联网搜索${nextWebSearch ? "已开启" : "已关闭"}`); + break; + } case "execution_strategy": if (setExecutionStrategy) { const strategyOrder: Array< @@ -298,16 +350,16 @@ export const Inputbar: React.FC = ({ setExecutionStrategy(nextStrategy); toast.info( nextStrategy === "react" - ? "执行模式:ReAct(需确认)" + ? "执行模式:ReAct" : nextStrategy === "code_orchestrated" - ? "执行模式:编排(需确认)" - : "执行模式:Auto(工具自动确认)", + ? "执行模式:Plan" + : "执行模式:Auto", ); break; } - setActiveTools((prev) => { + setLocalActiveTools((prev) => { const enabled = !prev["execution_strategy"]; - toast.info(`编排模式${enabled ? "已开启" : "已关闭"}`); + toast.info(`Plan 模式${enabled ? "已开启" : "已关闭"}`); return { ...prev, execution_strategy: enabled }; }); break; @@ -341,10 +393,13 @@ export const Inputbar: React.FC = ({ }, [ executionStrategy, + thinkingEnabled, onClearMessages, onToggleCanvas, setExecutionStrategy, setInput, + updateToolStates, + webSearchEnabled, isFullscreen, ], ); @@ -450,8 +505,8 @@ export const Inputbar: React.FC = ({ const handleSend = useCallback(() => { if (!input.trim() && pendingImages.length === 0) return; - const webSearch = activeTools["web_search"] || false; - const thinking = activeTools["thinking"] || false; + const webSearch = webSearchEnabled; + const thinking = thinkingEnabled; let strategy = executionStrategy || (activeTools["execution_strategy"] ? "code_orchestrated" : "react"); @@ -474,7 +529,17 @@ export const Inputbar: React.FC = ({ ); setPendingImages([]); clearActiveSkill(); - }, [activeSkill, activeTools, clearActiveSkill, executionStrategy, input, onSend, pendingImages]); + }, [ + activeSkill, + activeTools, + clearActiveSkill, + executionStrategy, + input, + onSend, + pendingImages, + thinkingEnabled, + webSearchEnabled, + ]); const handleToggleTaskFiles = useCallback(() => { onToggleTaskFiles?.(); @@ -485,7 +550,7 @@ export const Inputbar: React.FC = ({ resolvedExecutionStrategy === "auto" ? "Auto" : resolvedExecutionStrategy === "code_orchestrated" - ? "编排" + ? "Plan" : "ReAct"; const inputAdapter = useMemo( @@ -667,19 +732,19 @@ export const Inputbar: React.FC = ({
- ReAct · 需确认 + ReAct
- 编排 · 需确认 + Plan
- Auto · 自动确认 + Auto
diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx index 9a98a8dbf..8f65c5c74 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -1564,6 +1564,34 @@ describe("useAsterAgentChat 兼容接口", () => { } }); + it("发送请求时应透传 provider_id,避免 custom provider 类型丢失", async () => { + const harness = mountHook("ws-provider-id"); + const providerId = "custom-a32774c6-6fd0-433b-8b81-e95340e08793"; + const model = "gpt-5.3-codex"; + + try { + await flushEffects(); + act(() => { + harness.getValue().setProviderType(providerId); + harness.getValue().setModel(model); + }); + await flushEffects(); + + await act(async () => { + await harness.getValue().triggerAIGuide("检查 provider_id 透传"); + }); + + expect(mockSendAsterMessageStream).toHaveBeenCalledTimes(1); + expect(mockSendAsterMessageStream.mock.calls[0]?.[5]).toMatchObject({ + provider_id: providerId, + provider_name: providerId, + model_name: model, + }); + } finally { + harness.unmount(); + } + }); + it("renameTopic 应调用后端并刷新话题标题", async () => { const createdAt = Math.floor(Date.now() / 1000); mockListAsterSessions diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.ts b/src/components/agent/chat/hooks/useAsterAgentChat.ts index 21821f4aa..e1e687ad5 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.ts +++ b/src/components/agent/chat/hooks/useAsterAgentChat.ts @@ -37,6 +37,7 @@ import { type ConfirmResponse, type Question, } from "../types"; +import { activityLogger } from "@/components/content-creator/utils/activityLogger"; /** 话题信息 */ export interface Topic { @@ -642,6 +643,13 @@ const resolveActionPromptKey = (action: ActionRequired): string | null => { return null; }; +const truncateForLog = (text: string, maxLength = 80): string => { + const normalized = text.trim(); + if (!normalized) return ""; + if (normalized.length <= maxLength) return normalized; + return `${normalized.slice(0, maxLength)}...`; +}; + // 音效相关(复用) let toolcallAudio: HTMLAudioElement | null = null; let typewriterAudio: HTMLAudioElement | null = null; @@ -895,6 +903,8 @@ const mapProviderName = (providerType: string): string => { "deepseek-reasoner": "deepseek", // Ollama ollama: "ollama", + // Codex + codex: "codex", // OpenRouter openrouter: "openrouter", // 其他(OpenAI 兼容) @@ -1365,13 +1375,16 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { async ( content: string, images: MessageImage[], - _webSearch?: boolean, + webSearch?: boolean, _thinking?: boolean, skipUserMessage = false, executionStrategyOverride?: AsterExecutionStrategy, + modelOverride?: string, ) => { const effectiveExecutionStrategy = executionStrategyOverride || executionStrategy; + const effectiveProviderType = providerTypeRef.current; + const effectiveModel = modelOverride?.trim() || modelRef.current; // 助手消息占位符 const assistantMsgId = crypto.randomUUID(); const assistantMsg: Message = { @@ -1402,13 +1415,38 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { let accumulatedContent = ""; let unlisten: UnlistenFn | null = null; + let requestLogId: string | null = null; + let requestStartedAt = 0; + let requestFinished = false; + const toolLogIdByToolId = new Map(); + const toolStartedAtByToolId = new Map(); + const toolNameByToolId = new Map(); + const actionLoggedKeys = new Set(); try { const activeSessionId = await ensureSession(); if (!activeSessionId) throw new Error("无法创建会话"); currentStreamingSessionIdRef.current = activeSessionId; + const resolvedWorkspaceId = getRequiredWorkspaceId(); const eventName = `aster_stream_${assistantMsgId}`; + requestStartedAt = Date.now(); + requestLogId = activityLogger.log({ + eventType: "chat_request_start", + status: "pending", + title: skipUserMessage ? "系统引导请求" : "发送请求", + description: `模型: ${effectiveModel} · 策略: ${effectiveExecutionStrategy}`, + workspaceId: resolvedWorkspaceId, + sessionId: activeSessionId, + source: "aster-chat", + metadata: { + provider: mapProviderName(effectiveProviderType), + model: effectiveModel, + executionStrategy: effectiveExecutionStrategy, + contentLength: content.trim().length, + skipUserMessage, + }, + }); const upsertActionRequest = ( actionData: ActionRequired, @@ -1510,6 +1548,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { case "tool_start": { playToolcallSound(); + const startedAt = Date.now(); const newToolCall = { id: data.tool_id, name: data.tool_name, @@ -1517,6 +1556,25 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { status: "running" as const, startTime: new Date(), }; + if (!toolLogIdByToolId.has(data.tool_id)) { + const toolLogId = activityLogger.log({ + eventType: "tool_start", + status: "pending", + title: `调用工具 ${data.tool_name}`, + description: truncateForLog(data.arguments || "等待工具结果"), + workspaceId: resolvedWorkspaceId, + sessionId: activeSessionId, + source: "aster-chat", + correlationId: data.tool_id, + metadata: { + toolId: data.tool_id, + toolName: data.tool_name, + }, + }); + toolLogIdByToolId.set(data.tool_id, toolLogId); + toolStartedAtByToolId.set(data.tool_id, startedAt); + toolNameByToolId.set(data.tool_id, data.tool_name); + } const toolArgs = parseJsonObject(data.arguments); @@ -1588,7 +1646,45 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { break; } - case "tool_end": + case "tool_end": { + const isSuccess = data.result.success; + const eventType = isSuccess ? "tool_complete" : "tool_error"; + const startedAt = toolStartedAtByToolId.get(data.tool_id); + const toolName = toolNameByToolId.get(data.tool_id) || "未知工具"; + const duration = + typeof startedAt === "number" + ? Date.now() - startedAt + : undefined; + const toolLogId = toolLogIdByToolId.get(data.tool_id); + const outputText = + typeof data.result?.output === "string" + ? truncateForLog(data.result.output, 120) + : ""; + + if (toolLogId) { + activityLogger.updateLog(toolLogId, { + eventType, + status: isSuccess ? "success" : "error", + duration, + description: outputText || (isSuccess ? "工具执行完成" : "工具执行失败"), + error: isSuccess + ? undefined + : outputText || "工具返回失败状态", + }); + } else { + activityLogger.log({ + eventType, + status: isSuccess ? "success" : "error", + title: `工具 ${toolName}`, + description: outputText || (isSuccess ? "工具执行完成" : "工具执行失败"), + duration, + workspaceId: resolvedWorkspaceId, + sessionId: activeSessionId, + source: "aster-chat", + correlationId: data.tool_id, + }); + } + setMessages((prev) => prev.map((msg) => { if (msg.id !== assistantMsgId) return msg; @@ -1640,6 +1736,7 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { }), ); break; + } case "action_required": { const actionType = normalizeActionType(data.action_type); @@ -1660,6 +1757,29 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { requestedSchema: data.requested_schema, isFallback: false, }; + const actionKey = + actionData.requestId || + `${actionData.actionType}:${actionData.prompt || actionData.toolName || ""}`; + if (!actionLoggedKeys.has(actionKey)) { + actionLoggedKeys.add(actionKey); + activityLogger.log({ + eventType: "action_required", + status: "success", + title: "等待用户确认", + description: + truncateForLog(actionData.prompt || "", 120) || + `类型: ${actionData.actionType}`, + workspaceId: resolvedWorkspaceId, + sessionId: activeSessionId, + source: "aster-chat", + correlationId: actionData.requestId, + metadata: { + actionType: actionData.actionType, + toolName: actionData.toolName, + requestId: actionData.requestId, + }, + }); + } if ( effectiveExecutionStrategy === "auto" && @@ -1718,6 +1838,15 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { break; case "final_done": + if (requestLogId && !requestFinished) { + requestFinished = true; + activityLogger.updateLog(requestLogId, { + eventType: "chat_request_complete", + status: "success", + duration: Date.now() - requestStartedAt, + description: `请求完成,工具调用 ${toolLogIdByToolId.size} 次`, + }); + } setMessages((prev) => prev.map((msg) => msg.id === assistantMsgId @@ -1740,6 +1869,15 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { break; case "error": + if (requestLogId && !requestFinished) { + requestFinished = true; + activityLogger.updateLog(requestLogId, { + eventType: "chat_request_error", + status: "error", + duration: Date.now() - requestStartedAt, + error: data.message, + }); + } if (data.message.includes("429") || data.message.toLowerCase().includes("rate limit")) { toast.warning("请求过于频繁,请稍后重试"); } else { @@ -1793,12 +1931,11 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { // 构建 Provider 配置 const providerConfig = { - provider_name: mapProviderName(providerType), - model_name: model, + provider_id: effectiveProviderType, + provider_name: mapProviderName(effectiveProviderType), + model_name: effectiveModel, }; - const resolvedWorkspaceId = getRequiredWorkspaceId(); - await sendAsterMessageStream( content, activeSessionId, @@ -1807,8 +1944,18 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { imagesToSend, providerConfig, effectiveExecutionStrategy, + webSearch, ); } catch (error) { + if (requestLogId && !requestFinished) { + requestFinished = true; + activityLogger.updateLog(requestLogId, { + eventType: "chat_request_error", + status: "error", + duration: Date.now() - requestStartedAt, + error: error instanceof Error ? error.message : String(error), + }); + } console.error("[AsterChat] 发送失败:", error); const errMsg = error instanceof Error ? error.message : String(error); @@ -1830,8 +1977,6 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) { executionStrategy, getRequiredWorkspaceId, onWriteFile, - providerType, - model, ], ); diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index fda8599ad..654771ad4 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -191,6 +191,7 @@ vi.mock("@/components/content-creator/canvas/document", () => ({ content: "", versions: [], currentVersionId: "", + isEditing: true, })), })); @@ -565,6 +566,7 @@ describe("AgentChatPage 自动引导", () => { false, false, undefined, + expect.any(String), ); expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1); expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled(); diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 9a3f08b6f..a1bcd48e1 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -74,6 +74,17 @@ import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { LatestRunStatusBadge } from "@/components/execution/LatestRunStatusBadge"; import { setActiveContentTarget } from "@/lib/activeContentTarget"; import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; +import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { useProviderModels } from "@/hooks/useProviderModels"; +import { + isReasoningModel, + resolveBaseModelOnThinkingOff, + resolveThinkingModel, +} from "@/lib/model/thinkingModelResolver"; +import { + loadRememberedBaseModel, + saveRememberedBaseModel, +} from "@/lib/model/thinkingBaseModelMemory"; import type { MessageImage } from "./types"; import type { @@ -86,6 +97,11 @@ import { getFileToStepMap } from "./utils/workflowMapping"; import { normalizeProjectId } from "./utils/topicProjectResolution"; import { resolveTopicSwitchProject } from "./utils/topicProjectSwitch"; import { getDefaultGuidePromptByTheme } from "./utils/defaultGuidePrompt"; +import { + loadChatToolPreferences, + saveChatToolPreferences, + type ChatToolPreferences, +} from "./utils/chatToolPreferences"; const SUPPORTED_ENTRY_THEMES: ThemeType[] = [ "general", @@ -264,6 +280,7 @@ export function AgentChatPage({ newChatAt, onRecommendationClick: _onRecommendationClick, onHasMessagesChange, + onSessionChange, }: { onNavigate?: (page: Page, params?: PageParams) => void; projectId?: string; @@ -285,10 +302,13 @@ export function AgentChatPage({ newChatAt?: number; onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; onHasMessagesChange?: (hasMessages: boolean) => void; + onSessionChange?: (sessionId: string | null) => void; }) { const [showSidebar, setShowSidebar] = useState(true); const [input, setInput] = useState(""); const [selectedText, setSelectedText] = useState(""); + const [chatToolPreferences, setChatToolPreferences] = + useState(() => loadChatToolPreferences()); // 内容创作相关状态 const [activeTheme, setActiveTheme] = useState( @@ -309,6 +329,10 @@ export function AgentChatPage({ setCreationMode(initialCreationMode); }, [initialCreationMode]); + useEffect(() => { + saveChatToolPreferences(chatToolPreferences); + }, [chatToolPreferences]); + // 内部 projectId 状态(当外部未提供时使用) const [internalProjectId, setInternalProjectId] = useState( null, @@ -567,6 +591,19 @@ export function AgentChatPage({ }, workspaceId: projectId ?? "", }); + const { providers: configuredProviders } = useConfiguredProviders(); + const selectedProvider = useMemo( + () => configuredProviders.find((provider) => provider.key === providerType), + [configuredProviders, providerType], + ); + const { models: providerModels } = useProviderModels(selectedProvider, { + returnFullMetadata: true, + }); + const thinkingVariantWarnedRef = useRef>(new Set()); + + useEffect(() => { + onSessionChange?.(sessionId ?? null); + }, [onSessionChange, sessionId]); // 会话文件持久化 hook const { @@ -1015,6 +1052,8 @@ export function AgentChatPage({ ) => { const sourceText = textOverride ?? input; if (!sourceText.trim() && (!images || images.length === 0)) return; + const effectiveWebSearch = webSearch ?? chatToolPreferences.webSearch; + const effectiveThinking = thinking ?? chatToolPreferences.thinking; if (!projectId) { toast.error("请先选择项目后再开始对话"); @@ -1042,13 +1081,62 @@ export function AgentChatPage({ setMentionedCharacters([]); // 清空引用的角色 try { + const memoryParams = { + scope: "aster" as const, + workspaceId: projectId, + sessionId, + providerKey: providerType, + }; + const rememberedBaseModel = loadRememberedBaseModel(memoryParams); + let effectiveModel = model; + + if (effectiveThinking) { + if (!isReasoningModel(model, providerModels)) { + saveRememberedBaseModel({ + ...memoryParams, + modelId: model, + }); + } + + const thinkingResult = resolveThinkingModel({ + currentModelId: model, + models: providerModels, + }); + effectiveModel = thinkingResult.targetModelId; + + if (thinkingResult.switched) { + setModel(thinkingResult.targetModelId); + } else if ( + thinkingResult.reason === "no_variant" && + providerModels.length > 0 + ) { + const warnKey = `${providerType}:${model}`; + if (!thinkingVariantWarnedRef.current.has(warnKey)) { + thinkingVariantWarnedRef.current.add(warnKey); + toast.warning("当前 Provider 没有可用的 Thinking 模型,已保持原模型"); + } + } + } else { + const restoreResult = resolveBaseModelOnThinkingOff({ + currentModelId: model, + models: providerModels, + rememberedBaseModel, + }); + effectiveModel = restoreResult.targetModelId; + + if (restoreResult.switched) { + setModel(restoreResult.targetModelId); + } + } + await sendMessage( text, images || [], - webSearch, - thinking, + effectiveWebSearch, + effectiveThinking, false, sendExecutionStrategy, + effectiveModel, ); } catch (error) { console.error("[AgentChat] 发送消息失败:", error); @@ -1057,7 +1145,18 @@ export function AgentChatPage({ setInput(sourceText); } }, - [input, mentionedCharacters, projectId, sendMessage], + [ + chatToolPreferences, + input, + mentionedCharacters, + model, + projectId, + providerModels, + providerType, + sendMessage, + sessionId, + setModel, + ], ); const handleClearMessages = useCallback(() => { @@ -1809,7 +1908,12 @@ export function AgentChatPage({ if (pendingInitialPrompt) { console.log("[AgentChatPage] 自动发送首条创作意图消息"); void (async () => { - await handleSend([], false, false, pendingInitialPrompt); + await handleSend( + [], + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + pendingInitialPrompt, + ); onInitialUserPromptConsumed?.(); })(); return; @@ -1840,6 +1944,7 @@ export function AgentChatPage({ canvasState, initialUserPrompt, handleSend, + chatToolPreferences, onInitialUserPromptConsumed, ]); @@ -1969,8 +2074,14 @@ export function AgentChatPage({ { - handleSend([], false, false, text, sendExecutionStrategy); + onSend={(text, sendExecutionStrategy, images) => { + handleSend( + images || [], + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + text, + sendExecutionStrategy, + ); }} providerType={providerType} setProviderType={setProviderType} @@ -1979,6 +2090,20 @@ export function AgentChatPage({ executionStrategy={executionStrategy} setExecutionStrategy={setExecutionStrategy} onManageProviders={handleManageProviders} + webSearchEnabled={chatToolPreferences.webSearch} + onWebSearchEnabledChange={(enabled) => + setChatToolPreferences((prev) => ({ + ...prev, + webSearch: enabled, + })) + } + thinkingEnabled={chatToolPreferences.thinking} + onThinkingEnabledChange={(enabled) => + setChatToolPreferences((prev) => ({ + ...prev, + thinking: enabled, + })) + } creationMode={creationMode} onCreationModeChange={setCreationMode} activeTheme={activeTheme} @@ -2055,6 +2180,8 @@ export function AgentChatPage({ onTaskFileClick={handleTaskFileClick} characters={projectMemory?.characters || []} skills={skills} + toolStates={chatToolPreferences} + onToolStatesChange={setChatToolPreferences} onSelectCharacter={(character) => { setMentionedCharacters((prev) => { // 避免重复添加 diff --git a/src/components/agent/chat/utils/chatToolPreferences.ts b/src/components/agent/chat/utils/chatToolPreferences.ts new file mode 100644 index 000000000..03e80a5b7 --- /dev/null +++ b/src/components/agent/chat/utils/chatToolPreferences.ts @@ -0,0 +1,48 @@ +export interface ChatToolPreferences { + webSearch: boolean; + thinking: boolean; +} + +export const DEFAULT_CHAT_TOOL_PREFERENCES: ChatToolPreferences = { + webSearch: false, + thinking: false, +}; + +const CHAT_TOOL_PREFERENCES_KEY = "proxycast.chat.tool_preferences.v1"; + +const normalizeBoolean = (value: unknown, fallback: boolean): boolean => + typeof value === "boolean" ? value : fallback; + +export function loadChatToolPreferences(): ChatToolPreferences { + try { + const raw = localStorage.getItem(CHAT_TOOL_PREFERENCES_KEY); + if (!raw) { + return DEFAULT_CHAT_TOOL_PREFERENCES; + } + + const parsed = JSON.parse(raw) as Partial; + return { + webSearch: normalizeBoolean( + parsed.webSearch, + DEFAULT_CHAT_TOOL_PREFERENCES.webSearch, + ), + thinking: normalizeBoolean( + parsed.thinking, + DEFAULT_CHAT_TOOL_PREFERENCES.thinking, + ), + }; + } catch { + return DEFAULT_CHAT_TOOL_PREFERENCES; + } +} + +export function saveChatToolPreferences(preferences: ChatToolPreferences): void { + try { + localStorage.setItem( + CHAT_TOOL_PREFERENCES_KEY, + JSON.stringify(preferences), + ); + } catch { + // ignore persistence errors + } +} diff --git a/src/components/content-creator/agents/AgentChatPanel.tsx b/src/components/content-creator/agents/AgentChatPanel.tsx index b1ab0cc01..27b0ea8ab 100644 --- a/src/components/content-creator/agents/AgentChatPanel.tsx +++ b/src/components/content-creator/agents/AgentChatPanel.tsx @@ -31,6 +31,7 @@ import { Palette, Type, Download, + FileText, } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -39,6 +40,7 @@ import { type AgentSuggestion, type PosterAgentId, } from "./index"; +import { ActivityLogList } from "../components/ActivityLog"; /** * 消息类型 @@ -327,6 +329,7 @@ export function AgentChatPanel({ const [inputValue, setInputValue] = useState(""); const [isProcessing, setIsProcessing] = useState(false); const [activeAgent, setActiveAgent] = useState(null); + const [showActivityLog, setShowActivityLog] = useState(false); // Refs const scrollRef = useRef(null); @@ -516,16 +519,28 @@ export function AgentChatPanel({ ); return ( - - - - - AI 设计助手 - {activeAgent && ( - {getAgentName(activeAgent)} - )} - - +
+ {/* 主对话面板 */} + + +
+ + + AI 设计助手 + {activeAgent && ( + {getAgentName(activeAgent)} + )} + + +
+
{/* 快捷指令 */} @@ -594,6 +609,14 @@ export function AgentChatPanel({
+ + {/* 活动日志面板 */} + {showActivityLog && ( + + + + )} +
); } diff --git a/src/components/content-creator/agents/AgentScheduler.ts b/src/components/content-creator/agents/AgentScheduler.ts index d9bc34456..6e5643df2 100644 --- a/src/components/content-creator/agents/AgentScheduler.ts +++ b/src/components/content-creator/agents/AgentScheduler.ts @@ -11,6 +11,7 @@ import type { AgentProgressCallback, PosterAgentId, } from "./base/types"; +import { activityLogger } from "../utils/activityLogger"; /** * 海报 Agent 调度器 @@ -31,6 +32,15 @@ export class PosterAgentScheduler { initialInput: AgentInput, onProgress?: AgentProgressCallback, ): Promise> { + // 记录工作流开始 + activityLogger.log({ + eventType: 'workflow_start', + status: 'success', + title: '开始执行工作流', + description: `共 ${stages.length} 个阶段`, + metadata: { stages }, + }); + const results = new Map(); let currentInput = initialInput; @@ -69,6 +79,14 @@ export class PosterAgentScheduler { } } + // 记录工作流完成 + activityLogger.log({ + eventType: 'workflow_complete', + status: 'success', + title: '工作流执行完成', + description: `所有 ${stages.length} 个阶段已完成`, + }); + return results; } diff --git a/src/components/content-creator/agents/base/BaseAgent.ts b/src/components/content-creator/agents/base/BaseAgent.ts index 62af14abf..af28dfe45 100644 --- a/src/components/content-creator/agents/base/BaseAgent.ts +++ b/src/components/content-creator/agents/base/BaseAgent.ts @@ -6,6 +6,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { AgentConfig, AgentInput, AgentOutput } from "./types"; +import { activityLogger } from "../../utils/activityLogger"; /** * Agent 基类 @@ -63,6 +64,17 @@ export abstract class BaseAgent { * @returns LLM 响应 */ protected async callLLM(prompt: string): Promise> { + // 记录Agent调用开始 + const logId = activityLogger.log({ + eventType: 'agent_call_start', + status: 'pending', + title: `调用 ${this.config.name}`, + description: `提示词长度: ${prompt.length} 字符`, + metadata: { agentId: this.config.id }, + }); + + const startTime = Date.now(); + try { // 调用后端 LLM 服务 const response = await invoke("agent_chat", { @@ -72,9 +84,27 @@ export abstract class BaseAgent { temperature: this.config.temperature, }); + const duration = Date.now() - startTime; + + // 记录Agent调用成功 + activityLogger.updateLog(logId, { + status: 'success', + duration, + description: `响应长度: ${response.length} 字符,耗时: ${(duration / 1000).toFixed(1)}s`, + }); + // 尝试解析 JSON 响应 return this.parseResponse(response); } catch (error) { + const duration = Date.now() - startTime; + + // 记录Agent调用失败 + activityLogger.updateLog(logId, { + status: 'error', + duration, + error: error instanceof Error ? error.message : String(error), + }); + console.error(`[${this.config.id}] LLM 调用失败:`, error); throw error; } diff --git a/src/components/content-creator/agents/poster/ContentAgent.ts b/src/components/content-creator/agents/poster/ContentAgent.ts index afdd36b27..06af6ff84 100644 --- a/src/components/content-creator/agents/poster/ContentAgent.ts +++ b/src/components/content-creator/agents/poster/ContentAgent.ts @@ -154,24 +154,125 @@ export class ContentAgent extends BaseAgent { } protected buildPrompt(input: AgentInput): string { - const { layout, requirement, style } = input.context as { - layout?: LayoutScheme; - requirement?: Record; - style?: StyleRecommendation; + const { layout, requirement, style, contentType, platform } = + input.context as { + layout?: LayoutScheme; + requirement?: Record; + style?: StyleRecommendation; + contentType?: string; + platform?: string; + }; + + // 内容类型特定的提示词 + const contentTypePrompts: Record = { + 技术分享: ` +## 标题生成规则 +- 格式:[技术点] + [核心价值] + [数据/结果] +- 示例: + * "React 18 并发渲染:性能提升 3 倍的秘密" + * "从 0 到 1 搭建 AI Agent 平台:我踩过的 5 个坑" + * "TypeScript 5.0 新特性:让代码更安全的 3 个技巧" +- 要求: + * 简洁有力,控制在 20-30 字 + * 突出技术点和实际价值 + * 避免标题党,确保内容匹配 + +## 内容要点提炼 +要求: +- 每个要点 15-30 字 +- 使用数字增强说服力(如"性能提升 3 倍"、"节省 50% 时间") +- 突出差异化和核心价值 +- 使用专业术语但保持易懂`, + 行业洞察: ` +## 标题生成规则 +- 格式:[行业/技术] + [趋势/现象] + [时间/数据] +- 示例: + * "Agent 炒作何时停?3 个关键信号" + * "2026 AI Agent 行业:泡沫 落地 现状" + * "从 ChatGPT 到 Agent:AI 应用的下一站" +- 要求: + * 简洁专业,控制在 15-25 字 + * 包含关键词和数据 + * 突出核心观点 + +## 内容要点提炼 +要求: +- 基于数据和事实 +- 多角度分析(技术、市场、用户) +- 提供趋势预测 +- 保持客观中立`, + 产品发布: ` +## 标题生成规则 +- 格式:[产品名] + [核心功能] + [使用场景] +- 示例: + * "ProxyCast:创作者的 AI Agent 平台" + * "告别低效创作,一个工具搞定全流程" + * "支持 9 大创作主题的 AI 内容平台" +- 要求: + * 突出痛点和解决方案 + * 控制在 20-30 字 + * 强调核心价值 + +## 内容要点提炼 +要求: +- 痛点 → 功能亮点 → 使用场景 +- 每个功能点配数据支撑 +- 突出差异化优势 +- 包含行动号召`, }; - return `你是一个海报文案专家。请基于以下需求生成海报文案: + // 平台特定的行动号召 + const platformCTA: Record = { + 小红书: "点赞收藏不迷路 / 评论区见 / 关注我了解更多", + 知乎: "关注专栏获取更多内容 / 点赞支持 / 评论交流", + 掘金: "Star 项目 / 阅读完整文档 / 评论讨论", + 微信公众号: "点击阅读原文 / 分享给朋友 / 在看支持", + }; -设计需求: + const contentTypePrompt = + contentTypePrompts[contentType || ""] || + ` +## 标题生成规则 +- 简洁有力,8-15 字 +- 突出核心价值 +- 吸引目标受众 + +## 内容要点提炼 +- 每个要点清晰明确 +- 使用数据支撑 +- 突出差异化`; + + const cta = + platformCTA[platform || ""] || "立即查看 / 了解更多 / 点击关注"; + + return `你是一个专业的内容创作专家,擅长为${platform || "社交媒体"}平台创作${contentType || "图文"}内容。 + +## 设计需求 ${JSON.stringify(requirement, null, 2)} -布局类型: ${layout?.name || "未指定"} -设计风格: ${style?.name || "未指定"} +## 布局类型 +${layout?.name || "未指定"} + +## 设计风格 +${style?.name || "未指定"} + +${contentTypePrompt} + +## 行动号召建议 +${cta} + +## 内容要点示例 +✅ "支持 9 大创作主题,覆盖社媒、短视频、小说等场景" +✅ "项目化管理,历史版本和素材自动沉淀" +✅ "一键适配小红书、知乎等 6 大平台规范" + +❌ "功能很强大"(过于笼统) +❌ "用户体验很好"(缺少具体说明) 请生成以下内容: -1. 主标题(简洁有力,8-15 字) -2. 副标题(补充说明,15-30 字) -3. 行动号召(引导用户,2-6 字) +1. 主标题(简洁有力,15-25 字,参考上述规则) +2. 副标题(补充说明,20-40 字,提供更多细节) +3. 行动号召(引导用户,2-8 字,根据平台特性) 4. 图片建议(需要什么类型的图片) 输出 JSON 格式: @@ -181,7 +282,7 @@ ${JSON.stringify(requirement, null, 2)} "text": { "title": "主标题内容", "subtitle": "副标题内容", - "callToAction": "立即抢购" + "callToAction": "立即查看" }, "images": [ { diff --git a/src/components/content-creator/agents/poster/LayoutAgent.ts b/src/components/content-creator/agents/poster/LayoutAgent.ts index c5a3bb315..436a20121 100644 --- a/src/components/content-creator/agents/poster/LayoutAgent.ts +++ b/src/components/content-creator/agents/poster/LayoutAgent.ts @@ -12,6 +12,11 @@ import type { StyleRecommendation, FabricObject, } from "../base/types"; +import { + generateTechArticleLayout, + generateIndustryInsightLayout, + generateDataDrivenLayout, +} from "./ProfessionalLayoutMethods"; /** * 布局生成 Agent @@ -134,6 +139,39 @@ export class LayoutAgent extends BaseAgent { ); break; + case "tech-article": + generateTechArticleLayout( + objects, + width, + height, + layout, + colorPalette, + typography, + ); + break; + + case "industry-insight": + generateIndustryInsightLayout( + objects, + width, + height, + layout, + colorPalette, + typography, + ); + break; + + case "data-driven": + generateDataDrivenLayout( + objects, + width, + height, + layout, + colorPalette, + typography, + ); + break; + default: this.generateHeroImageLayout( objects, @@ -325,26 +363,57 @@ export class LayoutAgent extends BaseAgent { } protected buildPrompt(input: AgentInput): string { - const { requirement, style, canvasSize } = input.context as { + const { requirement, style, canvasSize, contentType } = input.context as { requirement?: Record; style?: StyleRecommendation; canvasSize?: { width: number; height: number }; + contentType?: string; }; const size = canvasSize || { width: 1080, height: 1440 }; + // 根据内容类型推荐布局 + const layoutRecommendations: Record = { + 技术分享: ` +推荐布局: +1. tech-article: 技术文章型(深色背景 + 大标题 + 要点列表) +2. data-driven: 数据驱动型(数据卡片 + 图表展示) +3. text-dominant: 文字主导型(强调信息传达)`, + 行业洞察: ` +推荐布局: +1. industry-insight: 行业洞察型(年份标签 + 关键词标签 + 数据展示) +2. data-driven: 数据驱动型(数据卡片 + 图表展示) +3. tech-article: 技术文章型(深色背景 + 要点列表)`, + 产品发布: ` +推荐布局: +1. hero-image: 大图展示型(产品图 + 标题 + 行动号召) +2. text-dominant: 文字主导型(功能亮点 + 行动号召) +3. grid: 九宫格型(多功能展示)`, + 热点借势: ` +推荐布局: +1. tech-article: 技术文章型(深色背景 + 大标题 + 要点列表) +2. text-dominant: 文字主导型(强调信息传达) +3. hero-image: 大图展示型(视觉冲击)`, + }; + + const layoutGuide = + layoutRecommendations[contentType || ""] || + ` +推荐布局: +1. hero-image: 大图+标题型(突出产品/主视觉) +2. text-dominant: 文字主导型(强调信息传达) +3. grid: 九宫格型(展示多个元素)`; + return `你是一个专业的海报设计师。请基于以下需求生成 3 个不同的布局方案: 设计需求: ${JSON.stringify(requirement, null, 2)} +内容类型: ${contentType || "未指定"} 设计风格: ${style?.name || "简约现代"} 画布尺寸: ${size.width}×${size.height} -请生成 3 个不同类型的布局: -1. hero-image: 大图+标题型(突出产品/主视觉) -2. text-dominant: 文字主导型(强调信息传达) -3. grid: 九宫格型(展示多个元素) +${layoutGuide} 每个布局包含: - 布局类型和名称 @@ -352,21 +421,43 @@ ${JSON.stringify(requirement, null, 2)} - 视觉层次顺序 - 图片/文字/留白占比 +## 专业布局类型说明 + +**tech-article**: 技术文章型 +- 深色背景(#0a1929) +- 大标题 + 副标题 +- 要点列表(带序号) +- 科技感装饰元素 +- 适合:技术分享、热点分析 + +**industry-insight**: 行业洞察型 +- 深色背景(#1a1a2e) +- 年份标签 +- 关键词标签(如"泡沫"、"落地"、"现状") +- 数据展示区域 +- 适合:行业分析、趋势预测 + +**data-driven**: 数据驱动型 +- 浅色背景 + 顶部色块 +- 数据卡片(用户增长、市场份额等) +- 图表占位区域 +- 适合:数据报告、成果展示 + 输出 JSON 格式: \`\`\`json { "layouts": [ { - "type": "hero-image", - "name": "大图展示", - "description": "以产品大图为主,文字为辅,视觉冲击强", + "type": "tech-article", + "name": "技术文章型", + "description": "深色背景,大标题,要点列表,科技感强", "primaryText": "主标题", "secondaryText": "副标题", - "callToAction": "立即购买", - "imageRatio": 0.5, - "textRatio": 0.3, + "callToAction": "了解更多", + "imageRatio": 0.2, + "textRatio": 0.6, "whiteSpace": 0.2, - "hierarchy": ["产品图", "主标题", "副标题", "行动按钮"] + "hierarchy": ["主标题", "副标题", "要点列表"] } ] } diff --git a/src/components/content-creator/agents/poster/ProfessionalLayoutMethods.ts b/src/components/content-creator/agents/poster/ProfessionalLayoutMethods.ts new file mode 100644 index 000000000..cb771db37 --- /dev/null +++ b/src/components/content-creator/agents/poster/ProfessionalLayoutMethods.ts @@ -0,0 +1,450 @@ +/** + * @file ProfessionalLayoutMethods.ts + * @description 专业排版布局方法(技术文章、行业洞察、数据驱动) + * @module components/content-creator/agents/poster/ProfessionalLayoutMethods + */ + +import type { + FabricObject, + StyleRecommendation, +} from "../base/types"; + +/** + * 生成技术文章型布局(类似"Agent 炒作何时停?") + */ +export function generateTechArticleLayout( + objects: FabricObject[], + width: number, + height: number, + layout: Record, + colorPalette: StyleRecommendation["colorPalette"], + typography: StyleRecommendation["typography"], +): void { + // 深色背景渐变 + objects[0] = { + type: "rect", + left: 0, + top: 0, + width, + height, + fill: "#0a1929", + name: "dark-background", + }; + + // 科技感装饰元素 - 左上角 + objects.push({ + type: "circle", + left: -50, + top: -50, + radius: 150, + fill: "rgba(33, 150, 243, 0.1)", + name: "decoration-circle-1", + }); + + // 科技感装饰元素 - 右下角 + objects.push({ + type: "circle", + left: width - 100, + top: height - 100, + radius: 200, + fill: "rgba(33, 150, 243, 0.05)", + name: "decoration-circle-2", + }); + + // 主标题 - 大字号,居中或左对齐 + objects.push({ + type: "textbox", + left: width * 0.08, + top: height * 0.25, + width: width * 0.84, + text: (layout.primaryText as string) || "AGENT 炒作何时停", + fontSize: typography.titleSize * 1.3, + fontFamily: typography.titleFont, + fontWeight: 700, + fill: "#FFFFFF", + lineHeight: 1.2, + name: "main-title", + }); + + // 副标题 - 关键信号/要点 + objects.push({ + type: "textbox", + left: width * 0.08, + top: height * 0.45, + width: width * 0.84, + text: (layout.secondaryText as string) || "3 个关键信号", + fontSize: typography.bodySize * 1.5, + fontFamily: typography.bodyFont, + fill: "#2196F3", + name: "subtitle", + }); + + // 要点列表区域背景 + objects.push({ + type: "rect", + left: width * 0.08, + top: height * 0.58, + width: width * 0.84, + height: height * 0.28, + fill: "rgba(255, 255, 255, 0.05)", + rx: 12, + ry: 12, + name: "content-box", + }); + + // 要点 1 + objects.push({ + type: "textbox", + left: width * 0.12, + top: height * 0.62, + width: width * 0.76, + text: "① 市场热度持续下降", + fontSize: typography.bodySize, + fontFamily: typography.bodyFont, + fill: "#E0E0E0", + name: "point-1", + }); + + // 要点 2 + objects.push({ + type: "textbox", + left: width * 0.12, + top: height * 0.70, + width: width * 0.76, + text: "② 实际落地案例减少", + fontSize: typography.bodySize, + fontFamily: typography.bodyFont, + fill: "#E0E0E0", + name: "point-2", + }); + + // 要点 3 + objects.push({ + type: "textbox", + left: width * 0.12, + top: height * 0.78, + width: width * 0.76, + text: "③ 投资回报率不及预期", + fontSize: typography.bodySize, + fontFamily: typography.bodyFont, + fill: "#E0E0E0", + name: "point-3", + }); +} + +/** + * 生成行业洞察型布局(类似"2026 AI Agent 行业") + */ +export function generateIndustryInsightLayout( + objects: FabricObject[], + width: number, + height: number, + layout: Record, + colorPalette: StyleRecommendation["colorPalette"], + typography: StyleRecommendation["typography"], +): void { + // 深色背景 + objects[0] = { + type: "rect", + left: 0, + top: 0, + width, + height, + fill: "#1a1a2e", + name: "dark-background", + }; + + // 年份标签 + objects.push({ + type: "textbox", + left: width * 0.08, + top: height * 0.12, + width: width * 0.3, + text: "2026", + fontSize: typography.titleSize * 0.8, + fontFamily: typography.titleFont, + fontWeight: 700, + fill: "#53a8b6", + name: "year-label", + }); + + // 主标题 + objects.push({ + type: "textbox", + left: width * 0.08, + top: height * 0.22, + width: width * 0.84, + text: (layout.primaryText as string) || "AI Agent 行业", + fontSize: typography.titleSize * 1.2, + fontFamily: typography.titleFont, + fontWeight: 700, + fill: "#FFFFFF", + lineHeight: 1.3, + name: "main-title", + }); + + // 关键词标签区域 + const keywords = ["泡沫", "落地", "现状"]; + keywords.forEach((keyword, index) => { + // 标签背景 + objects.push({ + type: "rect", + left: width * 0.08 + index * (width * 0.25), + top: height * 0.42, + width: width * 0.22, + height: 50, + fill: "rgba(83, 168, 182, 0.2)", + rx: 8, + ry: 8, + name: `keyword-bg-${index}`, + }); + + // 标签文字 + objects.push({ + type: "textbox", + left: width * 0.08 + index * (width * 0.25), + top: height * 0.42 + 12, + width: width * 0.22, + text: keyword, + fontSize: typography.bodySize, + fontFamily: typography.bodyFont, + fill: "#53a8b6", + textAlign: "center", + name: `keyword-${index}`, + }); + }); + + // 数据展示区域 + objects.push({ + type: "rect", + left: width * 0.08, + top: height * 0.58, + width: width * 0.84, + height: height * 0.28, + fill: "rgba(255, 255, 255, 0.03)", + rx: 12, + ry: 12, + name: "data-box", + }); + + // 数据标题 + objects.push({ + type: "textbox", + left: width * 0.12, + top: height * 0.62, + width: width * 0.76, + text: "市场规模预测", + fontSize: typography.bodySize * 0.9, + fontFamily: typography.bodyFont, + fill: "#999999", + name: "data-title", + }); + + // 数据内容 + objects.push({ + type: "textbox", + left: width * 0.12, + top: height * 0.68, + width: width * 0.76, + text: "500 亿美元", + fontSize: typography.titleSize * 0.9, + fontFamily: typography.titleFont, + fontWeight: 700, + fill: "#53a8b6", + name: "data-value", + }); + + // 趋势说明 + objects.push({ + type: "textbox", + left: width * 0.12, + top: height * 0.78, + width: width * 0.76, + text: "同比增长 45%", + fontSize: typography.bodySize, + fontFamily: typography.bodyFont, + fill: "#E0E0E0", + name: "trend-text", + }); +} + +/** + * 生成数据驱动型布局 + */ +export function generateDataDrivenLayout( + objects: FabricObject[], + width: number, + height: number, + layout: Record, + colorPalette: StyleRecommendation["colorPalette"], + typography: StyleRecommendation["typography"], +): void { + // 浅色背景 + objects[0] = { + type: "rect", + left: 0, + top: 0, + width, + height, + fill: "#f5f5f5", + name: "light-background", + }; + + // 顶部色块 + objects.push({ + type: "rect", + left: 0, + top: 0, + width, + height: height * 0.35, + fill: "#2196F3", + name: "header-block", + }); + + // 主标题 + objects.push({ + type: "textbox", + left: width * 0.08, + top: height * 0.12, + width: width * 0.84, + text: (layout.primaryText as string) || "数据洞察报告", + fontSize: typography.titleSize, + fontFamily: typography.titleFont, + fontWeight: 700, + fill: "#FFFFFF", + name: "main-title", + }); + + // 副标题 + objects.push({ + type: "textbox", + left: width * 0.08, + top: height * 0.24, + width: width * 0.84, + text: (layout.secondaryText as string) || "基于 10,000+ 样本分析", + fontSize: typography.bodySize, + fontFamily: typography.bodyFont, + fill: "rgba(255, 255, 255, 0.9)", + name: "subtitle", + }); + + // 数据卡片 1 + objects.push({ + type: "rect", + left: width * 0.08, + top: height * 0.42, + width: width * 0.4, + height: height * 0.18, + fill: "#FFFFFF", + rx: 12, + ry: 12, + shadow: { + color: "rgba(0, 0, 0, 0.1)", + blur: 10, + offsetX: 0, + offsetY: 4, + }, + name: "data-card-1", + }); + + objects.push({ + type: "textbox", + left: width * 0.12, + top: height * 0.46, + width: width * 0.32, + text: "用户增长", + fontSize: typography.bodySize * 0.8, + fontFamily: typography.bodyFont, + fill: "#666666", + name: "card-1-label", + }); + + objects.push({ + type: "textbox", + left: width * 0.12, + top: height * 0.51, + width: width * 0.32, + text: "+127%", + fontSize: typography.titleSize * 0.7, + fontFamily: typography.titleFont, + fontWeight: 700, + fill: "#4CAF50", + name: "card-1-value", + }); + + // 数据卡片 2 + objects.push({ + type: "rect", + left: width * 0.52, + top: height * 0.42, + width: width * 0.4, + height: height * 0.18, + fill: "#FFFFFF", + rx: 12, + ry: 12, + shadow: { + color: "rgba(0, 0, 0, 0.1)", + blur: 10, + offsetX: 0, + offsetY: 4, + }, + name: "data-card-2", + }); + + objects.push({ + type: "textbox", + left: width * 0.56, + top: height * 0.46, + width: width * 0.32, + text: "市场份额", + fontSize: typography.bodySize * 0.8, + fontFamily: typography.bodyFont, + fill: "#666666", + name: "card-2-label", + }); + + objects.push({ + type: "textbox", + left: width * 0.56, + top: height * 0.51, + width: width * 0.32, + text: "32.5%", + fontSize: typography.titleSize * 0.7, + fontFamily: typography.titleFont, + fontWeight: 700, + fill: "#2196F3", + name: "card-2-value", + }); + + // 趋势图占位 + objects.push({ + type: "rect", + left: width * 0.08, + top: height * 0.68, + width: width * 0.84, + height: height * 0.22, + fill: "#FFFFFF", + rx: 12, + ry: 12, + shadow: { + color: "rgba(0, 0, 0, 0.1)", + blur: 10, + offsetX: 0, + offsetY: 4, + }, + name: "chart-placeholder", + }); + + objects.push({ + type: "textbox", + left: width * 0.12, + top: height * 0.72, + width: width * 0.76, + text: "📈 趋势图表", + fontSize: typography.bodySize, + fontFamily: typography.bodyFont, + fill: "#999999", + textAlign: "center", + name: "chart-label", + }); +} diff --git a/src/components/content-creator/agents/poster/RequirementAgent.ts b/src/components/content-creator/agents/poster/RequirementAgent.ts index 3fc2f9b9a..fd7a38ead 100644 --- a/src/components/content-creator/agents/poster/RequirementAgent.ts +++ b/src/components/content-creator/agents/poster/RequirementAgent.ts @@ -51,26 +51,134 @@ export class RequirementAgent extends BaseAgent { } protected buildPrompt(input: AgentInput): string { - const { purpose, platform, content, style } = input.context as { - purpose?: string; - platform?: string; - content?: string; - style?: string; + const { purpose, platform, content, style, contentType, tone } = + input.context as { + purpose?: string; + platform?: string; + content?: string; + style?: string; + contentType?: string; + tone?: string; + }; + + // 平台特性分析 + const platformGuidelines: Record = { + 知乎: ` +知乎用户特点: +- 重视内容深度和逻辑性 +- 喜欢数据支撑和案例分析 +- 对专业术语接受度高 +- 偏好长文和结构化内容 + +内容要求: +- 标题:简洁专业,突出核心价值(如"Agent 炒作何时停?3 个关键信号") +- 结构:清晰的章节层次,使用 H2/H3 标题 +- 论证:数据 + 案例 + 逻辑推理 +- 语气:专业但不失易懂,避免过度营销`, + 掘金: ` +掘金用户特点: +- 以技术开发者为主 +- 重视代码示例和实用性 +- 喜欢技术深度和最佳实践 +- 偏好简洁直接的表达 + +内容要求: +- 标题:突出技术点和实用价值(如"React 18 并发渲染:性能提升 3 倍的秘密") +- 结构:问题背景 → 解决方案 → 代码示例 → 最佳实践 +- 论证:代码 + 性能数据 + 实际案例 +- 语气:技术专业,简洁直接`, + 小红书: ` +小红书用户特点: +- 重视视觉冲击力 +- 喜欢轻松易懂的内容 +- 偏好图文并茂的呈现 +- 互动性强 + +内容要求: +- 标题:吸引眼球,使用 emoji(如"🔥 Agent 炒作何时停?") +- 结构:图片为主,文字为辅 +- 论证:案例 + 体验 + 互动引导 +- 语气:轻松有趣,贴近生活`, }; - return `你是一个资深的海报设计师。请分析以下设计需求: + // 内容类型结构建议 + const contentTypeGuidelines: Record = { + 技术分享: ` +推荐结构: +1. 问题背景(为什么需要这个技术/方案) +2. 核心概念(关键术语解释) +3. 解决方案(具体实现方法) +4. 代码示例(可运行的代码片段) +5. 最佳实践(注意事项和优化建议) +6. 总结(核心要点回顾)`, + 行业洞察: ` +推荐结构: +1. 现状分析(描述当前行业现象,使用数据支撑) +2. 趋势预测(分析未来发展趋势,提供论据) +3. 数据支撑(引用权威数据和研究报告) +4. 结论(总结核心观点,提出建议)`, + 产品发布: ` +推荐结构: +1. 痛点(用户面临的问题) +2. 功能亮点(3-5 个核心功能) +3. 使用场景(具体应用案例) +4. 行动号召(下载/试用引导)`, + 热点借势: ` +推荐结构: +1. 热点事件(简要描述热点) +2. 关联分析(与产品/服务的关联) +3. 观点输出(独特的见解或态度) +4. 互动引导(引发讨论)`, + }; -使用场景: ${purpose || "未指定"} -目标平台: ${platform || "未指定"} -核心信息: ${content || "未指定"} -风格偏好: ${style || "未指定"} + const platformGuide = + platformGuidelines[platform || ""] || "根据平台特性优化内容"; + const contentTypeGuide = + contentTypeGuidelines[contentType || ""] || "根据内容类型优化结构"; + + return `你是一个资深的内容策划专家,擅长为不同平台创作专业内容。 + +## 用户需求 +- 使用场景: ${purpose || "未指定"} +- 目标平台: ${platform || "未指定"} +- 内容类型: ${contentType || "未指定"} +- 核心主题: ${content || "未指定"} +- 风格偏好: ${style || "未指定"} +- 内容调性: ${tone || "未指定"} + +## 平台特性分析 +${platformGuide} + +## 内容类型结构建议 +${contentTypeGuide} + +## 标题生成 +请生成 3-5 个专业标题,参考以下格式: + +**技术分享类**: +- "[技术点] + [核心价值] + [数据/结果]" +- 示例:"React 18 并发渲染:性能提升 3 倍的秘密" + +**行业洞察类**: +- "[行业/技术] + [趋势/现象] + [时间/数据]" +- 示例:"Agent 炒作何时停?3 个关键信号" +- 示例:"2026 AI Agent 行业:泡沫 落地 现状" + +**产品发布类**: +- "[产品名] + [核心功能] + [使用场景]" +- 示例:"ProxyCast:创作者的 AI Agent 平台" + +**热点借势类**: +- "[热点] + [观点] + [互动]" +- 示例:"ChatGPT 爆火背后:AI 创作的下一站" 请输出结构化的需求分析报告,包含: 1. 设计目的(吸引点击/传达信息/品牌展示等) 2. 目标受众分析(人群特征、年龄、兴趣) 3. 关键元素提取(主文案、副文案、行动号召) -4. 视觉要求(推荐尺寸、色彩氛围、风格) -5. 约束条件(平台规范、品牌要求等) +4. 标题候选(3-5 个专业标题) +5. 视觉要求(推荐尺寸、色彩氛围、风格) +6. 约束条件(平台规范、品牌要求等) 输出 JSON 格式: \`\`\`json @@ -85,14 +193,19 @@ export class RequirementAgent extends BaseAgent { "keyElements": { "primaryText": "主要文案", "secondaryText": "次要文案", - "callToAction": "立即抢购" + "callToAction": "立即查看" }, + "titleCandidates": [ + "标题候选 1", + "标题候选 2", + "标题候选 3" + ], "visualRequirements": { "recommendedSize": { "width": 1080, "height": 1440 }, - "colorMood": "春季清新、粉色系", - "style": "简约现代" + "colorMood": "科技感深色背景", + "style": "专业简洁" }, - "constraints": ["小红书竖版规范", "需要留出安全区域"] + "constraints": ["平台规范", "品牌要求"] } } \`\`\``; diff --git a/src/components/content-creator/canvas/document/DocumentCanvas.tsx b/src/components/content-creator/canvas/document/DocumentCanvas.tsx index 258f8584a..a12d9753d 100644 --- a/src/components/content-creator/canvas/document/DocumentCanvas.tsx +++ b/src/components/content-creator/canvas/document/DocumentCanvas.tsx @@ -4,7 +4,13 @@ * @module components/content-creator/canvas/document/DocumentCanvas */ -import React, { memo, useMemo, useCallback, useState, useEffect } from "react"; +import React, { + memo, + useMemo, + useCallback, + useState, + useEffect, +} from "react"; import styled from "styled-components"; import { invoke } from "@tauri-apps/api/core"; import type { DocumentCanvasProps, ExportFormat, PlatformType } from "./types"; @@ -118,7 +124,7 @@ export const DocumentCanvas: React.FC = memo( contentId, autoImageTopic, }) => { - const [editingContent, setEditingContent] = useState(""); + const [editingContent, setEditingContent] = useState(state.content); const [toastMessage, setToastMessage] = useState(""); const [showToast, setShowToast] = useState(false); const [autoInsertLoading, setAutoInsertLoading] = useState(false); @@ -126,6 +132,9 @@ export const DocumentCanvas: React.FC = memo( requestId: string; image: InsertableImage; } | null>(null); + const [allowPreview, setAllowPreview] = useState(false); + const currentDocumentId = state.versions[0]?.id || state.currentVersionId; + const isEditing = state.isEditing || !allowPreview; // 当前版本 const currentVersion = useMemo(() => { @@ -136,7 +145,24 @@ export const DocumentCanvas: React.FC = memo( useEffect(() => { onSelectionTextChange?.(""); - }, [state.currentVersionId, state.isEditing, onSelectionTextChange]); + }, [state.currentVersionId, isEditing, onSelectionTextChange]); + + useEffect(() => { + if (!allowPreview && !state.isEditing) { + setEditingContent(state.content); + onStateChange({ ...state, isEditing: true }); + } + }, [allowPreview, onStateChange, state]); + + useEffect(() => { + setAllowPreview(false); + }, [currentDocumentId]); + + useEffect(() => { + if (isEditing) { + setEditingContent(state.content); + } + }, [state.content, state.currentVersionId, isEditing]); // 显示提示 const showMessage = useCallback((message: string) => { @@ -187,7 +213,7 @@ export const DocumentCanvas: React.FC = memo( return; } - if (state.isEditing) { + if (isEditing) { setPendingEditorInsert({ requestId: request.requestId, image: request.image, @@ -208,7 +234,7 @@ export const DocumentCanvas: React.FC = memo( }); ackCanvasImageInsertRequest(request.requestId); }, - [appendImageIntoDocument, matchesRequestTarget, showMessage, state.isEditing], + [appendImageIntoDocument, isEditing, matchesRequestTarget, showMessage], ); useEffect(() => { @@ -320,8 +346,8 @@ export const DocumentCanvas: React.FC = memo( if (autoInsertLoading) { return; } - if (state.isEditing) { - showMessage("ℹ️ 请先保存当前编辑,再执行主题配图"); + if (isEditing) { + showMessage("ℹ️ 请先切换到预览模式,再执行主题配图"); return; } @@ -388,6 +414,7 @@ export const DocumentCanvas: React.FC = memo( }, [ autoImageTopic, autoInsertLoading, + isEditing, onStateChange, searchImageWithFallback, showMessage, @@ -399,6 +426,9 @@ export const DocumentCanvas: React.FC = memo( (versionId: string) => { const version = state.versions.find((v) => v.id === versionId); if (version) { + if (isEditing) { + setEditingContent(version.content); + } onStateChange({ ...state, content: version.content, @@ -406,11 +436,12 @@ export const DocumentCanvas: React.FC = memo( }); } }, - [state, onStateChange], + [isEditing, state, onStateChange], ); // 进入编辑模式 const handleEditToggle = useCallback(() => { + setAllowPreview(false); setEditingContent(state.content); onStateChange({ ...state, isEditing: true }); }, [state, onStateChange]); @@ -429,20 +460,21 @@ export const DocumentCanvas: React.FC = memo( content: editingContent, versions: [...state.versions, newVersion], currentVersionId: newVersion.id, - isEditing: false, + isEditing: true, }); showMessage("✅ 保存成功"); - } else { - onStateChange({ ...state, isEditing: false }); } - setEditingContent(""); + setEditingContent(editingContent); }, [editingContent, state, onStateChange, showMessage]); - // 取消编辑 + // 切换到预览 const handleCancel = useCallback(() => { - setEditingContent(""); + setAllowPreview(true); + if (editingContent !== state.content) { + showMessage("ℹ️ 已切换到预览,未保存修改已丢弃"); + } onStateChange({ ...state, isEditing: false }); - }, [state, onStateChange]); + }, [editingContent, onStateChange, showMessage, state]); // 导出文档 const handleExport = useCallback( @@ -504,7 +536,7 @@ export const DocumentCanvas: React.FC = memo( = memo( /> - {state.isEditing ? ( + {isEditing ? ( = memo( )} - {!state.isEditing && ( + {!isEditing && ( = memo( {isEditing ? ( <> - 取消 + + 预览 + 💾 保存 ) : ( diff --git a/src/components/content-creator/canvas/document/hooks/useDocumentCanvas.ts b/src/components/content-creator/canvas/document/hooks/useDocumentCanvas.ts index f305e1ec4..ca6a269dd 100644 --- a/src/components/content-creator/canvas/document/hooks/useDocumentCanvas.ts +++ b/src/components/content-creator/canvas/document/hooks/useDocumentCanvas.ts @@ -4,7 +4,7 @@ * @module components/content-creator/canvas/document/hooks/useDocumentCanvas */ -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import type { DocumentCanvasState, DocumentVersion, @@ -22,7 +22,13 @@ export function useDocumentCanvas(initialContent: string = "") { ); // 编辑中的内容(未保存) - const [editingContent, setEditingContent] = useState(""); + const [editingContent, setEditingContent] = useState(initialContent); + + useEffect(() => { + if (state.isEditing) { + setEditingContent(state.content); + } + }, [state.content, state.currentVersionId, state.isEditing]); /** * 当前版本 @@ -70,6 +76,9 @@ export function useDocumentCanvas(initialContent: string = "") { (versionId: string) => { const version = state.versions.find((v) => v.id === versionId); if (version) { + if (state.isEditing) { + setEditingContent(version.content); + } setState((prev) => ({ ...prev, content: version.content, @@ -77,7 +86,7 @@ export function useDocumentCanvas(initialContent: string = "") { })); } }, - [state.versions], + [state.isEditing, state.versions], ); /** @@ -92,10 +101,9 @@ export function useDocumentCanvas(initialContent: string = "") { }, [state.content]); /** - * 退出编辑模式(不保存) + * 切换到预览模式(不保存) */ const cancelEditing = useCallback(() => { - setEditingContent(""); setState((prev) => ({ ...prev, isEditing: false, @@ -109,11 +117,11 @@ export function useDocumentCanvas(initialContent: string = "") { if (editingContent !== state.content) { updateContent(editingContent, "手动编辑"); } - setEditingContent(""); setState((prev) => ({ ...prev, - isEditing: false, + isEditing: true, })); + setEditingContent(editingContent); }, [editingContent, state.content, updateContent]); /** diff --git a/src/components/content-creator/canvas/document/types.test.ts b/src/components/content-creator/canvas/document/types.test.ts new file mode 100644 index 000000000..5326f941d --- /dev/null +++ b/src/components/content-creator/canvas/document/types.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { createInitialDocumentState } from "./types"; + +describe("createInitialDocumentState", () => { + it("应默认进入编辑模式", () => { + const content = "# 学习计划\n\n- Go 并发"; + const state = createInitialDocumentState(content); + + expect(state.type).toBe("document"); + expect(state.content).toBe(content); + expect(state.isEditing).toBe(true); + expect(state.versions).toHaveLength(1); + expect(state.currentVersionId).toBe(state.versions[0].id); + expect(state.versions[0].content).toBe(content); + expect(state.versions[0].description).toBe("初始版本"); + }); +}); diff --git a/src/components/content-creator/canvas/document/types.ts b/src/components/content-creator/canvas/document/types.ts index 471e0b5b6..0ae6c337d 100644 --- a/src/components/content-creator/canvas/document/types.ts +++ b/src/components/content-creator/canvas/document/types.ts @@ -86,7 +86,7 @@ export interface DocumentToolbarProps { onEditToggle: () => void; /** 保存回调 */ onSave: () => void; - /** 取消编辑回调 */ + /** 切换到预览回调(不保存) */ onCancel: () => void; /** 导出回调 */ onExport: (format: ExportFormat) => void; @@ -199,6 +199,6 @@ export function createInitialDocumentState( platform: "markdown", versions: [initialVersion], currentVersionId: initialVersion.id, - isEditing: false, + isEditing: true, }; } diff --git a/src/components/content-creator/canvas/poster/platforms/juejin.ts b/src/components/content-creator/canvas/poster/platforms/juejin.ts new file mode 100644 index 000000000..b419c4df2 --- /dev/null +++ b/src/components/content-creator/canvas/poster/platforms/juejin.ts @@ -0,0 +1,72 @@ +/** + * @file juejin.ts + * @description 掘金平台规范 + * @module components/content-creator/canvas/poster/platforms/juejin + */ + +import type { PlatformSpec } from "./types"; + +/** + * 掘金平台规范 + */ +export const juejinSpec: PlatformSpec = { + id: "custom", + name: "掘金", + icon: "juejin", + description: "掘金文章封面和配图规范", + sizes: [ + { + name: "文章封面", + width: 1200, + height: 630, + aspectRatio: "1.91:1", + usage: "掘金文章封面图,显示在文章列表和详情页", + recommended: true, + }, + { + name: "文章配图", + width: 800, + height: 450, + aspectRatio: "16:9", + usage: "文章内容配图,适合代码示例和技术图解", + }, + { + name: "专栏封面", + width: 1080, + height: 608, + aspectRatio: "16:9", + usage: "专栏封面图,显示在专栏列表", + }, + ], + safeZone: { + top: 80, + bottom: 80, + left: 100, + right: 100, + description: "预留边距,确保标题和关键信息清晰可见", + }, + fileSpec: { + formats: ["jpg", "png", "webp"], + maxSizeKB: 5120, // 5MB + recommendedDPI: 72, + colorMode: "RGB", + }, + textSpec: { + minFontSize: 18, + recommendedTitleSize: 42, + recommendedBodySize: 20, + lineHeightRatio: 1.5, + }, + notes: [ + "掘金用户以技术开发者为主,封面设计要体现技术感", + "推荐使用代码编辑器风格的配色(如 VS Code 主题色)", + "标题要突出技术关键词和实用价值", + "可以在封面中展示代码片段或技术架构图", + "避免过于花哨的设计,保持简洁专业", + "推荐使用深色背景(#1e1e1e、#282c34)+ 亮色文字", + "技术标签和关键词可以使用品牌色(#1e80ff)高亮", + ], + guideUrl: "https://juejin.cn/creator", +}; + +export default juejinSpec; diff --git a/src/components/content-creator/canvas/poster/platforms/zhihu.ts b/src/components/content-creator/canvas/poster/platforms/zhihu.ts new file mode 100644 index 000000000..cddc0ccb3 --- /dev/null +++ b/src/components/content-creator/canvas/poster/platforms/zhihu.ts @@ -0,0 +1,71 @@ +/** + * @file zhihu.ts + * @description 知乎平台规范 + * @module components/content-creator/canvas/poster/platforms/zhihu + */ + +import type { PlatformSpec } from "./types"; + +/** + * 知乎平台规范 + */ +export const zhihuSpec: PlatformSpec = { + id: "custom", + name: "知乎", + icon: "zhihu", + description: "知乎文章封面和回答配图规范", + sizes: [ + { + name: "文章封面", + width: 1200, + height: 500, + aspectRatio: "12:5", + usage: "知乎文章封面图,显示在文章顶部", + recommended: true, + }, + { + name: "回答配图", + width: 800, + height: 600, + aspectRatio: "4:3", + usage: "回答中的配图,适合图文混排", + }, + { + name: "专栏封面", + width: 1080, + height: 608, + aspectRatio: "16:9", + usage: "专栏封面图,显示在专栏列表", + }, + ], + safeZone: { + top: 60, + bottom: 60, + left: 80, + right: 80, + description: "预留边距,确保文字和关键元素不被裁切", + }, + fileSpec: { + formats: ["jpg", "png", "gif"], + maxSizeKB: 5120, // 5MB + recommendedDPI: 72, + colorMode: "RGB", + }, + textSpec: { + minFontSize: 16, + recommendedTitleSize: 36, + recommendedBodySize: 18, + lineHeightRatio: 1.6, + }, + notes: [ + "知乎用户重视内容深度和逻辑性,标题要简洁专业", + "文章封面建议使用科技感或专业感的设计风格", + "避免过度营销化的设计,保持专业和克制", + "配图要与内容相关,避免纯装饰性图片", + "标题字号不宜过大,保持专业感", + "推荐使用深色背景 + 浅色文字,或浅色背景 + 深色文字的高对比度设计", + ], + guideUrl: "https://www.zhihu.com/creator", +}; + +export default zhihuSpec; diff --git a/src/components/content-creator/components/ActivityLog/ActivityLogList.tsx b/src/components/content-creator/components/ActivityLog/ActivityLogList.tsx new file mode 100644 index 000000000..24a1ac05b --- /dev/null +++ b/src/components/content-creator/components/ActivityLog/ActivityLogList.tsx @@ -0,0 +1,147 @@ +/** + * @file ActivityLogList.tsx + * @description 活动日志列表组件 - 显示工作流执行过程中的所有操作记录 + * @module components/content-creator/components/ActivityLog + */ + +import { Check, Loader, AlertCircle, ChevronDown } from 'lucide-react'; +import { useState } from 'react'; +import { useActivityLog } from '../../hooks/useActivityLog'; +import type { ActivityLog } from '../../utils/activityLogger'; + +export interface ActivityLogListProps { + workspaceId?: string; + sessionId?: string | null; +} + +/** + * 活动日志列表组件 + * + * 显示所有活动日志,支持展开查看详细信息。 + */ +export function ActivityLogList({ workspaceId, sessionId }: ActivityLogListProps) { + const { logs, clearLogs } = useActivityLog({ workspaceId, sessionId }); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + /** + * 切换日志展开状态 + */ + const toggleExpand = (id: string) => { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + /** + * 获取状态图标 + */ + const getStatusIcon = (status: ActivityLog['status']) => { + switch (status) { + case 'success': + return ; + case 'pending': + return ; + case 'error': + return ; + } + }; + + /** + * 格式化时间 + */ + const formatTime = (timestamp: number) => { + const now = Date.now(); + const diff = now - timestamp; + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return '刚刚'; + if (minutes < 60) return `${minutes}分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}小时前`; + const days = Math.floor(hours / 24); + return `${days}天前`; + }; + + return ( +
+ {/* 头部 */} +
+

活动日志

+ +
+ + {/* 日志列表 */} +
+ {logs.length === 0 ? ( +
+ 暂无活动记录 +
+ ) : ( + logs.slice().reverse().map(log => ( +
+
+ {/* 状态图标 */} + {getStatusIcon(log.status)} + + {/* 日志内容 */} +
+
{log.title}
+
+ {formatTime(log.timestamp)} + {log.duration && ` · 耗时 ${(log.duration / 1000).toFixed(1)}s`} +
+ {log.description && ( +
+ {log.description} +
+ )} + {log.error && ( +
+ 错误: {log.error} +
+ )} +
+ + {/* 展开按钮 */} + {log.metadata && ( + + )} +
+ + {/* 展开的详细信息 */} + {expandedIds.has(log.id) && log.metadata && ( +
+                  {JSON.stringify(log.metadata, null, 2)}
+                
+ )} +
+ )) + )} +
+
+ ); +} + +export default ActivityLogList; diff --git a/src/components/content-creator/components/ActivityLog/index.ts b/src/components/content-creator/components/ActivityLog/index.ts new file mode 100644 index 000000000..98341300b --- /dev/null +++ b/src/components/content-creator/components/ActivityLog/index.ts @@ -0,0 +1,8 @@ +/** + * @file index.ts + * @description 活动日志组件导出 + * @module components/content-creator/components/ActivityLog + */ + +export { ActivityLogList } from './ActivityLogList'; +export { default } from './ActivityLogList'; diff --git a/src/components/content-creator/hooks/useActivityLog.ts b/src/components/content-creator/hooks/useActivityLog.ts new file mode 100644 index 000000000..b082d3aa1 --- /dev/null +++ b/src/components/content-creator/hooks/useActivityLog.ts @@ -0,0 +1,58 @@ +/** + * @file useActivityLog.ts + * @description 活动日志 Hook - 订阅和管理活动日志状态 + * @module components/content-creator/hooks/useActivityLog + */ + +import { useState, useEffect } from 'react'; +import { activityLogger, ActivityLog, type ActivityLogScope } from '../utils/activityLogger'; + +/** + * 活动日志 Hook 返回值 + */ +export interface UseActivityLogReturn { + /** 所有日志 */ + logs: ActivityLog[]; + /** 清空日志 */ + clearLogs: () => void; +} + +export type UseActivityLogFilter = ActivityLogScope; + +/** + * 活动日志 Hook + * + * 订阅活动日志的变化,自动更新组件状态。 + * + * @param filter - 日志作用域过滤条件 + * @returns 日志数据和操作方法 + */ +export function useActivityLog(filter?: UseActivityLogFilter): UseActivityLogReturn { + const [logs, setLogs] = useState([]); + const workspaceId = filter?.workspaceId; + const sessionId = filter?.sessionId; + + useEffect(() => { + const scope = + workspaceId === undefined && sessionId === undefined + ? undefined + : { workspaceId, sessionId }; + + // 初始化日志 + setLogs(activityLogger.getLogs(scope)); + + // 订阅日志变化 + const unsubscribe = activityLogger.subscribe(() => { + setLogs(activityLogger.getLogs(scope)); + }); + + return unsubscribe; + }, [workspaceId, sessionId]); + + return { + logs, + clearLogs: () => activityLogger.clear(filter), + }; +} + +export default useActivityLog; diff --git a/src/components/content-creator/templates/social-media/index.ts b/src/components/content-creator/templates/social-media/index.ts new file mode 100644 index 000000000..a59bec3b6 --- /dev/null +++ b/src/components/content-creator/templates/social-media/index.ts @@ -0,0 +1,39 @@ +/** + * @file index.ts + * @description 社媒内容模板索引 + * @module components/content-creator/templates/social-media + */ + +export { trendingTopicTemplate } from "./trending-topic"; +export { industryAnalysisTemplate } from "./industry-analysis"; +export { techSharingTemplate } from "./tech-sharing"; +export { productLaunchTemplate } from "./product-launch"; +export { visualContentTemplate } from "./visual-content"; + +export type { ContentTemplate } from "./trending-topic"; + +/** + * 所有社媒内容模板 + */ +export const socialMediaTemplates = { + "trending-topic": () => import("./trending-topic"), + "industry-analysis": () => import("./industry-analysis"), + "tech-sharing": () => import("./tech-sharing"), + "product-launch": () => import("./product-launch"), + "visual-content": () => import("./visual-content"), +}; + +/** + * 根据内容类型获取推荐模板 + */ +export function getRecommendedTemplate(contentType: string): string | null { + const templateMap: Record = { + 热点借势: "trending-topic", + 行业洞察: "industry-analysis", + 技术分享: "tech-sharing", + 产品发布: "product-launch", + 知识干货: "visual-content", + }; + + return templateMap[contentType] || null; +} diff --git a/src/components/content-creator/templates/social-media/industry-analysis.ts b/src/components/content-creator/templates/social-media/industry-analysis.ts new file mode 100644 index 000000000..abad6b512 --- /dev/null +++ b/src/components/content-creator/templates/social-media/industry-analysis.ts @@ -0,0 +1,139 @@ +/** + * @file industry-analysis.ts + * @description 行业分析内容模板(如"2026 AI Agent 行业") + * @module components/content-creator/templates/social-media/industry-analysis + */ + +import type { ContentTemplate } from "./trending-topic"; + +/** + * 行业分析模板 + */ +export const industryAnalysisTemplate: ContentTemplate = { + id: "industry-analysis", + name: "行业分析", + description: "数据驱动的行业深度分析内容", + + titleFormats: [ + "{年份} {行业} {关键词1} {关键词2} {关键词3}", + "{行业} 市场规模达 {数字}:{趋势分析}", + "{技术/产品} 渗透率突破 {百分比}:{影响分析}", + ], + + titleExamples: [ + "2026 AI Agent 行业:泡沫 落地 现状", + "AI 创作工具市场规模达 500 亿:3 大趋势预测", + "Agent 技术渗透率突破 30%:对创作行业的影响", + ], + + contentStructure: { + sections: [ + { + name: "市场现状", + prompt: "描述当前市场规模、主要玩家、竞争格局", + length: "200-300字", + requirements: [ + "引用权威市场数据", + "列举主要企业和产品", + "分析市场份额分布", + ], + }, + { + name: "关键趋势", + prompt: "分析 3-5 个关键发展趋势", + length: "400-500字", + requirements: [ + "每个趋势配数据支撑", + "分析趋势背后的驱动因素", + "预测趋势的持续性", + ], + }, + { + name: "挑战与机遇", + prompt: "分析行业面临的挑战和潜在机遇", + length: "300-400字", + requirements: [ + "客观分析挑战", + "识别潜在机遇", + "提供应对建议", + ], + }, + { + name: "未来展望", + prompt: "预测未来 1-3 年的发展方向", + length: "200-300字", + requirements: [ + "基于数据和逻辑", + "提供时间线", + "保持客观理性", + ], + }, + ], + }, + + visualStyle: { + coverImage: { + style: "数据可视化风格", + colors: ["#1a1a2e", "#16213e", "#0f3460", "#53a8b6"], + elements: [ + "数据图表(柱状图、折线图)", + "趋势线和增长曲线", + "关键数据标注", + "专业排版", + ], + layout: "数据图表为主,标题简洁醒目", + }, + }, + + agentPrompt: `你是一个资深的行业分析师,擅长撰写数据驱动的行业报告。 + +## 任务 +创作一篇关于「{{topic}}」的行业分析报告,适合发布在{{platform}}平台。 + +## 标题要求 +- 格式参考:「2026 AI Agent 行业:泡沫 落地 现状」 +- 包含年份、行业、关键词 +- 15-30 字 + +## 内容结构 +1. **市场现状**(200-300字) + - 市场规模和增长率 + - 主要玩家和产品 + - 竞争格局分析 + +2. **关键趋势**(400-500字) + - 3-5 个关键趋势 + - 每个趋势配数据支撑 + - 分析驱动因素 + +3. **挑战与机遇**(300-400字) + - 行业面临的挑战 + - 潜在的发展机遇 + - 应对建议 + +4. **未来展望**(200-300字) + - 1-3 年发展预测 + - 关键里程碑 + - 投资建议 + +## 数据要求 +- 引用权威数据源(如 Gartner、IDC、艾瑞咨询) +- 提供具体数字和百分比 +- 使用图表辅助说明 + +## 语气要求 +- 专业客观,数据驱动 +- 避免情绪化表达 +- 保持中立立场 + +## 输出格式 +请输出 Markdown 格式的报告,包含: +- H1 标题 +- H2 章节标题 +- 数据表格和列表 +- 关键数据加粗 + +开始创作...`, +}; + +export default industryAnalysisTemplate; diff --git a/src/components/content-creator/templates/social-media/product-launch.ts b/src/components/content-creator/templates/social-media/product-launch.ts new file mode 100644 index 000000000..4f63b79c2 --- /dev/null +++ b/src/components/content-creator/templates/social-media/product-launch.ts @@ -0,0 +1,139 @@ +/** + * @file product-launch.ts + * @description 产品发布内容模板 + * @module components/content-creator/templates/social-media/product-launch + */ + +import type { ContentTemplate } from "./trending-topic"; + +/** + * 产品发布模板 + */ +export const productLaunchTemplate: ContentTemplate = { + id: "product-launch", + name: "产品发布", + description: "突出痛点和解决方案的产品发布内容", + + titleFormats: [ + "{产品名}:{核心功能} + {使用场景}", + "告别 {痛点},{解决方案}", + "支持 {数字} 大 {功能}的 {产品类型}", + ], + + titleExamples: [ + "ProxyCast:创作者的 AI Agent 平台", + "告别低效创作,一个工具搞定全流程", + "支持 9 大创作主题的 AI 内容平台", + ], + + contentStructure: { + sections: [ + { + name: "痛点", + prompt: "描述用户面临的问题和痛点", + length: "150-200字", + requirements: [ + "描述具体的使用场景", + "突出现有方案的不足", + "引发用户共鸣", + ], + }, + { + name: "功能亮点", + prompt: "介绍 3-5 个核心功能", + length: "300-400字", + requirements: [ + "每个功能配具体数据", + "突出差异化优势", + "使用简洁的语言", + ], + }, + { + name: "使用场景", + prompt: "展示具体的应用案例", + length: "200-300字", + requirements: [ + "提供 2-3 个典型场景", + "说明如何解决问题", + "展示实际效果", + ], + }, + { + name: "行动号召", + prompt: "引导用户下载/试用", + length: "100-150字", + requirements: [ + "明确的行动指引", + "提供优惠或福利", + "降低试用门槛", + ], + }, + ], + }, + + visualStyle: { + coverImage: { + style: "产品展示风格", + colors: ["#ffffff", "#f5f5f5", "#2196F3", "#4CAF50"], + elements: [ + "产品截图或界面", + "功能演示动图", + "核心数据可视化", + "行动号召按钮", + ], + layout: "产品为主,功能点清晰,视觉吸引", + }, + }, + + agentPrompt: `你是一个资深的产品经理,擅长撰写吸引用户的产品发布文案。 + +## 任务 +创作一篇关于「{{product}}」的产品发布内容,适合发布在{{platform}}平台。 + +## 标题要求 +- 格式参考:「ProxyCast:创作者的 AI Agent 平台」 +- 突出产品名和核心价值 +- 20-30 字 + +## 内容结构 +1. **痛点**(150-200字) + - 描述用户的具体问题 + - 现有方案的不足 + - 引发共鸣 + +2. **功能亮点**(300-400字) + - 3-5 个核心功能 + - 每个功能配数据 + - 突出差异化 + +示例: +✅ "支持 9 大创作主题,覆盖社媒、短视频、小说等场景" +✅ "项目化管理,历史版本和素材自动沉淀" +✅ "一键适配小红书、知乎等 6 大平台规范" + +3. **使用场景**(200-300字) + - 2-3 个典型场景 + - 如何解决问题 + - 实际效果展示 + +4. **行动号召**(100-150字) + - 明确的行动指引 + - 优惠或福利 + - 降低试用门槛 + +## 语气要求 +- 突出用户价值 +- 避免过度营销 +- 数据支撑观点 + +## 输出格式 +请输出 Markdown 格式的文案,包含: +- H1 标题 +- H2 章节标题 +- 功能点使用列表 +- 关键数据加粗 + +开始创作...`, +}; + +export default productLaunchTemplate; diff --git a/src/components/content-creator/templates/social-media/tech-sharing.ts b/src/components/content-creator/templates/social-media/tech-sharing.ts new file mode 100644 index 000000000..8415c3253 --- /dev/null +++ b/src/components/content-creator/templates/social-media/tech-sharing.ts @@ -0,0 +1,154 @@ +/** + * @file tech-sharing.ts + * @description 技术分享内容模板 + * @module components/content-creator/templates/social-media/tech-sharing + */ + +import type { ContentTemplate } from "./trending-topic"; + +/** + * 技术分享模板 + */ +export const techSharingTemplate: ContentTemplate = { + id: "tech-sharing", + name: "技术分享", + description: "技术深度和实用性并重的技术内容", + + titleFormats: [ + "{技术点} + {核心价值} + {数据/结果}", + "从 0 到 1 {实现目标}:{关键要点}", + "{技术} {版本} 新特性:{核心亮点}", + ], + + titleExamples: [ + "React 18 并发渲染:性能提升 3 倍的秘密", + "从 0 到 1 搭建 AI Agent 平台:我踩过的 5 个坑", + "TypeScript 5.0 新特性:让代码更安全的 3 个技巧", + ], + + contentStructure: { + sections: [ + { + name: "问题背景", + prompt: "说明为什么需要这个技术/方案", + length: "150-200字", + requirements: [ + "描述遇到的问题或痛点", + "说明现有方案的不足", + "引出新技术/方案的必要性", + ], + }, + { + name: "核心概念", + prompt: "解释关键术语和核心原理", + length: "200-300字", + requirements: [ + "用简单语言解释复杂概念", + "提供类比和示例", + "突出核心优势", + ], + }, + { + name: "解决方案", + prompt: "详细说明具体实现方法", + length: "400-500字", + requirements: [ + "提供完整的实现步骤", + "包含可运行的代码示例", + "说明关键配置和参数", + ], + }, + { + name: "最佳实践", + prompt: "分享注意事项和优化建议", + length: "200-300字", + requirements: [ + "列举常见坑点", + "提供优化建议", + "分享实战经验", + ], + }, + { + name: "总结", + prompt: "回顾核心要点", + length: "100-150字", + requirements: [ + "总结 3-5 个关键要点", + "提供进一步学习资源", + "鼓励读者实践", + ], + }, + ], + }, + + visualStyle: { + coverImage: { + style: "代码编辑器风格", + colors: ["#1e1e1e", "#282c34", "#1e80ff", "#98c379"], + elements: [ + "代码片段截图", + "技术架构图", + "性能对比图表", + "技术标签和关键词", + ], + layout: "代码为主,标题简洁,技术感强", + }, + }, + + agentPrompt: `你是一个资深的技术专家,擅长撰写技术深度和实用性并重的技术文章。 + +## 任务 +创作一篇关于「{{topic}}」的技术分享文章,适合发布在{{platform}}平台。 + +## 标题要求 +- 格式参考:「React 18 并发渲染:性能提升 3 倍的秘密」 +- 突出技术点和实际价值 +- 20-30 字 + +## 内容结构 +1. **问题背景**(150-200字) + - 描述遇到的问题 + - 现有方案的不足 + - 新技术的必要性 + +2. **核心概念**(200-300字) + - 解释关键术语 + - 提供类比和示例 + - 突出核心优势 + +3. **解决方案**(400-500字) + - 完整的实现步骤 + - 可运行的代码示例 + - 关键配置说明 + +4. **最佳实践**(200-300字) + - 常见坑点 + - 优化建议 + - 实战经验 + +5. **总结**(100-150字) + - 核心要点回顾 + - 进一步学习资源 + +## 代码要求 +- 提供完整可运行的代码 +- 添加必要的注释 +- 使用 Markdown 代码块 +- 标注语言类型 + +## 语气要求 +- 技术专业但易懂 +- 避免过度炫技 +- 注重实用性 + +## 输出格式 +请输出 Markdown 格式的文章,包含: +- H1 标题 +- H2 章节标题 +- 代码块(带语言标注) +- 关键术语加粗 + +开始创作...`, +}; + +export default techSharingTemplate; diff --git a/src/components/content-creator/templates/social-media/trending-topic.ts b/src/components/content-creator/templates/social-media/trending-topic.ts new file mode 100644 index 000000000..7577aa058 --- /dev/null +++ b/src/components/content-creator/templates/social-media/trending-topic.ts @@ -0,0 +1,156 @@ +/** + * @file trending-topic.ts + * @description 热点分析内容模板(如"Agent 炒作何时停?") + * @module components/content-creator/templates/social-media/trending-topic + */ + +export interface ContentTemplate { + id: string; + name: string; + description: string; + titleFormats: string[]; + titleExamples: string[]; + contentStructure: { + sections: Array<{ + name: string; + prompt: string; + length: string; + requirements: string[]; + }>; + }; + visualStyle: { + coverImage: { + style: string; + colors: string[]; + elements: string[]; + layout: string; + }; + }; + agentPrompt: string; +} + +/** + * 热点分析模板 + */ +export const trendingTopicTemplate: ContentTemplate = { + id: "trending-topic", + name: "热点分析", + description: "针对行业热点的深度分析内容", + + titleFormats: [ + "{技术/概念} 炒作何时停?{数字} 个关键信号", + "{年份} {行业} {现象}:{关键词1} {关键词2} {关键词3}", + "从 {起点} 到 {终点}:{行业} 的 {变化}", + ], + + titleExamples: [ + "Agent 炒作何时停?3 个关键信号", + "2026 AI Agent 行业:泡沫 落地 现状", + "从 ChatGPT 到 Agent:AI 应用的下一站", + ], + + contentStructure: { + sections: [ + { + name: "现象描述", + prompt: "描述当前的行业现象和热点话题,使用具体数据", + length: "150-200字", + requirements: [ + "引用具体数据(如市场规模、增长率)", + "提及关键事件或里程碑", + "使用专业术语但保持易懂", + ], + }, + { + name: "深度分析", + prompt: "分析现象背后的原因和逻辑", + length: "300-400字", + requirements: [ + "多角度分析(技术、市场、用户)", + "引用行业报告或专家观点", + "提供数据支撑", + ], + }, + { + name: "趋势预测", + prompt: "预测未来发展趋势", + length: "200-300字", + requirements: [ + "基于数据和逻辑推理", + "提供 2-3 个可能的发展方向", + "避免过度乐观或悲观", + ], + }, + { + name: "结论", + prompt: "总结核心观点", + length: "100-150字", + requirements: [ + "提炼 3-5 个核心要点", + "给出可操作的建议", + "呼应标题", + ], + }, + ], + }, + + visualStyle: { + coverImage: { + style: "科技感深色背景", + colors: ["#0a1929", "#1a237e", "#2196F3"], + elements: [ + "数字化人物剪影", + "AI 网络节点", + "科技光效和粒子", + "大标题文字(如 AGENT炒作何时停)", + ], + layout: "标题居中或左对齐,背景深蓝渐变", + }, + }, + + agentPrompt: `你是一个资深的科技行业分析师,擅长撰写深度热点分析文章。 + +## 任务 +创作一篇关于「{{topic}}」的热点分析内容,适合发布在{{platform}}平台。 + +## 标题要求 +- 格式参考:「Agent 炒作何时停?3 个关键信号」 +- 简洁有力,15-25 字 +- 包含关键词:{{keywords}} +- 突出核心观点或数据 + +## 内容结构 +1. **现象描述**(150-200字) + - 描述当前行业现象 + - 引用具体数据(市场规模、增长率等) + - 提及关键事件 + +2. **深度分析**(300-400字) + - 多角度分析(技术、市场、用户) + - 引用行业报告或专家观点 + - 提供数据支撑 + +3. **趋势预测**(200-300字) + - 基于数据和逻辑推理 + - 提供 2-3 个可能的发展方向 + +4. **结论**(100-150字) + - 提炼 3-5 个核心要点 + - 给出可操作的建议 + +## 语气要求 +- 专业但不失易懂 +- 使用数据和事实,避免情绪化 +- 保持客观中立,避免过度营销 + +## 输出格式 +请输出 Markdown 格式的文章,包含: +- H1 标题 +- H2 章节标题 +- 数据和要点使用列表 +- 关键术语使用加粗 + +开始创作...`, +}; + +export default trendingTopicTemplate; diff --git a/src/components/content-creator/templates/social-media/visual-content.ts b/src/components/content-creator/templates/social-media/visual-content.ts new file mode 100644 index 000000000..49413d51a --- /dev/null +++ b/src/components/content-creator/templates/social-media/visual-content.ts @@ -0,0 +1,139 @@ +/** + * @file visual-content.ts + * @description 图文排版内容模板(如"Agent 炒作 图文排版") + * @module components/content-creator/templates/social-media/visual-content + */ + +import type { ContentTemplate } from "./trending-topic"; + +/** + * 图文排版模板 + */ +export const visualContentTemplate: ContentTemplate = { + id: "visual-content", + name: "图文排版", + description: "视觉优先的图文内容", + + titleFormats: [ + "{主题} 一图看懂", + "{数字} 个 {主题} 的关键要点", + "{主题} 完全指南", + ], + + titleExamples: [ + "Agent 炒作 一图看懂", + "5 个 AI 创作工具的关键差异", + "ProxyCast 完全使用指南", + ], + + contentStructure: { + sections: [ + { + name: "标题", + prompt: "简洁有力的大标题", + length: "8-15字", + requirements: [ + "突出核心主题", + "使用大字号", + "视觉冲击力强", + ], + }, + { + name: "核心要点", + prompt: "3-5 个关键要点", + length: "每个 15-30字", + requirements: [ + "使用数字或图标", + "简洁明了", + "视觉层次清晰", + ], + }, + { + name: "视觉元素", + prompt: "配图和图标", + length: "适量", + requirements: [ + "与内容相关", + "风格统一", + "提升可读性", + ], + }, + { + name: "行动号召", + prompt: "互动引导", + length: "5-10字", + requirements: [ + "明确的行动指引", + "视觉突出", + "易于执行", + ], + }, + ], + }, + + visualStyle: { + coverImage: { + style: "图文并茂,视觉优先", + colors: ["#ffffff", "#f5f5f5", "#2196F3", "#FF5722"], + elements: [ + "大标题文字", + "图标和插图", + "数据可视化", + "色块分隔", + ], + layout: "标题 + 要点 + 配图,层次清晰", + }, + }, + + agentPrompt: `你是一个资深的视觉设计师,擅长创作视觉优先的图文内容。 + +## 任务 +创作一篇关于「{{topic}}」的图文排版内容,适合发布在{{platform}}平台。 + +## 标题要求 +- 格式参考:「Agent 炒作 一图看懂」 +- 简洁有力,8-15 字 +- 视觉冲击力强 + +## 内容结构 +1. **大标题**(8-15字) + - 突出核心主题 + - 使用大字号 + - 居中或左对齐 + +2. **核心要点**(3-5 个,每个 15-30字) + - 使用数字或图标 + - 简洁明了 + - 视觉层次清晰 + +示例: +✅ "① 支持 9 大创作主题" +✅ "② 项目化管理,素材自动沉淀" +✅ "③ 一键适配 6 大平台" + +3. **视觉元素** + - 配图和图标 + - 数据可视化 + - 色块分隔 + +4. **行动号召**(5-10字) + - 明确的行动指引 + - 视觉突出 + +## 设计要求 +- 视觉优先,文字为辅 +- 色彩搭配和谐 +- 层次清晰易读 +- 适合快速浏览 + +## 输出格式 +请输出 Markdown 格式的内容,包含: +- H1 大标题 +- 要点列表(使用数字或图标) +- 简短的说明文字 +- 行动号召 + +开始创作...`, +}; + +export default visualContentTemplate; diff --git a/src/components/content-creator/utils/__tests__/activityLogger.test.ts b/src/components/content-creator/utils/__tests__/activityLogger.test.ts new file mode 100644 index 000000000..6c93a1aae --- /dev/null +++ b/src/components/content-creator/utils/__tests__/activityLogger.test.ts @@ -0,0 +1,151 @@ +/** + * @file activityLogger.test.ts + * @description 活动日志系统测试 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ActivityLogger } from '../activityLogger'; + +describe('ActivityLogger', () => { + let logger: ActivityLogger; + + beforeEach(() => { + logger = new ActivityLogger(); + }); + + it('应该能够记录日志', () => { + const logId = logger.log({ + eventType: 'workflow_start', + status: 'success', + title: '测试工作流', + description: '这是一个测试', + }); + + expect(logId).toBeTruthy(); + const logs = logger.getLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].title).toBe('测试工作流'); + }); + + it('应该能够更新日志', () => { + const logId = logger.log({ + eventType: 'agent_call_start', + status: 'pending', + title: '调用Agent', + }); + + logger.updateLog(logId, { + status: 'success', + duration: 1000, + }); + + const logs = logger.getLogs(); + expect(logs[0].status).toBe('success'); + expect(logs[0].duration).toBe(1000); + }); + + it('应该能够订阅日志变化', () => { + let callCount = 0; + const unsubscribe = logger.subscribe(() => { + callCount++; + }); + + logger.log({ + eventType: 'step_start', + status: 'pending', + title: '步骤1', + }); + + expect(callCount).toBe(1); + + unsubscribe(); + + logger.log({ + eventType: 'step_complete', + status: 'success', + title: '步骤1完成', + }); + + expect(callCount).toBe(1); // 不应该再增加 + }); + + it('应该能够清空日志', () => { + logger.log({ + eventType: 'workflow_start', + status: 'success', + title: '工作流1', + }); + + logger.log({ + eventType: 'workflow_start', + status: 'success', + title: '工作流2', + }); + + expect(logger.getLogs()).toHaveLength(2); + + logger.clear(); + + expect(logger.getLogs()).toHaveLength(0); + }); + + it('应该按 workspaceId 和 sessionId 过滤日志', () => { + logger.log({ + eventType: 'chat_request_start', + status: 'pending', + title: '会话A请求', + workspaceId: 'workspace-a', + sessionId: 'session-a', + }); + + logger.log({ + eventType: 'chat_request_start', + status: 'pending', + title: '会话B请求', + workspaceId: 'workspace-a', + sessionId: 'session-b', + }); + + logger.log({ + eventType: 'chat_request_start', + status: 'pending', + title: '其他工作区请求', + workspaceId: 'workspace-b', + sessionId: 'session-c', + }); + + const sessionALogs = logger.getLogs({ + workspaceId: 'workspace-a', + sessionId: 'session-a', + }); + expect(sessionALogs).toHaveLength(1); + expect(sessionALogs[0].title).toBe('会话A请求'); + }); + + it('应该只清空指定作用域日志', () => { + logger.log({ + eventType: 'chat_request_start', + status: 'pending', + title: '会话A请求', + workspaceId: 'workspace-a', + sessionId: 'session-a', + }); + + logger.log({ + eventType: 'chat_request_start', + status: 'pending', + title: '会话B请求', + workspaceId: 'workspace-a', + sessionId: 'session-b', + }); + + logger.clear({ + workspaceId: 'workspace-a', + sessionId: 'session-a', + }); + + const logs = logger.getLogs(); + expect(logs).toHaveLength(1); + expect(logs[0].title).toBe('会话B请求'); + }); +}); diff --git a/src/components/content-creator/utils/activityLogger.ts b/src/components/content-creator/utils/activityLogger.ts new file mode 100644 index 000000000..7981a9f07 --- /dev/null +++ b/src/components/content-creator/utils/activityLogger.ts @@ -0,0 +1,182 @@ +/** + * @file activityLogger.ts + * @description 活动日志系统 - 记录工作流执行过程中的所有关键操作 + * @module components/content-creator/utils/activityLogger + */ + +/** + * 活动事件类型 + */ +export type ActivityEventType = + | 'workflow_start' // 工作流开始 + | 'workflow_complete' // 工作流完成 + | 'step_start' // 步骤开始 + | 'step_complete' // 步骤完成 + | 'step_skip' // 步骤跳过 + | 'step_error' // 步骤失败 + | 'agent_call_start' // Agent调用开始 + | 'agent_call_complete' // Agent调用完成 + | 'agent_call_error' // Agent调用失败 + | 'file_create' // 文件创建 + | 'file_update' // 文件更新 + | 'chat_request_start' // 对话请求开始 + | 'chat_request_complete' // 对话请求完成 + | 'chat_request_error' // 对话请求失败 + | 'tool_start' // 工具执行开始 + | 'tool_complete' // 工具执行完成 + | 'tool_error' // 工具执行失败 + | 'action_required' // 需要用户确认/输入 + | 'user_action'; // 用户操作 + +/** + * 日志作用域过滤器 + */ +export interface ActivityLogScope { + workspaceId?: string; + sessionId?: string | null; +} + +/** + * 活动日志条目 + */ +export interface ActivityLog { + id: string; + timestamp: number; + eventType: ActivityEventType; + status: 'pending' | 'success' | 'error'; + title: string; // 显示标题(如"执行需求分析Agent") + description?: string; // 详细描述 + duration?: number; // 耗时(毫秒) + metadata?: Record; // 额外数据 + error?: string; // 错误信息 + workspaceId?: string; // 项目工作区ID + sessionId?: string; // 会话ID + source?: 'aster-chat' | 'poster-workflow' | 'legacy-agent'; // 日志来源 + correlationId?: string; // 关联ID(如 tool_id/request_id) +} + +/** + * 日志监听器类型 + */ +type LogListener = (logs: ActivityLog[]) => void; + +/** + * 活动日志管理器 + * + * 负责记录、更新和管理所有活动日志。 + */ +export class ActivityLogger { + private logs: ActivityLog[] = []; + private listeners: Set = new Set(); + private idCounter = 0; + + /** + * 生成唯一ID + */ + private generateId(): string { + return `log_${Date.now()}_${++this.idCounter}`; + } + + /** + * 判断日志是否匹配作用域 + */ + private matchesScope(log: ActivityLog, scope?: ActivityLogScope): boolean { + if (!scope) { + return true; + } + + if (scope.workspaceId !== undefined && log.workspaceId !== scope.workspaceId) { + return false; + } + + if (scope.sessionId !== undefined) { + if (scope.sessionId === null) { + return !log.sessionId; + } + return log.sessionId === scope.sessionId; + } + + return true; + } + + /** + * 记录日志 + * + * @param event - 日志事件(不包含id和timestamp) + * @returns 日志ID,可用于后续更新 + */ + log(event: Omit): string { + const log: ActivityLog = { + id: this.generateId(), + timestamp: Date.now(), + ...event, + }; + this.logs.push(log); + this.notifyListeners(); + return log.id; + } + + /** + * 更新日志状态(用于异步操作) + * + * @param id - 日志ID + * @param updates - 要更新的字段 + */ + updateLog(id: string, updates: Partial): void { + const log = this.logs.find(l => l.id === id); + if (log) { + Object.assign(log, updates); + this.notifyListeners(); + } + } + + /** + * 订阅日志变化 + * + * @param listener - 监听器函数 + * @returns 取消订阅函数 + */ + subscribe(listener: LogListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * 获取所有日志 + * + * @returns 日志数组的副本 + */ + getLogs(scope?: ActivityLogScope): ActivityLog[] { + if (!scope) { + return [...this.logs]; + } + return this.logs.filter((log) => this.matchesScope(log, scope)); + } + + /** + * 清空日志 + */ + clear(scope?: ActivityLogScope): void { + if (!scope) { + this.logs = []; + this.notifyListeners(); + return; + } + + this.logs = this.logs.filter((log) => !this.matchesScope(log, scope)); + this.notifyListeners(); + } + + /** + * 通知所有监听器 + */ + private notifyListeners(): void { + const logs = this.getLogs(); + this.listeners.forEach(listener => listener(logs)); + } +} + +/** + * 全局单例 + */ +export const activityLogger = new ActivityLogger(); diff --git a/src/components/content-creator/workflows/poster/PosterWorkflowPanel.tsx b/src/components/content-creator/workflows/poster/PosterWorkflowPanel.tsx index b2a26c2dd..81a3199e0 100644 --- a/src/components/content-creator/workflows/poster/PosterWorkflowPanel.tsx +++ b/src/components/content-creator/workflows/poster/PosterWorkflowPanel.tsx @@ -37,9 +37,11 @@ import { RotateCcw, Play, AlertCircle, + FileText, } from "lucide-react"; import { cn } from "@/lib/utils"; import { usePosterWorkflow } from "@/hooks/usePosterWorkflow"; +import { ActivityLogList } from "@/components/content-creator/components/ActivityLog"; import type { WorkflowTemplate, WorkflowStep, @@ -382,6 +384,8 @@ export function PosterWorkflowPanel({ // 表单数据 const [formData, setFormData] = useState>({}); + // 活动日志面板展开状态 + const [showActivityLog, setShowActivityLog] = useState(false); // 处理表单变更 const handleFormChange = useCallback((key: string, value: unknown) => { @@ -490,56 +494,83 @@ export function PosterWorkflowPanel({
{progress}% + {/* 活动日志切换按钮 */} + - {/* 步骤导航 */} - - - + {/* 主内容区域 */} +
+ {/* 工作流内容 */} +
+ {/* 步骤导航 */} + + + - {/* 当前步骤内容 */} - {currentStep && ( - - )} + {/* 当前步骤内容 */} + {currentStep && ( + + )} - {/* 导航按钮 */} -
- - - + {/* 导航按钮 */} +
+ + + +
+
+ + {/* 活动日志面板 */} + {showActivityLog && ( +
+ + + +
+ )}
); diff --git a/src/components/content-creator/workflows/poster/social-media.ts b/src/components/content-creator/workflows/poster/social-media.ts index c90ae3259..5dad3347e 100644 --- a/src/components/content-creator/workflows/poster/social-media.ts +++ b/src/components/content-creator/workflows/poster/social-media.ts @@ -23,7 +23,16 @@ const socialMediaSteps: WorkflowStep[] = [ key: "platform", label: "发布平台", type: "select", - options: ["小红书", "微信公众号", "微博", "抖音", "B站", "Instagram"], + options: [ + "小红书", + "微信公众号", + "微博", + "抖音", + "B站", + "Instagram", + "知乎", + "掘金", + ], required: true, }, { @@ -37,6 +46,11 @@ const socialMediaSteps: WorkflowStep[] = [ "产品测评", "活动宣传", "热点借势", + "技术分享", + "行业洞察", + "产品发布", + "教程指南", + "案例研究", ], required: true, }, @@ -51,7 +65,16 @@ const socialMediaSteps: WorkflowStep[] = [ key: "tone", label: "内容调性", type: "select", - options: ["专业权威", "轻松有趣", "温馨治愈", "酷炫潮流", "简约高级"], + options: [ + "专业权威", + "轻松有趣", + "温馨治愈", + "酷炫潮流", + "简约高级", + "科技前沿", + "数据驱动", + "深度分析", + ], required: false, }, ], diff --git a/src/components/general-chat/chat/ChatPanel.tsx b/src/components/general-chat/chat/ChatPanel.tsx index d3e285917..3245bc5fe 100644 --- a/src/components/general-chat/chat/ChatPanel.tsx +++ b/src/components/general-chat/chat/ChatPanel.tsx @@ -8,6 +8,7 @@ import React, { useState, useMemo, useCallback, useEffect } from "react"; import { Settings, AlertTriangle, Loader2 } from "lucide-react"; +import { toast } from "sonner"; import { useGeneralChatStore } from "../store/useGeneralChatStore"; import { useProvider } from "../hooks/useProvider"; import type { CanvasState, ContentBlock } from "../types"; @@ -17,11 +18,26 @@ import { Inputbar } from "@/components/agent/chat/components/Inputbar"; import { CompactModelSelector } from "./CompactModelSelector"; import { WorkflowStatusPanel } from "../components/WorkflowStatusPanel"; import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { useProviderModels } from "@/hooks/useProviderModels"; import type { MessageImage } from "@/components/agent/chat/types"; import { createGeneralInputAdapter } from "@/components/input-kit"; import { skillsApi, type Skill } from "@/lib/api/skills"; import type { Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; +import { + loadChatToolPreferences, + saveChatToolPreferences, + type ChatToolPreferences, +} from "@/components/agent/chat/utils/chatToolPreferences"; +import { + isReasoningModel, + resolveBaseModelOnThinkingOff, + resolveThinkingModel, +} from "@/lib/model/thinkingModelResolver"; +import { + loadRememberedBaseModel, + saveRememberedBaseModel, +} from "@/lib/model/thinkingBaseModelMemory"; interface ChatPanelProps { /** 当前会话 ID */ @@ -114,6 +130,13 @@ export const ChatPanel: React.FC = ({ null, ); const [skills, setSkills] = useState([]); + const [chatToolPreferences, setChatToolPreferences] = + useState(() => loadChatToolPreferences()); + const thinkingVariantWarnedRef = React.useRef>(new Set()); + + useEffect(() => { + saveChatToolPreferences(chatToolPreferences); + }, [chatToolPreferences]); // 加载技能列表 useEffect(() => { @@ -162,6 +185,9 @@ export const ChatPanel: React.FC = ({ isLoading: providerSelectionLoading, error: providerSelectionError, } = useProvider(); + const { models: providerModels } = useProviderModels(selectedProvider, { + returnFullMetadata: true, + }); // 从 streaming 对象中解构状态 const { isStreaming, partialContent } = streaming; @@ -217,12 +243,61 @@ export const ChatPanel: React.FC = ({ const handleSend = useCallback( async ( images?: MessageImage[], - _webSearch?: boolean, - _thinking?: boolean, + webSearch?: boolean, + thinking?: boolean, textOverride?: string, ) => { if (!sessionId || (!input.trim() && (!images || images.length === 0))) return; + const effectiveWebSearch = webSearch ?? chatToolPreferences.webSearch; + const effectiveThinking = thinking ?? chatToolPreferences.thinking; + const currentModelId = selectedModelId || ""; + const providerKey = selectedProvider?.key || ""; + + if (providerKey && currentModelId) { + const memoryParams = { + scope: "general" as const, + workspaceId: "general-chat", + sessionId, + providerKey, + }; + const rememberedBaseModel = loadRememberedBaseModel(memoryParams); + + if (effectiveThinking) { + if (!isReasoningModel(currentModelId, providerModels)) { + saveRememberedBaseModel({ + ...memoryParams, + modelId: currentModelId, + }); + } + + const thinkingResult = resolveThinkingModel({ + currentModelId, + models: providerModels, + }); + if (thinkingResult.switched) { + selectModel(thinkingResult.targetModelId); + } else if ( + thinkingResult.reason === "no_variant" && + providerModels.length > 0 + ) { + const warnKey = `${providerKey}:${currentModelId}`; + if (!thinkingVariantWarnedRef.current.has(warnKey)) { + thinkingVariantWarnedRef.current.add(warnKey); + toast.warning("当前 Provider 没有可用的 Thinking 模型,已保持原模型"); + } + } + } else { + const restoreResult = resolveBaseModelOnThinkingOff({ + currentModelId, + models: providerModels, + rememberedBaseModel, + }); + if (restoreResult.switched) { + selectModel(restoreResult.targetModelId); + } + } + } const content = (textOverride || input).trim(); setInput(""); @@ -247,9 +322,19 @@ export const ChatPanel: React.FC = ({ }); } - await sendMessage(content || "请分析这张图片", files); + await sendMessage(content || "请分析这张图片", files, effectiveWebSearch); }, - [sessionId, input, sendMessage], + [ + chatToolPreferences.thinking, + chatToolPreferences.webSearch, + input, + providerModels, + selectedModelId, + selectedProvider?.key, + selectModel, + sendMessage, + sessionId, + ], ); // 处理停止生成 @@ -412,6 +497,8 @@ export const ChatPanel: React.FC = ({ isLoading={inputAdapter.state.isSending} disabled={inputAdapter.state.disabled} skills={skills} + toolStates={chatToolPreferences} + onToolStatesChange={setChatToolPreferences} /> diff --git a/src/components/general-chat/store/useGeneralChatStore.ts b/src/components/general-chat/store/useGeneralChatStore.ts index 27f70625a..c8b838e52 100644 --- a/src/components/general-chat/store/useGeneralChatStore.ts +++ b/src/components/general-chat/store/useGeneralChatStore.ts @@ -150,7 +150,11 @@ export interface GeneralChatState { // ========== 消息操作 ========== /** 发送消息 */ - sendMessage: (content: string, images?: File[]) => Promise; + sendMessage: ( + content: string, + images?: File[], + webSearch?: boolean, + ) => Promise; /** 停止生成 */ stopGeneration: () => void; /** 追加流式内容 */ @@ -424,7 +428,11 @@ export const useGeneralChatStore = create()( // ========== 消息操作实现 ========== - sendMessage: async (content: string, images?: File[]) => { + sendMessage: async ( + content: string, + images?: File[], + webSearch?: boolean, + ) => { const { currentSessionId, messages } = get(); // 验证:空白消息且无图片不发送 @@ -570,6 +578,7 @@ export const useGeneralChatStore = create()( toolParameters: { message: content.trim() || "请分析这张图片", hasImages: imageData ? "true" : "false", + webSearch: webSearch ? "true" : "false", }, messageCount, }; @@ -605,6 +614,7 @@ export const useGeneralChatStore = create()( message: messageToSend, eventName: `general-chat-stream-${currentSessionId}`, images: imageData, + web_search: webSearch, }); // 如果启用了工作流,执行 Action 阶段 diff --git a/src/components/settings-v2/general/chat-appearance/index.tsx b/src/components/settings-v2/general/chat-appearance/index.tsx index 95958cc33..81a24670d 100644 --- a/src/components/settings-v2/general/chat-appearance/index.tsx +++ b/src/components/settings-v2/general/chat-appearance/index.tsx @@ -36,16 +36,54 @@ const ALL_NAV_ITEMS = [ { id: "video", label: "视频" }, { id: "image-gen", label: "插图" }, { id: "batch", label: "批量任务" }, + { id: "terminal", label: "终端" }, { id: "plugins", label: "插件中心" }, + { id: "tools", label: "工具箱" }, ] as const; const DEFAULT_ENABLED_NAV_ITEMS = [ "home-general", "video", "image-gen", - "plugins", ]; +const ALL_NAV_ITEM_ID_SET = new Set( + ALL_NAV_ITEMS.map((item) => item.id), +); + +const LEGACY_DEFAULT_NAV_ITEM_SETS: string[][] = [ + ["home-general", "video", "image-gen", "plugins"], + ["home-general", "video", "image-gen", "terminal", "plugins"], +]; + +const normalizeEnabledNavItems = (items: string[]): string[] => { + const unique = Array.from(new Set(items)); + return unique.filter((item) => ALL_NAV_ITEM_ID_SET.has(item)); +}; + +const hasSameMembers = (left: string[], right: string[]): boolean => { + if (left.length !== right.length) return false; + const rightSet = new Set(right); + return left.every((item) => rightSet.has(item)); +}; + +const isLegacyDefaultEnabledItems = (items: string[]): boolean => { + return LEGACY_DEFAULT_NAV_ITEM_SETS.some((legacyItems) => + hasSameMembers(items, legacyItems), + ); +}; + +const resolveEnabledNavItems = (savedItems?: string[]): string[] => { + if (!savedItems || savedItems.length === 0) { + return [...DEFAULT_ENABLED_NAV_ITEMS]; + } + const normalized = normalizeEnabledNavItems(savedItems); + if (isLegacyDefaultEnabledItems(normalized)) { + return [...DEFAULT_ENABLED_NAV_ITEMS]; + } + return normalized; +}; + const Container = styled.div` display: flex; flex-direction: column; @@ -173,9 +211,7 @@ export function ChatAppearanceSettings() { setEnabledThemes( c.content_creator?.enabled_themes || DEFAULT_ENABLED_THEMES, ); - setEnabledNavItems( - c.navigation?.enabled_items || DEFAULT_ENABLED_NAV_ITEMS, - ); + setEnabledNavItems(resolveEnabledNavItems(c.navigation?.enabled_items)); setAppendSelectedTextToRecommendation( c.chat_appearance?.append_selected_text_to_recommendation ?? true, ); diff --git a/src/components/workspace/WorkbenchPage.test.tsx b/src/components/workspace/WorkbenchPage.test.tsx index 0dddcbed7..e1bc67636 100644 --- a/src/components/workspace/WorkbenchPage.test.tsx +++ b/src/components/workspace/WorkbenchPage.test.tsx @@ -4,7 +4,6 @@ import { useWorkbenchStore } from "@/stores/useWorkbenchStore"; import { clickButtonByText, clickButtonByTitle, - clickElement, cleanupMountedRoots, findAsideByClassFragment, findButtonByText, @@ -331,15 +330,6 @@ describe("WorkbenchPage 左侧栏模式行为", () => { expect(container.textContent).toContain("返回创作视图"); expect(findInputByPlaceholder(container, "搜索文稿...")).toBeNull(); - const openViewActionsButton = findButtonByTitle(container, "展开视图动作"); - expect(openViewActionsButton).not.toBeNull(); - - clickElement(openViewActionsButton); - await flushEffects(); - - expect(container.textContent).toContain("视图动作"); - expect(container.textContent).toContain("前往设置视图"); - const backToCreateButton = findButtonByText(container, "返回创作视图", { exact: true, }); diff --git a/src/components/workspace/WorkbenchPage.tsx b/src/components/workspace/WorkbenchPage.tsx index 00360a5ff..d9a480d7b 100644 --- a/src/components/workspace/WorkbenchPage.tsx +++ b/src/components/workspace/WorkbenchPage.tsx @@ -25,7 +25,6 @@ import { import { CREATION_MODE_OPTIONS, MIN_CREATION_INTENT_LENGTH, - getWorkflowStepStatusLabel, useWorkbenchController, } from "@/components/workspace/hooks/useWorkbenchController"; @@ -54,10 +53,9 @@ export function WorkbenchPage({ setActiveRightDrawer, showChatPanel, setShowChatPanel, - workflowProgress, setWorkflowProgress, - showWorkflowRail, - setShowWorkflowRail, + currentChatSessionId, + setCurrentChatSessionId, workspaceMode, activeWorkspaceView, setCreateProjectDialogOpen, @@ -99,9 +97,7 @@ export function WorkbenchPage({ isCreateWorkspaceView, shouldRenderWorkspaceRightRail, activeWorkspaceViewLabel, - hasWorkflowWorkspaceView, currentContentTitle, - nonCreateQuickActions, ActivePanelRenderer, PrimaryWorkspaceRenderer, handleEnterWorkspace, @@ -118,7 +114,6 @@ export function WorkbenchPage({ handleQuickCreateNovelEntry, handleOpenProjectWriting, consumePendingInitialPrompt, - handleQuickSaveCurrent, handleBackHome, handleOpenCreateHome, handleBackToProjectManagement, @@ -208,6 +203,7 @@ export function WorkbenchPage({ contentCreationModes={contentCreationModes} showChatPanel={showChatPanel} onWorkflowProgressChange={setWorkflowProgress} + onChatSessionChange={setCurrentChatSessionId} activePanelRenderer={ActivePanelRenderer} /> } @@ -218,27 +214,14 @@ export function WorkbenchPage({ activeRightDrawer={activeRightDrawer} showChatPanel={showChatPanel} onToggleChatPanel={() => setShowChatPanel((visible) => !visible)} - onToggleToolsDrawer={() => + onToggleActivityLogDrawer={() => setActiveRightDrawer((previous) => - previous === "tools" ? null : "tools", + previous === "activity-log" ? null : "activity-log", ) } - onCloseToolsDrawer={() => setActiveRightDrawer(null)} - workflowProgress={workflowProgress} - showWorkflowRail={showWorkflowRail} - onToggleWorkflowRail={() => setShowWorkflowRail((previous) => !previous)} - onQuickSaveCurrent={() => { - void handleQuickSaveCurrent(); - }} - selectedContentId={selectedContentId} - onOpenWorkflowView={handleOpenWorkflowView} - selectedProjectId={selectedProjectId} - hasWorkflowWorkspaceView={hasWorkflowWorkspaceView} - activeWorkspaceViewLabel={activeWorkspaceViewLabel} - currentContentTitle={currentContentTitle} - nonCreateQuickActions={nonCreateQuickActions} onBackToCreateView={() => handleSwitchWorkspaceView("create")} - getWorkflowStepStatusLabel={getWorkflowStepStatusLabel} + activityLogWorkspaceId={selectedProjectId} + activityLogSessionId={currentChatSessionId} /> } /> diff --git a/src/components/workspace/hooks/useWorkbenchController.ts b/src/components/workspace/hooks/useWorkbenchController.ts index 21e8d0aed..d0b6cdd8c 100644 --- a/src/components/workspace/hooks/useWorkbenchController.ts +++ b/src/components/workspace/hooks/useWorkbenchController.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { useWorkbenchStore } from "@/stores/useWorkbenchStore"; import { @@ -251,6 +251,9 @@ export function useWorkbenchController({ isAgentChatWorkspace, hasPrimaryWorkspaceRenderer: Boolean(PrimaryWorkspaceRenderer), }); + const [currentChatSessionId, setCurrentChatSessionId] = useState( + null, + ); const handleEnterWorkspace = useCallback( ( @@ -260,6 +263,7 @@ export function useWorkbenchController({ }, ) => { setSelectedContentId(contentId); + setCurrentChatSessionId(null); setWorkspaceMode("workspace"); setActiveWorkspaceView("create"); setShowChatPanel(options?.showChatPanel ?? true); @@ -269,6 +273,7 @@ export function useWorkbenchController({ [ setActiveRightDrawer, setActiveWorkspaceView, + setCurrentChatSessionId, setLeftSidebarCollapsed, setSelectedContentId, setShowChatPanel, @@ -280,6 +285,7 @@ export function useWorkbenchController({ (projectId: string) => { setSelectedProjectId(projectId); setContentQuery(""); + setCurrentChatSessionId(null); setWorkspaceMode("workspace"); setActiveWorkspaceView(themeModule.navigation.defaultView); setActiveRightDrawer(null); @@ -289,6 +295,7 @@ export function useWorkbenchController({ setActiveRightDrawer, setActiveWorkspaceView, setContentQuery, + setCurrentChatSessionId, setLeftSidebarCollapsed, setSelectedProjectId, setWorkspaceMode, @@ -420,6 +427,7 @@ export function useWorkbenchController({ setWorkspaceMode("workspace"); setActiveWorkspaceView("create"); setSelectedContentId(null); + setCurrentChatSessionId(null); setShowChatPanel(true); setActiveRightDrawer(null); setShowWorkflowRail(false); @@ -428,6 +436,7 @@ export function useWorkbenchController({ setActiveWorkspaceView, setSelectedContentId, setShowChatPanel, + setCurrentChatSessionId, setShowWorkflowRail, setWorkspaceMode, ]); @@ -470,6 +479,8 @@ export function useWorkbenchController({ setWorkflowProgress, showWorkflowRail, setShowWorkflowRail, + currentChatSessionId, + setCurrentChatSessionId, workspaceMode, activeWorkspaceView, diff --git a/src/components/workspace/hooks/useWorkbenchNavigation.test.tsx b/src/components/workspace/hooks/useWorkbenchNavigation.test.tsx index 164357c48..9982aca31 100644 --- a/src/components/workspace/hooks/useWorkbenchNavigation.test.tsx +++ b/src/components/workspace/hooks/useWorkbenchNavigation.test.tsx @@ -53,12 +53,17 @@ function NavigationHarness(props: NavigationHarnessProps) { onClick={() => navigation.handleSwitchWorkspaceView("create")} /> - - - ) : ( - <> -

视图动作

-

当前:{activeWorkspaceViewLabel}

- {currentContentTitle && ( -

- 当前文稿:{currentContentTitle} -

- )} -
- {nonCreateQuickActions.length === 0 ? ( -

当前暂无可用动作

- ) : ( - nonCreateQuickActions.map((action) => { - const ActionIcon = action.icon; - return ( - - ); - }) - )} -
- - )} + {activeRightDrawer === "activity-log" && isCreateWorkspaceView && ( + )}