From ba192996961da8f992da2a0f9791a271cc44f3af Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 5 Apr 2026 16:35:14 +0800 Subject: [PATCH] feat(settings): add model provider management with full CRUD support Add a new settings page for managing API model providers (name, API URL, API key, applicable agent types). Includes database migration, SeaORM entity, backend CRUD commands/handlers, frontend settings UI with agent type filter, add/edit/delete dialogs, and i18n support for all 10 locales. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/model_provider.rs | 171 ++++++++++++++ src-tauri/src/db/entities/mod.rs | 1 + src-tauri/src/db/entities/model_provider.rs | 19 ++ src-tauri/src/db/entities/prelude.rs | 1 + .../m20260404_000001_model_provider.rs | 61 +++++ src-tauri/src/db/migration/mod.rs | 2 + src-tauri/src/db/service/mod.rs | 1 + .../src/db/service/model_provider_service.rs | 77 +++++++ src-tauri/src/lib.rs | 7 +- src-tauri/src/models/mod.rs | 1 + src-tauri/src/models/model_provider.rs | 42 ++++ src-tauri/src/web/handlers/mod.rs | 1 + src-tauri/src/web/handlers/model_provider.rs | 88 +++++++ src-tauri/src/web/router.rs | 5 + src/app/settings/model-providers/page.tsx | 5 + .../settings/add-model-provider-dialog.tsx | 195 ++++++++++++++++ .../settings/edit-model-provider-dialog.tsx | 204 +++++++++++++++++ .../settings/model-provider-settings.tsx | 216 ++++++++++++++++++ src/components/settings/settings-shell.tsx | 7 + src/i18n/messages/ar.json | 35 ++- src/i18n/messages/de.json | 35 ++- src/i18n/messages/en.json | 35 ++- src/i18n/messages/es.json | 35 ++- src/i18n/messages/fr.json | 35 ++- src/i18n/messages/ja.json | 35 ++- src/i18n/messages/ko.json | 35 ++- src/i18n/messages/pt.json | 35 ++- src/i18n/messages/zh-CN.json | 35 ++- src/i18n/messages/zh-TW.json | 35 ++- src/lib/api.ts | 38 +++ src/lib/types.ts | 19 ++ 32 files changed, 1501 insertions(+), 11 deletions(-) create mode 100644 src-tauri/src/commands/model_provider.rs create mode 100644 src-tauri/src/db/entities/model_provider.rs create mode 100644 src-tauri/src/db/migration/m20260404_000001_model_provider.rs create mode 100644 src-tauri/src/db/service/model_provider_service.rs create mode 100644 src-tauri/src/models/model_provider.rs create mode 100644 src-tauri/src/web/handlers/model_provider.rs create mode 100644 src/app/settings/model-providers/page.tsx create mode 100644 src/components/settings/add-model-provider-dialog.tsx create mode 100644 src/components/settings/edit-model-provider-dialog.tsx create mode 100644 src/components/settings/model-provider-settings.tsx diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b1c051c..964fb79 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod conversations; pub mod folder_commands; pub mod folders; pub mod mcp; +pub mod model_provider; pub mod project_boot; pub mod system_settings; pub mod terminal; diff --git a/src-tauri/src/commands/model_provider.rs b/src-tauri/src/commands/model_provider.rs new file mode 100644 index 0000000..2e5afa8 --- /dev/null +++ b/src-tauri/src/commands/model_provider.rs @@ -0,0 +1,171 @@ +use crate::app_error::AppCommandError; +use crate::db::service::model_provider_service; +use crate::db::AppDatabase; +use crate::models::agent::AgentType; +use crate::models::model_provider::ModelProviderInfo; + +// --------------------------------------------------------------------------- +// Shared core functions (used by both Tauri commands and web handlers) +// --------------------------------------------------------------------------- + +fn validate_agent_types(agent_types: &[String]) -> Result<(), AppCommandError> { + if agent_types.is_empty() { + return Err(AppCommandError::invalid_input( + "At least one agent type is required", + )); + } + for at in agent_types { + let _: AgentType = serde_json::from_value(serde_json::Value::String(at.clone())) + .map_err(|_| { + AppCommandError::invalid_input(format!("Invalid agent type: {at}")) + })?; + } + Ok(()) +} + +fn validate_fields( + name: Option<&str>, + api_url: Option<&str>, + api_key: Option<&str>, +) -> Result<(), AppCommandError> { + if let Some(n) = name { + if n.len() > 256 { + return Err(AppCommandError::invalid_input("Name must be 256 characters or less")); + } + } + if let Some(u) = api_url { + if u.len() > 2048 { + return Err(AppCommandError::invalid_input("API URL must be 2048 characters or less")); + } + if !u.starts_with("http://") && !u.starts_with("https://") { + return Err(AppCommandError::invalid_input("API URL must start with http:// or https://")); + } + } + if let Some(k) = api_key { + if k.len() > 4096 { + return Err(AppCommandError::invalid_input("API Key must be 4096 characters or less")); + } + } + Ok(()) +} + +pub async fn list_model_providers_core( + db: &AppDatabase, +) -> Result, AppCommandError> { + let rows = model_provider_service::list_all(&db.conn) + .await + .map_err(AppCommandError::from)?; + Ok(rows.into_iter().map(ModelProviderInfo::from).collect()) +} + +pub async fn create_model_provider_core( + db: &AppDatabase, + name: String, + api_url: String, + api_key: String, + agent_types: Vec, +) -> Result { + validate_fields(Some(&name), Some(&api_url), Some(&api_key))?; + validate_agent_types(&agent_types)?; + let agent_types_json = serde_json::to_string(&agent_types) + .map_err(|e| AppCommandError::invalid_input(e.to_string()))?; + + let model = model_provider_service::create( + &db.conn, + name, + api_url, + api_key, + agent_types_json, + ) + .await + .map_err(AppCommandError::from)?; + Ok(ModelProviderInfo::from(model)) +} + +pub async fn update_model_provider_core( + db: &AppDatabase, + id: i32, + name: Option, + api_url: Option, + api_key: Option, + agent_types: Option>, +) -> Result { + validate_fields(name.as_deref(), api_url.as_deref(), api_key.as_deref())?; + let agent_types_json = if let Some(ref ats) = agent_types { + validate_agent_types(ats)?; + Some( + serde_json::to_string(ats) + .map_err(|e| AppCommandError::invalid_input(e.to_string()))?, + ) + } else { + None + }; + + let model = model_provider_service::update( + &db.conn, + id, + name, + api_url, + api_key, + agent_types_json, + ) + .await + .map_err(AppCommandError::from)?; + Ok(ModelProviderInfo::from(model)) +} + +pub async fn delete_model_provider_core( + db: &AppDatabase, + id: i32, +) -> Result<(), AppCommandError> { + model_provider_service::delete(&db.conn, id) + .await + .map_err(AppCommandError::from)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn list_model_providers( + db: tauri::State<'_, AppDatabase>, +) -> Result, AppCommandError> { + list_model_providers_core(&db).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn create_model_provider( + db: tauri::State<'_, AppDatabase>, + name: String, + api_url: String, + api_key: String, + agent_types: Vec, +) -> Result { + create_model_provider_core(&db, name, api_url, api_key, agent_types).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn update_model_provider( + db: tauri::State<'_, AppDatabase>, + id: i32, + name: Option, + api_url: Option, + api_key: Option, + agent_types: Option>, +) -> Result { + update_model_provider_core(&db, id, name, api_url, api_key, agent_types).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn delete_model_provider( + db: tauri::State<'_, AppDatabase>, + id: i32, +) -> Result<(), AppCommandError> { + delete_model_provider_core(&db, id).await +} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 66b4c61..9ac4302 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -7,4 +7,5 @@ pub mod conversation; pub mod folder; pub mod folder_command; pub mod folder_opened_conversation; +pub mod model_provider; pub mod prelude; diff --git a/src-tauri/src/db/entities/model_provider.rs b/src-tauri/src/db/entities/model_provider.rs new file mode 100644 index 0000000..2b780ea --- /dev/null +++ b/src-tauri/src/db/entities/model_provider.rs @@ -0,0 +1,19 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "model_provider")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + pub api_url: String, + pub api_key: String, + pub agent_types_json: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index ae0d71f..e2cb48c 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -9,3 +9,4 @@ pub use super::conversation::Entity as Conversation; pub use super::folder::Entity as Folder; pub use super::folder_command::Entity as FolderCommand; pub use super::folder_opened_conversation::Entity as FolderOpenedConversation; +pub use super::model_provider::Entity as ModelProvider; diff --git a/src-tauri/src/db/migration/m20260404_000001_model_provider.rs b/src-tauri/src/db/migration/m20260404_000001_model_provider.rs new file mode 100644 index 0000000..2c963fd --- /dev/null +++ b/src-tauri/src/db/migration/m20260404_000001_model_provider.rs @@ -0,0 +1,61 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ModelProvider::Table) + .if_not_exists() + .col( + ColumnDef::new(ModelProvider::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(ModelProvider::Name).string().not_null()) + .col(ColumnDef::new(ModelProvider::ApiUrl).text().not_null()) + .col(ColumnDef::new(ModelProvider::ApiKey).text().not_null()) + .col( + ColumnDef::new(ModelProvider::AgentTypesJson) + .text() + .not_null(), + ) + .col( + ColumnDef::new(ModelProvider::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(ModelProvider::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ModelProvider::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum ModelProvider { + Table, + Id, + Name, + ApiUrl, + ApiKey, + AgentTypesJson, + CreatedAt, + UpdatedAt, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 12b10e0..babc372 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -7,6 +7,7 @@ mod m20260226_000001_agent_setting; mod m20260227_000001_folder_parent_branch; mod m20260330_000001_chat_channel; mod m20260401_000001_chat_channel_sender_context; +mod m20260404_000001_model_provider; pub struct Migrator; #[async_trait::async_trait] @@ -20,6 +21,7 @@ impl MigratorTrait for Migrator { Box::new(m20260227_000001_folder_parent_branch::Migration), Box::new(m20260330_000001_chat_channel::Migration), Box::new(m20260401_000001_chat_channel_sender_context::Migration), + Box::new(m20260404_000001_model_provider::Migration), ] } } diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index a23c6d1..6572022 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -6,4 +6,5 @@ pub mod conversation_service; pub mod folder_command_service; pub mod folder_service; pub mod import_service; +pub mod model_provider_service; pub mod sender_context_service; diff --git a/src-tauri/src/db/service/model_provider_service.rs b/src-tauri/src/db/service/model_provider_service.rs new file mode 100644 index 0000000..7de7bfc --- /dev/null +++ b/src-tauri/src/db/service/model_provider_service.rs @@ -0,0 +1,77 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, DatabaseConnection, EntityTrait, IntoActiveModel, + QueryOrder, Set, +}; + +use crate::db::entities::model_provider; +use crate::db::error::DbError; + +pub async fn create( + conn: &DatabaseConnection, + name: String, + api_url: String, + api_key: String, + agent_types_json: String, +) -> Result { + let now = Utc::now(); + let active = model_provider::ActiveModel { + id: NotSet, + name: Set(name), + api_url: Set(api_url), + api_key: Set(api_key), + agent_types_json: Set(agent_types_json), + created_at: Set(now), + updated_at: Set(now), + }; + Ok(active.insert(conn).await?) +} + +pub async fn update( + conn: &DatabaseConnection, + id: i32, + name: Option, + api_url: Option, + api_key: Option, + agent_types_json: Option, +) -> Result { + let model = model_provider::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| DbError::Migration(format!("model provider not found: {id}")))?; + + let mut active = model.into_active_model(); + if let Some(v) = name { + active.name = Set(v); + } + if let Some(v) = api_url { + active.api_url = Set(v); + } + if let Some(v) = api_key { + active.api_key = Set(v); + } + if let Some(v) = agent_types_json { + active.agent_types_json = Set(v); + } + active.updated_at = Set(Utc::now()); + Ok(active.update(conn).await?) +} + +pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> { + model_provider::Entity::delete_by_id(id).exec(conn).await?; + Ok(()) +} + +pub async fn get_by_id( + conn: &DatabaseConnection, + id: i32, +) -> Result, DbError> { + Ok(model_provider::Entity::find_by_id(id).one(conn).await?) +} + +pub async fn list_all(conn: &DatabaseConnection) -> Result, DbError> { + Ok(model_provider::Entity::find() + .order_by_asc(model_provider::Column::Id) + .all(conn) + .await?) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9d9faf7..5e47784 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,7 +21,8 @@ mod tauri_app { use crate::chat_channel::manager::ChatChannelManager; use crate::commands::{ acp as acp_commands, chat_channel as chat_channel_commands, conversations, folder_commands, - folders, mcp as mcp_commands, notification, project_boot, system_settings, + folders, mcp as mcp_commands, model_provider as model_provider_commands, notification, + project_boot, system_settings, terminal as terminal_commands, version_control, windows, }; use crate::terminal::manager::TerminalManager; @@ -391,6 +392,10 @@ mod tauri_app { chat_channel_commands::set_chat_message_language, chat_channel_commands::weixin_get_qrcode, chat_channel_commands::weixin_check_qrcode, + model_provider_commands::list_model_providers, + model_provider_commands::create_model_provider, + model_provider_commands::update_model_provider, + model_provider_commands::delete_model_provider, web::start_web_server, web::stop_web_server, web::get_web_server_status, diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index b417223..e60c453 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -2,6 +2,7 @@ pub mod agent; pub mod chat_channel; pub mod conversation; pub mod folder; +pub mod model_provider; pub mod message; pub mod system; diff --git a/src-tauri/src/models/model_provider.rs b/src-tauri/src/models/model_provider.rs new file mode 100644 index 0000000..a4027eb --- /dev/null +++ b/src-tauri/src/models/model_provider.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelProviderInfo { + pub id: i32, + pub name: String, + pub api_url: String, + pub api_key_masked: String, + pub agent_types: Vec, + pub created_at: String, + pub updated_at: String, +} + +fn mask_api_key(key: &str) -> String { + let len = key.len(); + if len <= 8 { + "\u{2022}".repeat(len) + } else { + format!( + "{}{}{}", + &key[..4], + "\u{2022}".repeat(len.min(20) - 8), + &key[len - 4..] + ) + } +} + +impl From for ModelProviderInfo { + fn from(m: crate::db::entities::model_provider::Model) -> Self { + let agent_types: Vec = + serde_json::from_str(&m.agent_types_json).unwrap_or_default(); + Self { + id: m.id, + name: m.name, + api_url: m.api_url, + api_key_masked: mask_api_key(&m.api_key), + agent_types, + created_at: m.created_at.to_rfc3339(), + updated_at: m.updated_at.to_rfc3339(), + } + } +} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 243bb1e..ce80799 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -7,6 +7,7 @@ pub mod folder_commands; pub mod folders; pub mod git; pub mod mcp; +pub mod model_provider; pub mod project_boot; pub mod system_settings; pub mod terminal; diff --git a/src-tauri/src/web/handlers/model_provider.rs b/src-tauri/src/web/handlers/model_provider.rs new file mode 100644 index 0000000..ea9054f --- /dev/null +++ b/src-tauri/src/web/handlers/model_provider.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::model_provider as mp_commands; +use crate::models::model_provider::ModelProviderInfo; + +// --------------------------------------------------------------------------- +// Param structs +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateModelProviderParams { + pub name: String, + pub api_url: String, + pub api_key: String, + pub agent_types: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateModelProviderParams { + pub id: i32, + pub name: Option, + pub api_url: Option, + pub api_key: Option, + pub agent_types: Option>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelProviderIdParams { + pub id: i32, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +pub async fn list_model_providers( + Extension(state): Extension>, +) -> Result>, AppCommandError> { + let result = mp_commands::list_model_providers_core(&state.db).await?; + Ok(Json(result)) +} + +pub async fn create_model_provider( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let result = mp_commands::create_model_provider_core( + &state.db, + params.name, + params.api_url, + params.api_key, + params.agent_types, + ) + .await?; + Ok(Json(result)) +} + +pub async fn update_model_provider( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let result = mp_commands::update_model_provider_core( + &state.db, + params.id, + params.name, + params.api_url, + params.api_key, + params.agent_types, + ) + .await?; + Ok(Json(result)) +} + +pub async fn delete_model_provider( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + mp_commands::delete_model_provider_core(&state.db, params.id).await?; + Ok(Json(())) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 852e124..5f111bc 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -202,6 +202,11 @@ pub fn build_router(state: Arc, token: String, static_dir: std::path:: .route("/set_chat_message_language", post(handlers::chat_channel::set_chat_message_language)) .route("/weixin_get_qrcode", post(handlers::chat_channel::weixin_get_qrcode)) .route("/weixin_check_qrcode", post(handlers::chat_channel::weixin_check_qrcode)) + // ─── Model Providers ─── + .route("/list_model_providers", post(handlers::model_provider::list_model_providers)) + .route("/create_model_provider", post(handlers::model_provider::create_model_provider)) + .route("/update_model_provider", post(handlers::model_provider::update_model_provider)) + .route("/delete_model_provider", post(handlers::model_provider::delete_model_provider)) // ─── Terminal ─── .route("/terminal_spawn", post(handlers::terminal::terminal_spawn)) .route("/terminal_write", post(handlers::terminal::terminal_write)) diff --git a/src/app/settings/model-providers/page.tsx b/src/app/settings/model-providers/page.tsx new file mode 100644 index 0000000..9573e23 --- /dev/null +++ b/src/app/settings/model-providers/page.tsx @@ -0,0 +1,5 @@ +import { ModelProviderSettings } from "@/components/settings/model-provider-settings" + +export default function SettingsModelProvidersPage() { + return +} diff --git a/src/components/settings/add-model-provider-dialog.tsx b/src/components/settings/add-model-provider-dialog.tsx new file mode 100644 index 0000000..d33f427 --- /dev/null +++ b/src/components/settings/add-model-provider-dialog.tsx @@ -0,0 +1,195 @@ +"use client" + +import { useCallback, useState } from "react" +import { Loader2 } from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { createModelProvider } from "@/lib/api" +import { ALL_AGENT_TYPES, AGENT_LABELS, type AgentType } from "@/lib/types" + +interface AddModelProviderDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onProviderAdded: () => void +} + +export function AddModelProviderDialog({ + open, + onOpenChange, + onProviderAdded, +}: AddModelProviderDialogProps) { + const t = useTranslations("ModelProviderSettings") + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const [name, setName] = useState("") + const [apiUrl, setApiUrl] = useState("") + const [apiKey, setApiKey] = useState("") + const [selectedTypes, setSelectedTypes] = useState([]) + + const resetForm = useCallback(() => { + setName("") + setApiUrl("") + setApiKey("") + setSelectedTypes([]) + setError(null) + }, []) + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) resetForm() + onOpenChange(nextOpen) + }, + [onOpenChange, resetForm] + ) + + const toggleAgentType = useCallback((at: AgentType) => { + setSelectedTypes((prev) => + prev.includes(at) ? prev.filter((t) => t !== at) : [...prev, at] + ) + }, []) + + const handleSubmit = useCallback(async () => { + if (!name.trim()) { + setError(t("nameRequired")) + return + } + if (!apiUrl.trim()) { + setError(t("apiUrlRequired")) + return + } + if (!apiKey.trim()) { + setError(t("apiKeyRequired")) + return + } + if (selectedTypes.length === 0) { + setError(t("agentTypesRequired")) + return + } + + setLoading(true) + setError(null) + try { + await createModelProvider({ + name: name.trim(), + apiUrl: apiUrl.trim(), + apiKey: apiKey.trim(), + agentTypes: selectedTypes, + }) + toast.success(t("createSuccess")) + handleOpenChange(false) + onProviderAdded() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + setError(msg) + } finally { + setLoading(false) + } + }, [ + name, + apiUrl, + apiKey, + selectedTypes, + handleOpenChange, + onProviderAdded, + t, + ]) + + return ( + + + + {t("addProvider")} + + +
+
+ + setName(e.target.value)} + placeholder={t("providerNamePlaceholder")} + /> +
+ +
+ + setApiUrl(e.target.value)} + placeholder={t("apiUrlPlaceholder")} + /> +
+ +
+ + setApiKey(e.target.value)} + placeholder={t("apiKeyPlaceholder")} + /> +
+ +
+ +
+ {ALL_AGENT_TYPES.map((at) => ( + + ))} +
+
+ + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/src/components/settings/edit-model-provider-dialog.tsx b/src/components/settings/edit-model-provider-dialog.tsx new file mode 100644 index 0000000..aa9b0be --- /dev/null +++ b/src/components/settings/edit-model-provider-dialog.tsx @@ -0,0 +1,204 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { Loader2 } from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { updateModelProvider } from "@/lib/api" +import { + ALL_AGENT_TYPES, + AGENT_LABELS, + type AgentType, + type ModelProviderInfo, +} from "@/lib/types" + +interface EditModelProviderDialogProps { + provider: ModelProviderInfo | null + onOpenChange: (open: boolean) => void + onProviderUpdated: () => void +} + +export function EditModelProviderDialog({ + provider, + onOpenChange, + onProviderUpdated, +}: EditModelProviderDialogProps) { + const t = useTranslations("ModelProviderSettings") + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const [name, setName] = useState("") + const [apiUrl, setApiUrl] = useState("") + const [apiKey, setApiKey] = useState("") + const [selectedTypes, setSelectedTypes] = useState([]) + + useEffect(() => { + if (provider) { + setName(provider.name) + setApiUrl(provider.api_url) + setApiKey("") + setSelectedTypes([...provider.agent_types]) + setError(null) + } + }, [provider]) + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) setError(null) + onOpenChange(nextOpen) + }, + [onOpenChange] + ) + + const toggleAgentType = useCallback((at: AgentType) => { + setSelectedTypes((prev) => + prev.includes(at) ? prev.filter((t) => t !== at) : [...prev, at] + ) + }, []) + + const handleSubmit = useCallback(async () => { + if (!provider) return + if (!name.trim()) { + setError(t("nameRequired")) + return + } + if (!apiUrl.trim()) { + setError(t("apiUrlRequired")) + return + } + if (selectedTypes.length === 0) { + setError(t("agentTypesRequired")) + return + } + + setLoading(true) + setError(null) + try { + await updateModelProvider({ + id: provider.id, + name: name.trim() !== provider.name ? name.trim() : undefined, + apiUrl: apiUrl.trim() !== provider.api_url ? apiUrl.trim() : undefined, + apiKey: apiKey.trim() || undefined, + agentTypes: + JSON.stringify(selectedTypes) !== JSON.stringify(provider.agent_types) + ? selectedTypes + : undefined, + }) + toast.success(t("editSuccess")) + handleOpenChange(false) + onProviderUpdated() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + setError(msg) + } finally { + setLoading(false) + } + }, [ + provider, + name, + apiUrl, + apiKey, + selectedTypes, + handleOpenChange, + onProviderUpdated, + t, + ]) + + return ( + + + + {t("editProvider")} + + +
+
+ + setName(e.target.value)} + placeholder={t("providerNamePlaceholder")} + /> +
+ +
+ + setApiUrl(e.target.value)} + placeholder={t("apiUrlPlaceholder")} + /> +
+ +
+ + setApiKey(e.target.value)} + placeholder={t("apiKeyKeepCurrent")} + /> +
+ +
+ +
+ {ALL_AGENT_TYPES.map((at) => ( + + ))} +
+
+ + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/src/components/settings/model-provider-settings.tsx b/src/components/settings/model-provider-settings.tsx new file mode 100644 index 0000000..9ef04ce --- /dev/null +++ b/src/components/settings/model-provider-settings.tsx @@ -0,0 +1,216 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { Loader2, Pencil, Plus, Server, Trash2 } from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { listModelProviders, deleteModelProvider } from "@/lib/api" +import { + ALL_AGENT_TYPES, + AGENT_LABELS, + type AgentType, + type ModelProviderInfo, +} from "@/lib/types" +import { AddModelProviderDialog } from "./add-model-provider-dialog" +import { EditModelProviderDialog } from "./edit-model-provider-dialog" + +export function ModelProviderSettings() { + const t = useTranslations("ModelProviderSettings") + const [providers, setProviders] = useState([]) + const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState(null) + const [addDialogOpen, setAddDialogOpen] = useState(false) + const [editTarget, setEditTarget] = useState(null) + const [deleteTarget, setDeleteTarget] = useState( + null + ) + + const loadProviders = useCallback(async () => { + try { + const rows = await listModelProviders() + setProviders(rows) + } catch { + toast.error(t("loadFailed")) + } finally { + setLoading(false) + } + }, [t]) + + useEffect(() => { + loadProviders().catch(console.error) + }, [loadProviders]) + + const filteredProviders = useMemo(() => { + if (!filter) return providers + return providers.filter((p) => p.agent_types.includes(filter)) + }, [providers, filter]) + + const handleDelete = useCallback(async () => { + if (!deleteTarget) return + try { + await deleteModelProvider(deleteTarget.id) + toast.success(t("deleteSuccess")) + setDeleteTarget(null) + await loadProviders() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + toast.error(msg) + } + }, [deleteTarget, loadProviders, t]) + + return ( +
+
+
+

{t("sectionTitle")}

+

+ {t("sectionDescription")} +

+
+
+ +
+
+ + +
+ + {loading ? ( +
+ +
+ ) : filteredProviders.length === 0 ? ( +
+ + {t("noProviders")} +
+ ) : ( +
+ {filteredProviders.map((p) => ( +
+
+
+ {p.name} + {p.agent_types.map((at) => ( + + {AGENT_LABELS[at as AgentType] ?? at} + + ))} +
+
+ {p.api_url} +
+
+
+ + +
+
+ ))} +
+ )} +
+ + + + { + if (!open) setEditTarget(null) + }} + onProviderUpdated={loadProviders} + /> + + { + if (!open) setDeleteTarget(null) + }} + > + + + {t("deleteConfirmTitle")} + + {t("deleteConfirmMessage", { name: deleteTarget?.name ?? "" })} + + + + {t("cancel")} + + {t("delete")} + + + + +
+ ) +} diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index efa2a74..d3bbf88 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -15,6 +15,7 @@ import { BotMessageSquare, Palette, PlugZap, + Server, Settings, } from "lucide-react" import { useTranslations } from "next-intl" @@ -31,6 +32,7 @@ interface SettingsNavItem { labelKey: | "appearance" | "agents" + | "model_providers" | "mcp" | "skills" | "shortcuts" @@ -52,6 +54,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [ labelKey: "agents", icon: Bot, }, + { + href: "/settings/model-providers", + labelKey: "model_providers", + icon: Server, + }, { href: "/settings/mcp", labelKey: "mcp", diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index faeb5ac..3517d08 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -93,7 +93,8 @@ "version_control": "التحكم بالإصدارات", "system": "النظام", "chat_channels": "قنوات المحادثة", - "web_service": "خدمة الويب" + "web_service": "خدمة الويب", + "model_providers": "مزودو النماذج" } }, "AppearanceSettings": { @@ -1808,5 +1809,37 @@ "pt": "البرتغالية", "ar": "العربية" } + }, + "ModelProviderSettings": { + "sectionTitle": "مزودو النماذج", + "sectionDescription": "إدارة بيانات اعتماد مزودي API للوكلاء.", + "filterAll": "الكل", + "providerListTitle": "المزودون المُعدّون", + "addProvider": "إضافة مزود", + "editProvider": "تعديل المزود", + "noProviders": "لم يتم تكوين أي مزود نماذج بعد.", + "providerName": "الاسم", + "providerNamePlaceholder": "مثال: OpenAI، Anthropic", + "apiUrl": "عنوان API", + "apiUrlPlaceholder": "https://api.openai.com/v1", + "apiKey": "مفتاح API", + "apiKeyPlaceholder": "sk-...", + "apiKeyKeepCurrent": "اتركه فارغاً للإبقاء على الحالي", + "agentTypes": "أنواع الوكلاء", + "agentTypesRequired": "يجب اختيار نوع وكيل واحد على الأقل.", + "nameRequired": "اسم المزود مطلوب.", + "apiUrlRequired": "عنوان API مطلوب.", + "apiKeyRequired": "مفتاح API مطلوب.", + "loadFailed": "فشل تحميل المزودين.", + "saveFailed": "فشل حفظ التغييرات.", + "createSuccess": "تم إنشاء المزود.", + "editSuccess": "تم تحديث المزود.", + "deleteSuccess": "تم حذف المزود.", + "deleteConfirmTitle": "حذف المزود", + "deleteConfirmMessage": "سيتم حذف المزود \"{name}\" نهائياً. هل أنت متأكد؟", + "cancel": "إلغاء", + "delete": "حذف", + "create": "إنشاء", + "save": "حفظ" } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index e02e82e..a502ac7 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -93,7 +93,8 @@ "version_control": "Versionskontrolle", "system": "Systemeinstellungen", "chat_channels": "Chat-Kanäle", - "web_service": "Webdienst" + "web_service": "Webdienst", + "model_providers": "Modellanbieter" } }, "AppearanceSettings": { @@ -1808,5 +1809,37 @@ "pt": "Portugiesisch", "ar": "Arabisch" } + }, + "ModelProviderSettings": { + "sectionTitle": "Modellanbieter", + "sectionDescription": "API-Anbieter-Zugangsdaten für Agenten verwalten.", + "filterAll": "Alle", + "providerListTitle": "Konfigurierte Anbieter", + "addProvider": "Anbieter hinzufügen", + "editProvider": "Anbieter bearbeiten", + "noProviders": "Noch keine Modellanbieter konfiguriert.", + "providerName": "Name", + "providerNamePlaceholder": "z.B. OpenAI, Anthropic", + "apiUrl": "API-URL", + "apiUrlPlaceholder": "https://api.openai.com/v1", + "apiKey": "API-Schlüssel", + "apiKeyPlaceholder": "sk-...", + "apiKeyKeepCurrent": "Leer lassen, um aktuellen Wert beizubehalten", + "agentTypes": "Agententypen", + "agentTypesRequired": "Mindestens ein Agententyp ist erforderlich.", + "nameRequired": "Anbietername ist erforderlich.", + "apiUrlRequired": "API-URL ist erforderlich.", + "apiKeyRequired": "API-Schlüssel ist erforderlich.", + "loadFailed": "Anbieter konnten nicht geladen werden.", + "saveFailed": "Änderungen konnten nicht gespeichert werden.", + "createSuccess": "Anbieter erstellt.", + "editSuccess": "Anbieter aktualisiert.", + "deleteSuccess": "Anbieter gelöscht.", + "deleteConfirmTitle": "Anbieter löschen", + "deleteConfirmMessage": "Der Anbieter \"{name}\" wird dauerhaft gelöscht. Sind Sie sicher?", + "cancel": "Abbrechen", + "delete": "Löschen", + "create": "Erstellen", + "save": "Speichern" } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d238d75..df41fa4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -93,7 +93,8 @@ "version_control": "Version Control", "system": "System", "chat_channels": "Chat Channels", - "web_service": "Web Service" + "web_service": "Web Service", + "model_providers": "Model Providers" } }, "AppearanceSettings": { @@ -1808,5 +1809,37 @@ "pt": "Portuguese", "ar": "Arabic" } + }, + "ModelProviderSettings": { + "sectionTitle": "Model Providers", + "sectionDescription": "Manage API provider credentials for agents.", + "filterAll": "All", + "providerListTitle": "Configured Providers", + "addProvider": "Add Provider", + "editProvider": "Edit Provider", + "noProviders": "No model providers configured yet.", + "providerName": "Name", + "providerNamePlaceholder": "e.g. OpenAI, Anthropic", + "apiUrl": "API URL", + "apiUrlPlaceholder": "https://api.openai.com/v1", + "apiKey": "API Key", + "apiKeyPlaceholder": "sk-...", + "apiKeyKeepCurrent": "Leave blank to keep current", + "agentTypes": "Agent Types", + "agentTypesRequired": "At least one agent type is required.", + "nameRequired": "Provider name is required.", + "apiUrlRequired": "API URL is required.", + "apiKeyRequired": "API Key is required.", + "loadFailed": "Failed to load providers.", + "saveFailed": "Failed to save changes.", + "createSuccess": "Provider created.", + "editSuccess": "Provider updated.", + "deleteSuccess": "Provider deleted.", + "deleteConfirmTitle": "Delete Provider", + "deleteConfirmMessage": "This will permanently delete the provider \"{name}\". Are you sure?", + "cancel": "Cancel", + "delete": "Delete", + "create": "Create", + "save": "Save" } } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index b967c0f..3dc7481 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -93,7 +93,8 @@ "version_control": "Control de versiones", "system": "Sistema", "chat_channels": "Canales de chat", - "web_service": "Servicio Web" + "web_service": "Servicio Web", + "model_providers": "Proveedores de Modelos" } }, "AppearanceSettings": { @@ -1808,5 +1809,37 @@ "pt": "Portugués", "ar": "Árabe" } + }, + "ModelProviderSettings": { + "sectionTitle": "Proveedores de Modelos", + "sectionDescription": "Gestionar las credenciales de proveedores API para agentes.", + "filterAll": "Todos", + "providerListTitle": "Proveedores Configurados", + "addProvider": "Agregar Proveedor", + "editProvider": "Editar Proveedor", + "noProviders": "Aún no se han configurado proveedores de modelos.", + "providerName": "Nombre", + "providerNamePlaceholder": "Ej. OpenAI, Anthropic", + "apiUrl": "URL de API", + "apiUrlPlaceholder": "https://api.openai.com/v1", + "apiKey": "Clave API", + "apiKeyPlaceholder": "sk-...", + "apiKeyKeepCurrent": "Dejar vacío para mantener actual", + "agentTypes": "Tipos de Agente", + "agentTypesRequired": "Se requiere al menos un tipo de agente.", + "nameRequired": "El nombre del proveedor es obligatorio.", + "apiUrlRequired": "La URL de API es obligatoria.", + "apiKeyRequired": "La clave API es obligatoria.", + "loadFailed": "Error al cargar proveedores.", + "saveFailed": "Error al guardar cambios.", + "createSuccess": "Proveedor creado.", + "editSuccess": "Proveedor actualizado.", + "deleteSuccess": "Proveedor eliminado.", + "deleteConfirmTitle": "Eliminar Proveedor", + "deleteConfirmMessage": "Esto eliminará permanentemente el proveedor \"{name}\". ¿Está seguro?", + "cancel": "Cancelar", + "delete": "Eliminar", + "create": "Crear", + "save": "Guardar" } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 0f0fb39..2dcf116 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -93,7 +93,8 @@ "version_control": "Contrôle de version", "system": "Système", "chat_channels": "Canaux de chat", - "web_service": "Service Web" + "web_service": "Service Web", + "model_providers": "Fournisseurs de Modèles" } }, "AppearanceSettings": { @@ -1808,5 +1809,37 @@ "pt": "Portugais", "ar": "Arabe" } + }, + "ModelProviderSettings": { + "sectionTitle": "Fournisseurs de Modèles", + "sectionDescription": "Gérer les identifiants des fournisseurs d'API pour les agents.", + "filterAll": "Tous", + "providerListTitle": "Fournisseurs Configurés", + "addProvider": "Ajouter un Fournisseur", + "editProvider": "Modifier le Fournisseur", + "noProviders": "Aucun fournisseur de modèle configuré.", + "providerName": "Nom", + "providerNamePlaceholder": "Ex. OpenAI, Anthropic", + "apiUrl": "URL de l'API", + "apiUrlPlaceholder": "https://api.openai.com/v1", + "apiKey": "Clé API", + "apiKeyPlaceholder": "sk-...", + "apiKeyKeepCurrent": "Laisser vide pour conserver l'actuelle", + "agentTypes": "Types d'Agent", + "agentTypesRequired": "Au moins un type d'agent est requis.", + "nameRequired": "Le nom du fournisseur est requis.", + "apiUrlRequired": "L'URL de l'API est requise.", + "apiKeyRequired": "La clé API est requise.", + "loadFailed": "Échec du chargement des fournisseurs.", + "saveFailed": "Échec de la sauvegarde.", + "createSuccess": "Fournisseur créé.", + "editSuccess": "Fournisseur mis à jour.", + "deleteSuccess": "Fournisseur supprimé.", + "deleteConfirmTitle": "Supprimer le Fournisseur", + "deleteConfirmMessage": "Le fournisseur \"{name}\" sera définitivement supprimé. Êtes-vous sûr ?", + "cancel": "Annuler", + "delete": "Supprimer", + "create": "Créer", + "save": "Enregistrer" } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 42345e1..aa2cf63 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -93,7 +93,8 @@ "version_control": "バージョン管理", "system": "システム", "chat_channels": "チャットチャンネル", - "web_service": "Webサービス" + "web_service": "Webサービス", + "model_providers": "モデルプロバイダー" } }, "AppearanceSettings": { @@ -1808,5 +1809,37 @@ "pt": "ポルトガル語", "ar": "アラビア語" } + }, + "ModelProviderSettings": { + "sectionTitle": "モデルプロバイダー", + "sectionDescription": "エージェントのAPIプロバイダー認証情報を管理します。", + "filterAll": "すべて", + "providerListTitle": "設定済みプロバイダー", + "addProvider": "プロバイダーを追加", + "editProvider": "プロバイダーを編集", + "noProviders": "モデルプロバイダーはまだ設定されていません。", + "providerName": "名前", + "providerNamePlaceholder": "例: OpenAI、Anthropic", + "apiUrl": "API URL", + "apiUrlPlaceholder": "https://api.openai.com/v1", + "apiKey": "APIキー", + "apiKeyPlaceholder": "sk-...", + "apiKeyKeepCurrent": "空欄のまま現在の値を維持", + "agentTypes": "エージェントタイプ", + "agentTypesRequired": "少なくとも1つのエージェントタイプを選択してください。", + "nameRequired": "プロバイダー名は必須です。", + "apiUrlRequired": "API URLは必須です。", + "apiKeyRequired": "APIキーは必須です。", + "loadFailed": "プロバイダーの読み込みに失敗しました。", + "saveFailed": "変更の保存に失敗しました。", + "createSuccess": "プロバイダーを作成しました。", + "editSuccess": "プロバイダーを更新しました。", + "deleteSuccess": "プロバイダーを削除しました。", + "deleteConfirmTitle": "プロバイダーを削除", + "deleteConfirmMessage": "プロバイダー「{name}」を完全に削除しますか?", + "cancel": "キャンセル", + "delete": "削除", + "create": "作成", + "save": "保存" } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 36f8451..71b7d7f 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -93,7 +93,8 @@ "version_control": "버전 관리", "system": "시스템", "chat_channels": "채팅 채널", - "web_service": "웹 서비스" + "web_service": "웹 서비스", + "model_providers": "모델 제공업체" } }, "AppearanceSettings": { @@ -1808,5 +1809,37 @@ "pt": "포르투갈어", "ar": "아랍어" } + }, + "ModelProviderSettings": { + "sectionTitle": "모델 제공업체", + "sectionDescription": "에이전트의 API 제공업체 자격 증명을 관리합니다.", + "filterAll": "전체", + "providerListTitle": "구성된 제공업체", + "addProvider": "제공업체 추가", + "editProvider": "제공업체 편집", + "noProviders": "아직 구성된 모델 제공업체가 없습니다.", + "providerName": "이름", + "providerNamePlaceholder": "예: OpenAI, Anthropic", + "apiUrl": "API URL", + "apiUrlPlaceholder": "https://api.openai.com/v1", + "apiKey": "API 키", + "apiKeyPlaceholder": "sk-...", + "apiKeyKeepCurrent": "현재 값을 유지하려면 비워두세요", + "agentTypes": "에이전트 유형", + "agentTypesRequired": "최소 하나의 에이전트 유형을 선택하세요.", + "nameRequired": "제공업체 이름은 필수입니다.", + "apiUrlRequired": "API URL은 필수입니다.", + "apiKeyRequired": "API 키는 필수입니다.", + "loadFailed": "제공업체를 불러오지 못했습니다.", + "saveFailed": "변경 사항을 저장하지 못했습니다.", + "createSuccess": "제공업체가 생성되었습니다.", + "editSuccess": "제공업체가 업데이트되었습니다.", + "deleteSuccess": "제공업체가 삭제되었습니다.", + "deleteConfirmTitle": "제공업체 삭제", + "deleteConfirmMessage": "제공업체 \"{name}\"을(를) 영구적으로 삭제하시겠습니까?", + "cancel": "취소", + "delete": "삭제", + "create": "생성", + "save": "저장" } } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4992002..c0f784d 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -93,7 +93,8 @@ "version_control": "Controle de versão", "system": "Sistema", "chat_channels": "Canais de chat", - "web_service": "Serviço Web" + "web_service": "Serviço Web", + "model_providers": "Provedores de Modelos" } }, "AppearanceSettings": { @@ -1808,5 +1809,37 @@ "pt": "Português", "ar": "Árabe" } + }, + "ModelProviderSettings": { + "sectionTitle": "Provedores de Modelos", + "sectionDescription": "Gerenciar credenciais de provedores de API para agentes.", + "filterAll": "Todos", + "providerListTitle": "Provedores Configurados", + "addProvider": "Adicionar Provedor", + "editProvider": "Editar Provedor", + "noProviders": "Nenhum provedor de modelo configurado.", + "providerName": "Nome", + "providerNamePlaceholder": "Ex. OpenAI, Anthropic", + "apiUrl": "URL da API", + "apiUrlPlaceholder": "https://api.openai.com/v1", + "apiKey": "Chave da API", + "apiKeyPlaceholder": "sk-...", + "apiKeyKeepCurrent": "Deixe em branco para manter atual", + "agentTypes": "Tipos de Agente", + "agentTypesRequired": "Pelo menos um tipo de agente é necessário.", + "nameRequired": "O nome do provedor é obrigatório.", + "apiUrlRequired": "A URL da API é obrigatória.", + "apiKeyRequired": "A chave da API é obrigatória.", + "loadFailed": "Falha ao carregar provedores.", + "saveFailed": "Falha ao salvar alterações.", + "createSuccess": "Provedor criado.", + "editSuccess": "Provedor atualizado.", + "deleteSuccess": "Provedor excluído.", + "deleteConfirmTitle": "Excluir Provedor", + "deleteConfirmMessage": "O provedor \"{name}\" será excluído permanentemente. Tem certeza?", + "cancel": "Cancelar", + "delete": "Excluir", + "create": "Criar", + "save": "Salvar" } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 2c3274d..eb02ebd 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -93,7 +93,8 @@ "version_control": "版本控制", "system": "系统", "chat_channels": "消息渠道", - "web_service": "Web 服务" + "web_service": "Web 服务", + "model_providers": "模型供应商" } }, "AppearanceSettings": { @@ -1808,5 +1809,37 @@ "pt": "葡萄牙语", "ar": "阿拉伯语" } + }, + "ModelProviderSettings": { + "sectionTitle": "模型供应商", + "sectionDescription": "管理 Agent 的 API 供应商凭据。", + "filterAll": "全部", + "providerListTitle": "已配置的供应商", + "addProvider": "添加供应商", + "editProvider": "编辑供应商", + "noProviders": "尚未配置模型供应商。", + "providerName": "名称", + "providerNamePlaceholder": "例如 OpenAI、Anthropic", + "apiUrl": "API 地址", + "apiUrlPlaceholder": "https://api.openai.com/v1", + "apiKey": "API 密钥", + "apiKeyPlaceholder": "sk-...", + "apiKeyKeepCurrent": "留空则保持不变", + "agentTypes": "代理类型", + "agentTypesRequired": "至少选择一个代理类型。", + "nameRequired": "供应商名称不能为空。", + "apiUrlRequired": "API 地址不能为空。", + "apiKeyRequired": "API 密钥不能为空。", + "loadFailed": "加载供应商失败。", + "saveFailed": "保存更改失败。", + "createSuccess": "供应商已创建。", + "editSuccess": "供应商已更新。", + "deleteSuccess": "供应商已删除。", + "deleteConfirmTitle": "删除供应商", + "deleteConfirmMessage": "确定要永久删除供应商「{name}」吗?", + "cancel": "取消", + "delete": "删除", + "create": "创建", + "save": "保存" } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index ee6a3f1..3953166 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -93,7 +93,8 @@ "version_control": "版本控制", "system": "系統", "chat_channels": "訊息頻道", - "web_service": "Web 服務" + "web_service": "Web 服務", + "model_providers": "模型供應商" } }, "AppearanceSettings": { @@ -1808,5 +1809,37 @@ "pt": "葡萄牙語", "ar": "阿拉伯語" } + }, + "ModelProviderSettings": { + "sectionTitle": "模型供應商", + "sectionDescription": "管理 Agent 的 API 供應商憑據。", + "filterAll": "全部", + "providerListTitle": "已配置的供應商", + "addProvider": "新增供應商", + "editProvider": "編輯供應商", + "noProviders": "尚未配置模型供應商。", + "providerName": "名稱", + "providerNamePlaceholder": "例如 OpenAI、Anthropic", + "apiUrl": "API 位址", + "apiUrlPlaceholder": "https://api.openai.com/v1", + "apiKey": "API 金鑰", + "apiKeyPlaceholder": "sk-...", + "apiKeyKeepCurrent": "留空則保持不變", + "agentTypes": "代理類型", + "agentTypesRequired": "至少選擇一個代理類型。", + "nameRequired": "供應商名稱不能為空。", + "apiUrlRequired": "API 位址不能為空。", + "apiKeyRequired": "API 金鑰不能為空。", + "loadFailed": "載入供應商失敗。", + "saveFailed": "儲存變更失敗。", + "createSuccess": "供應商已建立。", + "editSuccess": "供應商已更新。", + "deleteSuccess": "供應商已刪除。", + "deleteConfirmTitle": "刪除供應商", + "deleteConfirmMessage": "確定要永久刪除供應商「{name}」嗎?", + "cancel": "取消", + "delete": "刪除", + "create": "建立", + "save": "儲存" } } diff --git a/src/lib/api.ts b/src/lib/api.ts index 70d7ac8..5683d3b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -57,6 +57,7 @@ import type { ChatChannelInfo, ChannelStatusInfo, ChatChannelMessageLog, + ModelProviderInfo, } from "./types" export async function listConversations(params?: { @@ -1468,3 +1469,40 @@ export async function weixinCheckQrcode( }> { return getTransport().call("weixin_check_qrcode", { channelId, qrcode }) } + +// --------------------------------------------------------------------------- +// Model Providers +// --------------------------------------------------------------------------- + +export async function listModelProviders(): Promise { + return getTransport().call("list_model_providers") +} + +export async function createModelProvider(params: { + name: string + apiUrl: string + apiKey: string + agentTypes: string[] +}): Promise { + return getTransport().call("create_model_provider", params) +} + +export async function updateModelProvider(params: { + id: number + name?: string | null + apiUrl?: string | null + apiKey?: string | null + agentTypes?: string[] | null +}): Promise { + return getTransport().call("update_model_provider", { + id: params.id, + name: params.name ?? null, + apiUrl: params.apiUrl ?? null, + apiKey: params.apiKey ?? null, + agentTypes: params.agentTypes ?? null, + }) +} + +export async function deleteModelProvider(id: number): Promise { + return getTransport().call("delete_model_provider", { id }) +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 6488eeb..c42fcce 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -220,6 +220,15 @@ export function compareAgentType(a: AgentType, b: AgentType): number { return aIndex - bIndex } +export const ALL_AGENT_TYPES: AgentType[] = [ + "claude_code", + "codex", + "open_code", + "gemini", + "open_claw", + "cline", +] + export const AGENT_LABELS: Record = { claude_code: "Claude Code", codex: "Codex", @@ -888,3 +897,13 @@ export interface ChatChannelMessageLog { error_detail: string | null created_at: string } + +export interface ModelProviderInfo { + id: number + name: string + api_url: string + api_key_masked: string + agent_types: AgentType[] + created_at: string + updated_at: string +}