feat: 添加渠道管理功能并修复 workspace_mismatch 兼容性问题

- 新增渠道管理页面(AI 模型提供商 + 消息通知渠道)
- 支持飞书、Telegram、Discord 通知渠道配置
- 新增 migration_v4:启动时自动修复 [object Promise] 路径污染
- 新增 migration_v4:自动统一旧会话 working_dir 到默认 workspace
- 修复老用户升级后出现的 workspace_mismatch 错误

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
coso
2026-02-25 11:16:14 +08:00
co-authored by Claude Sonnet 4.6
parent 54611cd3ab
commit 8fec0f1088
20 changed files with 2857 additions and 1 deletions
@@ -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<MigrationResult, String> {
// 检查是否已经迁移过
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<Option<String>, 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(())
}
+18
View File
@@ -2,6 +2,7 @@ pub mod dao;
pub mod migration; pub mod migration;
pub mod migration_v2; pub mod migration_v2;
pub mod migration_v3; pub mod migration_v3;
pub mod migration_v4;
pub mod schema; pub mod schema;
pub mod system_providers; pub mod system_providers;
@@ -144,5 +145,22 @@ pub fn init_database() -> Result<DbConnection, String> {
} }
} }
// 修复 [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))) Ok(Arc::new(Mutex::new(conn)))
} }
+13
View File
@@ -806,6 +806,19 @@ pub fn run() {
// MCP 资源管理命令 // MCP 资源管理命令
commands::mcp_cmd::mcp_list_resources, commands::mcp_cmd::mcp_list_resources,
commands::mcp_cmd::mcp_read_resource, 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 // Prompt commands
commands::prompt_cmd::get_prompts, commands::prompt_cmd::get_prompts,
commands::prompt_cmd::upsert_prompt, commands::prompt_cmd::upsert_prompt,
+289
View File
@@ -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<String>,
pub api_key_env: String,
pub base_url: String,
pub models: Vec<ModelInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub supports_streaming: Option<bool>,
}
/// 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<String>,
}
/// 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<String>,
pub api_key_env: String,
pub base_url: String,
pub models: Vec<ModelInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub supports_streaming: Option<bool>,
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<String>,
}
/// 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<String>,
}
/// 通知渠道
#[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<serde_json::Value>,
}
/// 测试消息结果
#[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<Vec<AIChannel>, 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<AIChannel, String> {
tracing::info!("[渠道] 获取 AI 渠道: {}", id);
Err("暂未实现".to_string())
}
/// 创建 AI 渠道
#[tauri::command]
pub async fn create_ai_channel(
_config: AIChannelConfig,
_state: State<'_, AppState>,
) -> Result<AIChannel, String> {
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<AIChannel, String> {
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<ConnectionTestResult, String> {
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<Vec<NotificationChannel>, String> {
// TODO: 实现通知渠道获取逻辑
tracing::info!("[渠道] 获取通知渠道列表");
Ok(vec![])
}
/// 获取单个通知渠道
#[tauri::command]
pub async fn get_notification_channel(
id: String,
_state: State<'_, AppState>,
) -> Result<NotificationChannel, String> {
tracing::info!("[渠道] 获取通知渠道: {}", id);
Err("暂未实现".to_string())
}
/// 创建通知渠道
#[tauri::command]
pub async fn create_notification_channel(
_config: NotificationChannelConfig,
_state: State<'_, AppState>,
) -> Result<NotificationChannel, String> {
tracing::info!("[渠道] 创建通知渠道: {}", _config.name);
// TODO: 实现创建逻辑
Err("暂未实现".to_string())
}
/// 更新通知渠道
#[tauri::command]
pub async fn update_notification_channel(
id: String,
_config: NotificationChannelConfig,
_state: State<'_, AppState>,
) -> Result<NotificationChannel, String> {
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<TestMessageResult, String> {
tracing::info!("[渠道] 测试通知渠道: {}, 消息: {}", id, message);
// TODO: 实现测试消息逻辑
Ok(TestMessageResult {
success: false,
message: "暂未实现".to_string(),
})
}
+1
View File
@@ -1,5 +1,6 @@
pub mod a2ui_form_cmd; pub mod a2ui_form_cmd;
pub mod agent_cmd; pub mod agent_cmd;
pub mod channels_cmd;
pub mod api_key_provider_cmd; pub mod api_key_provider_cmd;
pub mod asr_cmd; pub mod asr_cmd;
pub mod aster_agent_cmd; pub mod aster_agent_cmd;
+8
View File
@@ -132,6 +132,14 @@ pub async fn workspace_create(
db: State<'_, DbConnection>, db: State<'_, DbConnection>,
request: CreateWorkspaceRequest, request: CreateWorkspaceRequest,
) -> Result<WorkspaceListItem, String> { ) -> Result<WorkspaceListItem, String> {
// 验证 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 manager = WorkspaceManager::new(db.inner().clone());
let workspace_type = request let workspace_type = request
@@ -49,6 +49,7 @@ import { ProfileSettings } from "../account/profile";
import { ProviderPoolPage } from "@/components/provider-pool"; import { ProviderPoolPage } from "@/components/provider-pool";
import { ApiServerPage } from "@/components/api-server/ApiServerPage"; import { ApiServerPage } from "@/components/api-server/ApiServerPage";
import { McpPanel } from "@/components/mcp"; import { McpPanel } from "@/components/mcp";
import { ChannelsSettings } from "../system/channels";
import { SettingHeader } from "../features/SettingHeader"; import { SettingHeader } from "../features/SettingHeader";
@@ -237,6 +238,14 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
</> </>
); );
case SettingsTabs.Channels:
return (
<>
<SettingHeader title="渠道管理" />
<ChannelsSettings />
</>
);
case SettingsTabs.Proxy: case SettingsTabs.Proxy:
return ( return (
<> <>
@@ -321,6 +330,7 @@ const WIDE_CONTENT_TABS = new Set<SettingsTabs>([
SettingsTabs.Providers, SettingsTabs.Providers,
SettingsTabs.ApiServer, SettingsTabs.ApiServer,
SettingsTabs.McpServer, SettingsTabs.McpServer,
SettingsTabs.Channels,
SettingsTabs.ExecutionTracker, SettingsTabs.ExecutionTracker,
]); ]);
@@ -20,6 +20,7 @@ import {
Mic, Mic,
Server, Server,
Plug, Plug,
Route,
Globe, Globe,
ShieldCheck, ShieldCheck,
HeartPulse, HeartPulse,
@@ -151,6 +152,11 @@ export function useSettingsCategory(): CategoryGroup[] {
label: t("settings.tab.mcpServer", "MCP 服务器"), label: t("settings.tab.mcpServer", "MCP 服务器"),
icon: Plug, icon: Plug,
}, },
{
key: SettingsTabs.Channels,
label: t("settings.tab.channels", "渠道管理"),
icon: Route,
},
{ {
key: SettingsTabs.Proxy, key: SettingsTabs.Proxy,
label: t("settings.tab.proxy", "网络代理"), label: t("settings.tab.proxy", "网络代理"),
@@ -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<void>;
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<FormState>(INITIAL_FORM_STATE);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
// 重置表单或填充初始数据
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(
<K extends keyof FormState>(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<string, string> = {};
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<string, string> = 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 (
<Modal isOpen={isOpen} onClose={onClose} maxWidth="max-w-2xl">
<ModalHeader>
{initialData
? t("编辑 AI 渠道", "编辑 AI 渠道")
: t("添加 AI 渠道", "添加 AI 渠道")}
</ModalHeader>
<ModalBody className="space-y-4 max-h-[70vh] overflow-y-auto">
{/* 名称 */}
<div className="space-y-1.5">
<Label htmlFor="name">
名称 <span className="text-red-500">*</span>
</Label>
<Input
id="name"
type="text"
value={formState.name}
onChange={(e) => updateField("name", e.target.value)}
placeholder="my-openai-channel"
disabled={isSubmitting}
className={errors.name ? "border-red-500" : ""}
/>
{errors.name && (
<p className="text-xs text-red-500">{errors.name}</p>
)}
<p className="text-xs text-muted-foreground">
{t("唯一标识符,只能包含小写字母、数字和连字符", "唯一标识符,只能包含小写字母、数字和连字符")}
</p>
</div>
{/* 显示名称 */}
<div className="space-y-1.5">
<Label htmlFor="display_name">
{t("显示名称", "显示名称")} <span className="text-red-500">*</span>
</Label>
<Input
id="display_name"
type="text"
value={formState.display_name}
onChange={(e) => updateField("display_name", e.target.value)}
placeholder="我的 OpenAI 渠道"
disabled={isSubmitting}
className={errors.display_name ? "border-red-500" : ""}
/>
{errors.display_name && (
<p className="text-xs text-red-500">{errors.display_name}</p>
)}
</div>
{/* 引擎类型 */}
<div className="space-y-1.5">
<Label htmlFor="engine">{t("引擎类型", "引擎类型")}</Label>
<Select
value={formState.engine}
onValueChange={(value) => updateField("engine", value as AIProviderEngine)}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={AIProviderEngine.OPENAI}>OpenAI</SelectItem>
<SelectItem value={AIProviderEngine.OLLAMA}>Ollama</SelectItem>
<SelectItem value={AIProviderEngine.ANTHROPIC}>
Anthropic
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 描述 */}
<div className="space-y-1.5">
<Label htmlFor="description">{t("描述", "描述")}</Label>
<Textarea
id="description"
value={formState.description}
onChange={(e) => updateField("description", e.target.value)}
placeholder={t("可选的渠道描述", "可选的渠道描述")}
disabled={isSubmitting}
rows={2}
/>
</div>
{/* API Key 环境变量名 */}
<div className="space-y-1.5">
<Label htmlFor="api_key_env">
API Key {t("环境变量名", "环境变量名")} <span className="text-red-500">*</span>
</Label>
<Input
id="api_key_env"
type="text"
value={formState.api_key_env}
onChange={(e) => updateField("api_key_env", e.target.value)}
placeholder="OPENAI_API_KEY"
disabled={isSubmitting}
className={errors.api_key_env ? "border-red-500" : ""}
/>
{errors.api_key_env && (
<p className="text-xs text-red-500">{errors.api_key_env}</p>
)}
<p className="text-xs text-muted-foreground">
{t("存储 API Key 的环境变量名称", "存储 API Key 的环境变量名称")}
</p>
</div>
{/* API 地址 */}
<div className="space-y-1.5">
<Label htmlFor="base_url">
API {t("地址", "地址")} <span className="text-red-500">*</span>
</Label>
<Input
id="base_url"
type="text"
value={formState.base_url}
onChange={(e) => updateField("base_url", e.target.value)}
placeholder="https://api.openai.com/v1"
disabled={isSubmitting}
className={errors.base_url ? "border-red-500" : ""}
/>
{errors.base_url && (
<p className="text-xs text-red-500">{errors.base_url}</p>
)}
</div>
{/* 模型列表 */}
<div className="space-y-1.5">
<Label htmlFor="models_json">{t("模型列表", "模型列表")}</Label>
<Textarea
id="models_json"
value={formState.models_json}
onChange={(e) => updateField("models_json", e.target.value)}
placeholder={`[
{
"id": "gpt-4",
"name": "GPT-4",
"description": "最强大的模型"
}
]`}
disabled={isSubmitting}
rows={6}
className={errors.models_json ? "border-red-500" : "font-mono text-xs"}
/>
{errors.models_json && (
<p className="text-xs text-red-500">{errors.models_json}</p>
)}
<p className="text-xs text-muted-foreground">
{t("JSON 格式的模型列表", "JSON 格式的模型列表")}
</p>
</div>
{/* 自定义请求头 */}
<div className="space-y-1.5">
<Label htmlFor="headers_json">{t("自定义请求头", "自定义请求头")}</Label>
<Textarea
id="headers_json"
value={formState.headers_json}
onChange={(e) => updateField("headers_json", e.target.value)}
placeholder={`{
"X-Custom-Header": "value"
}`}
disabled={isSubmitting}
rows={3}
className={errors.headers_json ? "border-red-500" : "font-mono text-xs"}
/>
{errors.headers_json && (
<p className="text-xs text-red-500">{errors.headers_json}</p>
)}
<p className="text-xs text-muted-foreground">
{t("JSON 格式的自定义请求头(可选)", "JSON 格式的自定义请求头(可选)")}
</p>
</div>
{/* 超时时间 */}
<div className="space-y-1.5">
<Label htmlFor="timeout_seconds">{t("超时时间", "超时时间")}(秒)</Label>
<Input
id="timeout_seconds"
type="number"
value={formState.timeout_seconds}
onChange={(e) => updateField("timeout_seconds", e.target.value)}
min="1"
disabled={isSubmitting}
className={errors.timeout_seconds ? "border-red-500" : ""}
/>
{errors.timeout_seconds && (
<p className="text-xs text-red-500">{errors.timeout_seconds}</p>
)}
</div>
{/* 支持流式 */}
<div className="flex items-center justify-between">
<div>
<Label>{t("支持流式输出", "支持流式输出")}</Label>
<p className="text-xs text-muted-foreground">
{t("该渠道是否支持 SSE 流式响应", "该渠道是否支持 SSE 流式响应")}
</p>
</div>
<Switch
checked={formState.supports_streaming}
onCheckedChange={(checked) => updateField("supports_streaming", checked)}
disabled={isSubmitting}
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{t("取消", "取消")}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting
? t("保存中...", "保存中...")
: initialData
? t("保存", "保存")
: t("添加", "添加")}
</Button>
</ModalFooter>
</Modal>
);
}
export default AIChannelFormModal;
@@ -0,0 +1,255 @@
/**
* AI 渠道列表组件
*
* 显示 AI 模型提供商渠道列表,支持添加、编辑、删除、启用/禁用和连接测试
*/
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Pencil, Trash2, Power } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import {
aiChannelsApi,
type AIChannel,
type AIChannelConfig,
AIProviderEngine,
} from "@/lib/api/channels";
import { AIChannelFormModal } from "./AIChannelFormModal";
import { ConnectionTestButton } from "./ConnectionTestButton";
import { DeleteChannelDialog } from "./DeleteChannelDialog";
export function AIChannelsList() {
const { t } = useTranslation();
const [channels, setChannels] = useState<AIChannel[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [editingChannel, setEditingChannel] = useState<AIChannel | null>(null);
const [deletingChannel, setDeletingChannel] = useState<AIChannel | null>(null);
// 加载渠道列表
const loadChannels = useCallback(async () => {
setLoading(true);
try {
const data = await aiChannelsApi.getChannels();
setChannels(data);
} catch (e) {
console.error("加载 AI 渠道失败:", e);
toast.error(`加载失败: ${e instanceof Error ? e.message : e}`);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadChannels();
}, [loadChannels]);
// 添加渠道
const handleAdd = useCallback(
async (config: AIChannelConfig) => {
try {
await aiChannelsApi.createChannel(config);
toast.success("AI 渠道已创建");
await loadChannels();
setShowAddModal(false);
} catch (e) {
throw e;
}
},
[loadChannels],
);
// 更新渠道
const handleUpdate = useCallback(
async (id: string, config: AIChannelConfig) => {
try {
await aiChannelsApi.updateChannel(id, config);
toast.success("AI 渠道已更新");
await loadChannels();
setEditingChannel(null);
} catch (e) {
throw e;
}
},
[loadChannels],
);
// 删除渠道
const handleDelete = useCallback(async () => {
if (!deletingChannel) return;
try {
await aiChannelsApi.deleteChannel(deletingChannel.id);
toast.success("AI 渠道已删除");
await loadChannels();
setDeletingChannel(null);
} catch (e) {
toast.error(`删除失败: ${e instanceof Error ? e.message : e}`);
}
}, [deletingChannel, loadChannels]);
// 切换启用状态
const handleToggle = useCallback(
async (channel: AIChannel, enabled: boolean) => {
try {
await aiChannelsApi.updateChannel(channel.id, {
...channel,
enabled,
} as AIChannelConfig);
toast.success(enabled ? "AI 渠道已启用" : "AI 渠道已禁用");
await loadChannels();
} catch (e) {
toast.error(`操作失败: ${e instanceof Error ? e.message : e}`);
}
},
[loadChannels],
);
// 获取引擎名称
const getEngineName = (engine: AIProviderEngine): string => {
const names: Record<AIProviderEngine, string> = {
[AIProviderEngine.OPENAI]: "OpenAI",
[AIProviderEngine.OLLAMA]: "Ollama",
[AIProviderEngine.ANTHROPIC]: "Anthropic",
};
return names[engine] || engine;
};
if (loading) {
return (
<div className="flex items-center justify-center h-32">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="space-y-4">
{/* 头部操作栏 */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">
{t("AI 模型提供商", "AI 模型提供商")}
</h3>
<p className="text-xs text-muted-foreground">
{t("管理 OpenAI、Ollama、Anthropic 等 AI 服务提供商", "管理 OpenAI、Ollama、Anthropic 等 AI 服务提供商")}
</p>
</div>
<Button size="sm" onClick={() => setShowAddModal(true)}>
<Plus className="h-4 w-4 mr-1" />
{t("添加 AI 渠道", "添加 AI 渠道")}
</Button>
</div>
{/* 渠道列表 */}
{channels.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<p>{t("暂无 AI 渠道", "暂无 AI 渠道")}</p>
<p className="text-xs mt-1">
{t("点击上方按钮添加您的第一个 AI 渠道", "点击上方按钮添加您的第一个 AI 渠道")}
</p>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("名称", "名称")}</TableHead>
<TableHead>{t("引擎", "引擎")}</TableHead>
<TableHead>{t("API 地址", "API 地址")}</TableHead>
<TableHead>{t("模型数", "模型数")}</TableHead>
<TableHead>{t("状态", "状态")}</TableHead>
<TableHead className="text-right">
{t("操作", "操作")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channels.map((channel) => (
<TableRow key={channel.id}>
<TableCell className="font-medium">
{channel.display_name}
</TableCell>
<TableCell>{getEngineName(channel.engine)}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{channel.base_url}
</TableCell>
<TableCell>{channel.models.length}</TableCell>
<TableCell>
<Switch
checked={channel.enabled}
onCheckedChange={(checked) =>
handleToggle(channel, checked)
}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<ConnectionTestButton
channelId={channel.id}
channelName={channel.display_name}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditingChannel(channel)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setDeletingChannel(channel)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 添加模态框 */}
<AIChannelFormModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSubmit={handleAdd}
/>
{/* 编辑模态框 */}
{editingChannel && (
<AIChannelFormModal
isOpen={!!editingChannel}
onClose={() => setEditingChannel(null)}
onSubmit={(config) => handleUpdate(editingChannel.id, config)}
initialData={editingChannel}
/>
)}
{/* 删除确认对话框 */}
<DeleteChannelDialog
isOpen={!!deletingChannel}
channelName={deletingChannel?.display_name ?? ""}
onClose={() => setDeletingChannel(null)}
onConfirm={handleDelete}
/>
</div>
);
}
export default AIChannelsList;
@@ -0,0 +1,62 @@
/**
* 连接测试按钮组件
*
* 用于测试 AI 渠道的连接状态
*/
import React, { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Plug, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { aiChannelsApi } from "@/lib/api/channels";
export interface ConnectionTestButtonProps {
channelId: string;
channelName: string;
}
export function ConnectionTestButton({
channelId,
channelName,
}: ConnectionTestButtonProps) {
const { t } = useTranslation();
const [testing, setTesting] = useState(false);
const handleTest = useCallback(async () => {
setTesting(true);
try {
const result = await aiChannelsApi.testChannel(channelId);
if (result.success) {
toast.success(t("连接成功", "连接成功") + ": " + result.message);
} else {
toast.error(t("连接失败", "连接失败") + ": " + result.message);
}
} catch (e) {
toast.error(
t("测试失败", "测试失败") + ": " + (e instanceof Error ? e.message : e),
);
} finally {
setTesting(false);
}
}, [channelId, t]);
return (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleTest}
disabled={testing}
title={t("测试连接", "测试连接")}
>
{testing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plug className="h-4 w-4" />
)}
</Button>
);
}
export default ConnectionTestButton;
@@ -0,0 +1,62 @@
/**
* 删除渠道确认对话框组件
*
* 用于确认删除操作
*/
import React from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteChannelDialogProps {
isOpen: boolean;
channelName: string;
onClose: () => void;
onConfirm: () => void;
}
export function DeleteChannelDialog({
isOpen,
channelName,
onClose,
onConfirm,
}: DeleteChannelDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("删除确认", "删除确认")}
</DialogTitle>
<DialogDescription>
{t(
"确定要删除渠道 \"{name}\" 吗?此操作无法撤销。",
"确定要删除渠道 \"{name}\" 吗?此操作无法撤销。",
{ name: channelName }
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("取消", "取消")}
</Button>
<Button variant="destructive" onClick={onConfirm}>
{t("删除", "删除")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default DeleteChannelDialog;
@@ -0,0 +1,453 @@
/**
* 通知渠道表单模态框组件
*
* 用于添加或编辑消息通知渠道配置(飞书、Telegram、Discord)
*/
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
type NotificationChannel,
type NotificationChannelConfig,
NotificationChannelType,
} from "@/lib/api/channels";
export interface NotificationChannelFormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (config: NotificationChannelConfig) => Promise<void>;
initialData?: NotificationChannel;
}
interface FormState {
channel_type: NotificationChannelType;
name: string;
// 飞书配置
feishu_webhook_url: string;
feishu_secret: string;
// Telegram 配置
telegram_bot_token: string;
telegram_chat_id: string;
// Discord 配置
discord_webhook_url: string;
discord_username: string;
}
const INITIAL_FORM_STATE: FormState = {
channel_type: NotificationChannelType.FEISHU,
name: "",
feishu_webhook_url: "",
feishu_secret: "",
telegram_bot_token: "",
telegram_chat_id: "",
discord_webhook_url: "",
discord_username: "",
};
export function NotificationChannelFormModal({
isOpen,
onClose,
onSubmit,
initialData,
}: NotificationChannelFormModalProps) {
const { t } = useTranslation();
const [formState, setFormState] = useState<FormState>(INITIAL_FORM_STATE);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
// 重置表单或填充初始数据
useEffect(() => {
if (isOpen) {
if (initialData) {
const newState = { ...INITIAL_FORM_STATE };
newState.channel_type = initialData.channel_type;
newState.name = initialData.name;
// 根据渠道类型填充特定配置
switch (initialData.channel_type) {
case NotificationChannelType.FEISHU: {
const cfg = initialData.config as { type: NotificationChannelType.FEISHU; webhook_url: string; secret?: string };
newState.feishu_webhook_url = cfg.webhook_url ?? "";
newState.feishu_secret = cfg.secret ?? "";
break;
}
case NotificationChannelType.TELEGRAM: {
const cfg = initialData.config as { type: NotificationChannelType.TELEGRAM; bot_token: string; chat_id: string };
newState.telegram_bot_token = cfg.bot_token;
newState.telegram_chat_id = cfg.chat_id;
break;
}
case NotificationChannelType.DISCORD: {
const cfg = initialData.config as { type: NotificationChannelType.DISCORD; webhook_url: string; username?: string };
newState.discord_webhook_url = cfg.webhook_url;
newState.discord_username = cfg.username ?? "";
break;
}
}
setFormState(newState);
} else {
setFormState(INITIAL_FORM_STATE);
}
setErrors({});
}
}, [isOpen, initialData]);
const updateField = useCallback(
<K extends keyof FormState>(field: K, value: FormState[K]) => {
setFormState((prev) => ({ ...prev, [field]: value }));
// 清除该字段的错误
const fieldKey = field as string;
if (errors[fieldKey]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[fieldKey];
return newErrors;
});
}
},
[errors],
);
const validateForm = useCallback((): boolean => {
const newErrors: Record<string, string> = {};
if (!formState.name.trim()) {
newErrors.name = t("名称不能为空", "名称不能为空");
}
// 根据渠道类型验证特定字段
switch (formState.channel_type) {
case NotificationChannelType.FEISHU:
if (!formState.feishu_webhook_url.trim()) {
newErrors.feishu_webhook_url = t("Webhook 地址不能为空", "Webhook 地址不能为空");
} else {
try {
new URL(formState.feishu_webhook_url.trim());
} catch {
newErrors.feishu_webhook_url = t("请输入有效的 URL", "请输入有效的 URL");
}
}
break;
case NotificationChannelType.TELEGRAM:
if (!formState.telegram_bot_token.trim()) {
newErrors.telegram_bot_token = t("Bot Token 不能为空", "Bot Token 不能为空");
}
if (!formState.telegram_chat_id.trim()) {
newErrors.telegram_chat_id = t("聊天 ID 不能为空", "聊天 ID 不能为空");
}
break;
case NotificationChannelType.DISCORD:
if (!formState.discord_webhook_url.trim()) {
newErrors.discord_webhook_url = t("Webhook URL 不能为空", "Webhook URL 不能为空");
} else {
try {
new URL(formState.discord_webhook_url.trim());
} catch {
newErrors.discord_webhook_url = t("请输入有效的 URL", "请输入有效的 URL");
}
}
break;
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [formState, t]);
const handleSubmit = useCallback(async () => {
if (!validateForm()) {
return;
}
setIsSubmitting(true);
try {
let config: NotificationChannelConfig;
switch (formState.channel_type) {
case NotificationChannelType.FEISHU:
config = {
name: formState.name.trim(),
channel_type: formState.channel_type,
config: {
type: NotificationChannelType.FEISHU,
webhook_url: formState.feishu_webhook_url.trim(),
secret: formState.feishu_secret.trim() || undefined,
},
};
break;
case NotificationChannelType.TELEGRAM:
config = {
name: formState.name.trim(),
channel_type: formState.channel_type,
config: {
type: NotificationChannelType.TELEGRAM,
bot_token: formState.telegram_bot_token.trim(),
chat_id: formState.telegram_chat_id.trim(),
},
};
break;
case NotificationChannelType.DISCORD:
config = {
name: formState.name.trim(),
channel_type: formState.channel_type,
config: {
type: NotificationChannelType.DISCORD,
webhook_url: formState.discord_webhook_url.trim(),
username: formState.discord_username.trim() || undefined,
},
};
break;
default:
throw new Error("未知的通知渠道类型");
}
await onSubmit(config);
} catch (e) {
console.error("提交失败:", e);
// 错误已经在调用处处理
} finally {
setIsSubmitting(false);
}
}, [formState, onSubmit, validateForm]);
return (
<Modal isOpen={isOpen} onClose={onClose} maxWidth="max-w-md">
<ModalHeader>
{initialData
? t("编辑通知渠道", "编辑通知渠道")
: t("添加通知渠道", "添加通知渠道")}
</ModalHeader>
<ModalBody className="space-y-4">
{/* 渠道类型 */}
<div className="space-y-1.5">
<Label htmlFor="channel_type">
{t("渠道类型", "渠道类型")} <span className="text-red-500">*</span>
</Label>
<Select
value={formState.channel_type}
onValueChange={(value) =>
updateField("channel_type", value as NotificationChannelType)
}
disabled={isSubmitting || !!initialData}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NotificationChannelType.FEISHU}>
{t("飞书", "飞书")}
</SelectItem>
<SelectItem value={NotificationChannelType.TELEGRAM}>
Telegram
</SelectItem>
<SelectItem value={NotificationChannelType.DISCORD}>
Discord
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("选择消息通知渠道类型", "选择消息通知渠道类型")}
</p>
</div>
{/* 名称 */}
<div className="space-y-1.5">
<Label htmlFor="name">
{t("名称", "名称")} <span className="text-red-500">*</span>
</Label>
<Input
id="name"
type="text"
value={formState.name}
onChange={(e) => updateField("name", e.target.value)}
placeholder={t("我的飞书通知", "我的飞书通知")}
disabled={isSubmitting}
className={errors.name ? "border-red-500" : ""}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</div>
{/* 飞书配置 */}
{formState.channel_type === NotificationChannelType.FEISHU && (
<>
<div className="border-t pt-4">
<h4 className="text-sm font-medium mb-3">
{t("飞书配置", "飞书配置")}
</h4>
</div>
<div className="space-y-1.5">
<Label htmlFor="feishu_webhook_url">
Webhook {t("地址", "地址")} <span className="text-red-500">*</span>
</Label>
<Input
id="feishu_webhook_url"
type="text"
value={formState.feishu_webhook_url}
onChange={(e) => updateField("feishu_webhook_url", e.target.value)}
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
disabled={isSubmitting}
className={errors.feishu_webhook_url ? "border-red-500" : ""}
/>
{errors.feishu_webhook_url && (
<p className="text-xs text-red-500">{errors.feishu_webhook_url}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="feishu_secret">
{t("签名密钥", "签名密钥")} ({t("可选", "可选")})
</Label>
<Input
id="feishu_secret"
type="password"
value={formState.feishu_secret}
onChange={(e) => updateField("feishu_secret", e.target.value)}
placeholder="********"
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
{t(
"用于验证 Webhook 请求的签名密钥",
"用于验证 Webhook 请求的签名密钥",
)}
</p>
</div>
</>
)}
{/* Telegram 配置 */}
{formState.channel_type === NotificationChannelType.TELEGRAM && (
<>
<div className="border-t pt-4">
<h4 className="text-sm font-medium mb-3">
Telegram {t("配置", "配置")}
</h4>
</div>
<div className="space-y-1.5">
<Label htmlFor="telegram_bot_token">
Bot Token <span className="text-red-500">*</span>
</Label>
<Input
id="telegram_bot_token"
type="password"
value={formState.telegram_bot_token}
onChange={(e) => updateField("telegram_bot_token", e.target.value)}
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
disabled={isSubmitting}
className={errors.telegram_bot_token ? "border-red-500" : ""}
/>
{errors.telegram_bot_token && (
<p className="text-xs text-red-500">{errors.telegram_bot_token}</p>
)}
<p className="text-xs text-muted-foreground">
{t("从 @BotFather 获取", "从 @BotFather 获取")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="telegram_chat_id">
Chat ID <span className="text-red-500">*</span>
</Label>
<Input
id="telegram_chat_id"
type="text"
value={formState.telegram_chat_id}
onChange={(e) => updateField("telegram_chat_id", e.target.value)}
placeholder="-1001234567890"
disabled={isSubmitting}
className={errors.telegram_chat_id ? "border-red-500" : ""}
/>
{errors.telegram_chat_id && (
<p className="text-xs text-red-500">{errors.telegram_chat_id}</p>
)}
<p className="text-xs text-muted-foreground">
{t("目标聊天或频道的 ID", "目标聊天或频道的 ID")}
</p>
</div>
</>
)}
{/* Discord 配置 */}
{formState.channel_type === NotificationChannelType.DISCORD && (
<>
<div className="border-t pt-4">
<h4 className="text-sm font-medium mb-3">
Discord {t("配置", "配置")}
</h4>
</div>
<div className="space-y-1.5">
<Label htmlFor="discord_webhook_url">
Webhook URL <span className="text-red-500">*</span>
</Label>
<Input
id="discord_webhook_url"
type="text"
value={formState.discord_webhook_url}
onChange={(e) => updateField("discord_webhook_url", e.target.value)}
placeholder="https://discord.com/api/webhooks/..."
disabled={isSubmitting}
className={errors.discord_webhook_url ? "border-red-500" : ""}
/>
{errors.discord_webhook_url && (
<p className="text-xs text-red-500">{errors.discord_webhook_url}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="discord_username">
{t("自定义用户名", "自定义用户名")} ({t("可选", "可选")})
</Label>
<Input
id="discord_username"
type="text"
value={formState.discord_username}
onChange={(e) => updateField("discord_username", e.target.value)}
placeholder="ProxyCast Bot"
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
{t("覆盖机器人的默认用户名", "覆盖机器人的默认用户名")}
</p>
</div>
</>
)}
</ModalBody>
<ModalFooter>
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{t("取消", "取消")}
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting
? t("保存中...", "保存中...")
: initialData
? t("保存", "保存")
: t("添加", "添加")}
</Button>
</ModalFooter>
</Modal>
);
}
export default NotificationChannelFormModal;
@@ -0,0 +1,272 @@
/**
* 通知渠道列表组件
*
* 显示消息通知渠道列表(飞书、Telegram、Discord),支持添加、编辑、删除、启用/禁用和发送测试消息
*/
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Pencil, Trash2, Send } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import {
notificationChannelsApi,
type NotificationChannel,
type NotificationChannelConfig,
NotificationChannelType,
} from "@/lib/api/channels";
import { NotificationChannelFormModal } from "./NotificationChannelFormModal";
import { SendTestMessageButton } from "./SendTestMessageButton";
import { DeleteChannelDialog } from "./DeleteChannelDialog";
export function NotificationChannelsList() {
const { t } = useTranslation();
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [editingChannel, setEditingChannel] = useState<NotificationChannel | null>(null);
const [deletingChannel, setDeletingChannel] = useState<NotificationChannel | null>(null);
// 加载渠道列表
const loadChannels = useCallback(async () => {
setLoading(true);
try {
const data = await notificationChannelsApi.getChannels();
setChannels(data);
} catch (e) {
console.error("加载通知渠道失败:", e);
toast.error(`加载失败: ${e instanceof Error ? e.message : e}`);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadChannels();
}, [loadChannels]);
// 添加渠道
const handleAdd = useCallback(
async (config: NotificationChannelConfig) => {
try {
await notificationChannelsApi.createChannel(config);
toast.success("通知渠道已创建");
await loadChannels();
setShowAddModal(false);
} catch (e) {
throw e;
}
},
[loadChannels],
);
// 更新渠道
const handleUpdate = useCallback(
async (id: string, config: NotificationChannelConfig) => {
try {
await notificationChannelsApi.updateChannel(id, config);
toast.success("通知渠道已更新");
await loadChannels();
setEditingChannel(null);
} catch (e) {
throw e;
}
},
[loadChannels],
);
// 删除渠道
const handleDelete = useCallback(async () => {
if (!deletingChannel) return;
try {
await notificationChannelsApi.deleteChannel(deletingChannel.id);
toast.success("通知渠道已删除");
await loadChannels();
setDeletingChannel(null);
} catch (e) {
toast.error(`删除失败: ${e instanceof Error ? e.message : e}`);
}
}, [deletingChannel, loadChannels]);
// 切换启用状态
const handleToggle = useCallback(
async (channel: NotificationChannel, enabled: boolean) => {
try {
await notificationChannelsApi.updateChannel(channel.id, {
...channel,
enabled,
} as NotificationChannelConfig);
toast.success(enabled ? "通知渠道已启用" : "通知渠道已禁用");
await loadChannels();
} catch (e) {
toast.error(`操作失败: ${e instanceof Error ? e.message : e}`);
}
},
[loadChannels],
);
// 获取渠道类型名称
const getChannelTypeName = (type: NotificationChannelType): string => {
const names: Record<NotificationChannelType, string> = {
[NotificationChannelType.FEISHU]: t("飞书", "飞书"),
[NotificationChannelType.TELEGRAM]: t("Telegram", "Telegram"),
[NotificationChannelType.DISCORD]: t("Discord", "Discord"),
};
return names[type] || type;
};
// 获取渠道配置摘要
const getConfigSummary = (channel: NotificationChannel): string => {
switch (channel.channel_type) {
case NotificationChannelType.FEISHU:
return t("Webhook", "Webhook");
case NotificationChannelType.TELEGRAM:
return t("Bot", "Bot");
case NotificationChannelType.DISCORD:
return t("Webhook", "Webhook");
default:
return "-";
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-32">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="space-y-4">
{/* 头部操作栏 */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">
{t("消息通知渠道", "消息通知渠道")}
</h3>
<p className="text-xs text-muted-foreground">
{t("管理飞书、Telegram、Discord 等消息通知渠道", "管理飞书、Telegram、Discord 等消息通知渠道")}
</p>
</div>
<Button size="sm" onClick={() => setShowAddModal(true)}>
<Plus className="h-4 w-4 mr-1" />
{t("添加通知渠道", "添加通知渠道")}
</Button>
</div>
{/* 渠道列表 */}
{channels.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<p>{t("暂无通知渠道", "暂无通知渠道")}</p>
<p className="text-xs mt-1">
{t("点击上方按钮添加您的第一个通知渠道", "点击上方按钮添加您的第一个通知渠道")}
</p>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("名称", "名称")}</TableHead>
<TableHead>{t("类型", "类型")}</TableHead>
<TableHead>{t("配置", "配置")}</TableHead>
<TableHead>{t("状态", "状态")}</TableHead>
<TableHead className="text-right">
{t("操作", "操作")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{channels.map((channel) => (
<TableRow key={channel.id}>
<TableCell className="font-medium">
{channel.name}
</TableCell>
<TableCell>
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">
{getChannelTypeName(channel.channel_type)}
</span>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{getConfigSummary(channel)}
</TableCell>
<TableCell>
<Switch
checked={channel.enabled}
onCheckedChange={(checked) =>
handleToggle(channel, checked)
}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<SendTestMessageButton
channelId={channel.id}
channelName={channel.name}
channelType={channel.channel_type}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditingChannel(channel)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setDeletingChannel(channel)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 添加模态框 */}
<NotificationChannelFormModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSubmit={handleAdd}
/>
{/* 编辑模态框 */}
{editingChannel && (
<NotificationChannelFormModal
isOpen={!!editingChannel}
onClose={() => setEditingChannel(null)}
onSubmit={(config) => handleUpdate(editingChannel.id, config)}
initialData={editingChannel}
/>
)}
{/* 删除确认对话框 */}
<DeleteChannelDialog
isOpen={!!deletingChannel}
channelName={deletingChannel?.name ?? ""}
onClose={() => setDeletingChannel(null)}
onConfirm={handleDelete}
/>
</div>
);
}
export default NotificationChannelsList;
@@ -0,0 +1,113 @@
/**
* 发送测试消息按钮组件
*
* 用于向通知渠道发送测试消息
*/
import React, { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Send, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { notificationChannelsApi, NotificationChannelType } from "@/lib/api/channels";
export interface SendTestMessageButtonProps {
channelId: string;
channelName: string;
channelType: NotificationChannelType;
}
const DEFAULT_TEST_MESSAGES = {
[NotificationChannelType.FEISHU]: "这是一条来自 ProxyCast 的测试消息 📱",
[NotificationChannelType.TELEGRAM]: "This is a test message from ProxyCast 🚀",
[NotificationChannelType.DISCORD]: "🎉 Test message from ProxyCast",
};
export function SendTestMessageButton({
channelId,
channelName,
channelType,
}: SendTestMessageButtonProps) {
const { t } = useTranslation();
const [sending, setSending] = useState(false);
const handleSend = useCallback(
async (message?: string) => {
setSending(true);
try {
const testMessage =
message ||
DEFAULT_TEST_MESSAGES[channelType] ||
t("测试消息", "这是一条测试消息");
const result = await notificationChannelsApi.testChannel(
channelId,
testMessage
);
if (result.success) {
toast.success(
t("发送成功", "发送成功") + ": " + result.message
);
} else {
toast.error(
t("发送失败", "发送失败") + ": " + result.message
);
}
} catch (e) {
toast.error(
t("发送失败", "发送失败") + ": " + (e instanceof Error ? e.message : e)
);
} finally {
setSending(false);
}
},
[channelId, channelType, t]
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={sending}
title={t("发送测试消息", "发送测试消息")}
>
{sending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleSend()}>
{t("发送默认测试消息", "发送默认测试消息")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
const customMessage = prompt(
t("请输入测试消息内容", "请输入测试消息内容"),
DEFAULT_TEST_MESSAGES[channelType]
);
if (customMessage && customMessage.trim()) {
handleSend(customMessage.trim());
}
}}
>
{t("发送自定义消息...", "发送自定义消息...")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default SendTestMessageButton;
@@ -0,0 +1,54 @@
/**
* 渠道管理设置页面
*
* 提供两类渠道的管理:
* - AI 模型提供商渠道(OpenAI、Ollama、Anthropic 等)
* - 消息通知渠道(飞书、Telegram、Discord 等)
*/
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SettingHeader } from "@/components/settings-v2/features/SettingHeader";
import { AIChannelsList } from "./AIChannelsList";
import { NotificationChannelsList } from "./NotificationChannelsList";
export interface ChannelsSettingsProps {
className?: string;
}
export function ChannelsSettings({ className }: ChannelsSettingsProps) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"ai" | "notification">("ai");
return (
<div className={className}>
<SettingHeader title={t("渠道管理", "渠道管理")} />
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as "ai" | "notification")}
className="w-full"
>
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="ai">
{t("AI 模型提供商", "AI 模型提供商")}
</TabsTrigger>
<TabsTrigger value="notification">
{t("消息通知渠道", "消息通知渠道")}
</TabsTrigger>
</TabsList>
<TabsContent value="ai" className="mt-6">
<AIChannelsList />
</TabsContent>
<TabsContent value="notification" className="mt-6">
<NotificationChannelsList />
</TabsContent>
</Tabs>
</div>
);
}
export default ChannelsSettings;
+99
View File
@@ -3743,4 +3743,103 @@
"在下方输入详细的提示词以开始创作。": "Enter a detailed prompt below to begin your creative journey.", "在下方输入详细的提示词以开始创作。": "Enter a detailed prompt below to begin your creative journey.",
"描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "Describe the image you want to generate (e.g., 'A futuristic city with flying cars at sunset, cyberpunk style')...", "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "Describe the image you want to generate (e.g., 'A futuristic city with flying cars at sunset, cyberpunk style')...",
"新建图片": "New Image" "新建图片": "New Image"
,
"// === Channels Settings (src/components/settings-v2/system/channels/) ===": "",
"渠道管理": "Channel Management",
"AI 模型提供商": "AI Model Providers",
"消息通知渠道": "Notification Channels",
"添加 AI 渠道": "Add AI Channel",
"添加通知渠道": "Add Notification Channel",
"编辑 AI 渠道": "Edit AI Channel",
"编辑通知渠道": "Edit Notification Channel",
"飞书": "Feishu",
"Telegram": "Telegram",
"Discord": "Discord",
"名称": "Name",
"显示名称": "Display Name",
"引擎": "Engine",
"引擎类型": "Engine Type",
"描述": "Description",
"API 地址": "API URL",
"地址": "URL",
"Webhook 地址": "Webhook URL",
"Webhook URL": "Webhook URL",
"签名密钥": "Secret",
"Bot Token": "Bot Token",
"聊天 ID": "Chat ID",
"Chat ID": "Chat ID",
"自定义用户名": "Custom Username",
"模型数": "Models",
"模型列表": "Model List",
"API Key 环境变量名": "API Key Env Name",
"环境变量名": "Environment Variable Name",
"自定义请求头": "Custom Headers",
"超时时间": "Timeout",
"支持流式输出": "Support Streaming",
"状态": "Status",
"操作": "Actions",
"测试连接": "Test Connection",
"发送测试消息": "Send Test Message",
"测试消息": "Test Message",
"连接成功": "Connection Successful",
"连接失败": "Connection Failed",
"发送成功": "Sent Successfully",
"发送失败": "Send Failed",
"测试失败": "Test Failed",
"发送默认测试消息": "Send Default Test Message",
"发送自定义消息...": "Send Custom Message...",
"请输入测试消息内容": "Enter test message content",
"类型": "Type",
"配置": "Config",
"Webhook": "Webhook",
"Bot": "Bot",
"暂无 AI 渠道": "No AI Channels",
"暂无通知渠道": "No Notification Channels",
"点击上方按钮添加您的第一个 AI 渠道": "Click the button above to add your first AI channel",
"点击上方按钮添加您的第一个通知渠道": "Click the button above to add your first notification channel",
"选择消息通知渠道类型": "Select notification channel type",
"渠道类型": "Channel Type",
"我的飞书通知": "My Feishu Notification",
"用于验证 Webhook 请求的签名密钥": "Secret key for verifying webhook requests",
"从 @BotFather 获取": "Get from @BotFather",
"目标聊天或频道的 ID": "Target chat or channel ID",
"覆盖机器人的默认用户名": "Override the default bot username",
"唯一标识符,只能包含小写字母、数字和连字符": "Unique identifier, can only contain lowercase letters, numbers, and hyphens",
"可选的渠道描述": "Optional channel description",
"存储 API Key 的环境变量名称": "Environment variable name for storing API Key",
"JSON 格式的模型列表": "Model list in JSON format",
"JSON 格式的自定义请求头(可选)": "Custom headers in JSON format (optional)",
"该渠道是否支持 SSE 流式响应": "Whether this channel supports SSE streaming responses",
"保存中...": "Saving...",
"添加": "Add",
"保存": "Save",
"取消": "Cancel",
"删除": "Delete",
"确定要删除渠道 \"{name}\" 吗?此操作无法撤销。": "Are you sure you want to delete channel \"{name}\"? This action cannot be undone.",
"管理 OpenAI、Ollama、Anthropic 等 AI 服务提供商": "Manage AI service providers like OpenAI, Ollama, Anthropic, etc.",
"管理飞书、Telegram、Discord 等消息通知渠道": "Manage notification channels like Feishu, Telegram, Discord, etc.",
"这是一条来自 ProxyCast 的测试消息 📱": "This is a test message from ProxyCast 📱",
"AI 渠道已创建": "AI channel created",
"AI 渠道已更新": "AI channel updated",
"AI 渠道已删除": "AI channel deleted",
"AI 渠道已启用": "AI channel enabled",
"AI 渠道已禁用": "AI channel disabled",
"通知渠道已创建": "Notification channel created",
"通知渠道已更新": "Notification channel updated",
"通知渠道已删除": "Notification channel deleted",
"通知渠道已启用": "Notification channel enabled",
"通知渠道已禁用": "Notification channel disabled",
"无效的 JSON 格式": "Invalid JSON format",
"模型列表必须是数组格式": "Model list must be an array",
"超时时间必须是正整数": "Timeout must be a positive integer",
"请输入有效的 URL": "Please enter a valid URL",
"名称不能为空": "Name cannot be empty",
"显示名称不能为空": "Display name cannot be empty",
"API Key 环境变量名不能为空": "API Key env name cannot be empty",
"API 地址不能为空": "API URL cannot be empty",
"Webhook 地址不能为空": "Webhook URL cannot be empty",
"Webhook URL 不能为空": "Webhook URL cannot be empty",
"Bot Token 不能为空": "Bot token cannot be empty",
"聊天 ID 不能为空": "Chat ID cannot be empty",
"加载失败": "Load failed"
} }
+98 -1
View File
@@ -3723,5 +3723,102 @@
"准备就绪": "准备就绪", "准备就绪": "准备就绪",
"在下方输入详细的提示词以开始创作。": "在下方输入详细的提示词以开始创作。", "在下方输入详细的提示词以开始创作。": "在下方输入详细的提示词以开始创作。",
"描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...", "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...": "描述你想要生成的图片(例如:'赛博朋克风格的未来城市,日落时分,飞车穿梭,高细节')...",
"新建图片": "新建图片" "// === Channels Settings (src/components/settings-v2/system/channels/) ===": "",
"渠道管理": "渠道管理",
"AI 模型提供商": "AI 模型提供商",
"消息通知渠道": "消息通知渠道",
"添加 AI 渠道": "添加 AI 渠道",
"添加通知渠道": "添加通知渠道",
"编辑 AI 渠道": "编辑 AI 渠道",
"编辑通知渠道": "编辑通知渠道",
"飞书": "飞书",
"Telegram": "Telegram",
"Discord": "Discord",
"名称": "名称",
"显示名称": "显示名称",
"引擎": "引擎",
"引擎类型": "引擎类型",
"描述": "描述",
"API 地址": "API 地址",
"地址": "地址",
"Webhook 地址": "Webhook 地址",
"Webhook URL": "Webhook URL",
"签名密钥": "签名密钥",
"Bot Token": "Bot Token",
"聊天 ID": "聊天 ID",
"Chat ID": "Chat ID",
"自定义用户名": "自定义用户名",
"模型数": "模型数",
"模型列表": "模型列表",
"API Key 环境变量名": "API Key 环境变量名",
"环境变量名": "环境变量名",
"自定义请求头": "自定义请求头",
"超时时间": "超时时间",
"支持流式输出": "支持流式输出",
"状态": "状态",
"操作": "操作",
"测试连接": "测试连接",
"发送测试消息": "发送测试消息",
"测试消息": "测试消息",
"连接成功": "连接成功",
"连接失败": "连接失败",
"发送成功": "发送成功",
"发送失败": "发送失败",
"测试失败": "测试失败",
"发送默认测试消息": "发送默认测试消息",
"发送自定义消息...": "发送自定义消息...",
"请输入测试消息内容": "请输入测试消息内容",
"类型": "类型",
"配置": "配置",
"Webhook": "Webhook",
"Bot": "Bot",
"暂无 AI 渠道": "暂无 AI 渠道",
"暂无通知渠道": "暂无通知渠道",
"点击上方按钮添加您的第一个 AI 渠道": "点击上方按钮添加您的第一个 AI 渠道",
"点击上方按钮添加您的第一个通知渠道": "点击上方按钮添加您的第一个通知渠道",
"选择消息通知渠道类型": "选择消息通知渠道类型",
"渠道类型": "渠道类型",
"我的飞书通知": "我的飞书通知",
"用于验证 Webhook 请求的签名密钥": "用于验证 Webhook 请求的签名密钥",
"从 @BotFather 获取": "从 @BotFather 获取",
"目标聊天或频道的 ID": "目标聊天或频道的 ID",
"覆盖机器人的默认用户名": "覆盖机器人的默认用户名",
"唯一标识符,只能包含小写字母、数字和连字符": "唯一标识符,只能包含小写字母、数字和连字符",
"可选的渠道描述": "可选的渠道描述",
"存储 API Key 的环境变量名称": "存储 API Key 的环境变量名称",
"JSON 格式的模型列表": "JSON 格式的模型列表",
"JSON 格式的自定义请求头(可选)": "JSON 格式的自定义请求头(可选)",
"该渠道是否支持 SSE 流式响应": "该渠道是否支持 SSE 流式响应",
"保存中...": "保存中...",
"添加": "添加",
"保存": "保存",
"取消": "取消",
"删除": "删除",
"确定要删除渠道 \"{name}\" 吗?此操作无法撤销。": "确定要删除渠道 \"{name}\" 吗?此操作无法撤销。",
"管理 OpenAI、Ollama、Anthropic 等 AI 服务提供商": "管理 OpenAI、Ollama、Anthropic 等 AI 服务提供商",
"管理飞书、Telegram、Discord 等消息通知渠道": "管理飞书、Telegram、Discord 等消息通知渠道",
"这是一条来自 ProxyCast 的测试消息 📱": "这是一条来自 ProxyCast 的测试消息 📱",
"AI 渠道已创建": "AI 渠道已创建",
"AI 渠道已更新": "AI 渠道已更新",
"AI 渠道已删除": "AI 渠道已删除",
"AI 渠道已启用": "AI 渠道已启用",
"AI 渠道已禁用": "AI 渠道已禁用",
"通知渠道已创建": "通知渠道已创建",
"通知渠道已更新": "通知渠道已更新",
"通知渠道已删除": "通知渠道已删除",
"通知渠道已启用": "通知渠道已启用",
"通知渠道已禁用": "通知渠道已禁用",
"无效的 JSON 格式": "无效的 JSON 格式",
"模型列表必须是数组格式": "模型列表必须是数组格式",
"超时时间必须是正整数": "超时时间必须是正整数",
"请输入有效的 URL": "请输入有效的 URL",
"名称不能为空": "名称不能为空",
"显示名称不能为空": "显示名称不能为空",
"API Key 环境变量名不能为空": "API Key 环境变量名不能为空",
"API 地址不能为空": "API 地址不能为空",
"Webhook 地址不能为空": "Webhook 地址不能为空",
"Webhook URL 不能为空": "Webhook URL 不能为空",
"Bot Token 不能为空": "Bot Token 不能为空",
"聊天 ID 不能为空": "聊天 ID 不能为空",
"加载失败": "加载失败"
} }
+394
View File
@@ -0,0 +1,394 @@
/**
* 渠道管理 API
*
* 封装所有渠道管理的 API 调用,包括 AI 渠道和通知渠道
*/
import { safeInvoke } from "@/lib/dev-bridge";
// ============================================================================
// 类型定义
// ============================================================================
/**
* AI 提供商引擎类型
*/
export enum AIProviderEngine {
OPENAI = "openai",
OLLAMA = "ollama",
ANTHROPIC = "anthropic",
}
/**
* 模型信息
*/
export interface ModelInfo {
id: string;
name: string;
description?: string;
}
/**
* AI 渠道配置
*/
export interface AIChannelConfig {
name: string;
engine: AIProviderEngine;
display_name: string;
description?: string;
api_key_env: string;
base_url: string;
models: ModelInfo[];
headers?: Record<string, string>;
timeout_seconds?: number;
supports_streaming?: boolean;
}
/**
* AI 渠道
*/
export interface AIChannel {
id: string;
name: string;
engine: AIProviderEngine;
display_name: string;
description?: string;
api_key_env: string;
base_url: string;
models: ModelInfo[];
headers?: Record<string, string>;
timeout_seconds?: number;
supports_streaming?: boolean;
enabled: boolean;
}
/**
* 连接测试结果
*/
export interface ConnectionTestResult {
success: boolean;
message: string;
details?: unknown;
}
/**
* 通知渠道类型
*/
export enum NotificationChannelType {
FEISHU = "feishu",
TELEGRAM = "telegram",
DISCORD = "discord",
}
/**
* 飞书配置
*/
export interface FeishuConfig {
webhook_url: string;
secret?: string;
}
/**
* Telegram 配置
*/
export interface TelegramConfig {
bot_token: string;
chat_id: string;
}
/**
* Discord 配置
*/
export interface DiscordConfig {
webhook_url: string;
username?: string;
}
/**
* 通知渠道特定配置
*/
export type NotificationChannelSpecificConfig =
| { type: NotificationChannelType.FEISHU } & FeishuConfig
| { type: NotificationChannelType.TELEGRAM } & TelegramConfig
| { type: NotificationChannelType.DISCORD } & DiscordConfig;
/**
* 通知渠道配置
*/
export interface NotificationChannelConfig {
name: string;
channel_type: NotificationChannelType;
config: NotificationChannelSpecificConfig;
}
/**
* 通知渠道
*/
export interface NotificationChannel {
id: string;
name: string;
channel_type: NotificationChannelType;
config: NotificationChannelSpecificConfig;
enabled: boolean;
}
/**
* 测试消息结果
*/
export interface TestMessageResult {
success: boolean;
message: string;
}
// ============================================================================
// AI 渠道 API
// ============================================================================
/**
* AI 渠道 API
*/
export const aiChannelsApi = {
/**
* 获取所有 AI 渠道
*/
async getChannels(): Promise<AIChannel[]> {
return safeInvoke<Array<unknown>>("get_ai_channels").then((channels) =>
channels.map(mapAIChannelFromBackend),
);
},
/**
* 获取单个 AI 渠道
*/
async getChannel(id: string): Promise<AIChannel> {
const channel = await safeInvoke<AIChannel>("get_ai_channel", { id });
return mapAIChannelFromBackend(channel);
},
/**
* 创建 AI 渠道
*/
async createChannel(config: AIChannelConfig): Promise<AIChannel> {
const channel = await safeInvoke<AIChannel>("create_ai_channel", {
config: mapAIChannelConfigToBackend(config),
});
return mapAIChannelFromBackend(channel);
},
/**
* 更新 AI 渠道
*/
async updateChannel(
id: string,
config: Partial<AIChannelConfig>,
): Promise<AIChannel> {
const channel = await safeInvoke<AIChannel>("update_ai_channel", {
id,
config: mapAIChannelConfigToBackend(
config as AIChannelConfig,
),
});
return mapAIChannelFromBackend(channel);
},
/**
* 删除 AI 渠道
*/
async deleteChannel(id: string): Promise<void> {
await safeInvoke<void>("delete_ai_channel", { id });
},
/**
* 测试 AI 渠道连接
*/
async testChannel(id: string): Promise<ConnectionTestResult> {
return safeInvoke<ConnectionTestResult>("test_ai_channel", { id });
},
};
// ============================================================================
// 通知渠道 API
// ============================================================================
/**
* 通知渠道 API
*/
export const notificationChannelsApi = {
/**
* 获取所有通知渠道
*/
async getChannels(): Promise<NotificationChannel[]> {
return safeInvoke<Array<unknown>>("get_notification_channels").then(
(channels) => channels.map(mapNotificationChannelFromBackend),
);
},
/**
* 获取单个通知渠道
*/
async getChannel(id: string): Promise<NotificationChannel> {
const channel = await safeInvoke<NotificationChannel>(
"get_notification_channel",
{ id },
);
return mapNotificationChannelFromBackend(channel);
},
/**
* 创建通知渠道
*/
async createChannel(
config: NotificationChannelConfig,
): Promise<NotificationChannel> {
const channel = await safeInvoke<NotificationChannel>(
"create_notification_channel",
{ config: mapNotificationChannelConfigToBackend(config) },
);
return mapNotificationChannelFromBackend(channel);
},
/**
* 更新通知渠道
*/
async updateChannel(
id: string,
config: Partial<NotificationChannelConfig>,
): Promise<NotificationChannel> {
const channel = await safeInvoke<NotificationChannel>(
"update_notification_channel",
{
id,
config: mapNotificationChannelConfigToBackend(
config as NotificationChannelConfig,
),
},
);
return mapNotificationChannelFromBackend(channel);
},
/**
* 删除通知渠道
*/
async deleteChannel(id: string): Promise<void> {
await safeInvoke<void>("delete_notification_channel", { id });
},
/**
* 发送测试消息到通知渠道
*/
async testChannel(id: string, message: string): Promise<TestMessageResult> {
return safeInvoke<TestMessageResult>("test_notification_channel", {
id,
message,
});
},
};
// ============================================================================
// 数据映射函数
// ============================================================================
/**
* 将后端 AI 渠道数据映射到前端格式
*/
function mapAIChannelFromBackend(channel: unknown): AIChannel {
const data = channel as Record<string, unknown>;
return {
id: data.id as string,
name: data.name as string,
engine: data.engine as AIProviderEngine,
display_name: data.display_name as string,
description: data.description as string | undefined,
api_key_env: data.api_key_env as string,
base_url: data.base_url as string,
models: (data.models as Array<Record<string, unknown>> | undefined)?.map(
(m) => ({
id: m.id as string,
name: m.name as string,
description: m.description as string | undefined,
}),
) ?? [],
headers: data.headers as Record<string, string> | undefined,
timeout_seconds: data.timeout_seconds as number | undefined,
supports_streaming: data.supports_streaming as boolean | undefined,
enabled: data.enabled as boolean,
};
}
/**
* 将前端 AI 渠道配置映射到后端格式
*/
function mapAIChannelConfigToBackend(config: AIChannelConfig): Record<string, unknown> {
return {
name: config.name,
engine: config.engine,
display_name: config.display_name,
description: config.description,
api_key_env: config.api_key_env,
base_url: config.base_url,
models: config.models,
headers: config.headers,
timeout_seconds: config.timeout_seconds,
supports_streaming: config.supports_streaming,
};
}
/**
* 将后端通知渠道数据映射到前端格式
*/
function mapNotificationChannelFromBackend(channel: unknown): NotificationChannel {
const data = channel as Record<string, unknown>;
const baseConfig = {
id: data.id as string,
name: data.name as string,
channel_type: data.channel_type as NotificationChannelType,
enabled: data.enabled as boolean,
};
const config = data.config as Record<string, unknown>;
switch (baseConfig.channel_type) {
case NotificationChannelType.FEISHU:
return {
...baseConfig,
config: {
type: NotificationChannelType.FEISHU,
webhook_url: config.webhook_url as string,
secret: config.secret as string | undefined,
},
};
case NotificationChannelType.TELEGRAM:
return {
...baseConfig,
config: {
type: NotificationChannelType.TELEGRAM,
bot_token: config.bot_token as string,
chat_id: config.chat_id as string,
},
};
case NotificationChannelType.DISCORD:
return {
...baseConfig,
config: {
type: NotificationChannelType.DISCORD,
webhook_url: config.webhook_url as string,
username: config.username as string | undefined,
},
};
default:
throw new Error(`未知的通知渠道类型: ${baseConfig.channel_type}`);
}
}
/**
* 将前端通知渠道配置映射到后端格式
*/
function mapNotificationChannelConfigToBackend(
config: NotificationChannelConfig,
): Record<string, unknown> {
return {
name: config.name,
channel_type: config.channel_type,
config: config.config,
};
}
+2
View File
@@ -37,6 +37,7 @@ export enum SettingsTabs {
// 系统 // 系统
ApiServer = "api-server", ApiServer = "api-server",
McpServer = "mcp-server", McpServer = "mcp-server",
Channels = "channels",
Proxy = "proxy", Proxy = "proxy",
SecurityPerformance = "security-performance", SecurityPerformance = "security-performance",
Heartbeat = "heartbeat", Heartbeat = "heartbeat",
@@ -85,6 +86,7 @@ export const SETTINGS_GROUPS: Record<SettingsGroupKey, SettingsTabs[]> = {
[SettingsGroupKey.System]: [ [SettingsGroupKey.System]: [
SettingsTabs.ApiServer, SettingsTabs.ApiServer,
SettingsTabs.McpServer, SettingsTabs.McpServer,
SettingsTabs.Channels,
SettingsTabs.Proxy, SettingsTabs.Proxy,
SettingsTabs.SecurityPerformance, SettingsTabs.SecurityPerformance,
SettingsTabs.Heartbeat, SettingsTabs.Heartbeat,