From 8fec0f1088db5d908bf0db5c1f527afe000fd325 Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 25 Feb 2026 11:16:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20workspace=5Fmismatch=20=E5=85=BC=E5=AE=B9=E6=80=A7=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增渠道管理页面(AI 模型提供商 + 消息通知渠道) - 支持飞书、Telegram、Discord 通知渠道配置 - 新增 migration_v4:启动时自动修复 [object Promise] 路径污染 - 新增 migration_v4:自动统一旧会话 working_dir 到默认 workspace - 修复老用户升级后出现的 workspace_mismatch 错误 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../crates/core/src/database/migration_v4.rs | 232 +++++++++ src-tauri/crates/core/src/database/mod.rs | 18 + src-tauri/src/app/runner.rs | 13 + src-tauri/src/commands/channels_cmd.rs | 289 +++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/workspace_cmd.rs | 8 + src/components/settings-v2/_layout/index.tsx | 10 + .../settings-v2/hooks/useSettingsCategory.ts | 6 + .../system/channels/AIChannelFormModal.tsx | 416 ++++++++++++++++ .../system/channels/AIChannelsList.tsx | 255 ++++++++++ .../system/channels/ConnectionTestButton.tsx | 62 +++ .../system/channels/DeleteChannelDialog.tsx | 62 +++ .../channels/NotificationChannelFormModal.tsx | 453 ++++++++++++++++++ .../channels/NotificationChannelsList.tsx | 272 +++++++++++ .../system/channels/SendTestMessageButton.tsx | 113 +++++ .../settings-v2/system/channels/index.tsx | 54 +++ src/i18n/patches/en.json | 99 ++++ src/i18n/patches/zh.json | 99 +++- src/lib/api/channels.ts | 394 +++++++++++++++ src/types/settings.ts | 2 + 20 files changed, 2857 insertions(+), 1 deletion(-) create mode 100644 src-tauri/crates/core/src/database/migration_v4.rs create mode 100644 src-tauri/src/commands/channels_cmd.rs create mode 100644 src/components/settings-v2/system/channels/AIChannelFormModal.tsx create mode 100644 src/components/settings-v2/system/channels/AIChannelsList.tsx create mode 100644 src/components/settings-v2/system/channels/ConnectionTestButton.tsx create mode 100644 src/components/settings-v2/system/channels/DeleteChannelDialog.tsx create mode 100644 src/components/settings-v2/system/channels/NotificationChannelFormModal.tsx create mode 100644 src/components/settings-v2/system/channels/NotificationChannelsList.tsx create mode 100644 src/components/settings-v2/system/channels/SendTestMessageButton.tsx create mode 100644 src/components/settings-v2/system/channels/index.tsx create mode 100644 src/lib/api/channels.ts diff --git a/src-tauri/crates/core/src/database/migration_v4.rs b/src-tauri/crates/core/src/database/migration_v4.rs new file mode 100644 index 000000000..2e4fbc27e --- /dev/null +++ b/src-tauri/crates/core/src/database/migration_v4.rs @@ -0,0 +1,232 @@ +//! 修复 [object Promise] 路径污染问题 + 统一会话工作目录 +//! +//! 历史 bug: +//! 1. 前端代码未正确 await Promise,导致 root_path 和 working_dir +//! 被存储为 "[object Promise]/Project-xxx" 格式的字符串。 +//! 2. 旧会话的 working_dir 指向不同的项目目录,与默认 workspace 不匹配。 +//! +//! 本迁移自动检测并修复这些问题。 + +use rusqlite::{params, Connection}; + +/// 迁移设置键名 +const MIGRATION_KEY_FIX_PROMISE_PATHS: &str = "migrated_fix_promise_paths_v1"; +const MIGRATION_KEY_UNIFY_SESSION_DIRS: &str = "migrated_unify_session_dirs_v1"; + +/// 默认项目根目录(用于替换损坏的路径) +const DEFAULT_PROJECTS_DIR: &str = ".proxycast/projects"; + +/// 迁移结果 +pub struct MigrationResult { + /// 是否执行了迁移 + pub executed: bool, + /// 修复的 workspace 数量 + pub fixed_workspaces: usize, + /// 修复的 agent_session 数量 + pub fixed_sessions: usize, + /// 统一到默认 workspace 的会话数量 + pub unified_sessions: usize, +} + +/// 执行 [object Promise] 路径修复迁移 +pub fn migrate_fix_promise_paths(conn: &Connection) -> Result { + // 检查是否已经迁移过 + let promise_done = is_migration_completed(conn, MIGRATION_KEY_FIX_PROMISE_PATHS); + let unify_done = is_migration_completed(conn, MIGRATION_KEY_UNIFY_SESSION_DIRS); + + if promise_done && unify_done { + tracing::debug!("[迁移] Promise 路径修复和会话目录统一已执行过,跳过"); + return Ok(MigrationResult { + executed: false, + fixed_workspaces: 0, + fixed_sessions: 0, + unified_sessions: 0, + }); + } + + // 获取用户主目录 + let home = dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "/Users/unknown".to_string()); + let default_path = format!("{}/{}", home, DEFAULT_PROJECTS_DIR); + + // 开始事务 + conn.execute("BEGIN TRANSACTION", []) + .map_err(|e| format!("开始事务失败: {e}"))?; + + let result = execute_migration(conn, &default_path, promise_done, unify_done); + + match result { + Ok((fixed_ws, fixed_sess, unified_sess)) => { + if !promise_done { + mark_migration_completed(conn, MIGRATION_KEY_FIX_PROMISE_PATHS)?; + } + if !unify_done { + mark_migration_completed(conn, MIGRATION_KEY_UNIFY_SESSION_DIRS)?; + } + + conn.execute("COMMIT", []) + .map_err(|e| format!("提交事务失败: {e}"))?; + + if fixed_ws > 0 || fixed_sess > 0 { + tracing::info!( + "[迁移] Promise 路径修复完成: 修复 workspaces={}, sessions={}", + fixed_ws, + fixed_sess + ); + } + if unified_sess > 0 { + tracing::info!( + "[迁移] 会话目录统一完成: 统一 {} 个会话到默认 workspace", + unified_sess + ); + } + + Ok(MigrationResult { + executed: fixed_ws > 0 || fixed_sess > 0 || unified_sess > 0, + fixed_workspaces: fixed_ws, + fixed_sessions: fixed_sess, + unified_sessions: unified_sess, + }) + } + Err(e) => { + let _ = conn.execute("ROLLBACK", []); + tracing::error!("[迁移] 路径修复和会话统一失败,已回滚: {}", e); + Err(e) + } + } +} + +fn execute_migration( + conn: &Connection, + default_path: &str, + promise_done: bool, + unify_done: bool, +) -> Result<(usize, usize, usize), String> { + let mut fixed_ws = 0; + let mut fixed_sess = 0; + let mut unified_sess = 0; + + // 步骤 1: 修复 [object Promise] 路径污染(如果未完成) + if !promise_done { + let workspace_count = count_corrupted_workspaces(conn); + let session_count = count_corrupted_sessions(conn); + + if workspace_count > 0 || session_count > 0 { + tracing::info!( + "[迁移] 发现 Promise 路径污染数据: workspaces={}, sessions={},开始修复", + workspace_count, + session_count + ); + + // 修复 workspaces 表的 root_path + fixed_ws = conn + .execute( + "UPDATE workspaces SET root_path = REPLACE(root_path, '[object Promise]', ?1) \ + WHERE root_path LIKE '%[object Promise]%'", + params![default_path], + ) + .map_err(|e| format!("修复 workspaces.root_path 失败: {e}"))?; + + // 修复 agent_sessions 表的 working_dir + fixed_sess = conn + .execute( + "UPDATE agent_sessions SET working_dir = REPLACE(working_dir, '[object Promise]', ?1) \ + WHERE working_dir LIKE '%[object Promise]%'", + params![default_path], + ) + .map_err(|e| format!("修复 agent_sessions.working_dir 失败: {e}"))?; + } + } + + // 步骤 2: 统一所有会话的 working_dir 到默认 workspace(如果未完成) + if !unify_done { + // 获取默认 workspace 的 root_path + let default_workspace_path = get_default_workspace_path(conn)?; + + if let Some(default_ws_path) = default_workspace_path { + // 统计需要更新的会话数量 + let mismatched_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM agent_sessions \ + WHERE working_dir IS NOT NULL \ + AND working_dir != '' \ + AND working_dir != ?1", + params![&default_ws_path], + |row| row.get(0), + ) + .unwrap_or(0); + + if mismatched_count > 0 { + tracing::info!( + "[迁移] 发现 {} 个会话的 working_dir 与默认 workspace 不匹配,开始统一", + mismatched_count + ); + + // 将所有会话的 working_dir 统一到默认 workspace + unified_sess = conn + .execute( + "UPDATE agent_sessions SET working_dir = ?1 \ + WHERE working_dir IS NOT NULL \ + AND working_dir != '' \ + AND working_dir != ?1", + params![&default_ws_path], + ) + .map_err(|e| format!("统一会话 working_dir 失败: {e}"))?; + } + } else { + tracing::warn!("[迁移] 未找到默认 workspace,跳过会话目录统一"); + } + } + + Ok((fixed_ws, fixed_sess, unified_sess)) +} + +/// 获取默认 workspace 的 root_path +fn get_default_workspace_path(conn: &Connection) -> Result, String> { + let result = conn.query_row( + "SELECT root_path FROM workspaces WHERE is_default = 1 LIMIT 1", + [], + |row| row.get::<_, String>(0), + ); + + match result { + Ok(path) => Ok(Some(path)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(format!("查询默认 workspace 失败: {e}")), + } +} + +fn count_corrupted_workspaces(conn: &Connection) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM workspaces WHERE root_path LIKE '%[object Promise]%'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0) +} + +fn count_corrupted_sessions(conn: &Connection) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM agent_sessions WHERE working_dir LIKE '%[object Promise]%'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap_or(0) +} + +fn is_migration_completed(conn: &Connection, key: &str) -> bool { + conn.query_row("SELECT value FROM settings WHERE key = ?1", [key], |row| { + row.get::<_, String>(0) + }) + .is_ok() +} + +fn mark_migration_completed(conn: &Connection, key: &str) -> Result<(), String> { + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + [key, "1"], + ) + .map_err(|e| format!("标记迁移完成失败: {e}"))?; + Ok(()) +} diff --git a/src-tauri/crates/core/src/database/mod.rs b/src-tauri/crates/core/src/database/mod.rs index 282cc31df..b40cc0356 100644 --- a/src-tauri/crates/core/src/database/mod.rs +++ b/src-tauri/crates/core/src/database/mod.rs @@ -2,6 +2,7 @@ pub mod dao; pub mod migration; pub mod migration_v2; pub mod migration_v3; +pub mod migration_v4; pub mod schema; pub mod system_providers; @@ -144,5 +145,22 @@ pub fn init_database() -> Result { } } + // 修复 [object Promise] 路径污染问题(历史 bug 遗留数据) + match migration_v4::migrate_fix_promise_paths(&conn) { + Ok(result) => { + if result.executed { + tracing::info!( + "[数据库] 路径修复和会话统一完成: workspaces={}, sessions={}, unified={}", + result.fixed_workspaces, + result.fixed_sessions, + result.unified_sessions + ); + } + } + Err(e) => { + tracing::warn!("[数据库] 路径修复和会话统一失败(非致命): {}", e); + } + } + Ok(Arc::new(Mutex::new(conn))) } diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index f79e8a69e..0fdddf1f3 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -806,6 +806,19 @@ pub fn run() { // MCP 资源管理命令 commands::mcp_cmd::mcp_list_resources, commands::mcp_cmd::mcp_read_resource, + // Channel commands + commands::channels_cmd::get_ai_channels, + commands::channels_cmd::get_ai_channel, + commands::channels_cmd::create_ai_channel, + commands::channels_cmd::update_ai_channel, + commands::channels_cmd::delete_ai_channel, + commands::channels_cmd::test_ai_channel, + commands::channels_cmd::get_notification_channels, + commands::channels_cmd::get_notification_channel, + commands::channels_cmd::create_notification_channel, + commands::channels_cmd::update_notification_channel, + commands::channels_cmd::delete_notification_channel, + commands::channels_cmd::test_notification_channel, // Prompt commands commands::prompt_cmd::get_prompts, commands::prompt_cmd::upsert_prompt, diff --git a/src-tauri/src/commands/channels_cmd.rs b/src-tauri/src/commands/channels_cmd.rs new file mode 100644 index 000000000..eca3be1f0 --- /dev/null +++ b/src-tauri/src/commands/channels_cmd.rs @@ -0,0 +1,289 @@ +//! 渠道管理命令 +//! +//! 提供渠道管理相关的 Tauri 命令,包括 AI 渠道和通知渠道的 CRUD 操作。 + +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::AppState; + +// ============================================================================ +// 类型定义 +// ============================================================================ + +/// AI 渠道配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AIChannelConfig { + pub name: String, + pub engine: AIProviderEngine, + pub display_name: String, + pub description: Option, + pub api_key_env: String, + pub base_url: String, + pub models: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub supports_streaming: Option, +} + +/// AI 提供商引擎类型 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AIProviderEngine { + OpenAI, + Ollama, + Anthropic, +} + +/// 模型信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + pub id: String, + pub name: String, + pub description: Option, +} + +/// AI 渠道 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AIChannel { + pub id: String, + pub name: String, + pub engine: AIProviderEngine, + pub display_name: String, + pub description: Option, + pub api_key_env: String, + pub base_url: String, + pub models: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub supports_streaming: Option, + pub enabled: bool, +} + +/// 通知渠道类型 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NotificationChannelType { + Feishu, + Telegram, + Discord, +} + +/// 通知渠道配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationChannelConfig { + pub name: String, + pub channel_type: NotificationChannelType, + pub config: NotificationChannelSpecificConfig, +} + +/// 通知渠道特定配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum NotificationChannelSpecificConfig { + Feishu(FeishuConfig), + Telegram(TelegramConfig), + Discord(DiscordConfig), +} + +/// 飞书配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeishuConfig { + pub webhook_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option, +} + +/// Telegram 配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TelegramConfig { + pub bot_token: String, + pub chat_id: String, +} + +/// Discord 配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscordConfig { + pub webhook_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, +} + +/// 通知渠道 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationChannel { + pub id: String, + pub name: String, + pub channel_type: NotificationChannelType, + pub config: NotificationChannelSpecificConfig, + pub enabled: bool, +} + +/// 连接测试结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionTestResult { + pub success: bool, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +/// 测试消息结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestMessageResult { + pub success: bool, + pub message: String, +} + +// ============================================================================ +// AI 渠道命令 +// ============================================================================ + +/// 获取所有 AI 渠道 +#[tauri::command] +pub async fn get_ai_channels( + _state: State<'_, AppState>, +) -> Result, String> { + // TODO: 实现 AI 渠道获取逻辑 + // 需要从 aster-rust 获取 DeclarativeProviderConfig 列表 + tracing::info!("[渠道] 获取 AI 渠道列表"); + Ok(vec![]) +} + +/// 获取单个 AI 渠道 +#[tauri::command] +pub async fn get_ai_channel( + id: String, + _state: State<'_, AppState>, +) -> Result { + tracing::info!("[渠道] 获取 AI 渠道: {}", id); + Err("暂未实现".to_string()) +} + +/// 创建 AI 渠道 +#[tauri::command] +pub async fn create_ai_channel( + _config: AIChannelConfig, + _state: State<'_, AppState>, +) -> Result { + tracing::info!("[渠道] 创建 AI 渠道: {}", _config.name); + // TODO: 实现创建逻辑 + Err("暂未实现".to_string()) +} + +/// 更新 AI 渠道 +#[tauri::command] +pub async fn update_ai_channel( + id: String, + _config: AIChannelConfig, + _state: State<'_, AppState>, +) -> Result { + tracing::info!("[渠道] 更新 AI 渠道: {}", id); + // TODO: 实现更新逻辑 + Err("暂未实现".to_string()) +} + +/// 删除 AI 渠道 +#[tauri::command] +pub async fn delete_ai_channel( + id: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + tracing::info!("[渠道] 删除 AI 渠道: {}", id); + // TODO: 实现删除逻辑 + Err("暂未实现".to_string()) +} + +/// 测试 AI 渠道连接 +#[tauri::command] +pub async fn test_ai_channel( + id: String, + _state: State<'_, AppState>, +) -> Result { + tracing::info!("[渠道] 测试 AI 渠道连接: {}", id); + // TODO: 实现测试连接逻辑 + Ok(ConnectionTestResult { + success: false, + message: "暂未实现".to_string(), + details: None, + }) +} + +// ============================================================================ +// 通知渠道命令 +// ============================================================================ + +/// 获取所有通知渠道 +#[tauri::command] +pub async fn get_notification_channels( + _state: State<'_, AppState>, +) -> Result, String> { + // TODO: 实现通知渠道获取逻辑 + tracing::info!("[渠道] 获取通知渠道列表"); + Ok(vec![]) +} + +/// 获取单个通知渠道 +#[tauri::command] +pub async fn get_notification_channel( + id: String, + _state: State<'_, AppState>, +) -> Result { + tracing::info!("[渠道] 获取通知渠道: {}", id); + Err("暂未实现".to_string()) +} + +/// 创建通知渠道 +#[tauri::command] +pub async fn create_notification_channel( + _config: NotificationChannelConfig, + _state: State<'_, AppState>, +) -> Result { + tracing::info!("[渠道] 创建通知渠道: {}", _config.name); + // TODO: 实现创建逻辑 + Err("暂未实现".to_string()) +} + +/// 更新通知渠道 +#[tauri::command] +pub async fn update_notification_channel( + id: String, + _config: NotificationChannelConfig, + _state: State<'_, AppState>, +) -> Result { + tracing::info!("[渠道] 更新通知渠道: {}", id); + // TODO: 实现更新逻辑 + Err("暂未实现".to_string()) +} + +/// 删除通知渠道 +#[tauri::command] +pub async fn delete_notification_channel( + id: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + tracing::info!("[渠道] 删除通知渠道: {}", id); + // TODO: 实现删除逻辑 + Err("暂未实现".to_string()) +} + +/// 发送测试消息到通知渠道 +#[tauri::command] +pub async fn test_notification_channel( + id: String, + message: String, + _state: State<'_, AppState>, +) -> Result { + tracing::info!("[渠道] 测试通知渠道: {}, 消息: {}", id, message); + // TODO: 实现测试消息逻辑 + Ok(TestMessageResult { + success: false, + message: "暂未实现".to_string(), + }) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 65bf2a093..4f8c2d96f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod a2ui_form_cmd; pub mod agent_cmd; +pub mod channels_cmd; pub mod api_key_provider_cmd; pub mod asr_cmd; pub mod aster_agent_cmd; diff --git a/src-tauri/src/commands/workspace_cmd.rs b/src-tauri/src/commands/workspace_cmd.rs index 636216593..186406bc0 100644 --- a/src-tauri/src/commands/workspace_cmd.rs +++ b/src-tauri/src/commands/workspace_cmd.rs @@ -132,6 +132,14 @@ pub async fn workspace_create( db: State<'_, DbConnection>, request: CreateWorkspaceRequest, ) -> Result { + // 验证 root_path 不是 Promise 对象 + if request.root_path.contains("[object Promise]") { + return Err(format!( + "无效的 root_path: {}。请确保前端正确 await 了 Promise。", + request.root_path + )); + } + let manager = WorkspaceManager::new(db.inner().clone()); let workspace_type = request diff --git a/src/components/settings-v2/_layout/index.tsx b/src/components/settings-v2/_layout/index.tsx index d646c8d52..07dd99517 100644 --- a/src/components/settings-v2/_layout/index.tsx +++ b/src/components/settings-v2/_layout/index.tsx @@ -49,6 +49,7 @@ import { ProfileSettings } from "../account/profile"; import { ProviderPoolPage } from "@/components/provider-pool"; import { ApiServerPage } from "@/components/api-server/ApiServerPage"; import { McpPanel } from "@/components/mcp"; +import { ChannelsSettings } from "../system/channels"; import { SettingHeader } from "../features/SettingHeader"; @@ -237,6 +238,14 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode { ); + case SettingsTabs.Channels: + return ( + <> + + + + ); + case SettingsTabs.Proxy: return ( <> @@ -321,6 +330,7 @@ const WIDE_CONTENT_TABS = new Set([ SettingsTabs.Providers, SettingsTabs.ApiServer, SettingsTabs.McpServer, + SettingsTabs.Channels, SettingsTabs.ExecutionTracker, ]); diff --git a/src/components/settings-v2/hooks/useSettingsCategory.ts b/src/components/settings-v2/hooks/useSettingsCategory.ts index 712750152..c2f6bdabd 100644 --- a/src/components/settings-v2/hooks/useSettingsCategory.ts +++ b/src/components/settings-v2/hooks/useSettingsCategory.ts @@ -20,6 +20,7 @@ import { Mic, Server, Plug, + Route, Globe, ShieldCheck, HeartPulse, @@ -151,6 +152,11 @@ export function useSettingsCategory(): CategoryGroup[] { label: t("settings.tab.mcpServer", "MCP 服务器"), icon: Plug, }, + { + key: SettingsTabs.Channels, + label: t("settings.tab.channels", "渠道管理"), + icon: Route, + }, { key: SettingsTabs.Proxy, label: t("settings.tab.proxy", "网络代理"), diff --git a/src/components/settings-v2/system/channels/AIChannelFormModal.tsx b/src/components/settings-v2/system/channels/AIChannelFormModal.tsx new file mode 100644 index 000000000..8ee6c3118 --- /dev/null +++ b/src/components/settings-v2/system/channels/AIChannelFormModal.tsx @@ -0,0 +1,416 @@ +/** + * AI 渠道表单模态框组件 + * + * 用于添加或编辑 AI 渠道配置 + */ + +import React, { useState, useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Modal, ModalHeader, ModalBody, ModalFooter } from "@/components/Modal"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + type AIChannel, + type AIChannelConfig, + type ModelInfo, + AIProviderEngine, +} from "@/lib/api/channels"; + +export interface AIChannelFormModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (config: AIChannelConfig) => Promise; + initialData?: AIChannel; +} + +interface FormState { + name: string; + engine: AIProviderEngine; + display_name: string; + description: string; + api_key_env: string; + base_url: string; + models_json: string; // JSON 字符串格式存储模型列表 + headers_json: string; // JSON 字符串格式存储请求头 + timeout_seconds: string; + supports_streaming: boolean; +} + +const INITIAL_FORM_STATE: FormState = { + name: "", + engine: AIProviderEngine.OPENAI, + display_name: "", + description: "", + api_key_env: "", + base_url: "", + models_json: "[]", + headers_json: "{}", + timeout_seconds: "60", + supports_streaming: true, +}; + +export function AIChannelFormModal({ + isOpen, + onClose, + onSubmit, + initialData, +}: AIChannelFormModalProps) { + const { t } = useTranslation(); + const [formState, setFormState] = useState(INITIAL_FORM_STATE); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState>({}); + + // 重置表单或填充初始数据 + useEffect(() => { + if (isOpen) { + if (initialData) { + setFormState({ + name: initialData.name, + engine: initialData.engine, + display_name: initialData.display_name, + description: initialData.description ?? "", + api_key_env: initialData.api_key_env, + base_url: initialData.base_url, + models_json: JSON.stringify(initialData.models, null, 2), + headers_json: JSON.stringify(initialData.headers ?? {}, null, 2), + timeout_seconds: String(initialData.timeout_seconds ?? 60), + supports_streaming: initialData.supports_streaming ?? true, + }); + } else { + setFormState(INITIAL_FORM_STATE); + } + setErrors({}); + } + }, [isOpen, initialData]); + + const updateField = useCallback( + (field: K, value: FormState[K]) => { + setFormState((prev) => ({ ...prev, [field]: value })); + // 清除该字段的错误 + if (errors[field]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[field]; + return newErrors; + }); + } + }, + [errors], + ); + + const validateForm = useCallback((): boolean => { + const newErrors: Record = {}; + + if (!formState.name.trim()) { + newErrors.name = t("名称不能为空", "名称不能为空"); + } + + if (!formState.display_name.trim()) { + newErrors.display_name = t("显示名称不能为空", "显示名称不能为空"); + } + + if (!formState.api_key_env.trim()) { + newErrors.api_key_env = t("API Key 环境变量名不能为空", "API Key 环境变量名不能为空"); + } + + if (!formState.base_url.trim()) { + newErrors.base_url = t("API 地址不能为空", "API 地址不能为空"); + } else { + try { + new URL(formState.base_url.trim()); + } catch { + newErrors.base_url = t("请输入有效的 URL", "请输入有效的 URL"); + } + } + + // 验证 models_json + try { + const models = JSON.parse(formState.models_json); + if (!Array.isArray(models)) { + newErrors.models_json = t("模型列表必须是数组格式", "模型列表必须是数组格式"); + } + } catch { + newErrors.models_json = t("无效的 JSON 格式", "无效的 JSON 格式"); + } + + // 验证 headers_json + try { + JSON.parse(formState.headers_json); + } catch { + newErrors.headers_json = t("无效的 JSON 格式", "无效的 JSON 格式"); + } + + const timeout = Number(formState.timeout_seconds); + if (isNaN(timeout) || timeout <= 0) { + newErrors.timeout_seconds = t("超时时间必须是正整数", "超时时间必须是正整数"); + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }, [formState, t]); + + const handleSubmit = useCallback(async () => { + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + try { + const models: ModelInfo[] = JSON.parse(formState.models_json); + const headers: Record = JSON.parse(formState.headers_json); + + const config: AIChannelConfig = { + name: formState.name.trim(), + engine: formState.engine, + display_name: formState.display_name.trim(), + description: formState.description.trim() || undefined, + api_key_env: formState.api_key_env.trim(), + base_url: formState.base_url.trim(), + models, + headers: Object.keys(headers).length > 0 ? headers : undefined, + timeout_seconds: Number(formState.timeout_seconds), + supports_streaming: formState.supports_streaming, + }; + + await onSubmit(config); + } catch (e) { + console.error("提交失败:", e); + // 错误已经在调用处处理 + } finally { + setIsSubmitting(false); + } + }, [formState, onSubmit, validateForm]); + + return ( + + + {initialData + ? t("编辑 AI 渠道", "编辑 AI 渠道") + : t("添加 AI 渠道", "添加 AI 渠道")} + + + + {/* 名称 */} +
+ + updateField("name", e.target.value)} + placeholder="my-openai-channel" + disabled={isSubmitting} + className={errors.name ? "border-red-500" : ""} + /> + {errors.name && ( +

{errors.name}

+ )} +

+ {t("唯一标识符,只能包含小写字母、数字和连字符", "唯一标识符,只能包含小写字母、数字和连字符")} +

+
+ + {/* 显示名称 */} +
+ + updateField("display_name", e.target.value)} + placeholder="我的 OpenAI 渠道" + disabled={isSubmitting} + className={errors.display_name ? "border-red-500" : ""} + /> + {errors.display_name && ( +

{errors.display_name}

+ )} +
+ + {/* 引擎类型 */} +
+ + +
+ + {/* 描述 */} +
+ +