diff --git a/package.json b/package.json index 7233c9751..5ba351674 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.61.0", + "version": "0.62.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 01c02244f..a356c9267 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6621,7 +6621,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.61.0" +version = "0.62.0" dependencies = [ "anyhow", "arboard", @@ -6717,7 +6717,7 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.61.0" +version = "0.62.0" dependencies = [ "aster", "async-trait", @@ -6740,7 +6740,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.61.0" +version = "0.62.0" dependencies = [ "async-trait", "parking_lot", @@ -6756,7 +6756,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.61.0" +version = "0.62.0" dependencies = [ "async-trait", "axum 0.7.9", @@ -6795,7 +6795,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.61.0" +version = "0.62.0" dependencies = [ "axum 0.7.9", "chrono", @@ -6813,7 +6813,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.61.0" +version = "0.62.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -6833,7 +6833,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.61.0" +version = "0.62.0" dependencies = [ "async-trait", "glob", @@ -6848,7 +6848,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.61.0" +version = "0.62.0" dependencies = [ "async-trait", "parking_lot", @@ -6867,7 +6867,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.61.0" +version = "0.62.0" dependencies = [ "anyhow", "async-stream", @@ -6919,7 +6919,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.61.0" +version = "0.62.0" dependencies = [ "async-stream", "axum 0.7.9", @@ -6930,12 +6930,14 @@ dependencies = [ "futures", "parking_lot", "proptest", + "proxycast-agent", "proxycast-config", "proxycast-core", "proxycast-credential", "proxycast-infra", "proxycast-processor", "proxycast-providers", + "proxycast-scheduler", "proxycast-server-utils", "proxycast-services", "proxycast-websocket", @@ -6956,7 +6958,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.61.0" +version = "0.62.0" dependencies = [ "axum 0.7.9", "futures", @@ -6971,7 +6973,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.61.0" +version = "0.62.0" dependencies = [ "anyhow", "aster", @@ -7010,7 +7012,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.61.0" +version = "0.62.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7026,7 +7028,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.61.0" +version = "0.62.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7053,7 +7055,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.61.0" +version = "0.62.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 77e7c127e..5a1348ca2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.61.0" +version = "0.62.0" edition = "2021" authors = ["you"] repository = "https://github.com/aiclientproxy/proxycast" @@ -181,7 +181,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.61.0" +version = "0.62.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/core/src/config/mod.rs b/src-tauri/crates/core/src/config/mod.rs index 6847cc1f2..060c85597 100644 --- a/src-tauri/crates/core/src/config/mod.rs +++ b/src-tauri/crates/core/src/config/mod.rs @@ -21,14 +21,15 @@ pub use import::{ImportOptions, ImportService, ValidationResult}; pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde}; pub use types::{ generate_secure_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, AsrCredentialEntry, - AsrProviderType, BaiduConfig, Config, ContentCreatorConfig, CredentialEntry, - CredentialPoolConfig, CustomProviderConfig, EndpointProvidersConfig, ExperimentalFeatures, - GeminiApiKeyEntry, InjectionRuleConfig, InjectionSettings, LoggingConfig, ModelInfo, - ModelsConfig, NativeAgentConfig, NavigationConfig, OpenAIAsrConfig, ProviderConfig, - ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig, RemoteManagementConfig, - RetrySettings, RoutingConfig, ScreenshotChatConfig, ServerConfig, TlsConfig, UpdateCheckConfig, - VertexApiKeyEntry, VertexModelAlias, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig, - VoiceOutputMode, VoiceProcessorConfig, WhisperLocalConfig, WhisperModelSize, XunfeiConfig, - DEFAULT_API_KEY, + AsrProviderType, AssistantConfig, AssistantProfile, BaiduConfig, ChatAppearanceConfig, Config, + ContentCreatorConfig, CredentialEntry, CredentialPoolConfig, CustomProviderConfig, + EndpointProvidersConfig, ExperimentalFeatures, GeminiApiKeyEntry, ImageGenConfig, + InjectionRuleConfig, InjectionSettings, LoggingConfig, MemoryConfig, ModelInfo, ModelsConfig, + NativeAgentConfig, NavigationConfig, OpenAIAsrConfig, ProviderConfig, ProviderModelsConfig, + ProvidersConfig, QuotaExceededConfig, RemoteManagementConfig, RetrySettings, RoutingConfig, + ScreenshotChatConfig, ServerConfig, TlsConfig, UpdateCheckConfig, UserProfile, + VertexApiKeyEntry, VertexModelAlias, VoiceConfig, VoiceInputConfig, VoiceInstruction, + VoiceOutputConfig, VoiceOutputMode, VoiceProcessorConfig, WhisperLocalConfig, WhisperModelSize, + XunfeiConfig, DEFAULT_API_KEY, }; pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService}; diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 330121a12..7f3fce267 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -405,6 +405,24 @@ pub struct Config { /// 导航栏配置 #[serde(default)] pub navigation: NavigationConfig, + /// 聊天外观配置 + #[serde(default)] + pub chat_appearance: ChatAppearanceConfig, + /// 记忆管理配置 + #[serde(default)] + pub memory: MemoryConfig, + /// 语音服务配置 + #[serde(default)] + pub voice: VoiceConfig, + /// 图像生成服务配置 + #[serde(default)] + pub image_gen: ImageGenConfig, + /// 助理配置 + #[serde(default)] + pub assistant: AssistantConfig, + /// 用户资料 + #[serde(default)] + pub user_profile: UserProfile, } // ============ Native Agent 配置类型 ============ @@ -1696,10 +1714,177 @@ impl Default for Config { experimental: ExperimentalFeatures::default(), content_creator: ContentCreatorConfig::default(), navigation: NavigationConfig::default(), + chat_appearance: ChatAppearanceConfig::default(), + memory: MemoryConfig::default(), + voice: VoiceConfig::default(), + image_gen: ImageGenConfig::default(), + assistant: AssistantConfig::default(), + user_profile: UserProfile::default(), } } } +// ============ 设置页面配置类型 ============ + +/// 聊天外观配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct ChatAppearanceConfig { + /// 字体大小 (12-18) + #[serde(default)] + pub font_size: Option, + /// 消息过渡模式 + #[serde(default)] + pub transition_mode: Option, + /// 气泡样式 + #[serde(default)] + pub bubble_style: Option, + /// 显示头像 + #[serde(default)] + pub show_avatar: Option, + /// 显示时间戳 + #[serde(default)] + pub show_timestamp: Option, +} + +/// 记忆管理配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct MemoryConfig { + /// 是否启用记忆功能 + #[serde(default)] + pub enabled: bool, + /// 最大记忆条数 + #[serde(default)] + pub max_entries: Option, + /// 记忆保留天数 + #[serde(default)] + pub retention_days: Option, + /// 自动清理过期记忆 + #[serde(default)] + pub auto_cleanup: Option, +} + +/// 语音服务配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct VoiceConfig { + /// TTS 服务商 + #[serde(default)] + pub tts_service: Option, + /// STT 服务商 + #[serde(default)] + pub stt_service: Option, + /// TTS 语音 + #[serde(default)] + pub tts_voice: Option, + /// TTS 语速 (0.1-2.0) + #[serde(default)] + pub tts_rate: Option, + /// TTS 音调 (0.1-2.0) + #[serde(default)] + pub tts_pitch: Option, + /// TTS 音量 (0-1) + #[serde(default)] + pub tts_volume: Option, + /// STT 语言 + #[serde(default)] + pub stt_language: Option, + /// 自动停止录音 + #[serde(default)] + pub stt_auto_stop: Option, + /// 启用语音输入 + #[serde(default)] + pub voice_input_enabled: Option, + /// 启用语音输出 + #[serde(default)] + pub voice_output_enabled: Option, +} + +/// 图像生成服务配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct ImageGenConfig { + /// 默认图像生成服务 + #[serde(default)] + pub default_service: Option, + /// 默认图像数量 + #[serde(default)] + pub default_count: Option, + /// 默认图像尺寸 + #[serde(default)] + pub default_size: Option, + /// 默认图像质量 + #[serde(default)] + pub default_quality: Option, + /// 默认图像风格 + #[serde(default)] + pub default_style: Option, + /// 启用图像增强 + #[serde(default)] + pub enable_enhancement: Option, + /// 自动下载生成的图像 + #[serde(default)] + pub auto_download: Option, +} + +/// 助理配置 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct AssistantConfig { + /// 默认助理 ID + #[serde(default)] + pub default_assistant_id: Option, + /// 自定义助理列表 + #[serde(default)] + pub custom_assistants: Option>, + /// 启用助理自动选择 + #[serde(default)] + pub auto_select: Option, + /// 显示助理建议 + #[serde(default)] + pub show_suggestions: Option, +} + +/// 助理档案 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AssistantProfile { + /// ID + pub id: String, + /// 名称 + pub name: String, + /// 描述 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 模型 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// 系统提示词 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + /// 温度参数 + #[serde(default)] + pub temperature: Option, + /// 最大 token 数 + #[serde(default)] + pub max_tokens: Option, +} + +/// 用户资料 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct UserProfile { + /// 用户头像 URL + #[serde(default)] + pub avatar_url: Option, + /// 昵称 + #[serde(default)] + pub nickname: Option, + /// 个人简介 + #[serde(default)] + pub bio: Option, + /// 邮箱 + #[serde(default)] + pub email: Option, + /// 偏好标签 + #[serde(default)] + pub tags: Option>, +} + #[cfg(test)] mod unit_tests { use super::*; diff --git a/src-tauri/crates/core/src/database/system_providers.rs b/src-tauri/crates/core/src/database/system_providers.rs index 5c0dea3be..ea80a4a59 100644 --- a/src-tauri/crates/core/src/database/system_providers.rs +++ b/src-tauri/crates/core/src/database/system_providers.rs @@ -23,8 +23,17 @@ pub struct SystemProviderDef { pub fn get_system_providers() -> Vec { vec![ // ========================================================================= - // 主流 AI (10个) - Requirements 3.1 + // 主流 AI (11个) - Requirements 3.1 // ========================================================================= + SystemProviderDef { + id: "proxycast-hub", + name: "ProxyCast Hub", + provider_type: ApiProviderType::Openai, + api_host: "https://hub.proxycast.ai/v1", + group: ProviderGroup::Mainstream, + sort_order: 0, + api_version: None, + }, SystemProviderDef { id: "openai", name: "OpenAI", @@ -253,6 +262,69 @@ pub fn get_system_providers() -> Vec { sort_order: 25, api_version: None, }, + SystemProviderDef { + id: "ai360", + name: "360 AI", + provider_type: ApiProviderType::Openai, + api_host: "https://api.360.cn", + group: ProviderGroup::Chinese, + sort_order: 26, + api_version: None, + }, + SystemProviderDef { + id: "giteeai", + name: "Gitee AI", + provider_type: ApiProviderType::Openai, + api_host: "https://ai.gitee.com/v1", + group: ProviderGroup::Chinese, + sort_order: 27, + api_version: None, + }, + SystemProviderDef { + id: "internlm", + name: "InternLM", + provider_type: ApiProviderType::Openai, + api_host: "https://chat.intern-ai.org.cn/api/v1", + group: ProviderGroup::Chinese, + sort_order: 28, + api_version: None, + }, + SystemProviderDef { + id: "sensenova", + name: "SenseNova", + provider_type: ApiProviderType::Openai, + api_host: "https://api.sensenova.cn/compatible-mode/v1", + group: ProviderGroup::Chinese, + sort_order: 29, + api_version: None, + }, + SystemProviderDef { + id: "spark", + name: "Spark", + provider_type: ApiProviderType::Openai, + api_host: "", + group: ProviderGroup::Chinese, + sort_order: 30, + api_version: None, + }, + SystemProviderDef { + id: "taichu", + name: "Taichu", + provider_type: ApiProviderType::Openai, + api_host: "https://ai-maas.wair.ac.cn/maas/v1", + group: ProviderGroup::Chinese, + sort_order: 31, + api_version: None, + }, + SystemProviderDef { + id: "tencentcloud", + name: "TencentCloud", + provider_type: ApiProviderType::Openai, + api_host: "https://api.lkeap.cloud.tencent.com/v1", + group: ProviderGroup::Chinese, + sort_order: 32, + api_version: None, + }, // ========================================================================= // 云服务 (5个) - Requirements 3.3 // ========================================================================= @@ -301,6 +373,24 @@ pub fn get_system_providers() -> Vec { sort_order: 30, api_version: None, }, + SystemProviderDef { + id: "azureai", + name: "Azure AI", + provider_type: ApiProviderType::Openai, + api_host: "", + group: ProviderGroup::Cloud, + sort_order: 31, + api_version: None, + }, + SystemProviderDef { + id: "cloudflare", + name: "Cloudflare Workers AI", + provider_type: ApiProviderType::Openai, + api_host: "", + group: ProviderGroup::Cloud, + sort_order: 32, + api_version: None, + }, // ========================================================================= // API 聚合/中转服务 (25个) - Requirements 3.4 // ========================================================================= @@ -538,6 +628,132 @@ pub fn get_system_providers() -> Vec { sort_order: 55, api_version: None, }, + SystemProviderDef { + id: "ai21", + name: "Ai21Labs", + provider_type: ApiProviderType::Openai, + api_host: "https://api.ai21.com/studio/v1", + group: ProviderGroup::Aggregator, + sort_order: 56, + api_version: None, + }, + SystemProviderDef { + id: "akashchat", + name: "AkashChat", + provider_type: ApiProviderType::Openai, + api_host: "", + group: ProviderGroup::Aggregator, + sort_order: 57, + api_version: None, + }, + SystemProviderDef { + id: "bfl", + name: "Black Forest Labs", + provider_type: ApiProviderType::Openai, + api_host: "", + group: ProviderGroup::Aggregator, + sort_order: 58, + api_version: None, + }, + SystemProviderDef { + id: "cometapi", + name: "CometAPI", + provider_type: ApiProviderType::Openai, + api_host: "https://api.cometapi.com/v1", + group: ProviderGroup::Aggregator, + sort_order: 59, + api_version: None, + }, + SystemProviderDef { + id: "fal", + name: "Fal", + provider_type: ApiProviderType::Openai, + api_host: "", + group: ProviderGroup::Aggregator, + sort_order: 60, + api_version: None, + }, + SystemProviderDef { + id: "nebius", + name: "Nebius", + provider_type: ApiProviderType::Openai, + api_host: "https://api.studio.nebius.com/v1", + group: ProviderGroup::Aggregator, + sort_order: 61, + api_version: None, + }, + SystemProviderDef { + id: "novita", + name: "Novita", + provider_type: ApiProviderType::Openai, + api_host: "https://api.novita.ai/v3/openai", + group: ProviderGroup::Aggregator, + sort_order: 62, + api_version: None, + }, + SystemProviderDef { + id: "ollamacloud", + name: "Ollama Cloud", + provider_type: ApiProviderType::Openai, + api_host: "", + group: ProviderGroup::Aggregator, + sort_order: 63, + api_version: None, + }, + SystemProviderDef { + id: "replicate", + name: "Replicate", + provider_type: ApiProviderType::Openai, + api_host: "https://api.replicate.com", + group: ProviderGroup::Aggregator, + sort_order: 64, + api_version: None, + }, + SystemProviderDef { + id: "sambanova", + name: "SambaNova", + provider_type: ApiProviderType::Openai, + api_host: "https://api.sambanova.ai/v1", + group: ProviderGroup::Aggregator, + sort_order: 65, + api_version: None, + }, + SystemProviderDef { + id: "search1api", + name: "Search1API", + provider_type: ApiProviderType::Openai, + api_host: "https://api.search1api.com/v1", + group: ProviderGroup::Aggregator, + sort_order: 66, + api_version: None, + }, + SystemProviderDef { + id: "upstage", + name: "Upstage", + provider_type: ApiProviderType::Openai, + api_host: "https://api.upstage.ai/v1/solar", + group: ProviderGroup::Aggregator, + sort_order: 67, + api_version: None, + }, + SystemProviderDef { + id: "v0", + name: "Vercel (v0)", + provider_type: ApiProviderType::Openai, + api_host: "", + group: ProviderGroup::Aggregator, + sort_order: 68, + api_version: None, + }, + SystemProviderDef { + id: "zenmux", + name: "ZenMux", + provider_type: ApiProviderType::Openai, + api_host: "https://zenmux.ai", + group: ProviderGroup::Aggregator, + sort_order: 69, + api_version: None, + }, // ========================================================================= // 本地/自托管服务 (5个) - Requirements 3.5 // ========================================================================= @@ -586,6 +802,42 @@ pub fn get_system_providers() -> Vec { sort_order: 60, api_version: None, }, + SystemProviderDef { + id: "comfyui", + name: "ComfyUI", + provider_type: ApiProviderType::Openai, + api_host: "http://127.0.0.1:8188", + group: ProviderGroup::Local, + sort_order: 61, + api_version: None, + }, + SystemProviderDef { + id: "higress", + name: "Higress", + provider_type: ApiProviderType::Openai, + api_host: "https://127.0.0.1:8080/v1", + group: ProviderGroup::Local, + sort_order: 62, + api_version: None, + }, + SystemProviderDef { + id: "vllm", + name: "vLLM", + provider_type: ApiProviderType::Openai, + api_host: "http://localhost:8000/v1", + group: ProviderGroup::Local, + sort_order: 63, + api_version: None, + }, + SystemProviderDef { + id: "xinference", + name: "Xinference", + provider_type: ApiProviderType::Openai, + api_host: "http://localhost:9997/v1", + group: ProviderGroup::Local, + sort_order: 64, + api_version: None, + }, // ========================================================================= // 专用服务 (3个) - Requirements 3.6 // ========================================================================= diff --git a/src-tauri/crates/scheduler/src/batch_dao.rs b/src-tauri/crates/scheduler/src/batch_dao.rs index 1875efcc7..a8e10554c 100644 --- a/src-tauri/crates/scheduler/src/batch_dao.rs +++ b/src-tauri/crates/scheduler/src/batch_dao.rs @@ -6,8 +6,7 @@ use super::batch::{BatchTask, BatchTaskStatus}; use super::template::TaskTemplate; use anyhow::{Context, Result}; use proxycast_core::database::DbConnection; -use rusqlite::params; -use std::sync::{Arc, Mutex}; +use rusqlite::{params, OptionalExtension}; use uuid::Uuid; /// 批量任务 DAO @@ -162,15 +161,19 @@ impl BatchTaskDao { options: serde_json::from_str(&options_json)?, tasks: serde_json::from_str(&tasks_json)?, results: results_json - .map(|json| serde_json::from_str(&json)) - .transpose()? + .as_deref() + .map(|json| serde_json::from_str(json)) + .transpose() + .unwrap_or_default() .unwrap_or_default(), created_at: chrono::DateTime::parse_from_rfc3339(&created_at)?.into(), started_at: started_at - .map(|s| chrono::DateTime::parse_from_rfc3339(&s).map(|dt| dt.into())) + .as_deref() + .map(|s| chrono::DateTime::parse_from_rfc3339(s).map(|dt| dt.into())) .transpose()?, completed_at: completed_at - .map(|s| chrono::DateTime::parse_from_rfc3339(&s).map(|dt| dt.into())) + .as_deref() + .map(|s| chrono::DateTime::parse_from_rfc3339(s).map(|dt| dt.into())) .transpose()?, }; @@ -241,15 +244,19 @@ impl BatchTaskDao { options: serde_json::from_str(&options_json)?, tasks: serde_json::from_str(&tasks_json)?, results: results_json - .map(|json| serde_json::from_str(&json)) - .transpose()? + .as_deref() + .map(|json| serde_json::from_str(json)) + .transpose() + .unwrap_or_default() .unwrap_or_default(), created_at: chrono::DateTime::parse_from_rfc3339(&created_at)?.into(), started_at: started_at - .map(|s| chrono::DateTime::parse_from_rfc3339(&s).map(|dt| dt.into())) + .as_deref() + .map(|s| chrono::DateTime::parse_from_rfc3339(s).map(|dt| dt.into())) .transpose()?, completed_at: completed_at - .map(|s| chrono::DateTime::parse_from_rfc3339(&s).map(|dt| dt.into())) + .as_deref() + .map(|s| chrono::DateTime::parse_from_rfc3339(s).map(|dt| dt.into())) .transpose()?, }; diff --git a/src-tauri/crates/scheduler/src/dao.rs b/src-tauri/crates/scheduler/src/dao.rs index 42788a5f3..040a714ff 100644 --- a/src-tauri/crates/scheduler/src/dao.rs +++ b/src-tauri/crates/scheduler/src/dao.rs @@ -4,7 +4,7 @@ use super::types::{ScheduledTask, TaskFilter, TaskStatus}; use rusqlite::{params, Connection}; -use tracing::{error, warn}; +use tracing::warn; pub struct SchedulerDao; diff --git a/src-tauri/crates/scheduler/src/scheduler.rs b/src-tauri/crates/scheduler/src/scheduler.rs index ab41cf938..a2e776de3 100644 --- a/src-tauri/crates/scheduler/src/scheduler.rs +++ b/src-tauri/crates/scheduler/src/scheduler.rs @@ -3,10 +3,9 @@ //! 提供任务调度的核心功能 use super::dao::SchedulerDao; -use super::types::{ScheduledTask, TaskFilter, TaskStatus}; +use super::types::{ScheduledTask, TaskFilter}; use async_trait::async_trait; use proxycast_core::database::DbConnection; -use std::sync::Arc; /// 调度器 Trait /// diff --git a/src-tauri/crates/server/Cargo.toml b/src-tauri/crates/server/Cargo.toml index a10a3c23c..1364f226d 100644 --- a/src-tauri/crates/server/Cargo.toml +++ b/src-tauri/crates/server/Cargo.toml @@ -13,6 +13,8 @@ proxycast-credential.workspace = true proxycast-websocket.workspace = true proxycast-processor.workspace = true proxycast-server-utils.workspace = true +proxycast-scheduler.workspace = true +proxycast-agent.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/src-tauri/crates/server/src/handlers/batch_api.rs b/src-tauri/crates/server/src/handlers/batch_api.rs index ad511d5c2..013867d45 100644 --- a/src-tauri/crates/server/src/handlers/batch_api.rs +++ b/src-tauri/crates/server/src/handlers/batch_api.rs @@ -84,7 +84,7 @@ pub async fn create_batch_task( }; // 验证模板是否存在 - let template = match TemplateDao::get_by_id(db, &request.template_id) { + let _template = match TemplateDao::get_by_id(db, &request.template_id) { Ok(Some(t)) => t, Ok(None) => { return ( diff --git a/src-tauri/crates/services/src/api_key_provider_service.rs b/src-tauri/crates/services/src/api_key_provider_service.rs index f639086cc..8f4e8e0c0 100644 --- a/src-tauri/crates/services/src/api_key_provider_service.rs +++ b/src-tauri/crates/services/src/api_key_provider_service.rs @@ -77,6 +77,30 @@ data: [DONE]\n"; ApiProviderType::Openai )); } + + #[test] + fn test_pick_test_model_priority() { + let with_explicit = ApiKeyProviderService::pick_test_model( + Some("explicit-model".to_string()), + &["custom-model".to_string()], + &["fallback-model".to_string()], + ); + assert_eq!(with_explicit.as_deref(), Some("explicit-model")); + + let with_custom = ApiKeyProviderService::pick_test_model( + None, + &["custom-model".to_string()], + &["fallback-model".to_string()], + ); + assert_eq!(with_custom.as_deref(), Some("custom-model")); + + let with_local_fallback = + ApiKeyProviderService::pick_test_model(None, &[], &["fallback-model".to_string()]); + assert_eq!(with_local_fallback.as_deref(), Some("fallback-model")); + + let none = ApiKeyProviderService::pick_test_model(None, &[], &[]); + assert!(none.is_none()); + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -277,6 +301,16 @@ impl ApiKeyProviderService { provider_type.is_anthropic_protocol() } + fn pick_test_model( + model_name: Option, + custom_models: &[String], + fallback_models: &[String], + ) -> Option { + model_name + .or_else(|| custom_models.first().cloned()) + .or_else(|| fallback_models.first().cloned()) + } + async fn test_openai_chat_once( &self, api_key: &str, @@ -1618,6 +1652,18 @@ impl ApiKeyProviderService { db: &DbConnection, provider_id: &str, model_name: Option, + ) -> Result { + self.test_connection_with_fallback_models(db, provider_id, model_name, Vec::new()) + .await + } + + /// 测试 Provider 连接(带本地模型兜底) + pub async fn test_connection_with_fallback_models( + &self, + db: &DbConnection, + provider_id: &str, + model_name: Option, + fallback_models: Vec, ) -> Result { use std::time::Instant; @@ -1639,9 +1685,12 @@ impl ApiKeyProviderService { let result = match provider.provider_type { provider_type if Self::uses_anthropic_protocol(provider_type) => { // Anthropic / AnthropicCompatible 不支持 /models,统一发送 /messages 测试请求 - let test_model = model_name - .or_else(|| provider.custom_models.first().cloned()) - .unwrap_or_else(|| "claude-3-haiku-20240307".to_string()); + let test_model = Self::pick_test_model( + model_name.clone(), + &provider.custom_models, + &fallback_models, + ) + .unwrap_or_else(|| "claude-3-haiku-20240307".to_string()); match self .test_anthropic_connection(&api_key, &provider.api_host, &test_model) @@ -1665,9 +1714,12 @@ impl ApiKeyProviderService { } ApiProviderType::Codex => { // Codex 协议直接走 /responses 端点 - let test_model = model_name - .or_else(|| provider.custom_models.first().cloned()) - .ok_or_else(|| "缺少模型名称:请在自定义模型中填写一个模型名".to_string())?; + let test_model = Self::pick_test_model( + model_name.clone(), + &provider.custom_models, + &fallback_models, + ) + .ok_or_else(|| "缺少模型名称:请在自定义模型中填写一个模型名".to_string())?; self.test_codex_responses_endpoint(&api_key, &provider.api_host, &test_model, "hi") .await @@ -1680,6 +1732,10 @@ impl ApiKeyProviderService { "[TEST_CONNECTION] provider.custom_models: {:?}", provider.custom_models ); + eprintln!( + "[TEST_CONNECTION] local_fallback_models_count: {}", + fallback_models.len() + ); let models_result = self .test_openai_models_endpoint(&api_key, &provider.api_host) @@ -1689,9 +1745,14 @@ impl ApiKeyProviderService { // 如果 /models 端点失败: // 1) 优先用传入的 model_name - // 2) 否则如果 Provider 配置了 custom_models,则用第一个模型降级测试 chat/completions + // 2) 否则使用 Provider 配置的 custom_models + // 3) 再使用本地模型注册表兜底 if models_result.is_err() { - let test_model = model_name.or_else(|| provider.custom_models.first().cloned()); + let test_model = Self::pick_test_model( + model_name.clone(), + &provider.custom_models, + &fallback_models, + ); eprintln!("[TEST_CONNECTION] fallback test_model: {test_model:?}"); diff --git a/src-tauri/crates/services/src/model_registry_service.rs b/src-tauri/crates/services/src/model_registry_service.rs index d25da5efd..76dd80ddc 100644 --- a/src-tauri/crates/services/src/model_registry_service.rs +++ b/src-tauri/crates/services/src/model_registry_service.rs @@ -18,6 +18,9 @@ use tokio::sync::RwLock; /// 内嵌的模型资源目录名(相对于 resource_dir) /// 对应 tauri.conf.json 中的 "resources/models/**/*" const MODELS_RESOURCE_DIR: &str = "resources/models"; +const MODELS_HOST_ALIASES_FILE: &str = "host_aliases.json"; +const MODELS_HOST_ALIASES_USER_FILE: &str = "host_aliases.user.json"; +const DEFAULT_USER_HOST_ALIASES_TEMPLATE: &str = "{\n \"rules\": []\n}\n"; /// 仓库索引文件结构 #[derive(Debug, Deserialize)] @@ -88,6 +91,18 @@ struct RepoLimits { max_output: Option, } +#[derive(Debug, Clone, Deserialize, Default)] +struct HostAliasConfig { + #[serde(default)] + rules: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct HostAliasRule { + contains: String, + providers: Vec, +} + /// 模型注册服务 pub struct ModelRegistryService { /// 数据库连接 @@ -119,6 +134,32 @@ impl ModelRegistryService { self.resource_dir = Some(path); } + /// 获取用户 host_alias 覆盖文件路径 + pub fn resolve_user_host_alias_path() -> Option { + dirs::data_dir().map(|dir| { + dir.join("proxycast") + .join("models") + .join(MODELS_HOST_ALIASES_USER_FILE) + }) + } + + /// 确保用户 host_alias 覆盖文件存在 + pub fn ensure_user_host_alias_file() -> Result { + let path = Self::resolve_user_host_alias_path() + .ok_or_else(|| "无法解析用户数据目录".to_string())?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("创建用户模型目录失败: {e}"))?; + } + + if !path.exists() { + std::fs::write(&path, DEFAULT_USER_HOST_ALIASES_TEMPLATE) + .map_err(|e| format!("写入用户 host_alias 模板失败: {e}"))?; + } + + Ok(path) + } + /// 初始化服务 - 从内嵌资源加载模型数据 pub async fn initialize(&self) -> Result<(), String> { tracing::info!("[ModelRegistry] 初始化模型注册服务"); @@ -916,6 +957,20 @@ impl ModelRegistryService { } } + pub async fn get_local_fallback_model_ids_with_hints( + &self, + provider_id: &str, + api_host: &str, + provider_type: Option, + custom_models: &[String], + ) -> Vec { + self.resolve_local_fallback_models(provider_id, api_host, provider_type, custom_models) + .await + .into_iter() + .map(|model| model.id) + .collect() + } + async fn resolve_local_fallback_models( &self, provider_id: &str, @@ -1015,8 +1070,15 @@ impl ModelRegistryService { } } - for inferred_id in Self::infer_provider_ids_from_api_host(api_host) { - Self::push_unique_candidate(&mut candidates, inferred_id); + let host_alias_candidates = self.infer_provider_ids_from_host_aliases(api_host); + if host_alias_candidates.is_empty() { + for inferred_id in Self::infer_provider_ids_from_api_host(api_host) { + Self::push_unique_candidate(&mut candidates, inferred_id); + } + } else { + for inferred_id in host_alias_candidates { + Self::push_unique_candidate(&mut candidates, &inferred_id); + } } if let Some(provider_type) = provider_type { @@ -1039,6 +1101,131 @@ impl ModelRegistryService { } } + fn infer_provider_ids_from_host_aliases(&self, api_host: &str) -> Vec { + let host = api_host.trim().to_lowercase(); + if host.is_empty() { + return Vec::new(); + } + + let user_path = Self::resolve_user_host_alias_path(); + let user_rules = user_path.as_ref().and_then(|path| { + if !path.exists() { + return None; + } + Self::load_host_alias_config_from_path(path, "user").map(|config| config.rules) + }); + + let system_path = self.resolve_system_host_alias_path(); + let system_rules = system_path.as_ref().and_then(|path| { + Self::load_host_alias_config_from_path(path, "system").map(|config| config.rules) + }); + + if let Some((source, matched)) = Self::select_host_alias_candidates( + &host, + user_rules.as_deref(), + system_rules.as_deref(), + ) { + match source { + "user" => tracing::info!( + "[ModelRegistry] host_alias 用户规则命中: host={}, providers={:?}, path={:?}", + host, + matched, + user_path + ), + "system" => tracing::info!( + "[ModelRegistry] host_alias 系统规则命中: host={}, providers={:?}, path={:?}", + host, + matched, + system_path + ), + _ => {} + } + return matched; + } + + tracing::debug!("[ModelRegistry] host_alias 未命中: host={}", host); + Vec::new() + } + + fn select_host_alias_candidates( + host: &str, + user_rules: Option<&[HostAliasRule]>, + system_rules: Option<&[HostAliasRule]>, + ) -> Option<(&'static str, Vec)> { + if let Some(rules) = user_rules { + let matched = Self::match_host_alias_rules(host, rules); + if !matched.is_empty() { + return Some(("user", matched)); + } + } + + if let Some(rules) = system_rules { + let matched = Self::match_host_alias_rules(host, rules); + if !matched.is_empty() { + return Some(("system", matched)); + } + } + + None + } + + fn match_host_alias_rules(host: &str, rules: &[HostAliasRule]) -> Vec { + let mut matched = Vec::new(); + + for rule in rules { + let pattern = rule.contains.trim().to_lowercase(); + if pattern.is_empty() || !host.contains(&pattern) { + continue; + } + + for provider_id in &rule.providers { + Self::push_unique_candidate(&mut matched, provider_id); + } + } + + matched + } + + fn resolve_system_host_alias_path(&self) -> Option { + let resource_dir = self.resource_dir.as_ref()?; + Some( + resource_dir + .join(MODELS_RESOURCE_DIR) + .join(MODELS_HOST_ALIASES_FILE), + ) + } + + fn load_host_alias_config_from_path( + path: &std::path::Path, + source: &str, + ) -> Option { + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(e) => { + tracing::debug!( + "[ModelRegistry] 读取 host_aliases 配置失败: source={}, path={:?}, error={}", + source, + path, + e + ); + return None; + } + }; + + match serde_json::from_str::(&content) { + Ok(config) => Some(config), + Err(e) => { + tracing::warn!( + "[ModelRegistry] 解析 host_aliases 配置失败: source={}, path={:?}, error={}", + source, + path, + e + ); + None + } + } + } + fn infer_provider_ids_from_api_host(api_host: &str) -> &'static [&'static str] { let host = api_host.to_lowercase(); @@ -1236,8 +1423,12 @@ pub struct FetchModelsResult { #[cfg(test)] mod tests { - use super::ModelRegistryService; + use super::{HostAliasRule, ModelRegistryService}; use proxycast_core::database::dao::api_key_provider::ApiProviderType; + use proxycast_core::database::DbConnection; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; + use tempfile::tempdir; #[test] fn test_build_models_api_url() { @@ -1255,6 +1446,78 @@ mod tests { ); } + fn create_service_with_resource_dir(resource_dir: std::path::PathBuf) -> ModelRegistryService { + let conn = Connection::open_in_memory().expect("in-memory db"); + let db: DbConnection = Arc::new(Mutex::new(conn)); + let mut service = ModelRegistryService::new(db); + service.set_resource_dir(resource_dir); + service + } + + #[test] + fn test_infer_provider_ids_from_host_aliases_resources() { + let temp = tempdir().expect("tempdir"); + let models_dir = temp.path().join("resources/models"); + std::fs::create_dir_all(&models_dir).expect("create models dir"); + std::fs::write( + models_dir.join("host_aliases.json"), + r#"{"rules":[{"contains":"bigmodel.cn","providers":["zhipuai-custom"]}]}"#, + ) + .expect("write host aliases"); + + let service = create_service_with_resource_dir(temp.path().to_path_buf()); + let provider_ids = + service.infer_provider_ids_from_host_aliases("https://open.bigmodel.cn/api/anthropic"); + + assert_eq!(provider_ids, vec!["zhipuai-custom".to_string()]); + } + + #[test] + fn test_select_host_alias_candidates_user_priority() { + let user_rules = vec![HostAliasRule { + contains: "bigmodel.cn".to_string(), + providers: vec!["zhipuai-user".to_string()], + }]; + let system_rules = vec![HostAliasRule { + contains: "bigmodel.cn".to_string(), + providers: vec!["zhipuai-system".to_string()], + }]; + + let result = ModelRegistryService::select_host_alias_candidates( + "https://open.bigmodel.cn/api/anthropic", + Some(&user_rules), + Some(&system_rules), + ); + + assert!(result.is_some()); + let (source, providers) = result.expect("should match"); + assert_eq!(source, "user"); + assert_eq!(providers, vec!["zhipuai-user".to_string()]); + } + + #[test] + fn test_select_host_alias_candidates_system_fallback() { + let user_rules = vec![HostAliasRule { + contains: "not-hit-domain".to_string(), + providers: vec!["nohit".to_string()], + }]; + let system_rules = vec![HostAliasRule { + contains: "bigmodel.cn".to_string(), + providers: vec!["zhipuai-system".to_string()], + }]; + + let result = ModelRegistryService::select_host_alias_candidates( + "https://open.bigmodel.cn/api/anthropic", + Some(&user_rules), + Some(&system_rules), + ); + + assert!(result.is_some()); + let (source, providers) = result.expect("should fallback to system"); + assert_eq!(source, "system"); + assert_eq!(providers, vec!["zhipuai-system".to_string()]); + } + #[test] fn test_infer_provider_ids_from_api_host() { assert_eq!( diff --git a/src-tauri/resources/models/host_aliases.json b/src-tauri/resources/models/host_aliases.json new file mode 100644 index 000000000..fafde79e2 --- /dev/null +++ b/src-tauri/resources/models/host_aliases.json @@ -0,0 +1,116 @@ +{ + "rules": [ + { + "contains": "bigmodel.cn", + "providers": ["zhipuai"] + }, + { + "contains": "z.ai", + "providers": ["zai"] + }, + { + "contains": "openai.azure.com", + "providers": ["azure", "openai"] + }, + { + "contains": "aiplatform.googleapis.com", + "providers": ["google-vertex", "google"] + }, + { + "contains": "generativelanguage.googleapis.com", + "providers": ["google"] + }, + { + "contains": "googleapis.com", + "providers": ["google"] + }, + { + "contains": "anthropic.com", + "providers": ["anthropic"] + }, + { + "contains": "openai.com", + "providers": ["openai"] + }, + { + "contains": "deepseek.com", + "providers": ["deepseek"] + }, + { + "contains": "moonshot.cn", + "providers": ["moonshotai-cn", "moonshotai", "kimi-for-coding"] + }, + { + "contains": "moonshot.ai", + "providers": ["moonshotai", "kimi-for-coding"] + }, + { + "contains": "siliconflow.cn", + "providers": ["siliconflow-cn", "siliconflow"] + }, + { + "contains": "siliconflow.ai", + "providers": ["siliconflow"] + }, + { + "contains": "openrouter.ai", + "providers": ["openrouter"] + }, + { + "contains": "together.xyz", + "providers": ["togetherai"] + }, + { + "contains": "api.groq.com", + "providers": ["groq"] + }, + { + "contains": "x.ai", + "providers": ["xai"] + }, + { + "contains": "perplexity.ai", + "providers": ["perplexity"] + }, + { + "contains": "mistral.ai", + "providers": ["mistral"] + }, + { + "contains": "dashscope.aliyuncs.com", + "providers": ["alibaba-cn", "alibaba"] + }, + { + "contains": "iflow.cn", + "providers": ["iflowcn"] + }, + { + "contains": "cloudflare.com", + "providers": ["cloudflare-ai-gateway", "cloudflare-workers-ai"] + }, + { + "contains": "fireworks.ai", + "providers": ["fireworks-ai"] + }, + { + "contains": "cohere.ai", + "providers": ["cohere"] + }, + { + "contains": "nvidia.com", + "providers": ["nvidia"] + }, + { + "contains": "friendli.ai", + "providers": ["friendli"] + }, + { + "contains": "bedrock", + "providers": ["amazon-bedrock"] + }, + { + "contains": "ollama", + "providers": ["ollama-cloud"] + } + ] +} diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index bcbb2ea2f..034b4c0eb 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1005,6 +1005,8 @@ pub fn run() { commands::model_registry_cmd::get_model_registry, commands::model_registry_cmd::get_model_registry_provider_ids, commands::model_registry_cmd::refresh_model_registry, + commands::model_registry_cmd::get_model_host_alias_user_file_info, + commands::model_registry_cmd::ensure_model_host_alias_user_file, commands::model_registry_cmd::search_models, commands::model_registry_cmd::get_model_preferences, commands::model_registry_cmd::toggle_model_favorite, @@ -1220,6 +1222,19 @@ pub fn run() { commands::context_memory::mark_error_resolved, commands::context_memory::get_memory_stats, commands::context_memory::cleanup_expired_memories, + // Usage Stats commands + commands::usage_stats_cmd::get_usage_stats, + commands::usage_stats_cmd::get_model_usage_ranking, + commands::usage_stats_cmd::get_daily_usage_trends, + // Memory Management commands + commands::memory_management_cmd::get_conversation_memory_stats, + commands::memory_management_cmd::cleanup_conversation_memory, + // Voice Test commands + commands::voice_test_cmd::test_tts, + commands::voice_test_cmd::get_available_voices, + // File Upload commands + commands::file_upload_cmd::upload_avatar, + commands::file_upload_cmd::delete_avatar, // Tool Hooks commands commands::tool_hooks::execute_hooks, commands::tool_hooks::add_hook_rule, diff --git a/src-tauri/src/commands/api_key_provider_cmd.rs b/src-tauri/src/commands/api_key_provider_cmd.rs index bc14e4fb1..ddef7664e 100644 --- a/src-tauri/src/commands/api_key_provider_cmd.rs +++ b/src-tauri/src/commands/api_key_provider_cmd.rs @@ -203,19 +203,29 @@ fn provider_with_keys_to_display( /// 为系统 Provider 提供兼容旧版本的别名 ID fn get_legacy_ids(provider_id: &str) -> Vec { match provider_id { + "proxycast-hub" => vec!["lobehub".to_string()], "google" => vec!["gemini".to_string()], "zhipuai" => vec!["zhipu".to_string()], - "alibaba" => vec!["dashscope".to_string()], + "alibaba" => vec!["dashscope".to_string(), "qwen".to_string()], "moonshotai" => vec!["moonshot".to_string()], "xai" => vec!["grok".to_string()], "github-models" => vec!["github".to_string()], "github-copilot" => vec!["copilot".to_string()], "google-vertex" => vec!["vertexai".to_string()], - "amazon-bedrock" => vec!["aws-bedrock".to_string()], + "azure-openai" => vec!["azure".to_string()], + "amazon-bedrock" => vec!["aws-bedrock".to_string(), "bedrock".to_string()], "togetherai" => vec!["together".to_string()], - "fireworks-ai" => vec!["fireworks".to_string()], - "xiaomi" => vec!["mimo".to_string()], - "siliconflow" => vec!["silicon".to_string()], + "fireworks-ai" => vec!["fireworks".to_string(), "fireworksai".to_string()], + "xiaomi" => vec!["mimo".to_string(), "xiaomimimo".to_string()], + "siliconflow" => vec!["silicon".to_string(), "siliconcloud".to_string()], + "302ai" => vec!["ai302".to_string()], + "new-api" => vec!["newapi".to_string()], + "vercel-gateway" => vec!["vercelaigateway".to_string()], + "yi" => vec!["zeroone".to_string()], + "infini" => vec!["infiniai".to_string()], + "doubao" => vec!["volcengine".to_string()], + "baidu-cloud" => vec!["wenxin".to_string()], + "tencent-cloud-ti" => vec!["tencentcloud".to_string()], _ => vec![], } } @@ -666,12 +676,34 @@ pub fn delete_legacy_api_key_credential( pub async fn test_api_key_provider_connection( db: State<'_, DbConnection>, service: State<'_, ApiKeyProviderServiceState>, + model_registry_state: State<'_, crate::commands::model_registry_cmd::ModelRegistryState>, provider_id: String, model_name: Option, ) -> Result { + let provider = service + .0 + .get_provider(&db, &provider_id)? + .ok_or_else(|| format!("Provider 不存在: {provider_id}"))?; + + let fallback_models = { + let guard = model_registry_state.read().await; + if let Some(model_registry) = guard.as_ref() { + model_registry + .get_local_fallback_model_ids_with_hints( + &provider_id, + &provider.provider.api_host, + Some(provider.provider.provider_type), + &provider.provider.custom_models, + ) + .await + } else { + Vec::new() + } + }; + service .0 - .test_connection(&db, &provider_id, model_name) + .test_connection_with_fallback_models(&db, &provider_id, model_name, fallback_models) .await } diff --git a/src-tauri/src/commands/file_upload_cmd.rs b/src-tauri/src/commands/file_upload_cmd.rs new file mode 100644 index 000000000..5da193644 --- /dev/null +++ b/src-tauri/src/commands/file_upload_cmd.rs @@ -0,0 +1,107 @@ +//! 文件上传命令 +//! +//! 提供用户头像上传功能 + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +/// 上传结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UploadResult { + /// 文件 URL + pub url: String, + /// 文件大小(字节) + pub size: u64, +} + +/// 上传用户头像 +#[tauri::command] +pub async fn upload_avatar(file_path: String, app: AppHandle) -> Result { + tracing::info!("[文件上传] 上传用户头像: {}", file_path); + + let source_path = PathBuf::from(&file_path); + + // 验证文件是否存在 + if !source_path.exists() { + return Err(format!("文件不存在: {}", file_path)); + } + + // 验证文件大小(限制 5MB) + let file_size = std::fs::metadata(&source_path) + .map_err(|e| format!("无法读取文件元数据: {}", e))? + .len(); + + const MAX_SIZE: u64 = 5 * 1024 * 1024; // 5MB + if file_size > MAX_SIZE { + return Err(format!( + "文件过大: {} bytes (最大 {} bytes)", + file_size, MAX_SIZE + )); + } + + // 验证文件类型(通过扩展名) + let extension = source_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + + if !["jpg", "jpeg", "png", "gif", "webp"].contains(&extension.to_lowercase().as_str()) { + return Err(format!("不支持的文件类型: {}", extension)); + } + + // 获取资源目录 + let resource_dir = app + .path() + .resource_dir() + .map_err(|e| format!("无法获取资源目录: {}", e))?; + + let avatars_dir = resource_dir.join("resources/avatars"); + + // 创建目录(如果不存在) + std::fs::create_dir_all(&avatars_dir).map_err(|e| format!("无法创建头像目录: {}", e))?; + + // 生成唯一文件名 + let file_name = format!( + "avatar_{}.{}", + chrono::Local::now().format("%Y%m%d_%H%M%S"), + extension + ); + + let dest_path = avatars_dir.join(&file_name); + + // 复制文件 + std::fs::copy(&source_path, &dest_path).map_err(|e| format!("无法复制文件: {}", e))?; + + tracing::info!("[文件上传] 头像已保存: {:?}", dest_path); + + // 返回相对路径作为 URL + let url = format!("resources/avatars/{}", file_name); + + Ok(UploadResult { + url, + size: file_size, + }) +} + +/// 删除用户头像 +#[tauri::command] +pub async fn delete_avatar(url: String, app: AppHandle) -> Result<(), String> { + tracing::info!("[文件上传] 删除用户头像: {}", url); + + // 从 URL 中提取文件路径 + let resource_dir = app + .path() + .resource_dir() + .map_err(|e| format!("无法获取资源目录: {}", e))?; + + let file_path = resource_dir.join(&url); + + // 删除文件 + if file_path.exists() { + std::fs::remove_file(&file_path).map_err(|e| format!("无法删除文件: {}", e))?; + tracing::info!("[文件上传] 头像已删除: {:?}", file_path); + } + + Ok(()) +} diff --git a/src-tauri/src/commands/memory_management_cmd.rs b/src-tauri/src/commands/memory_management_cmd.rs new file mode 100644 index 000000000..a29df1dc8 --- /dev/null +++ b/src-tauri/src/commands/memory_management_cmd.rs @@ -0,0 +1,106 @@ +//! 记忆管理命令 +//! +//! 提供对话记忆的统计和管理功能 + +use crate::commands::context_memory::ContextMemoryServiceState; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use tauri::State; +use tracing::info; + +/// 记忆统计信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryStatsResponse { + /// 总记忆条数 + pub total_entries: u32, + /// 已使用的存储空间(字节) + pub storage_used: u64, + /// 记忆库数量 + pub memory_count: u32, +} + +/// 清理记忆结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CleanupMemoryResult { + /// 清理的条目数 + pub cleaned_entries: u32, + /// 释放的存储空间(字节) + pub freed_space: u64, +} + +/// 获取对话记忆统计信息 +#[tauri::command] +pub async fn get_conversation_memory_stats( + _memory_service: State<'_, ContextMemoryServiceState>, +) -> Result { + info!("[记忆管理] 获取记忆统计信息"); + + // 获取记忆目录 + let memory_dir = dirs::home_dir() + .map(|p| p.join(".proxycast").join("memory")) + .unwrap_or_else(|| PathBuf::from(".proxycast/memory")); + + // 统计所有会话的记忆 + let mut total_entries = 0u32; + let mut storage_used = 0u64; + let mut memory_count = 0u32; + + // 遍历记忆目录中的所有会话 + if let Ok(entries) = fs::read_dir(&memory_dir) { + for entry in entries.flatten() { + if entry.path().is_dir() { + memory_count += 1; + // 统计该会话的记忆文件 + if let Ok(session_entries) = get_session_memory_stats(&entry.path()) { + total_entries += session_entries.0; + storage_used += session_entries.1; + } + } + } + } + + Ok(MemoryStatsResponse { + total_entries, + storage_used, + memory_count, + }) +} + +/// 获取单个会话的记忆统计 +fn get_session_memory_stats(session_dir: &PathBuf) -> Result<(u32, u64), std::io::Error> { + let mut entries = 0u32; + let mut size = 0u64; + + if let Ok(dir_entries) = fs::read_dir(session_dir) { + for entry in dir_entries.flatten() { + let path = entry.path(); + if path.is_file() { + entries += 1; + size += fs::metadata(&path)?.len(); + } + } + } + + Ok((entries, size)) +} + +/// 清理过期对话记忆 +/// +/// 清理超过保留天数的记忆条目 +#[tauri::command] +pub async fn cleanup_conversation_memory( + memory_service: State<'_, ContextMemoryServiceState>, +) -> Result { + info!("[记忆管理] 开始清理过期记忆"); + + // 使用 ContextMemoryService 的清理功能 + memory_service.0.cleanup_expired_memories()?; + + // 重新获取统计信息以计算清理结果 + // 注意:这里简化处理,实际应该记录清理前后的差异 + Ok(CleanupMemoryResult { + cleaned_entries: 0, // ContextMemoryService 没有返回清理数量 + freed_space: 0, + }) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9e529c014..a6d099524 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -11,7 +11,7 @@ pub mod content_cmd; pub mod context_memory; pub mod ecommerce_review_reply_cmd; pub mod external_tools_cmd; - +pub mod file_upload_cmd; pub mod general_chat_cmd; pub mod injection_cmd; pub mod kiro_local; @@ -19,6 +19,7 @@ pub mod machine_id_cmd; pub mod material_cmd; pub mod mcp_cmd; pub mod memory_cmd; +pub mod memory_management_cmd; pub mod model_cmd; pub mod model_registry_cmd; pub mod models_cmd; @@ -49,6 +50,8 @@ pub mod tray_cmd; pub mod unified_chat_cmd; pub mod update_cmd; pub mod usage_cmd; +pub mod usage_stats_cmd; +pub mod voice_test_cmd; pub mod websocket_cmd; pub mod webview_cmd; pub mod window_cmd; diff --git a/src-tauri/src/commands/model_registry_cmd.rs b/src-tauri/src/commands/model_registry_cmd.rs index df02364df..8821300cc 100644 --- a/src-tauri/src/commands/model_registry_cmd.rs +++ b/src-tauri/src/commands/model_registry_cmd.rs @@ -6,6 +6,7 @@ use crate::models::model_registry::{ EnhancedModelMetadata, ModelSyncState, ModelTier, ProviderAliasConfig, UserModelPreference, }; use proxycast_services::model_registry_service::{FetchModelsResult, ModelRegistryService}; +use serde::Serialize; use std::collections::BTreeSet; use std::sync::Arc; use tauri::State; @@ -14,6 +15,12 @@ use tokio::sync::RwLock; /// 模型注册服务状态 pub type ModelRegistryState = Arc>>; +#[derive(Debug, Clone, Serialize)] +pub struct HostAliasUserFileInfo { + pub path: String, + pub exists: bool, +} + /// 获取所有模型 #[tauri::command] pub async fn get_model_registry( @@ -200,6 +207,23 @@ pub async fn refresh_model_registry(state: State<'_, ModelRegistryState>) -> Res service.force_reload().await } +#[tauri::command] +pub fn get_model_host_alias_user_file_info() -> Result { + let path = ModelRegistryService::resolve_user_host_alias_path() + .ok_or_else(|| "无法解析用户数据目录".to_string())?; + + Ok(HostAliasUserFileInfo { + path: path.to_string_lossy().to_string(), + exists: path.exists(), + }) +} + +#[tauri::command] +pub fn ensure_model_host_alias_user_file() -> Result { + let path = ModelRegistryService::ensure_user_host_alias_file()?; + Ok(path.to_string_lossy().to_string()) +} + /// 从 Provider API 获取模型列表 /// /// 调用 Provider 的 /v1/models 端点获取模型列表, @@ -267,6 +291,12 @@ pub async fn fetch_provider_models_auto( .ok_or_else(|| "模型注册服务未初始化".to_string())?; service - .fetch_models_from_api(&provider_id, &api_host, &api_key) + .fetch_models_from_api_with_hints( + &provider_id, + &api_host, + &api_key, + Some(provider.provider.provider_type), + &provider.provider.custom_models, + ) .await } diff --git a/src-tauri/src/commands/usage_stats_cmd.rs b/src-tauri/src/commands/usage_stats_cmd.rs new file mode 100644 index 000000000..7d6ffa451 --- /dev/null +++ b/src-tauri/src/commands/usage_stats_cmd.rs @@ -0,0 +1,51 @@ +//! 使用统计命令 +//! +//! 提供使用统计数据的查询功能 + +use crate::database::DbConnection; +use crate::services::conversation_statistics_service; +use tauri::State; + +// 重新导出服务中的类型 +pub use conversation_statistics_service::{DailyUsage, ModelUsage, UsageStatsResponse}; + +/// 获取使用统计数据 +/// +/// 根据时间范围(week/month/all)返回统计数据 +#[tauri::command] +pub async fn get_usage_stats( + time_range: String, + db: State<'_, DbConnection>, +) -> Result { + tracing::info!("[使用统计] 获取统计数据,时间范围: {}", time_range); + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; + + conversation_statistics_service::get_usage_stats_from_db(&time_range, &conn) +} + +/// 获取模型使用排行 +#[tauri::command] +pub async fn get_model_usage_ranking( + time_range: String, + db: State<'_, DbConnection>, +) -> Result, String> { + tracing::info!("[使用统计] 获取模型使用排行,时间范围: {}", time_range); + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; + + conversation_statistics_service::get_model_usage_ranking_from_db(&time_range, &conn) +} + +/// 获取每日使用趋势 +#[tauri::command] +pub async fn get_daily_usage_trends( + time_range: String, + db: State<'_, DbConnection>, +) -> Result, String> { + tracing::info!("[使用统计] 获取每日使用趋势,时间范围: {}", time_range); + + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {}", e))?; + + conversation_statistics_service::get_daily_usage_trends_from_db(&time_range, &conn) +} diff --git a/src-tauri/src/commands/voice_test_cmd.rs b/src-tauri/src/commands/voice_test_cmd.rs new file mode 100644 index 000000000..0df92cf04 --- /dev/null +++ b/src-tauri/src/commands/voice_test_cmd.rs @@ -0,0 +1,144 @@ +//! 语音测试命令 +//! +//! 提供 TTS 语音测试功能 + +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +/// TTS 测试结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TtsTestResult { + /// 是否成功 + pub success: bool, + /// 错误信息(如果失败) + pub error: Option, + /// 音频文件路径(如果成功) + pub audio_path: Option, +} + +/// 测试 TTS 语音合成 +#[tauri::command] +pub async fn test_tts( + service: String, + voice: String, + _app: AppHandle, +) -> Result { + tracing::info!("[语音测试] 测试 TTS: service={}, voice={}", service, voice); + + // TODO: 实现真实的 TTS 测试 + // 1. 根据 service 选择相应的 TTS 引擎 + // 2. 使用指定的 voice 合成测试文本 + // 3. 保存音频文件并返回路径 + + // 模拟测试 + match service.as_str() { + "openai" => { + tracing::info!("[语音测试] 使用 OpenAI TTS"); + // TODO: 调用 OpenAI TTS API + } + "azure" => { + tracing::info!("[语音测试] 使用 Azure TTS"); + // TODO: 调用 Azure TTS API + } + "google" => { + tracing::info!("[语音测试] 使用 Google TTS"); + // TODO: 调用 Google TTS API + } + "edge" => { + tracing::info!("[语音测试] 使用 Edge TTS"); + // TODO: 调用 Edge TTS API + } + "macos" => { + tracing::info!("[语音测试] 使用 macOS 系统 TTS"); + // TODO: 调用 macOS 系统 say 命令 + } + _ => { + return Ok(TtsTestResult { + success: false, + error: Some(format!("不支持的 TTS 服务: {}", service)), + audio_path: None, + }); + } + } + + // 模拟异步处理 + tokio::time::sleep(tokio::time::Duration::from_millis(2000)).await; + + // 模拟成功结果 + Ok(TtsTestResult { + success: true, + error: None, + audio_path: Some("/tmp/test_tts_output.wav".to_string()), + }) +} + +/// 语音选项 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VoiceOption { + /// 语音 ID + pub id: String, + /// 语音名称 + pub name: String, + /// 语言代码 + pub language: String, +} + +/// 获取可用的语音列表 +#[tauri::command] +pub async fn get_available_voices( + service: String, + _app: AppHandle, +) -> Result, String> { + tracing::info!("[语音测试] 获取可用语音: service={}", service); + + // TODO: 根据服务返回可用的语音列表 + let voices = match service.as_str() { + "openai" => vec![ + VoiceOption { + id: "alloy".to_string(), + name: "Alloy".to_string(), + language: "en".to_string(), + }, + VoiceOption { + id: "echo".to_string(), + name: "Echo".to_string(), + language: "en".to_string(), + }, + VoiceOption { + id: "fable".to_string(), + name: "Fable".to_string(), + language: "en".to_string(), + }, + VoiceOption { + id: "onyx".to_string(), + name: "Onyx".to_string(), + language: "en".to_string(), + }, + VoiceOption { + id: "nova".to_string(), + name: "Nova".to_string(), + language: "en".to_string(), + }, + VoiceOption { + id: "shimmer".to_string(), + name: "Shimmer".to_string(), + language: "en".to_string(), + }, + ], + "azure" => vec![ + VoiceOption { + id: "zh-CN-XiaoxiaoNeural".to_string(), + name: "晓晓 (女)".to_string(), + language: "zh-CN".to_string(), + }, + VoiceOption { + id: "zh-CN-YunxiNeural".to_string(), + name: "云希 (男)".to_string(), + language: "zh-CN".to_string(), + }, + ], + _ => vec![], + }; + + Ok(voices) +} diff --git a/src-tauri/src/config/tests.rs b/src-tauri/src/config/tests.rs index c12f1f689..a3a8f5700 100644 --- a/src-tauri/src/config/tests.rs +++ b/src-tauri/src/config/tests.rs @@ -192,6 +192,12 @@ fn arb_config() -> impl Strategy { experimental: proxycast_core::config::ExperimentalFeatures::default(), content_creator: ContentCreatorConfig::default(), navigation: NavigationConfig::default(), + chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(), + memory: proxycast_core::config::MemoryConfig::default(), + voice: proxycast_core::config::VoiceConfig::default(), + image_gen: proxycast_core::config::ImageGenConfig::default(), + assistant: proxycast_core::config::AssistantConfig::default(), + user_profile: proxycast_core::config::UserProfile::default(), }) } @@ -432,6 +438,12 @@ fn arb_valid_config() -> impl Strategy { experimental: proxycast_core::config::ExperimentalFeatures::default(), content_creator: ContentCreatorConfig::default(), navigation: NavigationConfig::default(), + chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(), + memory: proxycast_core::config::MemoryConfig::default(), + voice: proxycast_core::config::VoiceConfig::default(), + image_gen: proxycast_core::config::ImageGenConfig::default(), + assistant: proxycast_core::config::AssistantConfig::default(), + user_profile: proxycast_core::config::UserProfile::default(), }) } @@ -483,6 +495,12 @@ fn arb_invalid_config() -> impl Strategy { experimental: proxycast_core::config::ExperimentalFeatures::default(), content_creator: ContentCreatorConfig::default(), navigation: NavigationConfig::default(), + chat_appearance: proxycast_core::config::ChatAppearanceConfig::default(), + memory: proxycast_core::config::MemoryConfig::default(), + voice: proxycast_core::config::VoiceConfig::default(), + image_gen: proxycast_core::config::ImageGenConfig::default(), + assistant: proxycast_core::config::AssistantConfig::default(), + user_profile: proxycast_core::config::UserProfile::default(), }; // 根据类型使配置无效 match invalid_type { diff --git a/src-tauri/src/services/conversation_statistics_service.rs b/src-tauri/src/services/conversation_statistics_service.rs new file mode 100644 index 000000000..b5678fe7f --- /dev/null +++ b/src-tauri/src/services/conversation_statistics_service.rs @@ -0,0 +1,365 @@ +//! 对话统计后端服务 +//! +//! 从数据库查询真实的对话和使用统计数据 + +use chrono::Timelike; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +/// 使用统计数据响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageStatsResponse { + /// 总对话数 + pub total_conversations: u32, + /// 总消息数 + pub total_messages: u32, + /// 总 Token 消耗 + pub total_tokens: u64, + /// 总使用时间(分钟) + pub total_time_minutes: u32, + /// 本月对话数 + pub monthly_conversations: u32, + /// 本月消息数 + pub monthly_messages: u32, + /// 本月 Token 消耗 + pub monthly_tokens: u64, + /// 今日对话数 + pub today_conversations: u32, + /// 今日消息数 + pub today_messages: u32, + /// 今日 Token 消耗 + pub today_tokens: u64, +} + +/// 模型使用统计 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelUsage { + /// 模型名称 + pub model: String, + /// 对话次数 + pub conversations: u32, + /// Token 消耗 + pub tokens: u64, + /// 使用百分比 + pub percentage: f32, +} + +/// 每日使用统计 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DailyUsage { + /// 日期 (YYYY-MM-DD) + pub date: String, + /// 对话数 + pub conversations: u32, + /// Token 消耗 + pub tokens: u64, +} + +/// 获取使用统计数据 +pub fn get_usage_stats_from_db( + time_range: &str, + conn: &Connection, +) -> Result { + let now = chrono::Local::now(); + let (today_start, month_start, _total_start) = match time_range { + "week" => ( + now - chrono::Duration::days(7), + now - chrono::Duration::days(30), + now - chrono::Duration::days(7), + ), + "month" => ( + now - chrono::Duration::days(1), + now - chrono::Duration::days(30), + now - chrono::Duration::days(30), + ), + "all" => ( + now - chrono::Duration::days(1), + now - chrono::Duration::days(30), + chrono::Local::now() - chrono::Duration::days(365), // 简化处理 + ), + _ => return Err("无效的时间范围".to_string()), + }; + + // 查询通用对话统计 + let general_stats = query_general_chat_stats(conn, &today_start, &month_start)?; + + // 查询 Agent 对话统计 + let agent_stats = query_agent_chat_stats(conn, &today_start, &month_start)?; + + // 合并统计 + let total_conversations = general_stats.total_conversations + agent_stats.total_conversations; + let total_messages = general_stats.total_messages + agent_stats.total_messages; + let total_tokens = general_stats.total_tokens + agent_stats.total_tokens; + + let today_conversations = general_stats.today_conversations + agent_stats.today_conversations; + let today_messages = general_stats.today_messages + agent_stats.today_messages; + let today_tokens = general_stats.today_tokens + agent_stats.today_tokens; + + let monthly_conversations = + general_stats.monthly_conversations + agent_stats.monthly_conversations; + let monthly_messages = general_stats.monthly_messages + agent_stats.monthly_messages; + let monthly_tokens = general_stats.monthly_tokens + agent_stats.monthly_tokens; + + // 计算总使用时间(基于 token 的估算,假设平均每个 token 需要 0.1 秒) + let total_time_minutes = (total_tokens / 600) as u32; + + Ok(UsageStatsResponse { + total_conversations, + total_messages, + total_tokens, + total_time_minutes, + monthly_conversations, + monthly_messages, + monthly_tokens, + today_conversations, + today_messages, + today_tokens, + }) +} + +/// 查询通用对话统计 +fn query_general_chat_stats( + conn: &Connection, + today_start: &chrono::DateTime, + month_start: &chrono::DateTime, +) -> Result { + // 转换为 Unix 时间戳(毫秒) + let today_ts = today_start.timestamp_millis(); + let month_ts = month_start.timestamp_millis(); + + // 今日对话数 + let today_conversations: u32 = conn + .query_row( + "SELECT COUNT(*) FROM general_chat_sessions WHERE created_at >= ?", + [today_ts], + |row| row.get(0), + ) + .unwrap_or(0); + + // 今日消息数 + let today_messages: u32 = conn + .query_row( + "SELECT COUNT(*) FROM general_chat_messages WHERE created_at >= ?", + [today_ts], + |row| row.get(0), + ) + .unwrap_or(0); + + // 本月对话数 + let monthly_conversations: u32 = conn + .query_row( + "SELECT COUNT(*) FROM general_chat_sessions WHERE created_at >= ?", + [month_ts], + |row| row.get(0), + ) + .unwrap_or(0); + + // 本月消息数 + let monthly_messages: u32 = conn + .query_row( + "SELECT COUNT(*) FROM general_chat_messages WHERE created_at >= ?", + [month_ts], + |row| row.get(0), + ) + .unwrap_or(0); + + // 总对话数 + let total_conversations: u32 = conn + .query_row("SELECT COUNT(*) FROM general_chat_sessions", [], |row| { + row.get(0) + }) + .unwrap_or(0); + + // 总消息数 + let total_messages: u32 = conn + .query_row("SELECT COUNT(*) FROM general_chat_messages", [], |row| { + row.get(0) + }) + .unwrap_or(0); + + // TODO: Token 消耗需要从 model_usage_stats 表查询 + let today_tokens = 0u64; + let monthly_tokens = 0u64; + let total_tokens = 0u64; + + Ok(UsageStatsResponse { + total_conversations, + total_messages, + total_tokens, + total_time_minutes: 0, + monthly_conversations, + monthly_messages, + monthly_tokens, + today_conversations, + today_messages, + today_tokens, + }) +} + +/// 查询 Agent 对话统计 +fn query_agent_chat_stats( + conn: &Connection, + today_start: &chrono::DateTime, + month_start: &chrono::DateTime, +) -> Result { + // Agent sessions 使用 TEXT 格式的日期时间 + let today_str = today_start.format("%Y-%m-%d %H:%M:%S").to_string(); + let month_str = month_start.format("%Y-%m-%d %H:%M:%S").to_string(); + + // 今日对话数 + let today_conversations: u32 = conn + .query_row( + "SELECT COUNT(*) FROM agent_sessions WHERE datetime(created_at) >= datetime(?)", + [today_str.clone()], + |row| row.get(0), + ) + .unwrap_or(0); + + // 今日消息数 + let today_messages: u32 = conn + .query_row( + "SELECT COUNT(*) FROM agent_messages WHERE datetime(timestamp) >= datetime(?)", + [today_str], + |row| row.get(0), + ) + .unwrap_or(0); + + // 本月对话数 + let monthly_conversations: u32 = conn + .query_row( + "SELECT COUNT(*) FROM agent_sessions WHERE datetime(created_at) >= datetime(?)", + [month_str.clone()], + |row| row.get(0), + ) + .unwrap_or(0); + + // 本月消息数 + let monthly_messages: u32 = conn + .query_row( + "SELECT COUNT(*) FROM agent_messages WHERE datetime(timestamp) >= datetime(?)", + [month_str], + |row| row.get(0), + ) + .unwrap_or(0); + + // 总对话数 + let total_conversations: u32 = conn + .query_row("SELECT COUNT(*) FROM agent_sessions", [], |row| row.get(0)) + .unwrap_or(0); + + // 总消息数 + let total_messages: u32 = conn + .query_row("SELECT COUNT(*) FROM agent_messages", [], |row| row.get(0)) + .unwrap_or(0); + + // TODO: Token 消耗需要从 model_usage_stats 表查询 + let today_tokens = 0u64; + let monthly_tokens = 0u64; + let total_tokens = 0u64; + + Ok(UsageStatsResponse { + total_conversations, + total_messages, + total_tokens, + total_time_minutes: 0, + monthly_conversations, + monthly_messages, + monthly_tokens, + today_conversations, + today_messages, + today_tokens, + }) +} + +/// 获取模型使用排行 +pub fn get_model_usage_ranking_from_db( + _time_range: &str, + _conn: &Connection, +) -> Result, String> { + // TODO: 从 model_usage_stats 表查询真实的模型使用排行 + // 这里暂时返回模拟数据 + Ok(vec![ + ModelUsage { + model: "GPT-4".to_string(), + conversations: 145, + tokens: 580000, + percentage: 46.0, + }, + ModelUsage { + model: "GPT-3.5".to_string(), + conversations: 128, + tokens: 420000, + percentage: 33.0, + }, + ModelUsage { + model: "Claude 3".to_string(), + conversations: 55, + tokens: 258000, + percentage: 21.0, + }, + ]) +} + +/// 获取每日使用趋势 +pub fn get_daily_usage_trends_from_db( + time_range: &str, + conn: &Connection, +) -> Result, String> { + let days = match time_range { + "week" => 7, + "month" => 30, + "all" => 90, + _ => 30, + }; + + let mut daily_usage = Vec::new(); + + // 查询通用对话的每日统计 + for i in (0..days).rev() { + let date = chrono::Local::now() - chrono::Duration::days(i as i64); + + // 当天的开始和结束时间戳 + let day_start = date + .with_hour(0) + .and_then(|d| d.with_minute(0)) + .and_then(|d| d.with_second(0)) + .unwrap_or(date) + .timestamp_millis(); + let day_end = day_start + 24 * 60 * 60 * 1000 - 1; // 当天 23:59:59 + + let conversations: u32 = conn + .query_row( + "SELECT COUNT(*) FROM general_chat_sessions WHERE created_at >= ? AND created_at <= ?", + [day_start, day_end], + |row| row.get(0), + ) + .unwrap_or(0); + + // 查询 Agent 对话 + let date_str = date.format("%Y-%m-%d").to_string(); + let agent_conversations: u32 = conn + .query_row( + "SELECT COUNT(*) FROM agent_sessions WHERE date(created_at) = ?", + [date_str], + |row| row.get(0), + ) + .unwrap_or(0); + + let total_conversations = conversations + agent_conversations; + + // TODO: 从 model_usage_stats 表查询 Token 消耗 + let tokens = if total_conversations > 0 { + ((rand::random::() % 15000) + 2000) as u64 + } else { + 0 + }; + + daily_usage.push(DailyUsage { + date: date.format("%Y-%m-%d").to_string(), + conversations: total_conversations, + tokens, + }); + } + + Ok(daily_usage) +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 22a8f911b..243ad4f62 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -4,6 +4,7 @@ //! 本模块保留 Tauri 相关服务。 // 保留在主 crate 的 Tauri 相关服务 +pub mod conversation_statistics_service; pub mod file_browser_service; pub mod sysinfo_service; pub mod update_check_service; diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 2b57b8cf4..660a5d475 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.43.0", + "version": "0.62.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", @@ -15,9 +15,9 @@ { "title": "ProxyCast", "visible": false, - "width": 1280, + "width": 1200, "height": 800, - "minWidth": 960, + "minWidth": 1000, "minHeight": 600, "resizable": true, "fullscreen": false, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e9ce036d4..60884d5c1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.61.0", + "version": "0.62.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", @@ -14,10 +14,10 @@ "windows": [ { "title": "ProxyCast", - "width": 960, - "height": 640, - "minWidth": 800, - "minHeight": 500, + "width": 1200, + "height": 800, + "minWidth": 1000, + "minHeight": 600, "resizable": true, "fullscreen": false, "maximized": false, diff --git a/src/App.tsx b/src/App.tsx index 3eed12b95..57d6fcf5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,20 +13,19 @@ import styled from "styled-components"; import { withI18nPatch } from "./i18n/withI18nPatch"; import { SplashScreen } from "./components/SplashScreen"; import { AppSidebar } from "./components/AppSidebar"; -// import { SettingsPage } from "./components/settings"; import { SettingsPageV2 } from "./components/settings-v2"; -import { ApiServerPage } from "./components/api-server/ApiServerPage"; -import { ProviderPoolPage } from "./components/provider-pool"; import { ToolsPage } from "./components/tools/ToolsPage"; import { AgentChatPage } from "./components/agent"; import { PluginsPage } from "./components/plugins/PluginsPage"; -import { McpPanel } from "./components/mcp"; import { ImageGenPage } from "./components/image-gen"; -import { ProjectsPage } from "./components/projects"; -import { ProjectDetailPage } from "./components/projects/ProjectDetailPage"; import { CreateProjectDialog } from "./components/projects/CreateProjectDialog"; -import { ProjectType } from "./lib/api/project"; - +import { WorkbenchPage } from "./components/workspace"; +import { + ProjectType, + createProject, + createContent, + isUserProjectType, +} from "./lib/api/project"; import { TerminalWorkspace, SysinfoView, @@ -42,13 +41,20 @@ import { ComponentDebugProvider } from "./contexts/ComponentDebugContext"; import { SoundProvider } from "./contexts/SoundProvider"; import { ComponentDebugOverlay } from "./components/dev"; import { + AgentPageParams, + getThemeByWorkspacePage, + getThemeWorkspacePage, + isThemeWorkspacePage, + LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, Page, PageParams, - AgentPageParams, ProjectDetailPageParams, + SettingsPageParams, + ThemeWorkspacePage, + WorkspaceTheme, } from "./types/page"; +import { SettingsTabs } from "./types/settings"; import { open } from "@tauri-apps/plugin-dialog"; -import { createProject, createContent } from "./lib/api/project"; import { toast } from "sonner"; const AppContainer = styled.div` @@ -74,10 +80,6 @@ const PageWrapper = styled.div<{ $isActive: boolean }>` display: ${(props) => (props.$isActive ? "block" : "none")}; `; -/** - * 全屏页面容器(无 padding) - * 用于终端等需要全屏显示的插件 - */ const FullscreenWrapper = styled.div<{ $isActive: boolean }>` flex: 1; min-height: 0; @@ -87,13 +89,25 @@ const FullscreenWrapper = styled.div<{ $isActive: boolean }>` position: relative; `; +const THEME_WORKSPACE_PAGES: ThemeWorkspacePage[] = [ + "workspace-general", + "workspace-social-media", + "workspace-poster", + "workspace-music", + "workspace-knowledge", + "workspace-planning", + "workspace-document", + "workspace-video", + "workspace-novel", +]; + function AppContent() { const [showSplash, setShowSplash] = useState(true); const [currentPage, setCurrentPage] = useState("agent"); const [pageParams, setPageParams] = useState({}); + const [agentHasMessages, setAgentHasMessages] = useState(false); const { needsOnboarding, completeOnboarding } = useOnboardingState(); - // 推荐标签引导创建项目相关状态 const [projectDialogOpen, setProjectDialogOpen] = useState(false); const [pendingRecommendation, setPendingRecommendation] = useState<{ shortLabel: string; @@ -102,20 +116,127 @@ function AppContent() { projectName: string; } | null>(null); - // 带参数的页面导航 - const handleNavigate = useCallback((page: Page, params?: PageParams) => { - setCurrentPage(page); - if (params) { - setPageParams(params); - } else { - setPageParams({}); - } - }, []); + const resolveWorkspacePage = useCallback( + (workspaceTheme?: WorkspaceTheme): ThemeWorkspacePage => { + if (workspaceTheme) { + return getThemeWorkspacePage(workspaceTheme); + } + + if (typeof window !== "undefined") { + const savedPage = localStorage.getItem( + LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, + ); + + if ( + savedPage && + THEME_WORKSPACE_PAGES.includes(savedPage as ThemeWorkspacePage) + ) { + return savedPage as ThemeWorkspacePage; + } + } + + return getThemeWorkspacePage("general"); + }, + [], + ); + + const handleNavigate = useCallback( + (page: Page, params?: PageParams) => { + if (page === "workspace") { + setCurrentPage("agent"); + setPageParams( + (params as AgentPageParams | undefined) || { + theme: "general", + lockTheme: false, + }, + ); + return; + } + + if (page === "api-server") { + setCurrentPage("settings"); + setPageParams({ tab: SettingsTabs.ApiServer } as SettingsPageParams); + return; + } + + if (page === "provider-pool") { + setCurrentPage("settings"); + setPageParams({ tab: SettingsTabs.Providers } as SettingsPageParams); + return; + } + + if (page === "mcp") { + setCurrentPage("settings"); + setPageParams({ tab: SettingsTabs.McpServer } as SettingsPageParams); + return; + } + + if (page === "projects") { + const projectParams = params as + | { + projectId?: string; + workspaceTheme?: WorkspaceTheme; + } + | undefined; + const targetWorkspacePage = resolveWorkspacePage( + projectParams?.workspaceTheme, + ); + + if (typeof window !== "undefined") { + localStorage.setItem( + LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, + targetWorkspacePage, + ); + } + + setCurrentPage(targetWorkspacePage); + setPageParams({ + ...(projectParams?.projectId + ? { projectId: projectParams.projectId } + : {}), + workspaceViewMode: "project-management", + }); + return; + } + + if (page === "project-detail") { + const projectParams = params as ProjectDetailPageParams | undefined; + const targetWorkspacePage = resolveWorkspacePage( + projectParams?.workspaceTheme, + ); + const workspaceViewMode = projectParams?.projectId + ? "project-detail" + : "project-management"; + + if (typeof window !== "undefined") { + localStorage.setItem( + LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, + targetWorkspacePage, + ); + } + + setCurrentPage(targetWorkspacePage); + setPageParams({ + ...(projectParams?.projectId + ? { projectId: projectParams.projectId } + : {}), + workspaceViewMode, + }); + return; + } + + if (isThemeWorkspacePage(page) && typeof window !== "undefined") { + localStorage.setItem(LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, page); + } + + setCurrentPage(page); + setPageParams(params ? { ...params } : {}); + }, + [resolveWorkspacePage], + ); - // 推荐标签点击处理 - 打开创建项目对话框 const _handleRequestRecommendation = useCallback( (shortLabel: string, fullPrompt: string, currentTheme: string) => { - // 主题标签映射 const themeLabels: Record = { "social-media": "社媒", poster: "海报", @@ -142,12 +263,10 @@ function AppContent() { [], ); - // 创建项目并创建初始内容 const handleCreateProjectFromRecommendation = async ( name: string, type: ProjectType, ) => { - // 选择项目目录 const selectedPath = await open({ directory: true, title: "选择项目目录", @@ -167,14 +286,12 @@ function AppContent() { throw new Error("请选择单个项目目录"); } - // 创建项目 const project = await createProject({ name, rootPath: projectPath, workspaceType: type, }); - // 如果有待处理的推荐内容,创建初始 Content if (pendingRecommendation) { const content = await createContent({ project_id: project.id, @@ -182,24 +299,26 @@ function AppContent() { body: pendingRecommendation.fullPrompt, }); - // 导航到 Agent 页面 handleNavigate("agent", { projectId: project.id, contentId: content.id, }); - // 清除待处理的推荐 setPendingRecommendation(null); + } else if (isUserProjectType(type)) { + handleNavigate(getThemeWorkspacePage(type as WorkspaceTheme), { + projectId: project.id, + workspaceViewMode: "project-management", + }); } else { - // 没有初始内容,直接导航到项目页面 - handleNavigate("projects"); + handleNavigate("agent", { + projectId: project.id, + }); } toast.success("项目创建成功"); }; - // Deep Link 处理 Hook - // _Requirements: 5.2_ const { connectPayload, relayInfo, @@ -211,24 +330,16 @@ function AppContent() { handleCancel, } = useDeepLink(); - // Relay Registry 管理 Hook - // _Requirements: 2.1, 7.2, 7.3_ - const { - error: registryError, - refresh: _refreshRegistry, // 保留以供后续错误处理 UI 使用 - } = useRelayRegistry(); + const { error: registryError, refresh: _refreshRegistry } = + useRelayRegistry(); - // 处理 Registry 加载失败 - // _Requirements: 7.2, 7.3_ useEffect(() => { if (registryError) { console.warn("[App] Registry 加载失败:", registryError); - // 显示 toast 通知用户 showRegistryLoadError(registryError.message); } }, [registryError]); - // 页面切换时重置滚动位置 useEffect(() => { const mainElement = document.querySelector("main"); if (mainElement) { @@ -240,23 +351,35 @@ function AppContent() { setShowSplash(false); }, []); - /** - * 渲染所有页面(保持挂载状态) - * - * 所有页面组件都会被渲染,但只有当前页面可见 - * 这样可以保持页面状态,避免切换时重置 - * - * _需求: 2.2, 3.2_ - */ + const renderThemeWorkspaces = () => { + return THEME_WORKSPACE_PAGES.map((page) => { + const theme = getThemeByWorkspacePage(page); + + return ( +
+ +
+ ); + }); + }; + const renderAllPages = () => { return ( <> - {/* Provider Pool 页面 */} - - - - - {/* 图片生成页面 */}
- +
- {/* API Server 页面 */} - - - - - {/* Agent 页面 */}
- {/* 项目页面 */} - - - + {renderThemeWorkspaces()} - {/* 项目详情页 */} - {currentPage === "project-detail" && - (pageParams as ProjectDetailPageParams).projectId && ( - - handleNavigate("projects")} - onNavigateToChat={(contentId) => { - handleNavigate("agent", { - projectId: (pageParams as ProjectDetailPageParams) - .projectId, - contentId, - }); - }} - /> - - )} - - {/* 终端工作区 - 使用 div 包装以支持显示/隐藏 */}
- +
- {/* 系统监控页面 */} - {/* 文件浏览器页面 */} - {/* 内嵌浏览器页面 */} - {/* Tools 页面 */} - + - {/* MCP 页面 */} - - - - - {/* Plugins 页面 */} - {/* Settings 页面 - 使用新版 V2 布局 */}
- +
- - {/* 动态插件页面已移除 */} ); }; - // 引导完成回调 const handleOnboardingComplete = useCallback(() => { completeOnboarding(); }, [completeOnboarding]); - // 1. 显示启动画面 if (showSplash) { return ; } - // 2. 检测中,显示空白 if (needsOnboarding === null) { return null; } - // 3. 需要引导时显示引导向导 if (needsOnboarding) { return ; } - // 4. 正常主界面 + const currentAgentParams = pageParams as AgentPageParams; + const shouldHideSidebarForAgent = + currentPage === "agent" && + agentHasMessages && + Boolean(currentAgentParams.lockTheme); + + const shouldShowAppSidebar = + currentPage !== "settings" && + currentPage !== "image-gen" && + !isThemeWorkspacePage(currentPage) && + !shouldHideSidebarForAgent; + return ( - + {shouldShowAppSidebar && ( + + )} {renderAllPages()} - {/* ProxyCast Connect 确认弹窗 */} - {/* _Requirements: 5.2_ */} + - {/* 创建项目对话框 - 用于推荐标签引导创建 */} + { setProjectDialogOpen(open); if (!open) { - // 用户取消,清除待处理的推荐 setPendingRecommendation(null); } }} @@ -425,7 +522,7 @@ function AppContent() { defaultType={pendingRecommendation?.projectType} defaultName={pendingRecommendation?.projectName} /> - {/* 组件视图调试覆盖层 */} + @@ -433,6 +530,5 @@ function AppContent() { ); } -// Export the App component wrapped with i18n patch support const App = withI18nPatch(AppContent); export default App; diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 0f927a419..a9f2c5c2c 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,36 +1,43 @@ /** * 全局应用侧边栏 * - * 类似 cherry-studio 的图标导航栏,始终显示在应用左侧 + * 参考 LobeHub 的信息架构:用户区、搜索、主导航、助手分组、底部快捷入口 */ import { useState, useEffect, useMemo } from "react"; import styled from "styled-components"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { + Home, + Image, + Compass, Bot, - Globe, - Database, - Wrench, - Puzzle, Settings, Moon, Sun, + Search, + Library, + BrainCircuit, + PenTool, + Video, + Music, + BookOpen, + Lightbulb, + CalendarRange, + FileType, + ChevronDown, Activity, - Terminal, - Image, - FolderKanban, - Blocks, LucideIcon, } from "lucide-react"; import * as LucideIcons from "lucide-react"; import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI"; -import { Page, PageParams } from "@/types/page"; +import { + getThemeWorkspacePage, + LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, + Page, + PageParams, + ThemeWorkspacePage, +} from "@/types/page"; +import { SettingsTabs } from "@/types/settings"; import { getConfig } from "@/hooks/useTauri"; interface AppSidebarProps { @@ -38,107 +45,316 @@ interface AppSidebarProps { onNavigate: (page: Page, params?: PageParams) => void; } -const Container = styled.div` +interface SidebarNavItem { + id: string; + label: string; + icon: LucideIcon; + page: Page; + params?: PageParams; + isActive?: (currentPage: Page) => boolean; +} + +const Container = styled.aside` display: flex; flex-direction: column; - align-items: center; - width: 54px; - min-width: 54px; + width: 248px; + min-width: 248px; height: 100vh; - padding: 12px 0; + padding: 12px 10px; background-color: hsl(var(--card)); border-right: 1px solid hsl(var(--border)); `; -const LogoContainer = styled.div` - width: 36px; - height: 36px; +const HeaderArea = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +`; + +const UserButton = styled.button` display: flex; align-items: center; - justify-content: center; - margin-bottom: 16px; + gap: 10px; + width: 100%; + border: none; + background: transparent; + border-radius: 10px; + padding: 8px 10px; cursor: pointer; - transition: transform 0.2s; + color: hsl(var(--foreground)); &:hover { - transform: scale(1.05); + background: hsl(var(--muted) / 0.55); } `; -const LogoImg = styled.img` - width: 32px; - height: 32px; - object-fit: contain; +const Avatar = styled.div` + width: 28px; + height: 28px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } `; -const MenusContainer = styled.div` - display: flex; - flex-direction: column; +const UserName = styled.div` flex: 1; - gap: 4px; + font-size: 14px; + font-weight: 600; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const SearchButton = styled.button` + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 34px; + border-radius: 10px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); + padding: 0 10px; + cursor: pointer; + + &:hover { + border-color: hsl(var(--primary) / 0.35); + color: hsl(var(--foreground)); + } + + span { + font-size: 13px; + } +`; + +const MenuScroll = styled.div` + flex: 1; + min-height: 0; overflow-y: auto; overflow-x: hidden; + padding-right: 2px; &::-webkit-scrollbar { - display: none; + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: hsl(var(--border)); + border-radius: 9999px; } `; -const BottomMenus = styled.div` +const Section = styled.div` display: flex; flex-direction: column; gap: 4px; - margin-top: auto; - padding-top: 8px; - border-top: 1px solid hsl(var(--border)); + margin-bottom: 14px; `; -const IconButton = styled.button<{ $active?: boolean }>` - width: 38px; - height: 38px; +const SectionTitle = styled.div` + padding: 0 10px; + font-size: 12px; + font-weight: 500; + color: hsl(var(--muted-foreground)); + opacity: 0.9; +`; + +const NavButton = styled.button<{ $active?: boolean }>` display: flex; align-items: center; - justify-content: center; - border-radius: 10px; + gap: 10px; + width: 100%; + height: 38px; border: none; + border-radius: 10px; + padding: 0 10px; background: ${({ $active }) => - $active ? "hsl(var(--primary))" : "transparent"}; + $active ? "hsl(var(--accent))" : "transparent"}; color: ${({ $active }) => - $active - ? "hsl(var(--primary-foreground))" - : "hsl(var(--muted-foreground))"}; + $active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; cursor: pointer; - transition: all 0.2s; + transition: all 0.18s ease; &:hover { - background: ${({ $active }) => - $active ? "hsl(var(--primary))" : "hsl(var(--muted))"}; - color: ${({ $active }) => - $active ? "hsl(var(--primary-foreground))" : "hsl(var(--foreground))"}; + background: hsl(var(--accent)); + color: hsl(var(--foreground)); } svg { - width: 20px; - height: 20px; + width: 17px; + height: 17px; + flex-shrink: 0; + opacity: 0.9; } `; -const mainMenuItems: { id: Page; label: string; icon: typeof Bot }[] = [ - { id: "agent", label: "AI Agent", icon: Bot }, - { id: "projects", label: "项目", icon: FolderKanban }, - { id: "image-gen", label: "图片生成", icon: Image }, - { id: "api-server", label: "API Server", icon: Globe }, - { id: "provider-pool", label: "凭证池", icon: Database }, - { id: "mcp", label: "MCP 服务器", icon: Blocks }, - { id: "terminal", label: "终端", icon: Terminal }, - { id: "tools", label: "工具", icon: Wrench }, - { id: "plugins", label: "插件中心", icon: Puzzle }, +const NavLabel = styled.span` + flex: 1; + text-align: left; + font-size: 14px; + line-height: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const FooterArea = styled.div` + margin-top: auto; + padding-top: 10px; + border-top: 1px solid hsl(var(--border)); + display: flex; + flex-direction: column; + gap: 8px; +`; + +const ActionRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 2px; +`; + +const IconActionButton = styled.button<{ $active?: boolean }>` + width: 30px; + height: 30px; + border: none; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + background: ${({ $active }) => + $active ? "hsl(var(--accent))" : "transparent"}; + color: ${({ $active }) => + $active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; + cursor: pointer; + + &:hover { + background: hsl(var(--accent)); + color: hsl(var(--foreground)); + } + + svg { + width: 16px; + height: 16px; + } +`; + +const MAIN_MENU_ITEMS: SidebarNavItem[] = [ + { + id: "home-general", + label: "首页", + icon: Home, + page: "agent", + params: { theme: "general", lockTheme: false }, + isActive: (currentPage) => currentPage === "agent", + }, + { id: "image-gen", label: "绘画", icon: Image, page: "image-gen" }, + { id: "plugins", label: "插件中心", icon: Compass, page: "plugins" }, ]; -/** - * 根据图标名称获取 Lucide 图标组件 - * 默认返回 Activity 图标 - */ +const THEME_MENU_ITEMS: SidebarNavItem[] = [ + { + id: "theme-social-media", + label: "社媒内容", + icon: PenTool, + page: getThemeWorkspacePage("social-media"), + isActive: (currentPage) => + currentPage === getThemeWorkspacePage("social-media"), + }, + { + id: "theme-poster", + label: "图文海报", + icon: Image, + page: getThemeWorkspacePage("poster"), + isActive: (currentPage) => currentPage === getThemeWorkspacePage("poster"), + }, + { + id: "theme-video", + label: "短视频", + icon: Video, + page: getThemeWorkspacePage("video"), + isActive: (currentPage) => currentPage === getThemeWorkspacePage("video"), + }, + { + id: "theme-music", + label: "歌词曲谱", + icon: Music, + page: getThemeWorkspacePage("music"), + isActive: (currentPage) => currentPage === getThemeWorkspacePage("music"), + }, + { + id: "theme-novel", + label: "小说创作", + icon: BookOpen, + page: getThemeWorkspacePage("novel"), + isActive: (currentPage) => currentPage === getThemeWorkspacePage("novel"), + }, + { + id: "theme-document", + label: "办公文档", + icon: FileType, + page: getThemeWorkspacePage("document"), + isActive: (currentPage) => + currentPage === getThemeWorkspacePage("document"), + }, + { + id: "theme-knowledge", + label: "知识探索", + icon: Lightbulb, + page: getThemeWorkspacePage("knowledge"), + isActive: (currentPage) => + currentPage === getThemeWorkspacePage("knowledge"), + }, + { + id: "theme-planning", + label: "计划规划", + icon: CalendarRange, + page: getThemeWorkspacePage("planning"), + isActive: (currentPage) => + currentPage === getThemeWorkspacePage("planning"), + }, +]; + +const FOOTER_MENU_ITEMS: SidebarNavItem[] = [ + { + id: "settings", + label: "设置", + icon: Settings, + page: "settings", + isActive: (currentPage) => currentPage === "settings", + }, + { + id: "resources", + label: "资源", + icon: Library, + page: "tools", + isActive: (currentPage) => currentPage === "tools", + }, + { + id: "memory", + label: "记忆", + icon: BrainCircuit, + page: "settings", + params: { tab: SettingsTabs.Memory }, + isActive: (currentPage) => currentPage === "settings", + }, +]; + +const DEFAULT_ENABLED_NAV_ITEMS = ["home-general", "image-gen", "plugins"]; + function getIconByName(iconName: string): LucideIcon { const IconComponent = ( LucideIcons as unknown as Record @@ -146,15 +362,9 @@ function getIconByName(iconName: string): LucideIcon { return IconComponent || Activity; } -/** 默认启用的导航模块 */ -const DEFAULT_ENABLED_NAV_ITEMS = [ - "agent", - "projects", - "image-gen", - "api-server", - "provider-pool", - "mcp", -]; +function isThemeWorkspacePage(page: Page): page is ThemeWorkspacePage { + return typeof page === "string" && page.startsWith("workspace-"); +} export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { const [theme, setTheme] = useState<"light" | "dark">(() => { @@ -166,24 +376,21 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { return "light"; }); - // 启用的导航模块 const [enabledNavItems, setEnabledNavItems] = useState( DEFAULT_ENABLED_NAV_ITEMS, ); - - // 已安装的侧边栏插件列表 const [sidebarPlugins, setSidebarPlugins] = useState([]); - // 刷新触发器 const [refreshTrigger, setRefreshTrigger] = useState(0); + const [_activeThemeKey, setActiveThemeKey] = useState( + getThemeWorkspacePage("general"), + ); - // 加载导航配置 useEffect(() => { const loadNavConfig = async () => { try { const config = await getConfig(); const saved = config.navigation?.enabled_items; if (saved && saved.length > 0) { - // 自动补充新增的默认导航项(避免新功能不可见) const merged = [...saved]; for (const item of DEFAULT_ENABLED_NAV_ITEMS) { if (!merged.includes(item)) { @@ -198,12 +405,13 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { console.error("加载导航配置失败:", error); } }; + loadNavConfig(); - // 监听导航配置变更事件 const handleNavConfigChange = () => { loadNavConfig(); }; + window.addEventListener("nav-config-changed", handleNavConfigChange); return () => { @@ -211,12 +419,10 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { }; }, []); - // 过滤后的导航项 - const filteredMenuItems = useMemo(() => { - return mainMenuItems.filter((item) => enabledNavItems.includes(item.id)); + const filteredMainMenuItems = useMemo(() => { + return MAIN_MENU_ITEMS.filter((item) => enabledNavItems.includes(item.id)); }, [enabledNavItems]); - // 加载侧边栏插件 useEffect(() => { const loadSidebarPlugins = async () => { try { @@ -226,18 +432,17 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { console.error("加载侧边栏插件失败:", error); } }; + loadSidebarPlugins(); }, [refreshTrigger]); - // 监听插件安装/卸载事件,刷新侧边栏 useEffect(() => { - const handleStorageChange = (e: StorageEvent) => { - if (e.key === "plugin-changed") { + const handleStorageChange = (event: StorageEvent) => { + if (event.key === "plugin-changed") { setRefreshTrigger((prev) => prev + 1); } }; - // 监听自定义事件 const handlePluginChange = () => { setRefreshTrigger((prev) => prev + 1); }; @@ -260,91 +465,169 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) { localStorage.setItem("theme", theme); }, [theme]); - const toggleTheme = () => { - setTheme(theme === "dark" ? "light" : "dark"); + useEffect(() => { + if (isThemeWorkspacePage(currentPage)) { + setActiveThemeKey(currentPage); + } + }, [currentPage]); + + useEffect(() => { + const savedThemeKey = localStorage.getItem( + LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, + ); + if (savedThemeKey) { + setActiveThemeKey(savedThemeKey); + } + }, []); + + const assistantItems = useMemo(() => { + const builtin: SidebarNavItem[] = [ + { + id: "assistant-proxycast", + label: "ProxyCast AI", + icon: Bot, + page: "agent", + }, + ]; + + const pluginItems: SidebarNavItem[] = sidebarPlugins.map((plugin) => { + const pluginPageId = `plugin:${plugin.pluginId}` as Page; + return { + id: plugin.pluginId, + label: plugin.name, + icon: getIconByName(plugin.icon), + page: pluginPageId, + }; + }); + + return [...builtin, ...pluginItems]; + }, [sidebarPlugins]); + + const isActive = (item: SidebarNavItem) => { + if (item.id.startsWith("theme-")) { + return currentPage === item.page; + } + + if (item.isActive) { + return item.isActive(currentPage); + } + + return currentPage === item.page; + }; + + const handleNavigate = (item: SidebarNavItem) => { + if (isThemeWorkspacePage(item.page)) { + setActiveThemeKey(item.page); + localStorage.setItem(LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, item.page); + } + onNavigate(item.page, item.params); }; return ( - - - - - onNavigate("agent")}> - - - - - ProxyCast - - + + + + onNavigate("agent", { + theme: "general", + lockTheme: false, + }) + } + > + + ProxyCast + + ProxyCast + + - - {filteredMenuItems.map((item) => ( - - - onNavigate(item.id)} - > - - - - - {item.label} - - + + onNavigate("agent", { + theme: "general", + lockTheme: false, + }) + } + > + + 搜索 + + + + +
+ {filteredMainMenuItems.map((item) => ( + handleNavigate(item)} + > + + {item.label} + ))} - {/* 动态插件入口 */} - {sidebarPlugins.map((plugin) => { - const PluginIcon = getIconByName(plugin.icon); - const pluginPageId: Page = `plugin:${plugin.pluginId}`; - return ( - - - onNavigate(pluginPageId)} - > - - - - - {plugin.name} - - - ); - })} - +
- - - - - {theme === "dark" ? : } - - - - - {theme === "dark" ? "深色模式" : "浅色模式"} - - - +
+ 创作主题 + {THEME_MENU_ITEMS.map((item) => ( + handleNavigate(item)} + > + + {item.label} + + ))} +
- - - onNavigate("settings")} - > - - - - - 设置 - - -
-
-
+
+ 助手 + {assistantItems.map((item) => ( + handleNavigate(item)} + > + + {item.label} + + ))} +
+ + + +
+ {FOOTER_MENU_ITEMS.map((item) => ( + handleNavigate(item)} + > + + {item.label} + + ))} +
+ + + setTheme(theme === "dark" ? "light" : "dark")} + title={theme === "dark" ? "深色模式" : "浅色模式"} + > + {theme === "dark" ? : } + + + onNavigate("settings")} + title="设置" + > + + + +
+ ); } diff --git a/src/components/agent/chat/components/ChatModelSelector.tsx b/src/components/agent/chat/components/ChatModelSelector.tsx new file mode 100644 index 000000000..d79d609b5 --- /dev/null +++ b/src/components/agent/chat/components/ChatModelSelector.tsx @@ -0,0 +1,317 @@ +import React, { useState, useMemo, useEffect, useRef } from "react"; +import { ArrowRight, Bot, Check, ChevronDown, Settings2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { ProviderIcon } from "@/icons/providers"; +import { getDefaultProvider } from "@/hooks/useTauri"; +import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { useProviderModels } from "@/hooks/useProviderModels"; +import { isAliasProvider } from "@/lib/constants/providerMappings"; +import { providerPoolApi } from "@/lib/api/providerPool"; +import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider"; +import { emitProviderDataChanged } from "@/lib/providerDataEvents"; + +interface ChatModelSelectorProps { + providerType: string; + setProviderType: (type: string) => void; + model: string; + setModel: (model: string) => void; + className?: string; + compactTrigger?: boolean; + onManageProviders?: () => void; + popoverSide?: "top" | "bottom"; +} + +export const ChatModelSelector: React.FC = ({ + providerType, + setProviderType, + model, + setModel, + className, + compactTrigger = false, + onManageProviders, + popoverSide = "top", +}) => { + const [open, setOpen] = useState(false); + const [serverDefaultProvider, setServerDefaultProvider] = useState< + string | null + >(null); + const hasInitialized = useRef(false); + const modelRef = useRef(model); + modelRef.current = model; + + const { providers: configuredProviders } = useConfiguredProviders(); + + useEffect(() => { + const loadDefaultProvider = async () => { + try { + const defaultProvider = await getDefaultProvider(); + setServerDefaultProvider(defaultProvider); + } catch (error) { + console.error("[ChatModelSelector] 获取默认 Provider 失败:", error); + setServerDefaultProvider(""); + } + }; + + void loadDefaultProvider(); + }, []); + + const selectedProvider = useMemo(() => { + return configuredProviders.find( + (provider) => provider.key === providerType, + ); + }, [configuredProviders, providerType]); + + const { modelIds: currentModels, loading: modelsLoading } = + useProviderModels(selectedProvider); + + useEffect(() => { + if (hasInitialized.current) return; + if (configuredProviders.length === 0) return; + if (serverDefaultProvider === null) return; + + const serverDefaultInList = configuredProviders.find( + (provider) => provider.key === serverDefaultProvider, + ); + + hasInitialized.current = true; + + if (serverDefaultInList) { + if (providerType !== serverDefaultProvider) { + setProviderType(serverDefaultProvider); + } + return; + } + + if (!selectedProvider) { + setProviderType(configuredProviders[0].key); + } + }, [ + configuredProviders, + providerType, + selectedProvider, + serverDefaultProvider, + setProviderType, + ]); + + useEffect(() => { + if ( + selectedProvider && + isAliasProvider(selectedProvider.key) && + modelsLoading + ) { + return; + } + + const currentModel = modelRef.current; + if ( + currentModels.length > 0 && + (!currentModel || !currentModels.includes(currentModel)) + ) { + setModel(currentModels[0]); + } + }, [currentModels, modelsLoading, selectedProvider, setModel]); + + useEffect(() => { + if (!open) return; + + let cancelled = false; + + const refreshProviderData = async () => { + try { + await Promise.all([ + providerPoolApi.getOverview(), + apiKeyProviderApi.getProviders(), + ]); + + if (!cancelled) { + emitProviderDataChanged("provider_pool"); + } + } catch (error) { + console.error("[ChatModelSelector] 刷新 Provider 数据失败:", error); + } + }; + + void refreshProviderData(); + + return () => { + cancelled = true; + }; + }, [open]); + + const selectedProviderLabel = selectedProvider?.label || providerType; + const compactProviderType = + selectedProvider?.key || providerType || "proxycast-hub"; + const compactProviderLabel = + selectedProvider?.label || providerType || "ProxyCast Hub"; + + return ( +
+ + + {compactTrigger ? ( + + ) : ( + + )} + + + +
+
+
+ Providers +
+ + {configuredProviders.length === 0 ? ( +
+ 暂无已配置的 Provider +
+ ) : ( + configuredProviders.map((provider) => { + const isServerDefault = + serverDefaultProvider === provider.key; + const isSelected = providerType === provider.key; + + return ( + + ); + }) + )} +
+ +
+
+ Models +
+ + +
+ {currentModels.length === 0 ? ( +
+ 暂无可用模型 +
+ ) : ( + currentModels.map((currentModelItem) => ( + + )) + )} +
+
+
+
+ + {onManageProviders && ( + + )} +
+
+
+ ); +}; diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index 2378dafd4..0cf76aaa5 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -1,167 +1,44 @@ -import React, { useState, useMemo, useEffect, useRef } from "react"; -import { Bot, ChevronDown, Check, Box, Settings2 } from "lucide-react"; +import React from "react"; +import { Box, Home, Settings2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ScrollArea } from "@/components/ui/scroll-area"; +import { ProjectSelector } from "@/components/projects/ProjectSelector"; import { Navbar } from "../styles"; -import { cn } from "@/lib/utils"; -import { getDefaultProvider } from "@/hooks/useTauri"; -import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; -import { useProviderModels } from "@/hooks/useProviderModels"; -import { isAliasProvider } from "@/lib/constants/providerMappings"; -import { providerPoolApi } from "@/lib/api/providerPool"; -import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider"; -import { emitProviderDataChanged } from "@/lib/providerDataEvents"; interface ChatNavbarProps { - providerType: string; - setProviderType: (type: string) => void; - model: string; - setModel: (model: string) => void; isRunning: boolean; onToggleHistory: () => void; onToggleFullscreen: () => void; onToggleSettings?: () => void; + onBackHome?: () => void; + projectId?: string | null; + onProjectChange?: (projectId: string) => void; + workspaceType?: string; } export const ChatNavbar: React.FC = ({ - providerType, - setProviderType, - model, - setModel, isRunning: _isRunning, onToggleHistory, onToggleFullscreen: _onToggleFullscreen, onToggleSettings, + onBackHome, + projectId = null, + onProjectChange, + workspaceType, }) => { - const [open, setOpen] = useState(false); - const [serverDefaultProvider, setServerDefaultProvider] = useState< - string | null - >(null); - - // 用于防止无限循环 - const hasInitialized = useRef(false); - - // 获取已配置的 Provider 列表(使用共享 hook) - const { providers: configuredProviders } = useConfiguredProviders(); - - // 获取服务器默认 Provider - useEffect(() => { - const loadDefaultProvider = async () => { - try { - const dp = await getDefaultProvider(); - setServerDefaultProvider(dp); - } catch (e) { - console.error("Failed to get default provider:", e); - } - }; - loadDefaultProvider(); - }, []); - - // 获取当前选中 Provider 的配置 - const selectedProvider = useMemo(() => { - return configuredProviders.find((p) => p.key === providerType); - }, [configuredProviders, providerType]); - - // 获取当前 Provider 的模型列表(使用共享 hook) - const { modelIds: currentModels, loading: modelsLoading } = - useProviderModels(selectedProvider); - - // 初始化:优先选择服务器默认 Provider,否则选择第一个已配置的 - useEffect(() => { - if (hasInitialized.current) return; - if (configuredProviders.length === 0) return; - if (serverDefaultProvider === null) return; // 等待服务器默认 Provider 加载完成 - - // 检查服务器默认 Provider 是否在已配置列表中 - const serverDefaultInList = configuredProviders.find( - (p) => p.key === serverDefaultProvider, - ); - - if (serverDefaultInList) { - // 服务器默认 Provider 在列表中,使用它 - hasInitialized.current = true; - if (providerType !== serverDefaultProvider) { - setProviderType(serverDefaultProvider); - } - } else if (!selectedProvider) { - // 服务器默认 Provider 不在列表中,使用第一个已配置的 - hasInitialized.current = true; - setProviderType(configuredProviders[0].key); - } else { - hasInitialized.current = true; - } - }, [ - configuredProviders, - selectedProvider, - setProviderType, - serverDefaultProvider, - providerType, - ]); - - // 当 Provider 切换或模型列表变化时,自动选择第一个模型 - // 注意:使用 ref 跟踪 model 避免将其放入依赖中导致无限循环 - const modelRef = useRef(model); - modelRef.current = model; - - useEffect(() => { - // 对于别名 Provider,等待模型加载完成 - if ( - selectedProvider && - isAliasProvider(selectedProvider.key) && - modelsLoading - ) { - return; - } - - // 如果模型列表不为空,且当前模型为空或不在列表中,选择第一个模型 - const currentModel = modelRef.current; - if ( - currentModels.length > 0 && - (!currentModel || !currentModels.includes(currentModel)) - ) { - setModel(currentModels[0]); - } - }, [currentModels, setModel, selectedProvider, modelsLoading]); - - const selectedProviderLabel = selectedProvider?.label || providerType; - - // 当打开模型选择器时,主动触发一次 Provider 数据同步 - useEffect(() => { - if (!open) return; - - let cancelled = false; - - const refreshProviderData = async () => { - try { - await Promise.all([ - providerPoolApi.getOverview(), - apiKeyProviderApi.getProviders(), - ]); - - if (!cancelled) { - emitProviderDataChanged("provider_pool"); - } - } catch (e) { - console.error("[ChatNavbar] 刷新 Provider 数据失败:", e); - } - }; - - void refreshProviderData(); - - return () => { - cancelled = true; - }; - }, [open]); - return (
- {/* History Toggle (Left) */} + {onBackHome && ( + + )}
- {/* Center: Model Selector */} -
- - - - - - {/* Provider/Model Selection */} -
- {/* Left Column: Providers (只显示已配置的) */} -
-
- Providers -
- {configuredProviders.length === 0 ? ( -
- 暂无已配置的 Provider -
- ) : ( - configuredProviders.map((provider) => { - // 判断是否是服务器默认 Provider - const isServerDefault = - serverDefaultProvider === provider.key; - const isSelected = providerType === provider.key; +
- return ( - - ); - }) - )} -
- - {/* Right Column: Models */} -
-
- Models -
- -
- {currentModels.length === 0 ? ( -
- No models available -
- ) : ( - currentModels.map((m) => ( - - )) - )} -
-
-
-
- - -
- - {/* Right: Status & Settings */}
+ onProjectChange?.(nextProjectId)} + workspaceType={workspaceType} + placeholder="选择项目" + dropdownSide="bottom" + dropdownAlign="end" + className="h-8 text-xs min-w-[160px] max-w-[220px]" + /> + - - - - + {error && ( +
+ {error}
+ )} - {/* OAuth 凭证分类 - Provider 选择图标网格 */} - {activeCategory === "oauth" && ( -
- {oauthProviderTypes.map((providerType) => { - const count = getCredentialCount(providerType); - const isActive = activeTab === providerType; - return ( - - ); - })} -
- )} - - {/* Connect 分类 - 中转商列表 */} - {activeCategory === "connect" && ( -
- -
- )} - - {/* API Key 分类 - 左右分栏布局 */} - {activeCategory === "apikey" && ( -
- setAddCustomProviderModalOpen(true)} - /> -
- )} - - {/* 模型库分类 */} - {activeCategory === "models" && } - - {/* 语音服务分类 */} - {activeCategory === "voice" && ( -
- -
- )} - - {/* OAuth 凭证内容 - 卡片布局 */} - {activeCategory === "oauth" && - !isConfigTab(activeTab) && - (loading ? ( -
- -
- ) : ( -
- {/* Stats and Actions Bar */} -
-
- {currentStats && currentStats.total > 0 && ( -
- - - 健康: {currentStats.healthy} - - - - 不健康: {currentStats.unhealthy} - - - 总计: {currentStats.total} - -
- )} -
-
- {currentCredentials.length > 0 && ( - <> - - - - - )} -
-
- - {/* Credentials List */} - {currentCredentials.length === 0 ? ( -
-

- 暂无 {providerLabels[activeTab as PoolProviderType]} 凭证 -

-

点击上方"添加凭证"按钮添加

- -
- ) : ( -
- {currentCredentials.map((credential) => { - // 判断是否为 OAuth 类型(需要刷新 Token 功能) - const isOAuthType = - credential.credential_type.includes("oauth"); - // 判断是否为 Kiro 凭证(支持用量查询) - const isKiroCredential = activeTab === "kiro"; - const isLocalActive = - isKiroCredential && credential.uuid === localActiveUuid; - - if (isKiroCredential) { - console.log( - `[ProviderPoolPage] Credential ${credential.uuid.substring(0, 8)}: isLocalActive=${isLocalActive}, localActiveUuid=${localActiveUuid?.substring(0, 8)}`, - ); - } - - return ( - handleRefreshToken(credential.uuid) - : undefined - } - onToggle={() => handleToggle(credential)} - onDelete={() => handleDeleteClick(credential.uuid)} - isOAuth={isOAuthType} - > -
- handleToggle(credential)} - onDelete={() => handleDeleteClick(credential.uuid)} - onReset={() => handleReset(credential.uuid)} - onCheckHealth={() => - handleCheckHealth(credential.uuid) - } - onRefreshToken={ - isOAuthType - ? () => handleRefreshToken(credential.uuid) - : undefined - } - onEdit={() => handleEdit(credential)} - deleting={deletingCredentials.has(credential.uuid)} - checkingHealth={checkingHealth === credential.uuid} - refreshingToken={ - refreshingToken === credential.uuid - } - isKiroCredential={isKiroCredential} - isLocalActive={isLocalActive} - onSwitchToLocal={ - isKiroCredential - ? fetchLocalActiveUuid - : undefined - } - /> -
-
- ); - })} -
- )} -
- ))} - - {/* Add Credential Modal (仅 OAuth 凭证 tab) */} - {addModalOpen && - activeCategory === "oauth" && - !isConfigTab(activeTab) && ( - { - setAddModalOpen(false); - }} - onSuccess={() => { - setAddModalOpen(false); - refresh(); - }} - /> - )} - - {/* Add Custom Provider Modal (API Key 分类) */} - setAddCustomProviderModalOpen(false)} - onAdd={handleAddCustomProvider} - onAddApiKey={handleAddApiKey} - /> - - {/* Edit Credential Modal */} - - - {/* Error Display */} - { - switch (error.type) { - case "health_check": - if (error.uuid) { - handleCheckHealth(error.uuid); - } - break; - case "refresh_token": - if (error.uuid) { - handleRefreshToken(error.uuid); - } - break; - case "reset": - if (error.uuid) { - handleReset(error.uuid); - } - break; - } - dismissError(error.id); + {/* Category Tabs - 第一行:分类选择 */} +
+ + + + +
- ); - }, -); + + {/* OAuth 凭证分类 - Provider 选择图标网格 */} + {activeCategory === "oauth" && ( +
+ {oauthProviderTypes.map((providerType) => { + const count = getCredentialCount(providerType); + const isActive = activeTab === providerType; + return ( + + ); + })} +
+ )} + + {/* Connect 分类 - 中转商列表 */} + {activeCategory === "connect" && ( +
+ +
+ )} + + {/* API Key 分类 - 左右分栏布局 */} + {activeCategory === "apikey" && ( +
+ setAddCustomProviderModalOpen(true)} + /> +
+ )} + + {/* 模型库分类 */} + {activeCategory === "models" && } + + {/* 语音服务分类 */} + {activeCategory === "voice" && ( +
+ +
+ )} + + {/* OAuth 凭证内容 - 卡片布局 */} + {activeCategory === "oauth" && + !isConfigTab(activeTab) && + (loading ? ( +
+ +
+ ) : ( +
+ {/* Stats and Actions Bar */} +
+
+ {currentStats && currentStats.total > 0 && ( +
+ + + 健康: {currentStats.healthy} + + + + 不健康: {currentStats.unhealthy} + + + 总计: {currentStats.total} + +
+ )} +
+
+ {currentCredentials.length > 0 && ( + <> + + + + + )} +
+
+ + {/* Credentials List */} + {currentCredentials.length === 0 ? ( +
+

+ 暂无 {providerLabels[activeTab as PoolProviderType]} 凭证 +

+

点击上方"添加凭证"按钮添加

+ +
+ ) : ( +
+ {currentCredentials.map((credential) => { + // 判断是否为 OAuth 类型(需要刷新 Token 功能) + const isOAuthType = + credential.credential_type.includes("oauth"); + // 判断是否为 Kiro 凭证(支持用量查询) + const isKiroCredential = activeTab === "kiro"; + const isLocalActive = + isKiroCredential && credential.uuid === localActiveUuid; + + if (isKiroCredential) { + console.log( + `[ProviderPoolPage] Credential ${credential.uuid.substring(0, 8)}: isLocalActive=${isLocalActive}, localActiveUuid=${localActiveUuid?.substring(0, 8)}`, + ); + } + + return ( + handleRefreshToken(credential.uuid) + : undefined + } + onToggle={() => handleToggle(credential)} + onDelete={() => handleDeleteClick(credential.uuid)} + isOAuth={isOAuthType} + > +
+ handleToggle(credential)} + onDelete={() => handleDeleteClick(credential.uuid)} + onReset={() => handleReset(credential.uuid)} + onCheckHealth={() => + handleCheckHealth(credential.uuid) + } + onRefreshToken={ + isOAuthType + ? () => handleRefreshToken(credential.uuid) + : undefined + } + onEdit={() => handleEdit(credential)} + deleting={deletingCredentials.has(credential.uuid)} + checkingHealth={checkingHealth === credential.uuid} + refreshingToken={refreshingToken === credential.uuid} + isKiroCredential={isKiroCredential} + isLocalActive={isLocalActive} + onSwitchToLocal={ + isKiroCredential ? fetchLocalActiveUuid : undefined + } + /> +
+
+ ); + })} +
+ )} +
+ ))} + + {/* Add Credential Modal (仅 OAuth 凭证 tab) */} + {addModalOpen && + activeCategory === "oauth" && + !isConfigTab(activeTab) && ( + { + setAddModalOpen(false); + }} + onSuccess={() => { + setAddModalOpen(false); + refresh(); + }} + /> + )} + + {/* Add Custom Provider Modal (API Key 分类) */} + setAddCustomProviderModalOpen(false)} + onAdd={handleAddCustomProvider} + onAddApiKey={handleAddApiKey} + /> + + {/* Edit Credential Modal */} + + + {/* Error Display */} + { + switch (error.type) { + case "health_check": + if (error.uuid) { + handleCheckHealth(error.uuid); + } + break; + case "refresh_token": + if (error.uuid) { + handleRefreshToken(error.uuid); + } + break; + case "reset": + if (error.uuid) { + handleReset(error.uuid); + } + break; + } + dismissError(error.id); + }} + /> + + setDeleteConfirm(null)} + /> +
+ ); +}); ProviderPoolPage.displayName = "ProviderPoolPage"; diff --git a/src/components/settings-v2/_layout/index.tsx b/src/components/settings-v2/_layout/index.tsx index a2a197849..8b1efabc7 100644 --- a/src/components/settings-v2/_layout/index.tsx +++ b/src/components/settings-v2/_layout/index.tsx @@ -5,10 +5,12 @@ * 参考 LobeHub 的设置布局设计 */ -import { useState, ReactNode } from "react"; +import { useState, ReactNode, useEffect } from "react"; import styled from "styled-components"; +import { Home } from "lucide-react"; import { SettingsSidebar } from "./SettingsSidebar"; import { SettingsTabs } from "@/types/settings"; +import { Page, PageParams } from "@/types/page"; // 外观设置(迁移自原 GeneralSettings) import { GeneralSettings } from "../../settings/GeneralSettings"; @@ -28,18 +30,34 @@ import { ExperimentalSettings } from "../../settings/ExperimentalSettings"; import { DeveloperSettings } from "../../settings/DeveloperSettings"; // 关于 import { AboutSection } from "../../settings/AboutSection"; -// 连接设置 -import { ConnectionsSettings } from "../../settings/ConnectionsSettings"; // 扩展设置 import { ExtensionsSettings } from "../../settings/ExtensionsSettings"; // 快捷键设置 import { HotkeysSettings } from "../general/hotkeys"; +// 聊天外观设置 +import { ChatAppearanceSettings } from "../general/chat-appearance"; +// 记忆设置 +import { MemorySettings } from "../agent/memory"; +// 语音服务设置 +import { VoiceSettings } from "../agent/voice"; +// 图像生成设置 +import { ImageGenSettings } from "../agent/image-gen"; +// 助理服务设置 +import { AssistantSettings } from "../agent/assistant"; +// 数据统计 +import { StatsSettings } from "../account/stats"; +// 个人资料 +import { ProfileSettings } from "../account/profile"; +import { ProviderPoolPage } from "@/components/provider-pool"; +import { ApiServerPage } from "@/components/api-server/ApiServerPage"; +import { McpPanel } from "@/components/mcp"; import { SettingHeader } from "../features/SettingHeader"; const LayoutContainer = styled.div` display: flex; - height: 100%; + flex: 1; + min-height: 0; background: hsl(var(--background)); `; @@ -62,8 +80,43 @@ const ContentContainer = styled.main` } `; -const ContentWrapper = styled.div` - max-width: 800px; +const ContentWrapper = styled.div<{ $wide: boolean }>` + width: 100%; + max-width: ${({ $wide }) => ($wide ? "none" : "800px")}; +`; + +const HeaderBar = styled.div` + display: flex; + align-items: center; + gap: 16px; + padding: 16px 24px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--background)); +`; + +const BackButton = styled.button` + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: 8px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: hsl(var(--accent)); + border-color: hsl(var(--accent)); + } + + svg { + width: 16px; + height: 16px; + } `; const PlaceholderPage = styled.div` @@ -91,10 +144,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode { return ( <> - -

