mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
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:
co-authored by
Claude Sonnet 4.6
parent
54611cd3ab
commit
8fec0f1088
@@ -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(())
|
||||
}
|
||||
@@ -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<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)))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -132,6 +132,14 @@ pub async fn workspace_create(
|
||||
db: State<'_, DbConnection>,
|
||||
request: CreateWorkspaceRequest,
|
||||
) -> 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 workspace_type = request
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<SettingHeader title="渠道管理" />
|
||||
<ChannelsSettings />
|
||||
</>
|
||||
);
|
||||
|
||||
case SettingsTabs.Proxy:
|
||||
return (
|
||||
<>
|
||||
@@ -321,6 +330,7 @@ const WIDE_CONTENT_TABS = new Set<SettingsTabs>([
|
||||
SettingsTabs.Providers,
|
||||
SettingsTabs.ApiServer,
|
||||
SettingsTabs.McpServer,
|
||||
SettingsTabs.Channels,
|
||||
SettingsTabs.ExecutionTracker,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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", "网络代理"),
|
||||
|
||||
@@ -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;
|
||||
@@ -3743,4 +3743,103 @@
|
||||
"在下方输入详细的提示词以开始创作。": "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')...",
|
||||
"新建图片": "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"
|
||||
}
|
||||
|
||||
@@ -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 不能为空",
|
||||
"加载失败": "加载失败"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export enum SettingsTabs {
|
||||
// 系统
|
||||
ApiServer = "api-server",
|
||||
McpServer = "mcp-server",
|
||||
Channels = "channels",
|
||||
Proxy = "proxy",
|
||||
SecurityPerformance = "security-performance",
|
||||
Heartbeat = "heartbeat",
|
||||
@@ -85,6 +86,7 @@ export const SETTINGS_GROUPS: Record<SettingsGroupKey, SettingsTabs[]> = {
|
||||
[SettingsGroupKey.System]: [
|
||||
SettingsTabs.ApiServer,
|
||||
SettingsTabs.McpServer,
|
||||
SettingsTabs.Channels,
|
||||
SettingsTabs.Proxy,
|
||||
SettingsTabs.SecurityPerformance,
|
||||
SettingsTabs.Heartbeat,
|
||||
|
||||
Reference in New Issue
Block a user