From 2e4168313434b958225cad8db71211de9bca9c3d Mon Sep 17 00:00:00 2001 From: coso Date: Thu, 22 Jan 2026 12:37:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E8=B0=83=E7=94=A8=20UI=20=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=20macOS=20=E5=B4=A9=E6=BA=83=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复了在 tokio 后台线程中调用 macOS UI 操作导致的崩溃: - 使用 run_on_main_thread() 将更新窗口创建操作调度到主线程 - 解决 "Must only be used from the main thread" 断言失败 - 修复测试代码中过时的 iflow 字段引用 - 更新版本号到 0.48.0 --- package.json | 2 +- .../core/src/models/provider_pool_model.rs | 4 +- .../crates/core/src/models/provider_type.rs | 15 +- .../resources/models/providers/anthropic.json | 224 ---------- src-tauri/src/agent/native_agent.rs | 96 +++-- src-tauri/src/agent/protocols/anthropic.rs | 11 - src-tauri/src/agent/tool_loop.rs | 2 +- src-tauri/src/agent/types.rs | 35 ++ src-tauri/src/app/commands/api_test.rs | 2 + src-tauri/src/commands/native_agent_cmd.rs | 61 ++- src-tauri/src/commands/update_cmd.rs | 16 +- src-tauri/src/config/tests.rs | 110 +---- src-tauri/src/converter/protocol_selector.rs | 4 +- src-tauri/src/credential/sync.rs | 6 + src-tauri/src/models/provider_pool_model.rs | 14 +- src-tauri/src/providers/claude_custom.rs | 7 +- src-tauri/src/server/handlers/api.rs | 69 ++- src-tauri/src/server/handlers/management.rs | 19 + src-tauri/src/server/mod.rs | 356 --------------- .../src/services/api_key_provider_service.rs | 49 ++- .../src/services/provider_pool_service.rs | 17 +- src-tauri/tauri.conf.json | 2 +- .../agent/chat/components/DecisionPanel.tsx | 6 +- .../Inputbar/components/InputbarTools.tsx | 4 +- .../agent/chat/components/MessageList.tsx | 12 +- .../chat/components/StreamingRenderer.tsx | 23 +- .../agent/chat/hooks/useAgentChat.ts | 30 +- src/components/agent/chat/index.tsx | 69 ++- src/components/agent/chat/types.ts | 6 +- src/components/api-server/ApiServerPage.tsx | 1 + .../general-chat/GeneralChatPage.tsx | 61 ++- .../general-chat/canvas/CanvasPanel.tsx | 86 +++- .../general-chat/canvas/CodePreview.tsx | 4 +- .../general-chat/canvas/MarkdownPreview.tsx | 56 ++- src/components/general-chat/canvas/index.ts | 6 +- .../general-chat/chat/AssistantMessage.tsx | 116 +++-- .../general-chat/chat/ChatPanel.tsx | 189 ++++---- .../general-chat/chat/CodeBlock.tsx | 94 ++-- .../chat/CompactModelSelector.tsx | 71 +-- .../general-chat/chat/ErrorBoundary.tsx | 33 +- .../general-chat/chat/ErrorDisplay.tsx | 105 +++-- .../general-chat/chat/ImageMessage.tsx | 58 +-- .../general-chat/chat/MessageItem.tsx | 19 +- .../general-chat/chat/MessageList.tsx | 64 +-- .../general-chat/chat/MessageRenderer.test.ts | 131 +++--- .../general-chat/chat/UserMessage.tsx | 43 +- src/components/general-chat/chat/index.ts | 18 +- .../components/WorkflowStatusPanel.tsx | 116 +++-- src/components/general-chat/hooks/index.ts | 10 +- src/components/general-chat/hooks/useChat.ts | 117 +++-- .../general-chat/hooks/useProvider.ts | 46 +- .../general-chat/hooks/useSession.ts | 159 ++++--- .../general-chat/hooks/useStreaming.ts | 87 ++-- .../hooks/useWorkflowIntegration.ts | 402 ++++++++--------- src/components/general-chat/index.ts | 21 +- src/components/general-chat/store/index.ts | 4 +- .../general-chat/store/useGeneralChatStore.ts | 404 +++++++++++------- src/components/general-chat/types.ts | 134 +++--- src/components/onboarding/constants.ts | 18 - src/components/plugins/PluginUIRenderer.tsx | 2 - src/components/tools/ToolsPage.tsx | 60 ++- .../image-analysis/ImageAnalysisTool.tsx | 83 ++-- src/components/tools/image-analysis/index.ts | 1 + src/hooks/useThreeStageWorkflow.ts | 356 ++++++++------- src/index.css | 53 +-- src/lib/api/agent.ts | 30 +- src/lib/api/contextMemory.ts | 58 +-- src/lib/api/toolHooks.ts | 127 +++--- src/lib/plugin-loader/index.ts | 2 - src/lib/workflow/threeStageWorkflow.ts | 133 +++--- src/types/page.ts | 1 + 71 files changed, 2542 insertions(+), 2308 deletions(-) create mode 100644 src/components/tools/image-analysis/index.ts diff --git a/package.json b/package.json index fb53fd30e..556b9673e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.47.4", + "version": "0.48.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/crates/core/src/models/provider_pool_model.rs b/src-tauri/crates/core/src/models/provider_pool_model.rs index 1c2ad32e6..cdcb73896 100644 --- a/src-tauri/crates/core/src/models/provider_pool_model.rs +++ b/src-tauri/crates/core/src/models/provider_pool_model.rs @@ -498,11 +498,13 @@ pub fn get_default_check_model(provider_type: PoolProviderType) -> &'static str PoolProviderType::OpenAI => "gpt-3.5-turbo", // 使用 claude-sonnet-4-5-20250929,兼容更多代理服务器 PoolProviderType::Claude => "claude-sonnet-4-5-20250929", + PoolProviderType::ClaudeOAuth => "claude-sonnet-4-5-20250929", + // Anthropic 兼容格式使用相同的健康检查模型 + PoolProviderType::AnthropicCompatible => "claude-sonnet-4-5-20250929", PoolProviderType::Antigravity => "gemini-3-pro-preview", PoolProviderType::Vertex => "gemini-2.0-flash", PoolProviderType::GeminiApiKey => "gemini-2.5-flash", PoolProviderType::Codex => "gpt-4o-mini", - PoolProviderType::ClaudeOAuth => "claude-sonnet-4-5-20250929", // API Key Provider 类型 PoolProviderType::Anthropic => "claude-sonnet-4-5-20250929", PoolProviderType::AzureOpenai => "gpt-4o-mini", diff --git a/src-tauri/crates/core/src/models/provider_type.rs b/src-tauri/crates/core/src/models/provider_type.rs index 085cf0894..55254c482 100644 --- a/src-tauri/crates/core/src/models/provider_type.rs +++ b/src-tauri/crates/core/src/models/provider_type.rs @@ -13,13 +13,16 @@ pub enum ProviderType { #[serde(rename = "openai")] OpenAI, Claude, + #[serde(rename = "claude_oauth")] + ClaudeOAuth, + /// Anthropic 兼容格式(支持 system 数组格式等变体) + #[serde(rename = "anthropic_compatible")] + AnthropicCompatible, Antigravity, Vertex, #[serde(rename = "gemini_api_key")] GeminiApiKey, Codex, - #[serde(rename = "claude_oauth")] - ClaudeOAuth, // API Key Provider 类型 Anthropic, #[serde(rename = "azure_openai")] @@ -36,11 +39,12 @@ impl std::fmt::Display for ProviderType { ProviderType::Gemini => write!(f, "gemini"), ProviderType::OpenAI => write!(f, "openai"), ProviderType::Claude => write!(f, "claude"), + ProviderType::ClaudeOAuth => write!(f, "claude_oauth"), + ProviderType::AnthropicCompatible => write!(f, "anthropic_compatible"), ProviderType::Antigravity => write!(f, "antigravity"), ProviderType::Vertex => write!(f, "vertex"), ProviderType::GeminiApiKey => write!(f, "gemini_api_key"), ProviderType::Codex => write!(f, "codex"), - ProviderType::ClaudeOAuth => write!(f, "claude_oauth"), ProviderType::Anthropic => write!(f, "anthropic"), ProviderType::AzureOpenai => write!(f, "azure_openai"), ProviderType::AwsBedrock => write!(f, "aws_bedrock"), @@ -58,11 +62,14 @@ impl std::str::FromStr for ProviderType { "gemini" => Ok(ProviderType::Gemini), "openai" => Ok(ProviderType::OpenAI), "claude" => Ok(ProviderType::Claude), + "claude_oauth" => Ok(ProviderType::ClaudeOAuth), + "anthropic_compatible" | "anthropic-compatible" => { + Ok(ProviderType::AnthropicCompatible) + } "antigravity" => Ok(ProviderType::Antigravity), "vertex" => Ok(ProviderType::Vertex), "gemini_api_key" => Ok(ProviderType::GeminiApiKey), "codex" => Ok(ProviderType::Codex), - "claude_oauth" => Ok(ProviderType::ClaudeOAuth), "anthropic" => Ok(ProviderType::Anthropic), "azure_openai" | "azure-openai" => Ok(ProviderType::AzureOpenai), "aws_bedrock" | "aws-bedrock" => Ok(ProviderType::AwsBedrock), diff --git a/src-tauri/resources/models/providers/anthropic.json b/src-tauri/resources/models/providers/anthropic.json index 629ddf323..8ee3019dd 100644 --- a/src-tauri/resources/models/providers/anthropic.json +++ b/src-tauri/resources/models/providers/anthropic.json @@ -5,34 +5,6 @@ "name": "Anthropic" }, "models": [ - { - "id": "claude-opus-4-5", - "name": "Claude Opus 4.5 (latest)", - "family": "claude-opus", - "tier": "max", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 15, - "output": 75, - "cache_read": 1.5, - "cache_write": 18.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 32000 - }, - "status": "active", - "release_date": "2025-02-24", - "is_latest": true - }, { "id": "claude-opus-4-5-20251101", "name": "Claude Opus 4.5", @@ -61,34 +33,6 @@ "release_date": "2025-02-24", "is_latest": false }, - { - "id": "claude-sonnet-4-5", - "name": "Claude Sonnet 4.5 (latest)", - "family": "claude-sonnet", - "tier": "pro", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 64000 - }, - "status": "active", - "release_date": "2025-09-29", - "is_latest": true - }, { "id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet 4.5", @@ -172,174 +116,6 @@ "status": "active", "release_date": "2025-10-01", "is_latest": false - }, - { - "id": "claude-opus-4-1", - "name": "Claude Opus 4.1 (latest)", - "family": "claude-opus", - "tier": "max", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 15, - "output": 75, - "cache_read": 1.5, - "cache_write": 18.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 32000 - }, - "status": "active", - "release_date": "2025-08-05", - "is_latest": false - }, - { - "id": "claude-opus-4-1-20250805", - "name": "Claude Opus 4.1", - "family": "claude-opus", - "tier": "max", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 15, - "output": 75, - "cache_read": 1.5, - "cache_write": 18.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 32000 - }, - "status": "active", - "release_date": "2025-08-05", - "is_latest": false - }, - { - "id": "claude-opus-4-0", - "name": "Claude Opus 4 (latest)", - "family": "claude-opus", - "tier": "max", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 15, - "output": 75, - "cache_read": 1.5, - "cache_write": 18.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 32000 - }, - "status": "active", - "release_date": "2025-05-14", - "is_latest": false - }, - { - "id": "claude-opus-4-20250514", - "name": "Claude Opus 4", - "family": "claude-opus", - "tier": "max", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 15, - "output": 75, - "cache_read": 1.5, - "cache_write": 18.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 32000 - }, - "status": "active", - "release_date": "2025-05-14", - "is_latest": false - }, - { - "id": "claude-sonnet-4-0", - "name": "Claude Sonnet 4 (latest)", - "family": "claude-sonnet", - "tier": "pro", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 64000 - }, - "status": "active", - "release_date": "2025-05-14", - "is_latest": false - }, - { - "id": "claude-sonnet-4-20250514", - "name": "Claude Sonnet 4", - "family": "claude-sonnet", - "tier": "pro", - "capabilities": { - "vision": true, - "tools": true, - "streaming": true, - "json_mode": true, - "function_calling": true, - "reasoning": true - }, - "pricing": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75, - "currency": "USD" - }, - "limits": { - "context": 200000, - "max_output": 64000 - }, - "status": "active", - "release_date": "2025-05-14", - "is_latest": false } ], "updated_at": "2026-01-12T00:00:00.000Z", diff --git a/src-tauri/src/agent/native_agent.rs b/src-tauri/src/agent/native_agent.rs index 8637b1346..a3496966c 100644 --- a/src-tauri/src/agent/native_agent.rs +++ b/src-tauri/src/agent/native_agent.rs @@ -100,33 +100,9 @@ impl NativeAgent { /// 获取 API 请求的有效 base_url /// - /// 对于自定义 Provider(如 moonshot),返回 `{base_url}/api/provider/{provider_id}` - /// 对于内置 Provider,返回原始 base_url + /// 所有 Provider 都使用标准路由,不再使用 Amp CLI 路由前缀 fn get_effective_base_url(&self) -> String { - if let Some(ref pid) = self.provider_id { - // 检查 provider_id 是否是已知的内置类型 - let is_builtin = matches!( - pid.to_lowercase().as_str(), - "openai" - | "claude" - | "anthropic" - | "gemini" - | "kiro" - | "qwen" - | "codex" - | "antigravity" - | "iflow" - ); - if is_builtin { - self.base_url.clone() - } else { - // 自定义 Provider,使用 provider 特定路由 - // 例如:http://127.0.0.1:8999/api/provider/moonshot - format!("{}/api/provider/{}", self.base_url, pid) - } - } else { - self.base_url.clone() - } + self.base_url.clone() } /// 检查是否是自定义 Provider @@ -879,8 +855,56 @@ impl NativeAgentState { }) } + /// 创建临时 Agent 用于异步操作(支持根据模型名称动态选择协议) + fn create_temp_agent_with_model(&self, model: &str) -> Result { + let guard = self.agent.read(); + let agent = guard.as_ref().ok_or_else(|| "Agent 未初始化".to_string())?; + + let client = Client::builder() + .timeout(Duration::from_secs(300)) + .connect_timeout(Duration::from_secs(30)) + .no_proxy() + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?; + + // 如果有自定义 provider_id,尝试从模型名称推断协议类型 + let provider_type = if let Some(provider_id) = &agent.provider_id { + ProviderType::from_provider_and_model(provider_id, model) + } else { + agent.provider_type + }; + + let protocol = create_protocol(provider_type); + + info!( + "[NativeAgent] 创建临时 Agent: model={}, provider_type={:?}, provider_id={:?}, protocol_endpoint={}", + model, + provider_type, + agent.provider_id, + protocol.endpoint() + ); + + Ok(NativeAgent { + client, + base_url: agent.base_url.clone(), + api_key: agent.api_key.clone(), + sessions: agent.sessions.clone(), + config: agent.config.clone(), + provider_type, + protocol, + provider_id: agent.provider_id.clone(), + }) + } + pub async fn chat(&self, request: NativeChatRequest) -> Result { - let temp_agent = self.create_temp_agent()?; + let model = request.model.clone().unwrap_or_else(|| { + self.agent + .read() + .as_ref() + .map(|a| a.config.model.clone()) + .unwrap_or_default() + }); + let temp_agent = self.create_temp_agent_with_model(&model)?; temp_agent.chat(request).await } @@ -889,7 +913,14 @@ impl NativeAgentState { request: NativeChatRequest, tx: mpsc::Sender, ) -> Result { - let temp_agent = self.create_temp_agent()?; + let model = request.model.clone().unwrap_or_else(|| { + self.agent + .read() + .as_ref() + .map(|a| a.config.model.clone()) + .unwrap_or_default() + }); + let temp_agent = self.create_temp_agent_with_model(&model)?; temp_agent.chat_stream(request, None, tx).await } @@ -899,7 +930,14 @@ impl NativeAgentState { tx: mpsc::Sender, tool_loop_engine: &ToolLoopEngine, ) -> Result { - let temp_agent = self.create_temp_agent()?; + let model = request.model.clone().unwrap_or_else(|| { + self.agent + .read() + .as_ref() + .map(|a| a.config.model.clone()) + .unwrap_or_default() + }); + let temp_agent = self.create_temp_agent_with_model(&model)?; temp_agent .chat_stream_with_tools(request, tx, tool_loop_engine) .await diff --git a/src-tauri/src/agent/protocols/anthropic.rs b/src-tauri/src/agent/protocols/anthropic.rs index 7e8b379ee..2be21dd23 100644 --- a/src-tauri/src/agent/protocols/anthropic.rs +++ b/src-tauri/src/agent/protocols/anthropic.rs @@ -14,7 +14,6 @@ use async_trait::async_trait; use futures::StreamExt; use reqwest::Client; use serde::Serialize; -use std::collections::HashMap; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; @@ -532,11 +531,6 @@ impl Protocol for AnthropicProtocol { .header("Content-Type", "application/json") .header("anthropic-version", "2023-06-01"); - // 添加 X-Provider-Id header 用于精确路由 - if let Some(pid) = provider_id { - req_builder = req_builder.header("X-Provider-Id", pid); - } - let response = req_builder .json(&request) .send() @@ -646,11 +640,6 @@ impl Protocol for AnthropicProtocol { .header("Content-Type", "application/json") .header("anthropic-version", "2023-06-01"); - // 添加 X-Provider-Id header 用于精确路由 - if let Some(pid) = provider_id { - req_builder = req_builder.header("X-Provider-Id", pid); - } - let response = req_builder .json(&request) .send() diff --git a/src-tauri/src/agent/tool_loop.rs b/src-tauri/src/agent/tool_loop.rs index df32ca993..fa61b4ecf 100644 --- a/src-tauri/src/agent/tool_loop.rs +++ b/src-tauri/src/agent/tool_loop.rs @@ -109,7 +109,7 @@ pub struct ToolLoopConfig { impl Default for ToolLoopConfig { fn default() -> Self { Self { - max_iterations: 25, // 默认最大 25 次迭代 + max_iterations: 50, // 默认最大 25 次迭代 } } } diff --git a/src-tauri/src/agent/types.rs b/src-tauri/src/agent/types.rs index 934102f73..728fcdabd 100644 --- a/src-tauri/src/agent/types.rs +++ b/src-tauri/src/agent/types.rs @@ -52,6 +52,41 @@ impl ProviderType { } } + /// 从 provider 字符串和模型名称推断 provider 类型 + /// + /// 对于自定义 Provider ID(如 custom-xxx),尝试从模型名称推断协议类型 + pub fn from_provider_and_model(provider: &str, model: &str) -> Self { + // 首先检查是否是自定义 Provider ID(以 custom- 开头) + if provider.starts_with("custom-") { + // 自定义 Provider 使用 Anthropic 兼容协议(Anthropic Compatible) + return Self::AnthropicCompatible; + } + + // 对于其他 Provider,尝试直接解析 + let provider_type = Self::from_str(provider); + + // 如果能被识别,直接返回 + if !matches!(provider_type, Self::OpenAI) || provider.eq_ignore_ascii_case("openai") { + return provider_type; + } + + // 对于标准 Provider (kiro/openai/claude/gemini 等),尝试从模型名称推断协议类型 + let model_lower = model.to_lowercase(); + + // Claude 模型使用 Anthropic 协议 + if model_lower.starts_with("claude-") || model_lower.starts_with("anthropic-") { + return Self::Claude; + } + + // Gemini 模型 + if model_lower.starts_with("gemini") || model_lower.contains("gemini") { + return Self::Gemini; + } + + // 默认使用 OpenAI 协议 + Self::OpenAI + } + /// 获取 API 端点路径 pub fn endpoint(&self) -> &'static str { match self { diff --git a/src-tauri/src/app/commands/api_test.rs b/src-tauri/src/app/commands/api_test.rs index 67dc74750..728842047 100644 --- a/src-tauri/src/app/commands/api_test.rs +++ b/src-tauri/src/app/commands/api_test.rs @@ -89,6 +89,8 @@ pub async fn check_api_compatibility( ("claude-sonnet-4-5", "basic"), ("claude-sonnet-4-5", "tool_call"), ], + // Anthropic 兼容格式 - 使用 Claude 相同的测试 + ProviderType::AnthropicCompatible => vec![], ProviderType::OpenAI | ProviderType::Claude => vec![], // API Key Provider 类型 - 暂不支持自动测试 ProviderType::Anthropic diff --git a/src-tauri/src/commands/native_agent_cmd.rs b/src-tauri/src/commands/native_agent_cmd.rs index e25456cbe..dca23dbdc 100644 --- a/src-tauri/src/commands/native_agent_cmd.rs +++ b/src-tauri/src/commands/native_agent_cmd.rs @@ -26,6 +26,7 @@ pub struct NativeAgentStatus { pub async fn native_agent_init( agent_state: State<'_, NativeAgentState>, app_state: State<'_, AppState>, + db: State<'_, crate::database::DbConnection>, ) -> Result { tracing::info!("[NativeAgent] 初始化 Agent"); @@ -48,7 +49,63 @@ pub async fn native_agent_init( let api_key = api_key.ok_or_else(|| "ProxyCast API Server 未配置 API Key".to_string())?; let base_url = get_local_url(&host, port); - let provider_type = ProviderType::from_str(&default_provider); + + // 对于自定义 Provider ID(如 custom-xxx),使用 Anthropic 兼容协议 + let provider_type = if default_provider.starts_with("custom-") { + tracing::info!( + "[NativeAgent] 自定义 Provider ID '{}',使用 Anthropic 兼容协议", + default_provider + ); + ProviderType::AnthropicCompatible + } else { + // 对于标准 Provider,从数据库查询类型 + match crate::database::dao::api_key_provider::ApiKeyProviderDao::get_provider_by_id( + &*db.lock().map_err(|e| e.to_string())?, + &default_provider, + ) { + Ok(Some(provider)) => { + // 从数据库的 provider_type 转换为 ProviderType + match provider.provider_type { + crate::database::dao::api_key_provider::ApiProviderType::Anthropic | + crate::database::dao::api_key_provider::ApiProviderType::AnthropicCompatible => { + tracing::info!( + "[NativeAgent] 从数据库获取 Provider 类型: {:?} (Anthropic)", + provider.provider_type + ); + ProviderType::Claude + } + crate::database::dao::api_key_provider::ApiProviderType::Gemini => { + tracing::info!( + "[NativeAgent] 从数据库获取 Provider 类型: {:?} (Gemini)", + provider.provider_type + ); + ProviderType::Gemini + } + _ => { + tracing::info!( + "[NativeAgent] 从数据库获取 Provider 类型: {:?} (OpenAI)", + provider.provider_type + ); + ProviderType::OpenAI + } + } + } + Ok(None) => { + tracing::warn!( + "[NativeAgent] 数据库中未找到 Provider '{}',使用字符串解析", + default_provider + ); + ProviderType::from_str(&default_provider) + } + Err(e) => { + tracing::warn!( + "[NativeAgent] 从数据库查询 Provider 失败: {},使用字符串解析", + e + ); + ProviderType::from_str(&default_provider) + } + } + }; tracing::info!( "[NativeAgent] 初始化 Agent: base_url={}, provider={:?}, use_default_prompt={}", @@ -62,7 +119,7 @@ pub async fn native_agent_init( base_url.clone(), api_key, provider_type, - Some(default_provider), + Some(default_provider.to_string()), &agent_config, )?; diff --git a/src-tauri/src/commands/update_cmd.rs b/src-tauri/src/commands/update_cmd.rs index 9f7f2908c..11a5b9f6d 100644 --- a/src-tauri/src/commands/update_cmd.rs +++ b/src-tauri/src/commands/update_cmd.rs @@ -213,12 +213,16 @@ pub async fn start_background_update_check( }); if should_notify { - // 打开独立的更新提醒窗口 - if let Err(e) = - update_window::open_update_window(&app_handle_clone, &result) - { - tracing::error!("[更新检查] 打开更新窗口失败: {}", e); - } + // 打开独立的更新提醒窗口 - 必须在主线程执行 + let app_handle_for_ui = app_handle_clone.clone(); + let result_clone = result.clone(); + let _ = app_handle_clone.run_on_main_thread(move || { + if let Err(e) = + update_window::open_update_window(&app_handle_for_ui, &result_clone) + { + tracing::error!("[更新检查] 打开更新窗口失败: {}", e); + } + }); } } } diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index f610ef275..ba5533df3 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -1228,7 +1228,6 @@ fn arb_credential_pool_config() -> impl Strategy { gemini_api_keys: vec![], vertex_api_keys: vec![], codex: vec![], - iflow: vec![], }, ) } @@ -2223,30 +2222,6 @@ fn arb_vertex_api_key_entry() -> impl Strategy impl Strategy { - ( - "[a-z]{3,10}-[0-9]{1,5}".prop_map(|s| s), - proptest::option::of("[a-z]+/iflow-token-[0-9]{1,5}\\.json".prop_map(|s| s)), - prop_oneof![Just("oauth".to_string()), Just("cookie".to_string())], - proptest::option::of("[a-zA-Z0-9=;]+".prop_map(|s| s)), - proptest::option::of("http://proxy\\.[a-z]+\\.com:[0-9]{4}".prop_map(|s| s)), - any::(), - ) - .prop_map( - |(id, token_file, auth_type, cookies, proxy_url, disabled)| { - crate::config::IFlowCredentialEntry { - id, - token_file, - auth_type, - cookies, - proxy_url, - disabled, - } - }, - ) -} - /// 生成包含新 Provider 凭证的凭证池配置 fn arb_extended_credential_pool_config() -> impl Strategy { ( @@ -2257,30 +2232,20 @@ fn arb_extended_credential_pool_config() -> impl Strategy impl Strategy { Just("openai".to_string()), Just("claude".to_string()), Just("codex".to_string()), - Just("iflow".to_string()), ] } @@ -2581,7 +2497,6 @@ fn arb_valid_provider_type() -> impl Strategy { Just("gemini_api_key".to_string()), Just("codex".to_string()), Just("claude_oauth".to_string()), - Just("iflow".to_string()), ] } @@ -2601,7 +2516,6 @@ fn arb_invalid_provider_type() -> impl Strategy { | "gemini_api_key" | "codex" | "claude_oauth" - | "iflow" ) }) } diff --git a/src-tauri/src/converter/protocol_selector.rs b/src-tauri/src/converter/protocol_selector.rs index b6bd53214..f97acb656 100644 --- a/src-tauri/src/converter/protocol_selector.rs +++ b/src-tauri/src/converter/protocol_selector.rs @@ -57,11 +57,13 @@ impl ProtocolSelector { PoolProviderType::Gemini => Protocol::Gemini, PoolProviderType::OpenAI => Protocol::OpenAI, PoolProviderType::Claude => Protocol::Anthropic, + PoolProviderType::ClaudeOAuth => Protocol::Anthropic, // Claude OAuth uses Anthropic protocol + // Anthropic 兼容格式使用 Anthropic 协议 + PoolProviderType::AnthropicCompatible => Protocol::Anthropic, PoolProviderType::Antigravity => Protocol::Antigravity, PoolProviderType::Vertex => Protocol::Gemini, // Vertex AI uses Gemini protocol PoolProviderType::GeminiApiKey => Protocol::Gemini, // Gemini API Key uses Gemini protocol PoolProviderType::Codex => Protocol::OpenAI, // Codex uses OpenAI protocol - PoolProviderType::ClaudeOAuth => Protocol::Anthropic, // Claude OAuth uses Anthropic protocol // API Key Provider 类型 PoolProviderType::Anthropic => Protocol::Anthropic, PoolProviderType::AzureOpenai => Protocol::OpenAI, diff --git a/src-tauri/src/credential/sync.rs b/src-tauri/src/credential/sync.rs index 51eee4bd5..bb1b3ac96 100644 --- a/src-tauri/src/credential/sync.rs +++ b/src-tauri/src/credential/sync.rs @@ -368,6 +368,12 @@ impl CredentialSyncService { "Claude OAuth 凭证暂不支持同步到配置".to_string(), )); } + // Anthropic 兼容格式 - 不支持同步到配置 + PoolProviderType::AnthropicCompatible => { + return Err(SyncError::InvalidCredentialType( + "Anthropic Compatible 凭证暂不支持同步到配置".to_string(), + )); + } // API Key Provider 类型 - 不支持同步到配置 PoolProviderType::Anthropic | PoolProviderType::AzureOpenai diff --git a/src-tauri/src/models/provider_pool_model.rs b/src-tauri/src/models/provider_pool_model.rs index 460c85aa2..4143702b7 100644 --- a/src-tauri/src/models/provider_pool_model.rs +++ b/src-tauri/src/models/provider_pool_model.rs @@ -321,12 +321,18 @@ impl ProviderCredential { /// 检查凭证是否适用于指定的客户端类型 /// /// 某些凭证可能有使用限制,例如 Claude Code 专用凭证只能用于 Claude Code 客户端 - pub fn is_compatible_with_client(&self, client_type: Option<&crate::server::client_detector::ClientType>) -> bool { + pub fn is_compatible_with_client( + &self, + client_type: Option<&crate::server::client_detector::ClientType>, + ) -> bool { // 检查是否是 Claude Code 专用凭证 if let Some(error_msg) = &self.last_error_message { if error_msg.contains("only authorized for use with Claude Code") { // 这是 Claude Code 专用凭证,只能用于 Claude Code 客户端 - return matches!(client_type, Some(crate::server::client_detector::ClientType::ClaudeCode)); + return matches!( + client_type, + Some(crate::server::client_detector::ClientType::ClaudeCode) + ); } } @@ -514,11 +520,13 @@ pub fn get_default_check_model(provider_type: PoolProviderType) -> &'static str PoolProviderType::OpenAI => "gpt-3.5-turbo", // 使用 claude-sonnet-4-5-20250929,兼容更多代理服务器 PoolProviderType::Claude => "claude-sonnet-4-5-20250929", + PoolProviderType::ClaudeOAuth => "claude-sonnet-4-5-20250929", + // Anthropic 兼容格式使用相同的健康检查模型 + PoolProviderType::AnthropicCompatible => "claude-sonnet-4-5-20250929", PoolProviderType::Antigravity => "gemini-3-pro-preview", PoolProviderType::Vertex => "gemini-2.0-flash", PoolProviderType::GeminiApiKey => "gemini-2.5-flash", PoolProviderType::Codex => "gpt-4o-mini", - PoolProviderType::ClaudeOAuth => "claude-sonnet-4-5-20250929", // API Key Provider 类型 PoolProviderType::Anthropic => "claude-sonnet-4-5-20250929", PoolProviderType::AzureOpenai => "gpt-4o-mini", diff --git a/src-tauri/src/providers/claude_custom.rs b/src-tauri/src/providers/claude_custom.rs index ff6b95462..a8857583f 100644 --- a/src-tauri/src/providers/claude_custom.rs +++ b/src-tauri/src/providers/claude_custom.rs @@ -281,16 +281,17 @@ impl ClaudeCustomProvider { if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - + // 检查是否是 Claude Code 专用凭证限制错误 if body.contains("only authorized for use with Claude Code") { return Err(format!( "凭证限制错误: 当前 Claude 凭证只能用于 Claude Code,不能用于通用 API 调用。\ 请使用通用的 Claude API Key 或 Anthropic API Key。\ 错误详情: {status} - {body}" - ).into()); + ) + .into()); } - + return Err(format!("Claude API error: {status} - {body}").into()); } diff --git a/src-tauri/src/server/handlers/api.rs b/src-tauri/src/server/handlers/api.rs index 6792e81af..46a5984f2 100644 --- a/src-tauri/src/server/handlers/api.rs +++ b/src-tauri/src/server/handlers/api.rs @@ -743,7 +743,12 @@ pub async fn chat_completions( ); let cred = state .pool_service - .select_credential_with_client_check(db, explicit_provider_id, Some(&request.model), Some(&client_type)) + .select_credential_with_client_check( + db, + explicit_provider_id, + Some(&request.model), + Some(&client_type), + ) .ok() .flatten(); @@ -781,7 +786,12 @@ pub async fn chat_completions( ); let cred = state .pool_service - .select_credential_with_client_check(db, &selected_provider, Some(&request.model), Some(&client_type)) + .select_credential_with_client_check( + db, + &selected_provider, + Some(&request.model), + Some(&client_type), + ) .ok() .flatten(); @@ -825,12 +835,16 @@ pub async fn chat_completions( provider_id_lower ); - match state.api_key_service.get_fallback_credential( - db, - &crate::models::provider_pool_model::PoolProviderType::OpenAI, - Some(&provider_id_lower), - Some(&client_type), - ).await { + match state + .api_key_service + .get_fallback_credential( + db, + &crate::models::provider_pool_model::PoolProviderType::OpenAI, + Some(&provider_id_lower), + Some(&client_type), + ) + .await + { Ok(Some(cred)) => { eprintln!( "[CHAT_COMPLETIONS] 通过 provider_id '{}' 找到凭证: name={:?}", @@ -1932,7 +1946,12 @@ pub async fn anthropic_messages( ); let cred = state .pool_service - .select_credential_with_client_check(db, explicit_provider_id, Some(&request.model), Some(&client_type)) + .select_credential_with_client_check( + db, + explicit_provider_id, + Some(&request.model), + Some(&client_type), + ) .ok() .flatten(); @@ -1969,7 +1988,12 @@ pub async fn anthropic_messages( ); let cred = state .pool_service - .select_credential_with_client_check(db, &selected_provider, Some(&request.model), Some(&client_type)) + .select_credential_with_client_check( + db, + &selected_provider, + Some(&request.model), + Some(&client_type), + ) .ok() .flatten(); @@ -2008,12 +2032,16 @@ pub async fn anthropic_messages( selected_provider ); - match state.api_key_service.get_fallback_credential( - db, - &crate::models::provider_pool_model::PoolProviderType::Anthropic, - Some(&selected_provider), - Some(&client_type), - ).await { + match state + .api_key_service + .get_fallback_credential( + db, + &crate::models::provider_pool_model::PoolProviderType::Anthropic, + Some(&selected_provider), + Some(&client_type), + ) + .await + { Ok(Some(cred)) => { eprintln!( "[ANTHROPIC_MESSAGES] 通过 provider_id '{}' 找到凭证: name={:?}", @@ -2053,12 +2081,9 @@ pub async fn anthropic_messages( // 启动 Flow 捕获 let llm_request = build_llm_request_from_anthropic(&request, "/v1/messages", &headers); - // 尝试将 selected_provider 解析为 ProviderType - // 如果是自定义 provider ID,则使用 OpenAI 作为默认值 - // 使用实际的 provider ID 构建 Flow Metadata - let provider_type = selected_provider - .parse::() - .unwrap_or(ProviderType::OpenAI); + // 使用凭证的实际 provider_type(支持自定义 Provider) + // 对于自定义 Provider ID,凭证的 provider_type 已通过数据库查询正确设置 + let provider_type = cred.provider_type; // 从凭证名称中提取 Provider 显示名称 // 凭证名称格式:Some("[降级] DeepSeek") 或 Some("DeepSeek") diff --git a/src-tauri/src/server/handlers/management.rs b/src-tauri/src/server/handlers/management.rs index 8aa5993ac..1c983c03e 100644 --- a/src-tauri/src/server/handlers/management.rs +++ b/src-tauri/src/server/handlers/management.rs @@ -416,6 +416,25 @@ pub async fn management_add_credential( ); } } + // Anthropic 兼容格式 - 使用 ClaudeKey(与 Anthropic 相同) + PoolProviderType::AnthropicCompatible => { + if let Some(api_key) = request.api_key { + CredentialData::ClaudeKey { + api_key, + base_url: request.base_url, + } + } else { + return ( + StatusCode::BAD_REQUEST, + Json(AddCredentialResponse { + success: false, + message: "API key is required for Anthropic Compatible provider" + .to_string(), + id: None, + }), + ); + } + } // API Key Provider 类型 - 不支持通过此接口添加凭证 PoolProviderType::AzureOpenai | PoolProviderType::AwsBedrock | PoolProviderType::Ollama => { return ( diff --git a/src-tauri/src/server/mod.rs b/src-tauri/src/server/mod.rs index 7c01d930f..516bb096e 100644 --- a/src-tauri/src/server/mod.rs +++ b/src-tauri/src/server/mod.rs @@ -1016,8 +1016,6 @@ async fn run_server( "/v1/images/generations", post(handlers::handle_image_generation), ) - // Gemini 原生协议路由 - .route("/v1/gemini/{*path}", post(gemini_generate_content)) // WebSocket 路由 .route("/v1/ws", get(handlers::ws_upgrade_handler)) .route("/ws", get(handlers::ws_upgrade_handler)) @@ -1030,28 +1028,6 @@ async fn run_server( "/{selector}/v1/chat/completions", post(chat_completions_with_selector), ) - // Amp CLI 路由 - .route( - "/api/provider/{provider}/v1/chat/completions", - post( - |State(state): State, - Path(provider): Path, - headers: HeaderMap, - Json(mut request): Json| async { - amp_chat_completions(State(state), Path(provider), headers, Json(request)).await - } - ), - ) - // TODO: amp_messages 和 amp_management_proxy 路由暂时禁用 - // .route("/api/provider/{provider}/v1/messages", post(amp_messages)) - // .route( - // "/api/auth/{*path}", - // axum::routing::any(amp_management_proxy_auth), - // ) - // .route( - // "/api/user/{*path}", - // axum::routing::any(amp_management_proxy_user), - // ) // 管理 API 路由 .merge(management_routes) // Kiro凭证管理API路由 @@ -1743,338 +1719,6 @@ async fn chat_completions_with_selector( } } -// ============ Amp CLI 路由处理 ============ - -/// Amp CLI chat completions 处理 -/// -/// 处理 `/api/provider/:provider/v1/chat/completions` 路由 -/// 支持模型映射,将不可用模型映射到可用替代 -async fn amp_chat_completions( - State(state): State, - Path(provider): Path, - headers: HeaderMap, - Json(mut request): Json, -) -> Response { - if let Err(e) = handlers::verify_api_key(&headers, &state.api_key).await { - state.logs.write().await.add( - "warn", - &format!( - "Unauthorized request to /api/provider/{}/v1/chat/completions", - provider - ), - ); - return e.into_response(); - } - - // 应用模型映射 - let original_model = request.model.clone(); - let mapped_model = state.amp_router.apply_model_mapping(&request.model); - if mapped_model != original_model { - state.logs.write().await.add( - "info", - &format!( - "[AMP] Model mapping applied: {} -> {}", - original_model, mapped_model - ), - ); - request.model = mapped_model; - } - - state.logs.write().await.add( - "info", - &format!( - "[AMP] POST /api/provider/{}/v1/chat/completions model={} stream={}", - provider, request.model, request.stream - ), - ); - - // 尝试根据 provider 名称选择凭证 - eprintln!( - "[AMP] 开始查找凭证: provider={}, model={}, db={}", - provider, - request.model, - state.db.is_some() - ); - let credential = match &state.db { - Some(db) => { - eprintln!( - "[AMP] 使用 select_credential 查找凭证(Provider Pool): provider={}", - provider - ); - // 先尝试从 Provider Pool 查找 - let pool_cred = if let Ok(Some(cred)) = - state - .pool_service - .select_credential(db, &provider, Some(&request.model)) - { - eprintln!("[AMP] select_credential 找到凭证: {:?}", cred.name); - Some(cred) - } - // 然后尝试按名称查找 - else if let Ok(Some(cred)) = state.pool_service.get_by_name(db, &provider) { - eprintln!("[AMP] get_by_name 找到凭证: {:?}", cred.name); - Some(cred) - } - // 最后尝试按 UUID 查找 - else if let Ok(Some(cred)) = state.pool_service.get_by_uuid(db, &provider) { - eprintln!("[AMP] get_by_uuid 找到凭证: {:?}", cred.name); - Some(cred) - } else { - None - }; - - // 如果 Provider Pool 中没有找到,尝试从 API Key Provider 查找 - if pool_cred.is_none() { - eprintln!( - "[AMP] Provider Pool 中未找到凭证,尝试 API Key Provider: provider={}", - provider - ); - - match state.api_key_service.get_fallback_credential( - db, - &crate::models::provider_pool_model::PoolProviderType::OpenAI, - Some(&provider), - None, // 没有客户端类型检测 - ).await { - Ok(Some(cred)) => { - eprintln!( - "[AMP] 通过 provider_id '{}' 找到 API Key Provider 凭证: name={:?}", - provider, cred.name - ); - Some(cred) - } - Ok(None) => { - eprintln!("[AMP] 未找到任何凭证 for provider '{}'", provider); - None - } - Err(e) => { - eprintln!("[AMP] 查找 API Key Provider 凭证时出错: {}", e); - None - } - } - } else { - pool_cred - } - } - None => { - eprintln!("[AMP] 数据库未初始化"); - None - } - }; - - match credential { - Some(cred) => { - state.logs.write().await.add( - "info", - &format!( - "[AMP] Using credential: type={} name={:?} uuid={}", - cred.provider_type, - cred.name, - &cred.uuid[..8] - ), - ); - // 注意:这里没有 Flow 捕获,因为是通过 AMP CLI 路由的请求 - handlers::call_provider_openai(&state, &cred, &request, None).await.into_response() - } - None => { - // 不再回退到默认 provider,直接返回错误 - state.logs.write().await.add( - "error", - &format!( - "[AMP] No available credentials for provider '{}', refusing to fallback", - provider - ), - ); - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({ - "error": { - "message": format!("No available credentials for provider '{}'", provider), - "type": "provider_unavailable", - "code": "no_credentials" - } - })), - ) - .into_response() - } - } -} - -/// Amp CLI 管理代理内部实现 -/// -/// 处理 `/api/auth/*` 和 `/api/user/*` 路由 -/// 将请求代理到上游 URL -/// -/// # 参数 -/// - `path`: 请求路径(不含 /api/ 前缀,如 "auth/login" 或 "user/profile") -async fn amp_management_proxy_internal( - state: AppState, - path: &str, - headers: HeaderMap, - method: axum::http::Method, - body: axum::body::Bytes, -) -> Response { - let full_path = format!("/api/{}", path); - - // 检查是否是管理路由 - if !state.amp_router.is_management_route(&full_path) { - state.logs.write().await.add( - "warn", - &format!("[AMP] Invalid management route: {}", full_path), - ); - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": {"message": "Not found"}})), - ) - .into_response(); - } - - // 检查 localhost 限制 - if state.amp_router.restrict_management_to_localhost() { - // 从 headers 中获取客户端 IP - let client_ip = headers - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .map(|s| s.split(',').next().unwrap_or("").trim().to_string()) - .or_else(|| { - headers - .get("x-real-ip") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) - }); - - if let Some(ip) = &client_ip { - let is_localhost = ip == "127.0.0.1" || ip == "::1" || ip == "localhost"; - if !is_localhost { - state.logs.write().await.add( - "warn", - &format!("[AMP] Management proxy blocked from non-localhost: {}", ip), - ); - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({"error": {"message": "Management endpoints are restricted to localhost"}})), - ) - .into_response(); - } - } - } - - // 获取上游 URL - let upstream_url = match state.amp_router.get_management_upstream_path(&full_path) { - Some(url) => url, - None => { - state.logs.write().await.add( - "warn", - "[AMP] No upstream URL configured for management proxy", - ); - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({"error": {"message": "Upstream URL not configured"}})), - ) - .into_response(); - } - }; - - state.logs.write().await.add( - "info", - &format!( - "[AMP] Proxying management request: {} {} -> {}", - method, full_path, upstream_url - ), - ); - - // 创建 HTTP 客户端 - let client = reqwest::Client::new(); - - // 构建请求 - let mut request_builder = match method { - axum::http::Method::GET => client.get(&upstream_url), - axum::http::Method::POST => client.post(&upstream_url), - axum::http::Method::PUT => client.put(&upstream_url), - axum::http::Method::DELETE => client.delete(&upstream_url), - axum::http::Method::PATCH => client.patch(&upstream_url), - axum::http::Method::HEAD => client.head(&upstream_url), - axum::http::Method::OPTIONS => client.request(reqwest::Method::OPTIONS, &upstream_url), - _ => { - return ( - StatusCode::METHOD_NOT_ALLOWED, - Json(serde_json::json!({"error": {"message": "Method not allowed"}})), - ) - .into_response(); - } - }; - - // 复制请求头(排除 host 和 content-length) - for (name, value) in headers.iter() { - let name_str = name.as_str().to_lowercase(); - if name_str != "host" && name_str != "content-length" { - if let Ok(value_str) = value.to_str() { - request_builder = request_builder.header(name.as_str(), value_str); - } - } - } - - // 添加请求体 - if !body.is_empty() { - request_builder = request_builder.body(body.to_vec()); - } - - // 发送请求 - match request_builder.send().await { - Ok(response) => { - let status = response.status(); - let response_headers = response.headers().clone(); - - match response.bytes().await { - Ok(response_body) => { - let mut builder = Response::builder().status(status.as_u16()); - - // 复制响应头 - for (name, value) in response_headers.iter() { - let name_str = name.as_str().to_lowercase(); - // 排除 transfer-encoding 和 content-length(axum 会自动处理) - if name_str != "transfer-encoding" && name_str != "content-length" { - builder = builder.header(name.as_str(), value.to_str().unwrap_or("")); - } - } - - builder - .body(Body::from(response_body.to_vec())) - .unwrap_or_else(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": {"message": "Failed to build response"}})), - ) - .into_response() - }) - } - Err(e) => { - state.logs.write().await.add( - "error", - &format!("[AMP] Failed to read upstream response: {}", e), - ); - ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({"error": {"message": format!("Failed to read upstream response: {}", e)}})), - ) - .into_response() - } - } - } - Err(e) => { - state.logs.write().await.add( - "error", - &format!("[AMP] Failed to proxy request to upstream: {}", e), - ); - ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({"error": {"message": format!("Failed to connect to upstream: {}", e)}})), - ) - .into_response() - } - } -} - /// 内部 Anthropic messages 处理 (使用默认 Kiro) /// 预留:用于内部直接调用 Kiro API #[allow(dead_code)] diff --git a/src-tauri/src/services/api_key_provider_service.rs b/src-tauri/src/services/api_key_provider_service.rs index 0b92c78a3..307f2645b 100644 --- a/src-tauri/src/services/api_key_provider_service.rs +++ b/src-tauri/src/services/api_key_provider_service.rs @@ -861,7 +861,10 @@ impl ApiKeyProviderService { "[get_fallback_credential] 尝试按 provider_id '{}' 查找", provider_id ); - if let Some(cred) = self.find_by_provider_id(db, provider_id, client_type).await? { + if let Some(cred) = self + .find_by_provider_id(db, provider_id, client_type) + .await? + { eprintln!( "[get_fallback_credential] 通过 provider_id '{}' 找到凭证: {:?}", provider_id, cred.name @@ -912,6 +915,7 @@ impl ApiKeyProviderService { // API Key Provider 类型 - 直接映射 PoolProviderType::Anthropic => Some(ApiProviderType::Anthropic), + PoolProviderType::AnthropicCompatible => Some(ApiProviderType::AnthropicCompatible), PoolProviderType::AzureOpenai => Some(ApiProviderType::AzureOpenai), PoolProviderType::AwsBedrock => Some(ApiProviderType::AwsBedrock), PoolProviderType::Ollama => Some(ApiProviderType::Ollama), @@ -1004,8 +1008,8 @@ impl ApiKeyProviderService { let conn = db.lock().map_err(|e| e.to_string())?; // 直接按 provider_id 查找 - let provider = - ApiKeyProviderDao::get_provider_by_id(&conn, provider_id).map_err(|e| e.to_string())?; + let provider = ApiKeyProviderDao::get_provider_by_id(&conn, provider_id) + .map_err(|e| e.to_string())?; let provider = match provider { Some(p) if p.enabled => { @@ -1072,14 +1076,20 @@ impl ApiKeyProviderService { if provider.provider_type == ApiProviderType::Anthropic { if let Some(client) = client_type { // 对于 Claude Code 客户端,可以使用任何 Claude 凭证 - if matches!(client, crate::server::client_detector::ClientType::ClaudeCode) { + if matches!( + client, + crate::server::client_detector::ClientType::ClaudeCode + ) { selected_key = Some(candidate_key); break; } // 对于其他客户端,需要检查凭证是否是 Claude Code 专用 // 通过发送测试请求来检查 - if let Err(e) = self.test_claude_key_compatibility(&api_key, &provider.api_host).await { + if let Err(e) = self + .test_claude_key_compatibility(&api_key, &provider.api_host) + .await + { if e.contains("CLAUDE_CODE_ONLY") { eprintln!( "[find_by_provider_id] API Key {} 是 Claude Code 专用,跳过 (客户端: {:?})", @@ -1141,6 +1151,15 @@ impl ApiKeyProviderService { }; (data, PoolProviderType::Claude) } + ApiProviderType::AnthropicCompatible => { + // Anthropic 兼容格式使用 ClaudeKey(与 Anthropic 相同的凭证数据) + // 但使用 AnthropicCompatible 作为 PoolProviderType,以便使用正确的端点 + let data = CredentialData::ClaudeKey { + api_key: api_key.to_string(), + base_url: Some(provider.api_host.clone()), + }; + (data, PoolProviderType::AnthropicCompatible) + } ApiProviderType::Gemini => { // Gemini 类型使用 GeminiApiKey let data = CredentialData::GeminiApiKey { @@ -1289,12 +1308,18 @@ impl ApiKeyProviderService { let test_model = model_name .or_else(|| provider.custom_models.first().cloned()) .unwrap_or_else(|| "claude-3-haiku-20240307".to_string()); - - match self.test_anthropic_connection(&api_key, &provider.api_host, &test_model).await { + + match self + .test_anthropic_connection(&api_key, &provider.api_host, &test_model) + .await + { Ok(models) => Ok(models), Err(e) if e == "CLAUDE_CODE_ONLY" => { // Claude Code 专用凭证限制错误,返回特殊错误信息 - Err("凭证限制: 当前 Claude 凭证只能用于 Claude Code,不能用于通用 API 调用".to_string()) + Err( + "凭证限制: 当前 Claude 凭证只能用于 Claude Code,不能用于通用 API 调用" + .to_string(), + ) } Err(e) => Err(e), } @@ -1444,12 +1469,12 @@ impl ApiKeyProviderService { Ok(()) } else { let body = response.text().await.unwrap_or_default(); - + // 检查是否是 Claude Code 专用凭证限制错误 if body.contains("only authorized for use with Claude Code") { return Err("CLAUDE_CODE_ONLY".to_string()); } - + // 其他错误不影响兼容性判断 Ok(()) } @@ -1484,12 +1509,12 @@ impl ApiKeyProviderService { } else { let status = response.status(); let body = response.text().await.unwrap_or_default(); - + // 检查是否是 Claude Code 专用凭证限制错误 if body.contains("only authorized for use with Claude Code") { return Err("CLAUDE_CODE_ONLY".to_string()); } - + Err(format!("API 返回错误: {} - {}", status, body)) } } diff --git a/src-tauri/src/services/provider_pool_service.rs b/src-tauri/src/services/provider_pool_service.rs index 30f7d565b..f2779e101 100644 --- a/src-tauri/src/services/provider_pool_service.rs +++ b/src-tauri/src/services/provider_pool_service.rs @@ -270,8 +270,9 @@ impl ProviderPoolService { ); credentials.extend(assistant_creds); } else if pt == PoolProviderType::Claude { - let ai_provider_creds = ProviderPoolDao::get_by_type(&conn, &PoolProviderType::Anthropic) - .map_err(|e| e.to_string())?; + let ai_provider_creds = + ProviderPoolDao::get_by_type(&conn, &PoolProviderType::Anthropic) + .map_err(|e| e.to_string())?; eprintln!( "[SELECT_CREDENTIAL] Assistant: adding {} AI Provider credentials", ai_provider_creds.len() @@ -398,7 +399,9 @@ impl ProviderPoolService { ); // Step 1: 尝试从 Provider Pool 选择 (OAuth + API Key) - if let Some(cred) = self.select_credential_with_client_check(db, provider_type, model, client_type)? { + if let Some(cred) = + self.select_credential_with_client_check(db, provider_type, model, client_type)? + { eprintln!( "[select_credential_with_fallback] 从 Provider Pool 找到凭证: {:?}", cred.name @@ -416,7 +419,10 @@ impl ProviderPoolService { // 传入 provider_id_hint 支持 60+ Provider eprintln!("[select_credential_with_fallback] 调用 get_fallback_credential"); - if let Some(cred) = api_key_service.get_fallback_credential(db, &pt, provider_id_hint, client_type).await? { + if let Some(cred) = api_key_service + .get_fallback_credential(db, &pt, provider_id_hint, client_type) + .await? + { eprintln!( "[select_credential_with_fallback] 智能降级成功: {:?}", cred.name @@ -450,7 +456,8 @@ impl ProviderPoolService { model, provider_id_hint, None, // 兼容方法不传递客户端类型 - ).await + ) + .await } /// 基于权重分数选择最优凭证 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7c83b7edb..e1226c2fb 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.47.4", + "version": "0.48.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/agent/chat/components/DecisionPanel.tsx b/src/components/agent/chat/components/DecisionPanel.tsx index 278e0c0f1..9c91b558e 100644 --- a/src/components/agent/chat/components/DecisionPanel.tsx +++ b/src/components/agent/chat/components/DecisionPanel.tsx @@ -147,7 +147,11 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { }; // 渲染用户问题面板 - if (request.actionType === "ask_user" && request.questions && request.questions.length > 0) { + if ( + request.actionType === "ask_user" && + request.questions && + request.questions.length > 0 + ) { const questions = request.questions; return ( diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx index c844f0138..445e36a29 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarTools.tsx @@ -108,9 +108,7 @@ export const InputbarTools: React.FC = ({ onClick={() => onToolClick?.("canvas")} className={isCanvasOpen ? "active" : ""} > - + diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index 3b2c58d2e..83c2eb049 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -63,23 +63,23 @@ export const MessageList: React.FC = ({ const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = container; const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; // 50px 容差 - + setIsUserScrolling(true); setShouldAutoScroll(isAtBottom); - + // 清除之前的定时器 clearTimeout(scrollTimeout); - + // 500ms 后认为用户停止滚动 scrollTimeout = setTimeout(() => { setIsUserScrolling(false); }, 500); }; - container.addEventListener('scroll', handleScroll, { passive: true }); - + container.addEventListener("scroll", handleScroll, { passive: true }); + return () => { - container.removeEventListener('scroll', handleScroll); + container.removeEventListener("scroll", handleScroll); clearTimeout(scrollTimeout); }; }, []); diff --git a/src/components/agent/chat/components/StreamingRenderer.tsx b/src/components/agent/chat/components/StreamingRenderer.tsx index c49078a62..835f9b257 100644 --- a/src/components/agent/chat/components/StreamingRenderer.tsx +++ b/src/components/agent/chat/components/StreamingRenderer.tsx @@ -363,7 +363,12 @@ export const StreamingRenderer: React.FC = memo( const result = parseAIResponse(visibleText, isStreaming); // 添加调试日志 if (result.hasWriteFile) { - console.log("[StreamingRenderer] 检测到 write_file:", result.parts.filter(p => p.type === "write_file" || p.type === "pending_write_file")); + console.log( + "[StreamingRenderer] 检测到 write_file:", + result.parts.filter( + (p) => p.type === "write_file" || p.type === "pending_write_file", + ), + ); } return result; }, [visibleText, isStreaming]); @@ -409,10 +414,13 @@ export const StreamingRenderer: React.FC = memo( // 判断是否有可见内容 const hasVisibleContent = useInterleavedMode ? contentParts.some( - (part) => + (part) => (part.type === "text" && part.text.length > 0) || (part.type === "thinking" && part.text.length > 0), - ) || (isStreaming && (content.length > 0 || (externalThinking && externalThinking.length > 0))) + ) || + (isStreaming && + (content.length > 0 || + (externalThinking && externalThinking.length > 0))) : visibleText.length > 0; // 交错显示模式:按顺序渲染 contentParts @@ -433,7 +441,14 @@ export const StreamingRenderer: React.FC = memo( // 添加调试日志 if (partParsed.hasWriteFile) { - console.log("[StreamingRenderer] 交错模式检测到 write_file:", partParsed.parts.filter(p => p.type === "write_file" || p.type === "pending_write_file")); + console.log( + "[StreamingRenderer] 交错模式检测到 write_file:", + partParsed.parts.filter( + (p) => + p.type === "write_file" || + p.type === "pending_write_file", + ), + ); } // 处理文件写入回调 diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index 388ae9da6..3197999e1 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -627,10 +627,12 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { // 如果是写入文件工具,立即调用 onWriteFile 展开右边栏 const toolName = data.tool_name.toLowerCase(); - console.log(`[Tool Start] 工具名称: ${data.tool_name}, 小写: ${toolName}`); + console.log( + `[Tool Start] 工具名称: ${data.tool_name}, 小写: ${toolName}`, + ); console.log(`[Tool Start] 工具参数: ${data.arguments}`); console.log(`[Tool Start] onWriteFile 回调存在: ${!!onWriteFile}`); - + if (toolName.includes("write") || toolName.includes("create")) { console.log(`[Tool Start] 匹配到文件写入工具: ${data.tool_name}`); try { @@ -638,12 +640,16 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { console.log(`[Tool Start] 解析后的参数:`, args); const filePath = args.path || args.file_path || args.filePath; const content = args.content || args.text || ""; - console.log(`[Tool Start] 文件路径: ${filePath}, 内容长度: ${content.length}`); + console.log( + `[Tool Start] 文件路径: ${filePath}, 内容长度: ${content.length}`, + ); if (filePath && content && onWriteFile) { console.log(`[Tool Start] 触发文件写入: ${filePath}`); onWriteFile(content, filePath); } else { - console.log(`[Tool Start] 文件写入条件不满足: filePath=${!!filePath}, content=${!!content}, onWriteFile=${!!onWriteFile}`); + console.log( + `[Tool Start] 文件写入条件不满足: filePath=${!!filePath}, content=${!!content}, onWriteFile=${!!onWriteFile}`, + ); } } catch (e) { console.warn("[Tool Start] 解析工具参数失败:", e); @@ -683,11 +689,16 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { case "action_required": { // 权限确认请求 - 添加到权限请求列表和 contentParts - console.log(`[Action Required] ${data.action_type} (${data.request_id})`); + console.log( + `[Action Required] ${data.action_type} (${data.request_id})`, + ); const actionRequired: ActionRequired = { requestId: data.request_id, - actionType: data.action_type as "tool_confirmation" | "ask_user" | "elicitation", + actionType: data.action_type as + | "tool_confirmation" + | "ask_user" + | "elicitation", toolName: data.tool_name, arguments: data.arguments, prompt: data.prompt, @@ -712,7 +723,10 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { return { ...msg, - actionRequests: [...(msg.actionRequests || []), actionRequired], + actionRequests: [ + ...(msg.actionRequests || []), + actionRequired, + ], // 添加到 contentParts,支持交错显示 contentParts: addActionRequiredToParts( msg.contentParts || [], @@ -958,7 +972,7 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { confirmed: response.confirmed, response: response.response, }); - + // 移除已处理的权限请求 setMessages((prev) => prev.map((msg) => ({ diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 42107c700..8f8000528 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -475,7 +475,8 @@ export function AgentChatPage({ setGeneralCanvasState((prev) => ({ ...prev, isOpen: !prev.isOpen, - contentType: prev.contentType === "empty" ? "markdown" : prev.contentType, + contentType: + prev.contentType === "empty" ? "markdown" : prev.contentType, content: prev.content || "# 新文档\n\n在这里开始编写内容...", })); setLayoutMode((prev) => (prev === "chat" ? "chat-canvas" : "chat")); @@ -490,7 +491,8 @@ export function AgentChatPage({ createInitialCanvasState( mappedTheme, "# 新文档\n\n在这里开始编写内容...", - ) || createInitialDocumentState("# 新文档\n\n在这里开始编写内容..."); + ) || + createInitialDocumentState("# 新文档\n\n在这里开始编写内容..."); setCanvasState(initialState); } return "chat-canvas"; @@ -521,11 +523,39 @@ export function AgentChatPage({ // General 主题使用专门的画布处理 if (activeTheme === "general") { const ext = fileName.split(".").pop()?.toLowerCase() || ""; - const isCode = ["js", "ts", "tsx", "jsx", "py", "rs", "go", "java", "c", "cpp", "h", "css", "scss", "json", "yaml", "yml", "toml", "xml", "html", "sql", "sh", "bash"].includes(ext); + const isCode = [ + "js", + "ts", + "tsx", + "jsx", + "py", + "rs", + "go", + "java", + "c", + "cpp", + "h", + "css", + "scss", + "json", + "yaml", + "yml", + "toml", + "xml", + "html", + "sql", + "sh", + "bash", + ].includes(ext); const isMd = ["md", "markdown"].includes(ext); - - console.log("[AgentChatPage] General 主题文件写入:", fileName, "类型:", isCode ? "code" : isMd ? "markdown" : "file"); - + + console.log( + "[AgentChatPage] General 主题文件写入:", + fileName, + "类型:", + isCode ? "code" : isMd ? "markdown" : "file", + ); + setGeneralCanvasState({ isOpen: true, contentType: isCode ? "code" : isMd ? "markdown" : "file", @@ -678,9 +708,32 @@ export function AgentChatPage({ // General 主题使用专门的画布 if (activeTheme === "general") { const ext = fileName.split(".").pop()?.toLowerCase() || ""; - const isCode = ["js", "ts", "tsx", "jsx", "py", "rs", "go", "java", "c", "cpp", "h", "css", "scss", "json", "yaml", "yml", "toml", "xml", "html", "sql", "sh", "bash"].includes(ext); + const isCode = [ + "js", + "ts", + "tsx", + "jsx", + "py", + "rs", + "go", + "java", + "c", + "cpp", + "h", + "css", + "scss", + "json", + "yaml", + "yml", + "toml", + "xml", + "html", + "sql", + "sh", + "bash", + ].includes(ext); const isMd = ["md", "markdown"].includes(ext); - + setGeneralCanvasState({ isOpen: true, contentType: isCode ? "code" : isMd ? "markdown" : "file", diff --git a/src/components/agent/chat/types.ts b/src/components/agent/chat/types.ts index b3a26b13b..0e05fd2ff 100644 --- a/src/components/agent/chat/types.ts +++ b/src/components/agent/chat/types.ts @@ -131,7 +131,6 @@ export const PROVIDER_CONFIG: Record< "claude-opus-4-5-20251101", "claude-sonnet-4-5-20250929", "claude-sonnet-4-20250514", - ], }, openai: { @@ -153,10 +152,7 @@ export const PROVIDER_CONFIG: Record< }, gemini: { label: "Gemini", - models: [ - "gemini-3-pro-preview", - "gemini-3-flash-preview", - ], + models: ["gemini-3-pro-preview", "gemini-3-flash-preview"], }, qwen: { label: "通义千问", diff --git a/src/components/api-server/ApiServerPage.tsx b/src/components/api-server/ApiServerPage.tsx index 42ba672f7..86293b429 100644 --- a/src/components/api-server/ApiServerPage.tsx +++ b/src/components/api-server/ApiServerPage.tsx @@ -80,6 +80,7 @@ const getProviderApiType = (provider: string): ApiType => { // Anthropic 类型 if ( p === "anthropic" || + p === "anthropic-compatible" || p === "claude" || p === "claude_oauth" || p === "kiro" diff --git a/src/components/general-chat/GeneralChatPage.tsx b/src/components/general-chat/GeneralChatPage.tsx index 1a645fc80..9e085d813 100644 --- a/src/components/general-chat/GeneralChatPage.tsx +++ b/src/components/general-chat/GeneralChatPage.tsx @@ -6,13 +6,13 @@ * @requirements 3.1, 3.5, 9.4 */ -import React, { useState, useCallback, useEffect, useRef } from 'react'; -import { ChatPanel } from './chat/ChatPanel'; -import { CanvasPanel } from './canvas/CanvasPanel'; -import { ErrorBoundary } from './chat/ErrorBoundary'; -import { useGeneralChatStore } from './store/useGeneralChatStore'; -import type { CanvasState, GeneralChatPageProps } from './types'; -import { DEFAULT_CANVAS_STATE } from './types'; +import React, { useState, useCallback, useEffect, useRef } from "react"; +import { ChatPanel } from "./chat/ChatPanel"; +import { CanvasPanel } from "./canvas/CanvasPanel"; +import { ErrorBoundary } from "./chat/ErrorBoundary"; +import { useGeneralChatStore } from "./store/useGeneralChatStore"; +import type { CanvasState, GeneralChatPageProps } from "./types"; +import { DEFAULT_CANVAS_STATE } from "./types"; /** * 通用对话主页面 @@ -26,15 +26,12 @@ export const GeneralChatPage: React.FC = ({ initialSessionId, onNavigate, }) => { - const { - currentSessionId, - selectSession, - sessions, - createSession, - } = useGeneralChatStore(); + const { currentSessionId, selectSession, sessions, createSession } = + useGeneralChatStore(); // 画布状态 - const [canvasState, setCanvasState] = useState(DEFAULT_CANVAS_STATE); + const [canvasState, setCanvasState] = + useState(DEFAULT_CANVAS_STATE); // 使用 ref 防止重复创建会话 const sessionCreatedRef = useRef(false); @@ -43,12 +40,22 @@ export const GeneralChatPage: React.FC = ({ useEffect(() => { if (initialSessionId) { selectSession(initialSessionId); - } else if (sessions.length === 0 && !currentSessionId && !sessionCreatedRef.current) { + } else if ( + sessions.length === 0 && + !currentSessionId && + !sessionCreatedRef.current + ) { // 如果没有会话,创建一个新会话(只创建一次) sessionCreatedRef.current = true; createSession(); } - }, [initialSessionId, selectSession, sessions.length, currentSessionId, createSession]); + }, [ + initialSessionId, + selectSession, + sessions.length, + currentSessionId, + createSession, + ]); // 打开画布 const handleOpenCanvas = useCallback((state: CanvasState) => { @@ -62,7 +69,7 @@ export const GeneralChatPage: React.FC = ({ // 画布内容变更 const handleCanvasContentChange = useCallback((content: string) => { - setCanvasState(prev => ({ ...prev, content })); + setCanvasState((prev) => ({ ...prev, content })); }, []); return ( @@ -72,8 +79,14 @@ export const GeneralChatPage: React.FC = ({ { - console.error('[GeneralChatPage] ChatPanel 渲染错误:', error.message); - console.error('[GeneralChatPage] 组件堆栈:', errorInfo.componentStack); + console.error( + "[GeneralChatPage] ChatPanel 渲染错误:", + error.message, + ); + console.error( + "[GeneralChatPage] 组件堆栈:", + errorInfo.componentStack, + ); }} > = ({ { - console.error('[GeneralChatPage] CanvasPanel 渲染错误:', error.message); - console.error('[GeneralChatPage] 组件堆栈:', errorInfo.componentStack); + console.error( + "[GeneralChatPage] CanvasPanel 渲染错误:", + error.message, + ); + console.error( + "[GeneralChatPage] 组件堆栈:", + errorInfo.componentStack, + ); }} > = ({ // 下载内容 const handleDownload = () => { - const filename = state.filename || - (state.contentType === 'code' ? `code.${state.language || 'txt'}` : 'content.md'); - const blob = new Blob([state.content], { type: 'text/plain' }); + const filename = + state.filename || + (state.contentType === "code" + ? `code.${state.language || "txt"}` + : "content.md"); + const blob = new Blob([state.content], { type: "text/plain" }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); @@ -60,7 +63,8 @@ export const CanvasPanel: React.FC = ({
- {state.filename || (state.contentType === 'code' ? '代码预览' : '内容预览')} + {state.filename || + (state.contentType === "code" ? "代码预览" : "内容预览")} {state.language && ( @@ -73,15 +77,35 @@ export const CanvasPanel: React.FC = ({ @@ -91,8 +115,18 @@ export const CanvasPanel: React.FC = ({ className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors" title="下载" > - - + + {/* 关闭按钮 */} @@ -101,8 +135,18 @@ export const CanvasPanel: React.FC = ({ className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors" title="关闭" > - - + +
@@ -110,14 +154,14 @@ export const CanvasPanel: React.FC = ({ {/* 内容区域 */}
- {state.contentType === 'code' ? ( + {state.contentType === "code" ? ( - ) : state.contentType === 'markdown' ? ( + ) : state.contentType === "markdown" ? ( = ({ onContentChange, }) => { // 计算行号 - const lines = useMemo(() => code.split('\n'), [code]); + const lines = useMemo(() => code.split("\n"), [code]); const lineCount = lines.length; const lineNumberWidth = String(lineCount).length * 10 + 20; diff --git a/src/components/general-chat/canvas/MarkdownPreview.tsx b/src/components/general-chat/canvas/MarkdownPreview.tsx index b43d59c41..919f1d8ce 100644 --- a/src/components/general-chat/canvas/MarkdownPreview.tsx +++ b/src/components/general-chat/canvas/MarkdownPreview.tsx @@ -6,7 +6,7 @@ * @requirements 4.5 */ -import React, { useState } from 'react'; +import React, { useState } from "react"; interface MarkdownPreviewProps { /** Markdown 内容 */ @@ -24,27 +24,39 @@ interface MarkdownPreviewProps { const renderMarkdown = (content: string): string => { let html = content // 转义 HTML - .replace(/&/g, '&') - .replace(//g, '>') + .replace(/&/g, "&") + .replace(//g, ">") // 标题 - .replace(/^### (.*$)/gm, '

$1

') - .replace(/^## (.*$)/gm, '

$1

') + .replace( + /^### (.*$)/gm, + '

$1

', + ) + .replace( + /^## (.*$)/gm, + '

$1

', + ) .replace(/^# (.*$)/gm, '

$1

') // 粗体和斜体 - .replace(/\*\*\*(.*?)\*\*\*/g, '$1') - .replace(/\*\*(.*?)\*\*/g, '$1') - .replace(/\*(.*?)\*/g, '$1') + .replace(/\*\*\*(.*?)\*\*\*/g, "$1") + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/\*(.*?)\*/g, "$1") // 行内代码 - .replace(/`([^`]+)`/g, '$1') + .replace( + /`([^`]+)`/g, + '$1', + ) // 链接 - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + .replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + '$1', + ) // 列表 .replace(/^\s*[-*]\s+(.*$)/gm, '
  • $1
  • ') // 段落 .replace(/\n\n/g, '

    ') // 换行 - .replace(/\n/g, '
    '); + .replace(/\n/g, "
    "); return `

    ${html}

    `; }; @@ -57,7 +69,7 @@ export const MarkdownPreview: React.FC = ({ isEditing = false, onContentChange, }) => { - const [viewMode, setViewMode] = useState<'preview' | 'source'>('preview'); + const [viewMode, setViewMode] = useState<"preview" | "source">("preview"); if (isEditing) { return ( @@ -65,21 +77,21 @@ export const MarkdownPreview: React.FC = ({ {/* 模式切换 */}
    {/* 内容区域 */}
    - {viewMode === 'source' ? ( + {viewMode === "source" ? (