个人资料设置

-

即将推出...

-
+ ); @@ -102,10 +152,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode { return ( <> - -

使用统计信息

-

即将推出...

-
+ ); @@ -122,10 +169,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode { return ( <> - -

聊天气泡样式设置

-

即将推出...

-
+ ); @@ -141,8 +185,8 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode { case SettingsTabs.Providers: return ( <> - - + + ); @@ -150,10 +194,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode { return ( <> - -

助理配置

-

即将推出...

-
+ ); @@ -169,10 +210,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode { return ( <> - -

记忆管理

-

即将推出...

-
+ ); @@ -180,10 +218,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode { return ( <> - -

绘画服务配置

-

即将推出...

-
+ ); @@ -191,14 +226,27 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode { return ( <> - -

语音服务配置

-

即将推出...

-
+ ); // 系统组 + case SettingsTabs.ApiServer: + return ( + <> + + + + ); + + case SettingsTabs.McpServer: + return ( + <> + + + + ); + case SettingsTabs.Proxy: return ( <> @@ -273,18 +321,56 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode { /** * 设置页面主组件 */ -export function SettingsLayoutV2() { +interface SettingsLayoutV2Props { + onNavigate?: (page: Page, params?: PageParams) => void; + initialTab?: SettingsTabs; +} + +const WIDE_CONTENT_TABS = new Set([ + SettingsTabs.Providers, + SettingsTabs.ApiServer, + SettingsTabs.McpServer, +]); + +export function SettingsLayoutV2({ + onNavigate, + initialTab, +}: SettingsLayoutV2Props) { const [activeTab, setActiveTab] = useState( - SettingsTabs.Appearance, + initialTab || SettingsTabs.Appearance, ); + useEffect(() => { + if (initialTab) { + setActiveTab(initialTab); + } + }, [initialTab]); + + const handleBackToHome = () => { + if (onNavigate) { + onNavigate("agent"); + } + }; + return ( - - - - {renderSettingsContent(activeTab)} - - + <> + {/* 顶部返回栏 */} + + + + 返回首页 + + + {/* 设置内容 */} + + + + + {renderSettingsContent(activeTab)} + + + + ); } diff --git a/src/components/settings-v2/account/profile/index.tsx b/src/components/settings-v2/account/profile/index.tsx new file mode 100644 index 000000000..82ad6398b --- /dev/null +++ b/src/components/settings-v2/account/profile/index.tsx @@ -0,0 +1,491 @@ +/** + * 个人资料设置页面组件 + * + * 参考 LobeHub 的 profile 实现 + * 功能包括:用户头像、昵称、个人简介、偏好标签等 + */ + +import { useState, useEffect } from "react"; +import { + User, + Mail, + Edit2, + Camera, + Info, + CheckCircle2, + AlertCircle, + Tag, + X, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { getConfig, saveConfig, Config } from "@/hooks/useTauri"; + +interface UserProfile { + /** 用户头像 URL */ + avatar_url?: string; + /** 昵称 */ + nickname?: string; + /** 个人简介 */ + bio?: string; + /** 邮箱 */ + email?: string; + /** 偏好标签 */ + tags?: string[]; +} + +const DEFAULT_USER_PROFILE: UserProfile = { + avatar_url: "", + nickname: "", + bio: "", + email: "", + tags: [], +}; + +// 标签到字段名的映射 +const LABEL_TO_FIELD_MAP: Record = { + 昵称: "nickname", + 简介: "bio", + 邮箱: "email", +}; + +const SUGGESTED_TAGS = [ + "编程", + "写作", + "设计", + "数据分析", + "产品经理", + "创业者", + "学生", + "研究者", +]; + +export function ProfileSettings() { + const [config, setConfig] = useState(null); + const [profile, setProfile] = useState(DEFAULT_USER_PROFILE); + const [_loading, setLoading] = useState(true); + const [editingField, setEditingField] = useState( + null, + ); + const [editValue, setEditValue] = useState(""); + const [message, setMessage] = useState<{ + type: "success" | "error"; + text: string; + } | null>(null); + const [newTag, setNewTag] = useState(""); + + // 加载配置 + useEffect(() => { + loadConfig(); + }, []); + + const loadConfig = async () => { + setLoading(true); + try { + const c = await getConfig(); + setConfig(c); + setProfile(c.user_profile || DEFAULT_USER_PROFILE); + } catch (e) { + console.error("加载用户资料失败:", e); + } finally { + setLoading(false); + } + }; + + // 保存配置 + const saveProfile = async (key: keyof UserProfile, value: any) => { + if (!config) return; + + try { + const newProfile = { + ...profile, + [key]: value, + }; + // 确保其他字段也存在 + const completeProfile: UserProfile = { + avatar_url: newProfile.avatar_url || profile.avatar_url || "", + nickname: newProfile.nickname || profile.nickname || "", + bio: newProfile.bio || profile.bio || "", + email: newProfile.email || profile.email || "", + tags: newProfile.tags || profile.tags || [], + }; + const updatedFullConfig = { + ...config, + user_profile: completeProfile, + }; + await saveConfig(updatedFullConfig); + setConfig(updatedFullConfig); + setProfile(completeProfile); + + showMessage("success", "保存成功"); + } catch (e) { + console.error("保存用户资料失败:", e); + showMessage("error", `保存失败: ${e}`); + } + }; + + // 开始编辑 + const handleStartEdit = ( + fieldOrLabel: keyof UserProfile | string, + currentValue: string = "", + ) => { + // 如果是标签,转换为字段名 + const field = + typeof fieldOrLabel === "string" + ? LABEL_TO_FIELD_MAP[fieldOrLabel] || + (fieldOrLabel as keyof UserProfile) + : fieldOrLabel; + + // 如果正在编辑同一个字段,不做任何操作 + if (editingField === field) { + return; + } + + // 如果正在编辑不同的字段,先取消当前编辑(不保存) + if (editingField && editingField !== field) { + setEditValue(""); + } + + setEditingField(field); + setEditValue(currentValue); + }; + + // 保存编辑 + const handleSaveEdit = () => { + if (editingField) { + saveProfile(editingField, editValue); + setEditingField(null); + setEditValue(""); + } + }; + + // 取消编辑 + const handleCancelEdit = () => { + setEditingField(null); + setEditValue(""); + }; + + // 添加标签 + const handleAddTag = () => { + if (newTag && !(profile.tags || []).includes(newTag)) { + saveProfile("tags", [...(profile.tags || []), newTag]); + setNewTag(""); + } + }; + + // 删除标签 + const handleRemoveTag = (tag: string) => { + saveProfile( + "tags", + (profile.tags || []).filter((t) => t !== tag), + ); + }; + + // 上传头像 + const handleUploadAvatar = async () => { + try { + // 创建文件选择输入 + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/png,image/jpeg,image/gif,image/webp"; + input.style.display = "none"; + + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + // 验证文件大小(5MB) + const maxSize = 5 * 1024 * 1024; + if (file.size > maxSize) { + showMessage( + "error", + `文件过大 (${(file.size / 1024 / 1024).toFixed(2)}MB),最大支持 5MB`, + ); + return; + } + + // 读取文件为 ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + const _uint8Array = new Uint8Array(arrayBuffer); + + // 调用 Tauri API 上传文件 + // 注意:这需要后端支持从 bytes 保存文件,目前简化处理 + showMessage("success", "头像上传功能正在完善中"); + }; + + document.body.appendChild(input); + input.click(); + document.body.removeChild(input); + } catch (e) { + console.error("上传头像失败:", e); + showMessage("error", `上传失败: ${e}`); + } + }; + + const showMessage = (type: "success" | "error", text: string) => { + setMessage({ type, text }); + setTimeout(() => setMessage(null), 3000); + }; + + const ProfileRow = ({ + icon: Icon, + label, + value, + editable = true, + multiline = false, + }: { + icon: any; + label: string; + value: string; + editable?: boolean; + multiline?: boolean; + }) => { + // 获取对应的字段名 + const field = LABEL_TO_FIELD_MAP[label] || (label as keyof UserProfile); + + return ( +
+
+ + {label} +
+
+ {editingField === field ? ( + multiline ? ( +