chore: bump version to 0.62.0

This commit is contained in:
coso
2026-02-10 03:10:54 +08:00
parent aa49b23733
commit cb9c8255e4
65 changed files with 9730 additions and 2436 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.61.0",
"version": "0.62.0",
"type": "module",
"repository": {
"type": "git",
+17 -15
View File
@@ -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",
+2 -2
View File
@@ -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"
+10 -9
View File
@@ -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};
+185
View File
@@ -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<i32>,
/// 消息过渡模式
#[serde(default)]
pub transition_mode: Option<String>,
/// 气泡样式
#[serde(default)]
pub bubble_style: Option<String>,
/// 显示头像
#[serde(default)]
pub show_avatar: Option<bool>,
/// 显示时间戳
#[serde(default)]
pub show_timestamp: Option<bool>,
}
/// 记忆管理配置
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct MemoryConfig {
/// 是否启用记忆功能
#[serde(default)]
pub enabled: bool,
/// 最大记忆条数
#[serde(default)]
pub max_entries: Option<u32>,
/// 记忆保留天数
#[serde(default)]
pub retention_days: Option<u32>,
/// 自动清理过期记忆
#[serde(default)]
pub auto_cleanup: Option<bool>,
}
/// 语音服务配置
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct VoiceConfig {
/// TTS 服务商
#[serde(default)]
pub tts_service: Option<String>,
/// STT 服务商
#[serde(default)]
pub stt_service: Option<String>,
/// TTS 语音
#[serde(default)]
pub tts_voice: Option<String>,
/// TTS 语速 (0.1-2.0)
#[serde(default)]
pub tts_rate: Option<f32>,
/// TTS 音调 (0.1-2.0)
#[serde(default)]
pub tts_pitch: Option<f32>,
/// TTS 音量 (0-1)
#[serde(default)]
pub tts_volume: Option<f32>,
/// STT 语言
#[serde(default)]
pub stt_language: Option<String>,
/// 自动停止录音
#[serde(default)]
pub stt_auto_stop: Option<bool>,
/// 启用语音输入
#[serde(default)]
pub voice_input_enabled: Option<bool>,
/// 启用语音输出
#[serde(default)]
pub voice_output_enabled: Option<bool>,
}
/// 图像生成服务配置
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ImageGenConfig {
/// 默认图像生成服务
#[serde(default)]
pub default_service: Option<String>,
/// 默认图像数量
#[serde(default)]
pub default_count: Option<u32>,
/// 默认图像尺寸
#[serde(default)]
pub default_size: Option<String>,
/// 默认图像质量
#[serde(default)]
pub default_quality: Option<String>,
/// 默认图像风格
#[serde(default)]
pub default_style: Option<String>,
/// 启用图像增强
#[serde(default)]
pub enable_enhancement: Option<bool>,
/// 自动下载生成的图像
#[serde(default)]
pub auto_download: Option<bool>,
}
/// 助理配置
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct AssistantConfig {
/// 默认助理 ID
#[serde(default)]
pub default_assistant_id: Option<String>,
/// 自定义助理列表
#[serde(default)]
pub custom_assistants: Option<Vec<AssistantProfile>>,
/// 启用助理自动选择
#[serde(default)]
pub auto_select: Option<bool>,
/// 显示助理建议
#[serde(default)]
pub show_suggestions: Option<bool>,
}
/// 助理档案
#[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<String>,
/// 模型
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// 系统提示词
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
/// 温度参数
#[serde(default)]
pub temperature: Option<f32>,
/// 最大 token 数
#[serde(default)]
pub max_tokens: Option<u32>,
}
/// 用户资料
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct UserProfile {
/// 用户头像 URL
#[serde(default)]
pub avatar_url: Option<String>,
/// 昵称
#[serde(default)]
pub nickname: Option<String>,
/// 个人简介
#[serde(default)]
pub bio: Option<String>,
/// 邮箱
#[serde(default)]
pub email: Option<String>,
/// 偏好标签
#[serde(default)]
pub tags: Option<Vec<String>>,
}
#[cfg(test)]
mod unit_tests {
use super::*;
@@ -23,8 +23,17 @@ pub struct SystemProviderDef {
pub fn get_system_providers() -> Vec<SystemProviderDef> {
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<SystemProviderDef> {
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<SystemProviderDef> {
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<SystemProviderDef> {
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<SystemProviderDef> {
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
// =========================================================================
+17 -10
View File
@@ -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()?,
};
+1 -1
View File
@@ -4,7 +4,7 @@
use super::types::{ScheduledTask, TaskFilter, TaskStatus};
use rusqlite::{params, Connection};
use tracing::{error, warn};
use tracing::warn;
pub struct SchedulerDao;
+1 -2
View File
@@ -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
///
+2
View File
@@ -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
@@ -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 (
@@ -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<String>,
custom_models: &[String],
fallback_models: &[String],
) -> Option<String> {
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<String>,
) -> Result<ConnectionTestResult, String> {
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<String>,
fallback_models: Vec<String>,
) -> Result<ConnectionTestResult, String> {
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:?}");
@@ -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<u32>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct HostAliasConfig {
#[serde(default)]
rules: Vec<HostAliasRule>,
}
#[derive(Debug, Clone, Deserialize)]
struct HostAliasRule {
contains: String,
providers: Vec<String>,
}
/// 模型注册服务
pub struct ModelRegistryService {
/// 数据库连接
@@ -119,6 +134,32 @@ impl ModelRegistryService {
self.resource_dir = Some(path);
}
/// 获取用户 host_alias 覆盖文件路径
pub fn resolve_user_host_alias_path() -> Option<std::path::PathBuf> {
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<std::path::PathBuf, String> {
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<ApiProviderType>,
custom_models: &[String],
) -> Vec<String> {
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<String> {
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<String>)> {
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<String> {
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<std::path::PathBuf> {
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<HostAliasConfig> {
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::<HostAliasConfig>(&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!(
@@ -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"]
}
]
}
+15
View File
@@ -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,
+38 -6
View File
@@ -203,19 +203,29 @@ fn provider_with_keys_to_display(
/// 为系统 Provider 提供兼容旧版本的别名 ID
fn get_legacy_ids(provider_id: &str) -> Vec<String> {
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<String>,
) -> Result<ConnectionTestResult, String> {
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
}
+107
View File
@@ -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<UploadResult, String> {
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(())
}
@@ -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<MemoryStatsResponse, String> {
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<CleanupMemoryResult, String> {
info!("[记忆管理] 开始清理过期记忆");
// 使用 ContextMemoryService 的清理功能
memory_service.0.cleanup_expired_memories()?;
// 重新获取统计信息以计算清理结果
// 注意:这里简化处理,实际应该记录清理前后的差异
Ok(CleanupMemoryResult {
cleaned_entries: 0, // ContextMemoryService 没有返回清理数量
freed_space: 0,
})
}
+4 -1
View File
@@ -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;
+31 -1
View File
@@ -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<RwLock<Option<ModelRegistryService>>>;
#[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<HostAliasUserFileInfo, String> {
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<String, String> {
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
}
+51
View File
@@ -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<UsageStatsResponse, String> {
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<Vec<ModelUsage>, 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<Vec<DailyUsage>, 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)
}
+144
View File
@@ -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<String>,
/// 音频文件路径(如果成功)
pub audio_path: Option<String>,
}
/// 测试 TTS 语音合成
#[tauri::command]
pub async fn test_tts(
service: String,
voice: String,
_app: AppHandle,
) -> Result<TtsTestResult, String> {
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<Vec<VoiceOption>, 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)
}
+18
View File
@@ -192,6 +192,12 @@ fn arb_config() -> impl Strategy<Value = Config> {
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<Value = Config> {
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<Value = Config> {
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 {
@@ -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<UsageStatsResponse, String> {
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<chrono::Local>,
month_start: &chrono::DateTime<chrono::Local>,
) -> Result<UsageStatsResponse, String> {
// 转换为 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<chrono::Local>,
month_start: &chrono::DateTime<chrono::Local>,
) -> Result<UsageStatsResponse, String> {
// 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<Vec<ModelUsage>, 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<Vec<DailyUsage>, 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::<u32>() % 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)
}
+1
View File
@@ -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;
+3 -3
View File
@@ -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,
+5 -5
View File
@@ -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,
+214 -118
View File
@@ -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<Page>("agent");
const [pageParams, setPageParams] = useState<PageParams>({});
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<string, string> = {
"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 (
<div
key={page}
style={{
flex: 1,
minHeight: 0,
display: currentPage === page ? "flex" : "none",
flexDirection: "column",
}}
>
<WorkbenchPage
onNavigate={handleNavigate}
projectId={(pageParams as AgentPageParams).projectId}
contentId={(pageParams as AgentPageParams).contentId}
theme={theme}
viewMode={(pageParams as AgentPageParams).workspaceViewMode}
/>
</div>
);
});
};
const renderAllPages = () => {
return (
<>
{/* Provider Pool 页面 */}
<PageWrapper $isActive={currentPage === "provider-pool"}>
<ProviderPoolPage />
</PageWrapper>
{/* 图片生成页面 */}
<div
style={{
flex: 1,
@@ -265,15 +388,9 @@ function AppContent() {
flexDirection: "column",
}}
>
<ImageGenPage onNavigate={setCurrentPage} />
<ImageGenPage onNavigate={handleNavigate} />
</div>
{/* API Server 页面 */}
<PageWrapper $isActive={currentPage === "api-server"}>
<ApiServerPage />
</PageWrapper>
{/* Agent 页面 */}
<div
style={{
flex: 1,
@@ -283,35 +400,18 @@ function AppContent() {
}}
>
<AgentChatPage
key={`${(pageParams as AgentPageParams).projectId || ""}:${(pageParams as AgentPageParams).contentId || ""}:${(pageParams as AgentPageParams).theme || ""}:${(pageParams as AgentPageParams).lockTheme ? "1" : "0"}`}
onNavigate={handleNavigate}
projectId={(pageParams as AgentPageParams).projectId}
contentId={(pageParams as AgentPageParams).contentId}
theme={(pageParams as AgentPageParams).theme}
lockTheme={(pageParams as AgentPageParams).lockTheme}
onHasMessagesChange={setAgentHasMessages}
/>
</div>
{/* 项目页面 */}
<PageWrapper $isActive={currentPage === "projects"}>
<ProjectsPage onNavigate={handleNavigate} />
</PageWrapper>
{renderThemeWorkspaces()}
{/* 项目详情页 */}
{currentPage === "project-detail" &&
(pageParams as ProjectDetailPageParams).projectId && (
<PageWrapper $isActive={true}>
<ProjectDetailPage
projectId={(pageParams as ProjectDetailPageParams).projectId}
onBack={() => handleNavigate("projects")}
onNavigateToChat={(contentId) => {
handleNavigate("agent", {
projectId: (pageParams as ProjectDetailPageParams)
.projectId,
contentId,
});
}}
/>
</PageWrapper>
)}
{/* 终端工作区 - 使用 div 包装以支持显示/隐藏 */}
<div
style={{
flex: 1,
@@ -320,40 +420,29 @@ function AppContent() {
flexDirection: "column",
}}
>
<TerminalWorkspace onNavigate={setCurrentPage} />
<TerminalWorkspace onNavigate={handleNavigate} />
</div>
{/* 系统监控页面 */}
<FullscreenWrapper $isActive={currentPage === "sysinfo"}>
<SysinfoView />
</FullscreenWrapper>
{/* 文件浏览器页面 */}
<FullscreenWrapper $isActive={currentPage === "files"}>
<FileBrowserView />
</FullscreenWrapper>
{/* 内嵌浏览器页面 */}
<FullscreenWrapper $isActive={currentPage === "web"}>
<WebView />
</FullscreenWrapper>
{/* Tools 页面 */}
<PageWrapper $isActive={currentPage === "tools"}>
<ToolsPage onNavigate={setCurrentPage} />
<ToolsPage onNavigate={handleNavigate} />
</PageWrapper>
{/* MCP 页面 */}
<PageWrapper $isActive={currentPage === "mcp"}>
<McpPanel />
</PageWrapper>
{/* Plugins 页面 */}
<PageWrapper $isActive={currentPage === "plugins"}>
<PluginsPage />
</PageWrapper>
{/* Settings 页面 - 使用新版 V2 布局 */}
<div
style={{
flex: 1,
@@ -362,43 +451,52 @@ function AppContent() {
flexDirection: "column",
}}
>
<SettingsPageV2 />
<SettingsPageV2
onNavigate={handleNavigate}
initialTab={(pageParams as SettingsPageParams).tab}
/>
</div>
{/* 动态插件页面已移除 */}
</>
);
};
// 引导完成回调
const handleOnboardingComplete = useCallback(() => {
completeOnboarding();
}, [completeOnboarding]);
// 1. 显示启动画面
if (showSplash) {
return <SplashScreen onComplete={handleSplashComplete} />;
}
// 2. 检测中,显示空白
if (needsOnboarding === null) {
return null;
}
// 3. 需要引导时显示引导向导
if (needsOnboarding) {
return <OnboardingWizard onComplete={handleOnboardingComplete} />;
}
// 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 (
<SoundProvider>
<ComponentDebugProvider>
<AppContainer>
<AppSidebar currentPage={currentPage} onNavigate={handleNavigate} />
{shouldShowAppSidebar && (
<AppSidebar currentPage={currentPage} onNavigate={handleNavigate} />
)}
<MainContent>{renderAllPages()}</MainContent>
{/* ProxyCast Connect 确认弹窗 */}
{/* _Requirements: 5.2_ */}
<ConnectConfirmDialog
open={isDialogOpen}
relay={relayInfo}
@@ -411,13 +509,12 @@ function AppContent() {
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
{/* 创建项目对话框 - 用于推荐标签引导创建 */}
<CreateProjectDialog
open={projectDialogOpen}
onOpenChange={(open) => {
setProjectDialogOpen(open);
if (!open) {
// 用户取消,清除待处理的推荐
setPendingRecommendation(null);
}
}}
@@ -425,7 +522,7 @@ function AppContent() {
defaultType={pendingRecommendation?.projectType}
defaultName={pendingRecommendation?.projectName}
/>
{/* 组件视图调试覆盖层 */}
<ComponentDebugOverlay />
</AppContainer>
</ComponentDebugProvider>
@@ -433,6 +530,5 @@ function AppContent() {
);
}
// Export the App component wrapped with i18n patch support
const App = withI18nPatch(AppContent);
export default App;
+457 -174
View File
@@ -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<string, LucideIcon | undefined>
@@ -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<string[]>(
DEFAULT_ENABLED_NAV_ITEMS,
);
// 已安装的侧边栏插件列表
const [sidebarPlugins, setSidebarPlugins] = useState<PluginUIInfo[]>([]);
// 刷新触发器
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [_activeThemeKey, setActiveThemeKey] = useState<string>(
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<SidebarNavItem[]>(() => {
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 (
<TooltipProvider>
<Container>
<Tooltip>
<TooltipTrigger asChild>
<LogoContainer onClick={() => onNavigate("agent")}>
<LogoImg src="/logo.png" alt="ProxyCast" />
</LogoContainer>
</TooltipTrigger>
<TooltipContent side="right">
<span className="whitespace-nowrap">ProxyCast</span>
</TooltipContent>
</Tooltip>
<Container>
<HeaderArea>
<UserButton
onClick={() =>
onNavigate("agent", {
theme: "general",
lockTheme: false,
})
}
>
<Avatar>
<img src="/logo.png" alt="ProxyCast" />
</Avatar>
<UserName>ProxyCast</UserName>
<ChevronDown size={14} />
</UserButton>
<MenusContainer>
{filteredMenuItems.map((item) => (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<IconButton
$active={currentPage === item.id}
onClick={() => onNavigate(item.id)}
>
<item.icon />
</IconButton>
</TooltipTrigger>
<TooltipContent side="right">
<span className="whitespace-nowrap">{item.label}</span>
</TooltipContent>
</Tooltip>
<SearchButton
onClick={() =>
onNavigate("agent", {
theme: "general",
lockTheme: false,
})
}
>
<Search size={14} />
<span>搜索</span>
</SearchButton>
</HeaderArea>
<MenuScroll>
<Section>
{filteredMainMenuItems.map((item) => (
<NavButton
key={item.id}
$active={isActive(item)}
onClick={() => handleNavigate(item)}
>
<item.icon />
<NavLabel>{item.label}</NavLabel>
</NavButton>
))}
{/* 动态插件入口 */}
{sidebarPlugins.map((plugin) => {
const PluginIcon = getIconByName(plugin.icon);
const pluginPageId: Page = `plugin:${plugin.pluginId}`;
return (
<Tooltip key={plugin.pluginId}>
<TooltipTrigger asChild>
<IconButton
$active={currentPage === pluginPageId}
onClick={() => onNavigate(pluginPageId)}
>
<PluginIcon />
</IconButton>
</TooltipTrigger>
<TooltipContent side="right">
<span className="whitespace-nowrap">{plugin.name}</span>
</TooltipContent>
</Tooltip>
);
})}
</MenusContainer>
</Section>
<BottomMenus>
<Tooltip>
<TooltipTrigger asChild>
<IconButton onClick={toggleTheme}>
{theme === "dark" ? <Moon /> : <Sun />}
</IconButton>
</TooltipTrigger>
<TooltipContent side="right">
<span className="whitespace-nowrap">
{theme === "dark" ? "深色模式" : "浅色模式"}
</span>
</TooltipContent>
</Tooltip>
<Section>
<SectionTitle>创作主题</SectionTitle>
{THEME_MENU_ITEMS.map((item) => (
<NavButton
key={item.id}
$active={isActive(item)}
onClick={() => handleNavigate(item)}
>
<item.icon />
<NavLabel>{item.label}</NavLabel>
</NavButton>
))}
</Section>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
$active={currentPage === "settings"}
onClick={() => onNavigate("settings")}
>
<Settings />
</IconButton>
</TooltipTrigger>
<TooltipContent side="right">
<span className="whitespace-nowrap">设置</span>
</TooltipContent>
</Tooltip>
</BottomMenus>
</Container>
</TooltipProvider>
<Section>
<SectionTitle>助手</SectionTitle>
{assistantItems.map((item) => (
<NavButton
key={item.id}
$active={isActive(item)}
onClick={() => handleNavigate(item)}
>
<item.icon />
<NavLabel>{item.label}</NavLabel>
</NavButton>
))}
</Section>
</MenuScroll>
<FooterArea>
<Section>
{FOOTER_MENU_ITEMS.map((item) => (
<NavButton
key={item.id}
$active={isActive(item)}
onClick={() => handleNavigate(item)}
>
<item.icon />
<NavLabel>{item.label}</NavLabel>
</NavButton>
))}
</Section>
<ActionRow>
<IconActionButton
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
title={theme === "dark" ? "深色模式" : "浅色模式"}
>
{theme === "dark" ? <Moon /> : <Sun />}
</IconActionButton>
<IconActionButton
$active={currentPage === "settings"}
onClick={() => onNavigate("settings")}
title="设置"
>
<Settings />
</IconActionButton>
</ActionRow>
</FooterArea>
</Container>
);
}
@@ -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<ChatModelSelectorProps> = ({
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 (
<div className={cn("flex items-center", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{compactTrigger ? (
<Button
variant="ghost"
size="icon"
role="combobox"
aria-expanded={open}
className={cn(
"h-[30px] w-[30px] rounded-full p-0 text-muted-foreground",
"hover:bg-secondary hover:text-foreground",
open && "bg-secondary text-foreground",
)}
title={`${selectedProviderLabel} / ${model || "选择模型"}`}
>
<ProviderIcon
providerType={compactProviderType}
fallbackText={compactProviderLabel}
size={15}
/>
</Button>
) : (
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-9 px-3 gap-2 font-normal bg-background hover:bg-muted/60"
>
<Bot size={16} className="text-primary" />
<span className="font-medium truncate max-w-[160px]">
{selectedProviderLabel}
</span>
<span className="text-muted-foreground">/</span>
<span className="text-sm text-muted-foreground truncate max-w-[180px]">
{model || "选择模型"}
</span>
<ChevronDown className="ml-1 h-3 w-3 text-muted-foreground opacity-50" />
</Button>
)}
</PopoverTrigger>
<PopoverContent
className="w-[420px] p-0 bg-background/95 backdrop-blur-sm border-border shadow-lg"
align="start"
side={popoverSide}
sideOffset={8}
avoidCollisions
>
<div className="flex h-[320px]">
<div className="w-[140px] border-r bg-muted/30 p-2 flex flex-col gap-1 overflow-y-auto">
<div className="text-xs font-semibold text-muted-foreground px-2 py-1.5 mb-1">
Providers
</div>
{configuredProviders.length === 0 ? (
<div className="text-xs text-muted-foreground p-2">
暂无已配置的 Provider
</div>
) : (
configuredProviders.map((provider) => {
const isServerDefault =
serverDefaultProvider === provider.key;
const isSelected = providerType === provider.key;
return (
<button
key={provider.key}
onClick={() => setProviderType(provider.key)}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left",
isSelected
? "bg-primary/10 text-primary font-medium"
: isServerDefault
? "hover:bg-muted text-foreground hover:text-foreground"
: "hover:bg-muted text-muted-foreground/50 hover:text-muted-foreground",
)}
>
<span className="flex items-center gap-2 min-w-0">
<ProviderIcon
providerType={provider.key}
fallbackText={provider.label}
size={15}
/>
<span className="truncate">{provider.label}</span>
</span>
{isSelected && (
<div className="w-1 h-1 rounded-full bg-primary" />
)}
</button>
);
})
)}
</div>
<div className="flex-1 p-2 flex flex-col overflow-hidden">
<div className="text-xs font-semibold text-muted-foreground px-2 py-1.5 mb-1">
Models
</div>
<ScrollArea className="flex-1">
<div className="space-y-1 p-1">
{currentModels.length === 0 ? (
<div className="text-xs text-muted-foreground p-2">
暂无可用模型
</div>
) : (
currentModels.map((currentModelItem) => (
<button
key={currentModelItem}
onClick={() => {
setModel(currentModelItem);
setOpen(false);
}}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left group",
model === currentModelItem
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-muted-foreground hover:text-foreground",
)}
>
<span className="flex items-center gap-2 min-w-0">
{selectedProvider && (
<ProviderIcon
providerType={selectedProvider.key}
fallbackText={selectedProvider.label}
size={15}
/>
)}
<span className="truncate">{currentModelItem}</span>
</span>
{model === currentModelItem && (
<Check size={14} className="text-primary" />
)}
</button>
))
)}
</div>
</ScrollArea>
</div>
</div>
{onManageProviders && (
<button
type="button"
className="w-full h-11 px-3 border-t flex items-center justify-between text-sm hover:bg-muted/60 transition-colors"
onClick={() => {
setOpen(false);
onManageProviders();
}}
>
<span className="inline-flex items-center gap-2 text-foreground">
<Settings2 size={14} className="text-muted-foreground" />
管理供应商
</span>
<ArrowRight size={14} className="text-muted-foreground" />
</button>
)}
</PopoverContent>
</Popover>
</div>
);
};
@@ -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<ChatNavbarProps> = ({
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 (
<Navbar>
<div className="flex items-center gap-2">
{/* History Toggle (Left) */}
{onBackHome && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={onBackHome}
title="返回首页"
>
<Home size={18} />
</Button>
)}
<Button
variant="ghost"
size="icon"
@@ -172,113 +49,19 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
</Button>
</div>
{/* Center: Model Selector */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
aria-expanded={open}
className="h-9 px-3 gap-2 font-normal hover:bg-muted text-foreground"
>
<Bot size={16} className="text-primary" />
<span className="font-medium">{selectedProviderLabel}</span>
<span className="text-muted-foreground">/</span>
<span className="text-sm">{model || "Select Model"}</span>
<ChevronDown className="ml-1 h-3 w-3 text-muted-foreground opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[420px] p-0 bg-background/95 backdrop-blur-sm border-border shadow-lg"
align="center"
>
{/* Provider/Model Selection */}
<div className="flex h-[300px]">
{/* Left Column: Providers (只显示已配置的) */}
<div className="w-[140px] border-r bg-muted/30 p-2 flex flex-col gap-1 overflow-y-auto">
<div className="text-xs font-semibold text-muted-foreground px-2 py-1.5 mb-1">
Providers
</div>
{configuredProviders.length === 0 ? (
<div className="text-xs text-muted-foreground p-2">
暂无已配置的 Provider
</div>
) : (
configuredProviders.map((provider) => {
// 判断是否是服务器默认 Provider
const isServerDefault =
serverDefaultProvider === provider.key;
const isSelected = providerType === provider.key;
<div className="flex-1" />
return (
<button
key={provider.key}
onClick={() => {
setProviderType(provider.key);
}}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left",
isSelected
? "bg-primary/10 text-primary font-medium"
: isServerDefault
? "hover:bg-muted text-foreground hover:text-foreground"
: "hover:bg-muted text-muted-foreground/50 hover:text-muted-foreground",
)}
>
{provider.label}
{isSelected && (
<div className="w-1 h-1 rounded-full bg-primary" />
)}
</button>
);
})
)}
</div>
{/* Right Column: Models */}
<div className="flex-1 p-2 flex flex-col overflow-hidden">
<div className="text-xs font-semibold text-muted-foreground px-2 py-1.5 mb-1">
Models
</div>
<ScrollArea className="flex-1">
<div className="space-y-1 p-1">
{currentModels.length === 0 ? (
<div className="text-xs text-muted-foreground p-2">
No models available
</div>
) : (
currentModels.map((m) => (
<button
key={m}
onClick={() => {
setModel(m);
setOpen(false);
}}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded-md transition-colors text-left group",
model === m
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-muted-foreground hover:text-foreground",
)}
>
{m}
{model === m && (
<Check size={14} className="text-primary" />
)}
</button>
))
)}
</div>
</ScrollArea>
</div>
</div>
</PopoverContent>
</Popover>
</div>
{/* Right: Status & Settings */}
<div className="flex items-center gap-2">
<ProjectSelector
value={projectId}
onChange={(nextProjectId) => onProjectChange?.(nextProjectId)}
workspaceType={workspaceType}
placeholder="选择项目"
dropdownSide="bottom"
dropdownAlign="end"
className="h-8 text-xs min-w-[160px] max-w-[220px]"
/>
<Button
variant="ghost"
size="icon"
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import styled, { keyframes, css } from "styled-components";
import {
ArrowRight,
@@ -14,9 +14,10 @@ import {
Music,
} from "lucide-react";
import { getConfig } from "@/hooks/useTauri";
import type { CreationMode } from "./types";
import type { CreationMode, EntryTaskSlotValues, EntryTaskType } from "./types";
import { CREATION_MODE_CONFIG } from "./constants";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
@@ -30,9 +31,18 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ProjectSelector } from "@/components/projects/ProjectSelector";
import { useProjects } from "@/hooks/useProjects";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
composeEntryPrompt,
createDefaultEntrySlotValues,
formatEntryTaskPreview,
getEntryTaskRecommendations,
getEntryTaskTemplate,
SOCIAL_MEDIA_ENTRY_TASKS,
validateEntryTaskSlots,
} from "../utils/entryPromptComposer";
import { ChatModelSelector } from "./ChatModelSelector";
// Import Assets
import iconXhs from "@/assets/platforms/xhs.png";
@@ -285,6 +295,60 @@ const GridItem = styled.div<{ $active?: boolean }>`
}
`;
const EntryTaskContainer = styled.div`
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 20px 4px 20px;
border-bottom: 1px dashed hsl(var(--border) / 0.8);
`;
const EntryTaskTabs = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
`;
const EntryTaskTab = styled.button<{ $active?: boolean }>`
height: 32px;
padding: 0 12px;
border-radius: 9999px;
font-size: 12px;
border: 1px solid
${(props) =>
props.$active ? "hsl(var(--primary))" : "hsl(var(--border) / 0.8)"};
color: ${(props) =>
props.$active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"};
background: ${(props) =>
props.$active ? "hsl(var(--primary) / 0.08)" : "hsl(var(--background))"};
transition: all 0.2s ease;
&:hover {
border-color: hsl(var(--primary) / 0.6);
color: hsl(var(--foreground));
}
`;
const EntryTaskPreview = styled.div`
font-size: 14px;
line-height: 1.6;
color: hsl(var(--foreground));
`;
const SlotToken = styled.span`
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
border-radius: 8px;
padding: 2px 8px;
font-size: 13px;
`;
const SlotGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
`;
interface EmptyStateProps {
input: string;
setInput: (value: string) => void;
@@ -297,14 +361,19 @@ interface EmptyStateProps {
activeTheme?: string;
/** 主题变更回调 */
onThemeChange?: (theme: string) => void;
/** 是否显示主题切换 Tabs */
showThemeTabs?: boolean;
/** 推荐标签点击回调 */
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
/** 当前选中的项目 ID */
projectId?: string | null;
/** 项目变更回调 */
onProjectChange?: (projectId: string) => void;
providerType: string;
setProviderType: (type: string) => void;
model: string;
setModel: (model: string) => void;
onManageProviders?: () => void;
}
const ENTRY_THEME_ID = "social-media";
// Scenarios Configuration - 与 ProjectType 统一
const ALL_CATEGORIES = [
{
@@ -359,7 +428,7 @@ const CREATION_THEMES = [
* 格式: [简化标题, 完整 Prompt]
* 简化标题用于显示,完整 Prompt 用于点击发送
*/
const RECOMMENDATIONS: Record<string, [string, string][]> = {
const THEME_RECOMMENDATIONS: Record<string, [string, string][]> = {
"social-media": [
[
"爆款标题生成",
@@ -492,6 +561,45 @@ const THEME_ICONS: Record<string, string> = {
novel: "📖",
};
const THEME_HEADLINES: Record<string, { lead: string; focus: string }> = {
general: {
lead: "你想在这个平台",
focus: "完成什么?",
},
"social-media": {
lead: "今天想做什么",
focus: "社媒爆款内容?",
},
poster: {
lead: "今天想生成什么",
focus: "视觉海报作品?",
},
video: {
lead: "今天要打磨哪条",
focus: "短视频脚本?",
},
music: {
lead: "今天想写一首什么样的",
focus: "歌曲故事?",
},
novel: {
lead: "今天想开启哪段",
focus: "小说剧情?",
},
document: {
lead: "今天要输出哪份",
focus: "办公文档?",
},
knowledge: {
lead: "今天想深入研究什么",
focus: "知识主题?",
},
planning: {
lead: "今天要规划什么",
focus: "行动方案?",
},
};
export const EmptyState: React.FC<EmptyStateProps> = ({
input,
setInput,
@@ -500,57 +608,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onCreationModeChange,
activeTheme = "general",
onThemeChange,
showThemeTabs = false,
onRecommendationClick,
projectId: externalProjectId,
onProjectChange,
providerType,
setProviderType,
model,
setModel,
onManageProviders,
}) => {
// 项目管理 - 内部状态(当外部未提供时使用)
const { defaultProject, getOrCreateDefault } = useProjects();
const [internalProjectId, setInternalProjectId] = useState<string | null>(
null,
);
// 使用外部或内部的 projectId
const projectId = externalProjectId ?? internalProjectId;
// 初始化默认项目
useEffect(() => {
if (!externalProjectId && !internalProjectId) {
if (defaultProject) {
// 通知父组件
if (onProjectChange) {
onProjectChange(defaultProject.id);
} else {
setInternalProjectId(defaultProject.id);
}
} else {
getOrCreateDefault().then((p) => {
// 通知父组件
if (onProjectChange) {
onProjectChange(p.id);
} else {
setInternalProjectId(p.id);
}
});
}
}
}, [
externalProjectId,
internalProjectId,
defaultProject,
getOrCreateDefault,
onProjectChange,
]);
// 处理项目变更
const handleProjectChange = (newProjectId: string) => {
if (onProjectChange) {
onProjectChange(newProjectId);
} else {
setInternalProjectId(newProjectId);
}
};
// 从配置中读取启用的主题
const [enabledThemes, setEnabledThemes] = useState<string[]>(
DEFAULT_ENABLED_THEMES,
@@ -604,12 +669,86 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const [ratio, setRatio] = useState("3:4");
const [style, setStyle] = useState("minimal");
const [depth, setDepth] = useState("deep");
const [entryTaskType, setEntryTaskType] = useState<EntryTaskType>("direct");
const [entrySlotValues, setEntrySlotValues] = useState<EntryTaskSlotValues>(
() => createDefaultEntrySlotValues("direct"),
);
// Popover 打开状态
const [ratioPopoverOpen, setRatioPopoverOpen] = useState(false);
const [stylePopoverOpen, setStylePopoverOpen] = useState(false);
const isEntryTheme = activeTheme === ENTRY_THEME_ID;
useEffect(() => {
if (!isEntryTheme) {
return;
}
if (!SOCIAL_MEDIA_ENTRY_TASKS.includes(entryTaskType)) {
setEntryTaskType("direct");
setEntrySlotValues(createDefaultEntrySlotValues("direct"));
}
}, [isEntryTheme, entryTaskType]);
useEffect(() => {
setEntrySlotValues(createDefaultEntrySlotValues(entryTaskType));
}, [entryTaskType]);
const entryTemplate = useMemo(
() => getEntryTaskTemplate(entryTaskType),
[entryTaskType],
);
const entryPreview = useMemo(
() => formatEntryTaskPreview(entryTaskType, entrySlotValues),
[entryTaskType, entrySlotValues],
);
const currentRecommendations = useMemo(() => {
if (isEntryTheme) {
return getEntryTaskRecommendations(entryTaskType);
}
return THEME_RECOMMENDATIONS[activeTheme] || [];
}, [activeTheme, entryTaskType, isEntryTheme]);
const handleEntrySlotChange = (key: string, value: string) => {
setEntrySlotValues((prev) => ({
...prev,
[key]: value,
}));
};
const handleSend = () => {
if (!input.trim()) return;
if (!input.trim() && !isEntryTheme) return;
if (isEntryTheme) {
const validation = validateEntryTaskSlots(entryTaskType, entrySlotValues);
if (!validation.valid) {
const missingFields = validation.missing
.map((slot) => slot.label)
.join("、");
toast.error(`请先填写:${missingFields}`);
return;
}
const composedPrompt = composeEntryPrompt({
taskType: entryTaskType,
slotValues: entrySlotValues,
userInput: input,
activeTheme,
creationMode,
context: {
platform: getPlatformLabel(platform),
ratio,
style,
depth,
},
});
onSend(composedPrompt);
return;
}
let prefix = "";
if (activeTheme === "social-media") prefix = `[社媒创作: ${platform}] `;
if (activeTheme === "poster") prefix = `[图文生成: ${ratio}, ${style}] `;
@@ -679,37 +818,93 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
return val;
};
const themeHeadline = THEME_HEADLINES[activeTheme] || THEME_HEADLINES.general;
return (
<Container>
<ContentWrapper>
<Header>
<MainTitle>
你想在这个平台 <br />
<span>完成什么?</span>
{themeHeadline.lead} <br />
<span>{themeHeadline.focus}</span>
</MainTitle>
</Header>
<TabsContainer>
{categories.map((cat) => (
<TabItem
key={cat.id}
$active={activeTheme === cat.id}
onClick={() => handleThemeChange(cat.id)}
>
<span
className={
activeTheme === cat.id ? "text-primary" : "opacity-70"
}
{showThemeTabs && (
<TabsContainer>
{categories.map((cat) => (
<TabItem
key={cat.id}
$active={activeTheme === cat.id}
onClick={() => handleThemeChange(cat.id)}
>
{cat.icon}
</span>
{cat.label}
</TabItem>
))}
</TabsContainer>
<span
className={
activeTheme === cat.id ? "text-primary" : "opacity-70"
}
>
{cat.icon}
</span>
{cat.label}
</TabItem>
))}
</TabsContainer>
)}
{/* 输入卡片 */}
<InputCard>
{isEntryTheme && (
<EntryTaskContainer>
<EntryTaskTabs>
{SOCIAL_MEDIA_ENTRY_TASKS.map((task) => {
const template = getEntryTaskTemplate(task);
return (
<EntryTaskTab
key={task}
$active={entryTaskType === task}
onClick={() => setEntryTaskType(task)}
title={template.description}
>
{template.label}
</EntryTaskTab>
);
})}
</EntryTaskTabs>
<EntryTaskPreview>
{entryPreview.split(/(\[[^\]]+\])/g).map((chunk, index) => {
const isToken = /^\[[^\]]+\]$/.test(chunk);
if (!chunk) return null;
if (!isToken) {
return (
<React.Fragment key={`${chunk}-${index}`}>
{chunk}
</React.Fragment>
);
}
return (
<SlotToken key={`${chunk}-${index}`}>{chunk}</SlotToken>
);
})}
</EntryTaskPreview>
<SlotGrid>
{entryTemplate.slots.map((slot) => (
<Input
key={slot.key}
value={entrySlotValues[slot.key] ?? ""}
onChange={(event) =>
handleEntrySlotChange(slot.key, event.target.value)
}
placeholder={slot.placeholder}
className="h-9 text-xs"
/>
))}
</SlotGrid>
</EntryTaskContainer>
)}
<StyledTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -719,13 +914,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
<Toolbar>
<ToolLoginLeft>
{/* 项目选择器 - PRD 4.2:始终显示,默认选中「默认项目」,按主题筛选 */}
<ProjectSelector
value={projectId}
onChange={handleProjectChange}
workspaceType={activeTheme}
placeholder="选择项目"
className="h-8 text-xs min-w-[120px]"
<ChatModelSelector
providerType={providerType}
setProviderType={setProviderType}
model={model}
setModel={setModel}
compactTrigger
popoverSide="top"
onManageProviders={onManageProviders}
/>
{activeTheme === "social-media" && (
@@ -1002,7 +1198,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
<Button
size="sm"
onClick={handleSend}
disabled={!input.trim()}
disabled={!input.trim() && !isEntryTheme}
className="bg-primary hover:bg-primary/90 text-primary-foreground h-9 px-5 rounded-xl shadow-lg shadow-primary/20 transition-all hover:scale-105 active:scale-95"
>
开始生成
@@ -1013,7 +1209,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
{/* Dynamic Inspiration/Tips based on Tab - Styled nicely */}
<div className="w-full max-w-[800px] flex flex-wrap gap-3 justify-center">
{RECOMMENDATIONS[activeTheme]?.map(([shortLabel, fullPrompt]) => (
{currentRecommendations.map(([shortLabel, fullPrompt]) => (
<Badge
key={shortLabel}
variant="secondary"
@@ -1024,9 +1220,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onRecommendationClick(shortLabel, fullPrompt);
} else {
setInput(fullPrompt);
setTimeout(() => {
onSend(fullPrompt);
}, 100);
}
}}
>
@@ -42,6 +42,8 @@ interface InputbarCoreProps {
isCanvasOpen?: boolean;
/** Textarea ref(用于 CharacterMention) */
textareaRef?: React.RefObject<HTMLTextAreaElement>;
/** 输入框底栏左侧扩展区域 */
leftExtra?: React.ReactNode;
}
export const InputbarCore: React.FC<InputbarCoreProps> = ({
@@ -59,6 +61,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
isFullscreen = false,
isCanvasOpen = false,
textareaRef: externalTextareaRef,
leftExtra,
}) => {
const internalTextareaRef = useRef<HTMLTextAreaElement>(null);
const textareaRef = externalTextareaRef || internalTextareaRef;
@@ -126,6 +129,9 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
<BottomBar>
<LeftSection>
{leftExtra && (
<div className="flex items-center gap-2 mr-2">{leftExtra}</div>
)}
<InputbarTools
onToolClick={onToolClick}
activeTools={activeTools}
@@ -8,6 +8,7 @@ import type { MessageImage } from "../../types";
import type { Character } from "@/lib/api/memory";
import { TaskFileList, type TaskFile } from "../TaskFiles";
import { FolderOpen, ChevronUp } from "lucide-react";
import { ChatModelSelector } from "../ChatModelSelector";
// 任务文件触发器区域(在输入框上方,与输入框对齐)
const TaskFilesArea = styled.div`
@@ -74,6 +75,7 @@ interface InputbarProps {
images?: MessageImage[],
webSearch?: boolean,
thinking?: boolean,
textOverride?: string,
) => void;
/** 停止生成回调 */
onStop?: () => void;
@@ -98,6 +100,11 @@ interface InputbarProps {
characters?: Character[];
/** 选择角色回调 */
onSelectCharacter?: (character: Character) => void;
providerType?: string;
setProviderType?: (type: string) => void;
model?: string;
setModel?: (model: string) => void;
onManageProviders?: () => void;
}
export const Inputbar: React.FC<InputbarProps> = ({
@@ -117,6 +124,11 @@ export const Inputbar: React.FC<InputbarProps> = ({
onTaskFileClick,
characters = [],
onSelectCharacter,
providerType,
setProviderType,
model,
setModel,
onManageProviders,
}) => {
const [activeTools, setActiveTools] = useState<Record<string, boolean>>({});
const [pendingImages, setPendingImages] = useState<MessageImage[]>([]);
@@ -360,6 +372,23 @@ export const Inputbar: React.FC<InputbarProps> = ({
onPaste={handlePaste}
isFullscreen={isFullscreen}
isCanvasOpen={isCanvasOpen}
leftExtra={
!isFullscreen &&
providerType &&
setProviderType &&
model &&
setModel ? (
<ChatModelSelector
providerType={providerType}
setProviderType={setProviderType}
model={model}
setModel={setModel}
compactTrigger
popoverSide="top"
onManageProviders={onManageProviders}
/>
) : undefined
}
/>
</div>
);
@@ -3,3 +3,40 @@
* 不同模式下 AI 的角色和用户参与度不同
*/
export type CreationMode = "guided" | "fast" | "hybrid" | "framework";
/**
* 入口任务类型(文章创作)
*/
export type EntryTaskType =
| "direct"
| "multi_angle"
| "rewrite"
| "imitate"
| "geo";
/**
* 入口任务槽位定义
*/
export interface EntryTaskSlotDefinition {
key: string;
label: string;
placeholder: string;
required: boolean;
defaultValue?: string;
}
/**
* 入口任务模板
*/
export interface EntryTaskTemplate {
type: EntryTaskType;
label: string;
description: string;
pattern: string;
slots: EntryTaskSlotDefinition[];
}
/**
* 槽位键值对
*/
export type EntryTaskSlotValues = Record<string, string>;
@@ -985,10 +985,20 @@ export function useAgentChat(options: UseAgentChatOptions = {}) {
);
};
const clearMessages = () => {
const clearMessages = (
options: {
showToast?: boolean;
toastMessage?: string;
} = {},
) => {
const { showToast = true, toastMessage = "新话题已创建" } = options;
setMessages([]);
setSessionId(null);
toast.success("新话题已创建");
if (showToast) {
toast.success(toastMessage);
}
};
// 切换话题
+111 -23
View File
@@ -13,7 +13,6 @@ import { useSessionFiles } from "./hooks/useSessionFiles";
import { useContentSync } from "./hooks/useContentSync";
import { ChatNavbar } from "./components/ChatNavbar";
import { ChatSidebar } from "./components/ChatSidebar";
import { ChatSettings } from "./components/ChatSettings";
import { MessageList } from "./components/MessageList";
import { Inputbar } from "./components/Inputbar";
import { EmptyState } from "./components/EmptyState";
@@ -59,12 +58,34 @@ import {
type ProjectMemory,
type Character,
} from "@/lib/api/memory";
import type { Page, PageParams } from "@/types/page";
import { SettingsTabs } from "@/types/settings";
import type { MessageImage } from "./types";
import type { ThemeType, LayoutMode } from "@/components/content-creator/types";
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
import { getFileToStepMap } from "./utils/workflowMapping";
const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
"general",
"social-media",
"poster",
"music",
"knowledge",
"planning",
"document",
"video",
"novel",
];
function normalizeInitialTheme(value?: string): ThemeType {
if (!value) return "general";
if (SUPPORTED_ENTRY_THEMES.includes(value as ThemeType)) {
return value as ThemeType;
}
return "general";
}
const PageContainer = styled.div`
display: flex;
height: 100%;
@@ -156,22 +177,34 @@ export function AgentChatPage({
onNavigate: _onNavigate,
projectId: externalProjectId,
contentId,
theme: initialTheme,
lockTheme = false,
onRecommendationClick: _onRecommendationClick,
onHasMessagesChange,
}: {
onNavigate?: (page: string) => void;
onNavigate?: (page: Page, params?: PageParams) => void;
projectId?: string;
contentId?: string;
theme?: string;
lockTheme?: boolean;
onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void;
onHasMessagesChange?: (hasMessages: boolean) => void;
}) {
const [showSidebar, setShowSidebar] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [input, setInput] = useState("");
// 内容创作相关状态
const [activeTheme, setActiveTheme] = useState<string>("general");
const [activeTheme, setActiveTheme] = useState<string>(
normalizeInitialTheme(initialTheme),
);
const [creationMode, setCreationMode] = useState<CreationMode>("guided");
const [layoutMode, setLayoutMode] = useState<LayoutMode>("chat");
useEffect(() => {
if (!initialTheme) return;
setActiveTheme(normalizeInitialTheme(initialTheme));
}, [initialTheme]);
// 内部 projectId 状态(当外部未提供时使用)
const [internalProjectId, setInternalProjectId] = useState<string | null>(
null,
@@ -265,7 +298,9 @@ export function AgentChatPage({
setProject(p);
// 直接使用 projectType 作为 theme(类型已统一)
const theme = projectTypeToTheme(p.workspaceType);
setActiveTheme(theme);
if (!lockTheme || !initialTheme) {
setActiveTheme(theme);
}
// 2. 加载 Memory
const memory = await getProjectMemory(projectId);
@@ -275,8 +310,14 @@ export function AgentChatPage({
if (contentId) {
const content = await getContent(contentId);
if (content) {
const canvasTheme = (
lockTheme && initialTheme
? normalizeInitialTheme(initialTheme)
: theme
) as ThemeType;
const initialState =
createInitialCanvasState(theme, content.body || "") ||
createInitialCanvasState(canvasTheme, content.body || "") ||
createInitialDocumentState(content.body || "");
setCanvasState(initialState);
setLayoutMode("chat-canvas");
@@ -285,7 +326,7 @@ export function AgentChatPage({
};
loadData();
}, [projectId, contentId]);
}, [projectId, contentId, lockTheme, initialTheme]);
// 生成系统提示词(包含项目 Memory)
const systemPrompt = useMemo(() => {
@@ -432,7 +473,7 @@ export function AgentChatPage({
console.log("[AgentChatPage] 恢复会话元数据:", sessionId, sessionMeta);
// 从会话元数据恢复主题(类型已统一,直接使用)
if (sessionMeta.theme) {
if (sessionMeta.theme && (!lockTheme || !initialTheme)) {
console.log("[AgentChatPage] 恢复主题:", sessionMeta.theme);
setActiveTheme(sessionMeta.theme);
}
@@ -444,7 +485,7 @@ export function AgentChatPage({
}
restoredMetaSessionId.current = sessionId;
}, [sessionId, sessionMeta]);
}, [sessionId, sessionMeta, lockTheme, initialTheme]);
// 当 sessionFiles 加载完成时,恢复文件到 taskFiles
useEffect(() => {
@@ -619,9 +660,11 @@ export function AgentChatPage({
images?: MessageImage[],
webSearch?: boolean,
thinking?: boolean,
textOverride?: string,
) => {
if (!input.trim() && (!images || images.length === 0)) return;
let text = input;
const sourceText = textOverride ?? input;
if (!sourceText.trim() && (!images || images.length === 0)) return;
let text = sourceText;
// 如果有引用的角色,注入角色信息
if (mentionedCharacters.length > 0) {
@@ -660,8 +703,33 @@ export function AgentChatPage({
processedMessageIds.current.clear();
}, [clearMessages]);
const handleBackHome = useCallback(() => {
clearMessages({
showToast: false,
});
setInput("");
setLayoutMode("chat");
setShowSidebar(true);
setCanvasState(null);
setGeneralCanvasState(DEFAULT_CANVAS_STATE);
setTaskFiles([]);
setSelectedFileId(undefined);
processedMessageIds.current.clear();
setInternalProjectId(null);
setProject(null);
setProjectMemory(null);
setActiveTheme("general");
setCreationMode("guided");
_onNavigate?.("agent", { theme: "general", lockTheme: false });
}, [clearMessages, _onNavigate]);
// 当开始对话时自动折叠侧边栏
const hasMessages = messages.length > 0;
useEffect(() => {
onHasMessagesChange?.(hasMessages);
}, [hasMessages, onHasMessagesChange]);
useEffect(() => {
if (hasMessages) {
setShowSidebar(false);
@@ -1208,6 +1276,12 @@ export function AgentChatPage({
// 判断是否应该显示聊天布局(有消息)
const showChatLayout = hasMessages;
const handleManageProviders = useCallback(() => {
_onNavigate?.("settings", {
tab: SettingsTabs.Providers,
});
}, [_onNavigate]);
// 聊天区域内容
const chatContent = (
<ChatContainer>
@@ -1239,15 +1313,22 @@ export function AgentChatPage({
input={input}
setInput={setInput}
onSend={(text) => {
setInput(text);
setTimeout(() => handleSend([], false, false), 0);
handleSend([], false, false, text);
}}
providerType={providerType}
setProviderType={setProviderType}
model={model}
setModel={setModel}
onManageProviders={handleManageProviders}
creationMode={creationMode}
onCreationModeChange={setCreationMode}
activeTheme={activeTheme}
onThemeChange={setActiveTheme}
projectId={projectId}
onProjectChange={(newProjectId) => setInternalProjectId(newProjectId)}
onThemeChange={(theme) => {
if (!lockTheme) {
setActiveTheme(theme);
}
}}
showThemeTabs={false}
onRecommendationClick={(shortLabel, fullPrompt) => {
// 直接将推荐提示词放入输入框,不创建项目
setInput(fullPrompt);
@@ -1263,6 +1344,11 @@ export function AgentChatPage({
onSend={handleSend}
onStop={stopSending}
isLoading={isSending}
providerType={providerType}
setProviderType={setProviderType}
model={model}
setModel={setModel}
onManageProviders={handleManageProviders}
disabled={!processStatus.running && false}
onClearMessages={handleClearMessages}
onToggleCanvas={handleToggleCanvas}
@@ -1380,14 +1466,18 @@ export function AgentChatPage({
<MainArea>
<ChatNavbar
providerType={providerType}
setProviderType={setProviderType}
model={model}
setModel={setModel}
isRunning={isSending}
onToggleHistory={handleToggleSidebar}
onToggleFullscreen={() => {}}
onToggleSettings={() => setShowSettings(!showSettings)}
projectId={projectId ?? null}
onProjectChange={(newProjectId) => setInternalProjectId(newProjectId)}
workspaceType={activeTheme}
onBackHome={handleBackHome}
onToggleSettings={() => {
_onNavigate?.("settings", {
tab: SettingsTabs.ChatAppearance,
});
}}
/>
{/* 同步状态指示器 */}
@@ -1427,8 +1517,6 @@ export function AgentChatPage({
canvasContent={canvasContent}
/>
</MainArea>
{showSettings && <ChatSettings onClose={() => setShowSettings(false)} />}
</PageContainer>
);
}
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
composeEntryPrompt,
createDefaultEntrySlotValues,
formatEntryTaskPreview,
getEntryTaskTemplate,
validateEntryTaskSlots,
} from "./entryPromptComposer";
describe("entryPromptComposer", () => {
it("创建默认槽位值时应回填默认值", () => {
const values = createDefaultEntrySlotValues("geo");
expect(values.model_scope).toBe("DeepSeek、豆包等");
expect(values.word_count).toBe("800-1200");
expect(values.user_question).toBe("");
});
it("预览文本应展示未填写槽位占位符", () => {
const preview = formatEntryTaskPreview("multi_angle", {
topic: "",
angle_count: "",
article_type: "",
});
expect(preview).toContain("[输入文章主题]");
expect(preview).toContain("2");
expect(preview).toContain("社媒文章");
});
it("应正确校验必填槽位", () => {
const result = validateEntryTaskSlots("imitate", {
reference_style: "",
topic: "扫地机器人",
article_type: "测评文",
});
expect(result.valid).toBe(false);
expect(result.missing.map((slot) => slot.key)).toEqual(["reference_style"]);
});
it("应拼装结构化入口 Prompt", () => {
const prompt = composeEntryPrompt({
taskType: "direct",
slotValues: {
topic: "春季敏感肌修护",
article_type: "小红书文案",
},
userInput: "语气自然,加入真实体验感。",
activeTheme: "social-media",
creationMode: "guided",
context: {
platform: "小红书",
},
});
expect(prompt).toContain("[入口任务] 直接成文");
expect(prompt).toContain("[发布平台] 小红书");
expect(prompt).toContain("以春季敏感肌修护为主题,创作一篇小红书文案。");
expect(prompt).toContain("[补充要求] 语气自然,加入真实体验感。");
});
it("任务模板应包含描述与槽位", () => {
const template = getEntryTaskTemplate("rewrite");
expect(template.label).toBe("文章改写");
expect(template.description.length).toBeGreaterThan(0);
expect(template.slots.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,339 @@
import type {
CreationMode,
EntryTaskSlotDefinition,
EntryTaskSlotValues,
EntryTaskTemplate,
EntryTaskType,
} from "../components/types";
export interface EntryComposeContext {
platform?: string;
ratio?: string;
style?: string;
depth?: string;
}
export interface ComposeEntryPromptInput {
taskType: EntryTaskType;
slotValues: EntryTaskSlotValues;
userInput: string;
activeTheme: string;
creationMode: CreationMode;
context?: EntryComposeContext;
}
export interface SlotValidationResult {
valid: boolean;
missing: EntryTaskSlotDefinition[];
}
const CREATION_MODE_LABELS: Record<CreationMode, string> = {
guided: "引导模式",
fast: "快速模式",
hybrid: "混合模式",
framework: "框架模式",
};
export const SOCIAL_MEDIA_ENTRY_TASKS: EntryTaskType[] = [
"direct",
"multi_angle",
"rewrite",
"imitate",
"geo",
];
const ENTRY_TASK_TEMPLATE_MAP: Record<EntryTaskType, EntryTaskTemplate> = {
direct: {
type: "direct",
label: "直接成文",
description: "围绕单一主题直接生成一篇社媒内容",
pattern: "以{topic}为主题,创作一篇{article_type}。",
slots: [
{
key: "topic",
label: "文章主题",
placeholder: "输入文章主题",
required: true,
},
{
key: "article_type",
label: "文章类型",
placeholder: "社媒文章",
required: false,
defaultValue: "社媒文章",
},
],
},
multi_angle: {
type: "multi_angle",
label: "一题多写",
description: "同一主题生成多个视角版本",
pattern:
"以{topic}为主题,从{angle_count}个不同视角各写一篇{article_type}。",
slots: [
{
key: "topic",
label: "文章主题",
placeholder: "输入文章主题",
required: true,
},
{
key: "angle_count",
label: "视角数量",
placeholder: "2",
required: true,
defaultValue: "2",
},
{
key: "article_type",
label: "文章类型",
placeholder: "社媒文章",
required: false,
defaultValue: "社媒文章",
},
],
},
rewrite: {
type: "rewrite",
label: "文章改写",
description: "保留主题与核心事实,重写表达方式",
pattern:
"我想改写{source_material},保留核心观点与数据,主题不变,要求读起来像独立创作的新文章。",
slots: [
{
key: "source_material",
label: "原文内容/标题",
placeholder: "输入原文标题或素材",
required: true,
},
],
},
imitate: {
type: "imitate",
label: "文章仿写",
description: "参考示例文风,输出新内容",
pattern:
"请仿照{reference_style}的写作风格,围绕{topic}创作一篇全新{article_type}。",
slots: [
{
key: "reference_style",
label: "参考风格",
placeholder: "输入参考文案/风格描述",
required: true,
},
{
key: "topic",
label: "文章主题",
placeholder: "输入文章主题",
required: true,
},
{
key: "article_type",
label: "文章类型",
placeholder: "社媒文章",
required: false,
defaultValue: "社媒文章",
},
],
},
geo: {
type: "geo",
label: "GEO内容创作",
description: "围绕用户问题,生成有利于搜索与回答引用的内容",
pattern:
"我希望在{model_scope}模型中,针对提问{user_question}获得更高曝光。回答的核心信息包括{brand_value},文章类型{article_type},文章篇幅{word_count}。",
slots: [
{
key: "model_scope",
label: "目标模型",
placeholder: "DeepSeek、豆包等",
required: true,
defaultValue: "DeepSeek、豆包等",
},
{
key: "user_question",
label: "用户问题",
placeholder: "用户可能会问的问题",
required: true,
},
{
key: "brand_value",
label: "核心信息",
placeholder: "品牌价值点",
required: true,
},
{
key: "article_type",
label: "文章类型",
placeholder: "社媒文章",
required: false,
defaultValue: "社媒文章",
},
{
key: "word_count",
label: "字数",
placeholder: "800-1200",
required: false,
defaultValue: "800-1200",
},
],
},
};
const ENTRY_TASK_RECOMMENDATION_MAP: Record<EntryTaskType, [string, string][]> =
{
direct: [
[
"春季护肤笔记",
"写一篇关于春季敏感肌修护的社媒文章,语气真实且可执行。",
],
["咖啡店探店", "写一篇周末探店文案,突出环境、口味和拍照点位。"],
],
multi_angle: [
[
"房地产趋势多视角",
"围绕 2026 年房地产趋势,从购房者和商场运营方两个视角分别写文。",
],
["旅游攻略多人群", "围绕日本关西旅行,分别为学生党和亲子家庭写攻略。"],
],
rewrite: [
["技术文改写", "改写一篇 AI 行业分析文,保留数据结论但语言更口语化。"],
["商业文改写", "改写品牌介绍内容,保持观点一致但去除重复表达。"],
],
imitate: [
["仿公众号测评", "仿照公众号深度测评风格,写一篇扫地机器人对比测评。"],
["仿游记风格", "仿旅行日记风格,写一篇土耳其 5 日自由行攻略。"],
],
geo: [
[
"平台推荐 GEO",
"围绕“推荐一款适合内容团队的一站式创作平台”写 GEO 内容。",
],
["本地服务 GEO", "围绕“上海徐汇区买学区房如何选中介”写 GEO 优化内容。"],
],
};
export function getEntryTaskTemplate(
taskType: EntryTaskType,
): EntryTaskTemplate {
return ENTRY_TASK_TEMPLATE_MAP[taskType];
}
export function createDefaultEntrySlotValues(
taskType: EntryTaskType,
): EntryTaskSlotValues {
const template = getEntryTaskTemplate(taskType);
return template.slots.reduce<EntryTaskSlotValues>((acc, slot) => {
acc[slot.key] = slot.defaultValue ?? "";
return acc;
}, {});
}
export function getEntryTaskRecommendations(
taskType: EntryTaskType,
): [string, string][] {
return ENTRY_TASK_RECOMMENDATION_MAP[taskType] || [];
}
function resolveSlotValue(
slot: EntryTaskSlotDefinition,
slotValues: EntryTaskSlotValues,
): string {
const currentValue = slotValues[slot.key]?.trim();
if (currentValue) {
return currentValue;
}
return slot.defaultValue?.trim() || "";
}
function injectPattern(
pattern: string,
slotValues: EntryTaskSlotValues,
fallback: (slot: EntryTaskSlotDefinition) => string,
taskType: EntryTaskType,
): string {
const template = getEntryTaskTemplate(taskType);
const slotMap = new Map(template.slots.map((slot) => [slot.key, slot]));
return pattern.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key: string) => {
const slot = slotMap.get(key);
if (!slot) {
return match;
}
const value = resolveSlotValue(slot, slotValues);
if (value) {
return value;
}
return fallback(slot);
});
}
export function formatEntryTaskPreview(
taskType: EntryTaskType,
slotValues: EntryTaskSlotValues,
): string {
const template = getEntryTaskTemplate(taskType);
return injectPattern(
template.pattern,
slotValues,
(slot) => `[${slot.placeholder}]`,
taskType,
);
}
export function validateEntryTaskSlots(
taskType: EntryTaskType,
slotValues: EntryTaskSlotValues,
): SlotValidationResult {
const template = getEntryTaskTemplate(taskType);
const missing = template.slots.filter((slot) => {
if (!slot.required) {
return false;
}
return !resolveSlotValue(slot, slotValues);
});
return {
valid: missing.length === 0,
missing,
};
}
export function composeEntryPrompt({
taskType,
slotValues,
userInput,
activeTheme,
creationMode,
context,
}: ComposeEntryPromptInput): string {
const template = getEntryTaskTemplate(taskType);
const instruction = injectPattern(
template.pattern,
slotValues,
() => "",
taskType,
);
const lines: string[] = [
`[入口任务] ${template.label}`,
`[主题] ${activeTheme}`,
`[创作模式] ${CREATION_MODE_LABELS[creationMode]}`,
];
if (context?.platform) {
lines.push(`[发布平台] ${context.platform}`);
}
lines.push(`[任务描述] ${instruction}`);
if (userInput.trim()) {
lines.push(`[补充要求] ${userInput.trim()}`);
}
lines.push("[输出要求] 用结构化小标题输出,避免空话,给出可执行内容。");
return lines.join("\n");
}
+26 -20
View File
@@ -57,6 +57,10 @@ interface TestState {
type TabId = "server" | "logs";
interface ApiServerPageProps {
hideHeader?: boolean;
}
// Provider 到 API 类型的映射
type ApiType = "openai" | "anthropic" | "gemini";
const getProviderApiType = (provider: string): ApiType => {
@@ -130,7 +134,7 @@ interface AvailableProvider {
totalCount: number;
}
export function ApiServerPage() {
export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
const [status, setStatus] = useState<ServerStatus | null>(null);
const [config, setConfig] = useState<Config | null>(null);
const [loading, setLoading] = useState(false);
@@ -1040,28 +1044,30 @@ export function ApiServerPage() {
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-end gap-3">
<h2 className="text-2xl font-bold">API Server</h2>
<div className="flex items-center gap-2 text-sm text-muted-foreground pb-0.5">
<span className="flex items-center gap-1.5">
<span
className={`inline-block h-2 w-2 rounded-full ${status?.running ? "bg-green-500" : "bg-red-500"}`}
/>
{status?.running ? "运行中" : "已停止"}
</span>
<span>·</span>
<span>{status?.requests || 0} 请求</span>
<span>·</span>
<span className="capitalize">{defaultProvider}</span>
{!hideHeader && (
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-end gap-3">
<h2 className="text-2xl font-bold">API Server</h2>
<div className="flex items-center gap-2 text-sm text-muted-foreground pb-0.5">
<span className="flex items-center gap-1.5">
<span
className={`inline-block h-2 w-2 rounded-full ${status?.running ? "bg-green-500" : "bg-red-500"}`}
/>
{status?.running ? "运行中" : "已停止"}
</span>
<span>·</span>
<span>{status?.requests || 0} 请求</span>
<span>·</span>
<span className="capitalize">{defaultProvider}</span>
</div>
</div>
<p className="text-muted-foreground text-sm mt-1">
本地代理服务器,支持 OpenAI/Anthropic 格式
</p>
</div>
<p className="text-muted-foreground text-sm mt-1">
本地代理服务器,支持 OpenAI/Anthropic 格式
</p>
</div>
</div>
)}
{message && (
<div
File diff suppressed because it is too large Load Diff
+182 -136
View File
@@ -17,6 +17,81 @@ import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDER_IDS } from "./types";
const HISTORY_KEY = "image-gen-history";
interface GenerateImageOptions {
imageCount?: number;
referenceImages?: string[];
size?: string;
}
function extractImageUrlFromResponse(content: string): string | null {
const base64Match = content.match(
/!\[.*?\]\((data:image\/[^;]+;base64,[^)]+)\)/,
);
if (base64Match) {
return base64Match[1];
}
const urlMatch = content.match(/!\[.*?\]\((https?:\/\/[^)]+)\)/);
if (urlMatch) {
return urlMatch[1];
}
return null;
}
async function requestImageFromNewApi(
apiHost: string,
apiKey: string,
model: string,
prompt: string,
referenceImages: string[],
): Promise<string> {
const referenceText =
referenceImages.length > 0
? `\n参考图链接:\n${referenceImages
.map((url, index) => `${index + 1}. ${url}`)
.join("\n")}`
: "";
const chatRequest = {
model,
messages: [
{
role: "user",
content:
"请根据以下描述生成一张图片,并以 Markdown 图片格式返回结果。" +
`\n描述:${prompt}${referenceText}`,
},
],
temperature: 0.7,
stream: false,
};
const response = await fetch(`${apiHost}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(chatRequest),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`请求失败: ${response.status} - ${errorText}`);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content || "";
const imageUrl = extractImageUrlFromResponse(content);
if (!imageUrl) {
throw new Error("未能从响应中提取图片");
}
return imageUrl;
}
/**
* 检查 Provider 是否支持图片生成
* 通过 Provider ID 或 type 匹配
@@ -185,143 +260,106 @@ export function useImageGen() {
// 生成图片
const generateImage = useCallback(
async (prompt: string) => {
async (prompt: string, options?: GenerateImageOptions) => {
if (!selectedProvider) {
throw new Error("请先在凭证池中配置 API Key Provider");
}
// 获取 API Key
const apiKey = await apiKeyProviderApi.getNextApiKey(selectedProvider.id);
if (!apiKey) {
throw new Error("该 Provider 没有可用的 API Key,请在凭证池中添加");
}
const generationCount = Math.max(
1,
Math.min(options?.imageCount ?? 1, 8),
);
const requestSize = options?.size || selectedSize;
const referenceImages = options?.referenceImages || [];
// 如果当前选中的是 pending 状态的图片,更新它;否则创建新的
let imageId: string;
const currentImage = images.find((img) => img.id === selectedImageId);
if (currentImage?.status === "pending") {
// 更新已存在的 pending 图片
imageId = currentImage.id;
setImages((prev) => {
const updated = prev.map((img) =>
img.id === imageId
? { ...img, prompt, status: "generating" as const }
: img,
);
saveHistory(updated);
return updated;
});
} else {
// 创建新的图片
imageId = `img-${Date.now()}`;
const newImage: GeneratedImage = {
id: imageId,
const baseId = Date.now();
const generationItems: GeneratedImage[] = Array.from(
{ length: generationCount },
(_, index) => ({
id: `img-${baseId}-${index}`,
url: "",
prompt,
model: selectedModelId,
size: selectedSize,
size: requestSize,
providerId: selectedProvider.id,
providerName: selectedProvider.name,
createdAt: Date.now(),
createdAt: baseId + index,
status: "generating",
};
}),
);
// 添加到列表并选中
setImages((prev) => {
const updated = [newImage, ...prev];
saveHistory(updated);
return updated;
});
setSelectedImageId(imageId);
}
setImages((prev) => {
const updated = [...generationItems, ...prev];
saveHistory(updated);
return updated;
});
setSelectedImageId(generationItems[0]?.id || null);
setGenerating(true);
try {
let imageUrl: string;
// New API 使用聊天接口生成图片(通过 Provider ID 或 type 判断)
const isNewApi =
selectedProvider.id === "new-api" ||
selectedProvider.type === "new-api" ||
selectedProvider.type === "NewApi";
console.log(
"[useImageGen] Provider:",
selectedProvider.id,
"Type:",
selectedProvider.type,
"isNewApi:",
isNewApi,
);
if (isNewApi) {
const chatRequest = {
model: selectedModelId,
messages: [
{ role: "user", content: `请帮我画一张图片:${prompt}` },
],
temperature: 0.7,
stream: false,
};
console.log(
"[useImageGen] 发送 New API 请求:",
`${selectedProvider.api_host}/v1/chat/completions`,
);
const response = await fetch(
`${selectedProvider.api_host}/v1/chat/completions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(chatRequest),
},
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`请求失败: ${response.status} - ${errorText}`);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content || "";
console.log("[useImageGen] 响应内容长度:", content.length);
// 从响应中提取图片(支持 base64 和 URL)
// 优先匹配 base64: ![image](data:image/...;base64,...)
const base64Match = content.match(
/!\[.*?\]\((data:image\/[^;]+;base64,[^)]+)\)/,
);
if (base64Match) {
console.log("[useImageGen] 匹配到 base64 图片");
imageUrl = base64Match[1];
} else {
// 回退匹配 URL: ![image](https://...)
const urlMatch = content.match(/!\[.*?\]\((https?:\/\/[^)]+)\)/);
if (urlMatch) {
console.log("[useImageGen] 匹配到 URL 图片");
imageUrl = urlMatch[1];
} else {
console.log(
"[useImageGen] 未匹配到图片,内容预览:",
content.slice(0, 200),
for (const item of generationItems) {
try {
const apiKey = await apiKeyProviderApi.getNextApiKey(
selectedProvider.id,
);
throw new Error("未能从响应中提取图片");
if (!apiKey) {
throw new Error(
"该 Provider 没有可用的 API Key,请在凭证池中添加",
);
}
const imageUrl = await requestImageFromNewApi(
selectedProvider.api_host,
apiKey,
selectedModelId,
prompt,
referenceImages,
);
setImages((prev) => {
const updated = prev.map((img) =>
img.id === item.id
? { ...img, url: imageUrl, status: "complete" as const }
: img,
);
saveHistory(updated);
return updated;
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
setImages((prev) => {
const updated = prev.map((img) =>
img.id === item.id
? { ...img, status: "error" as const, error: errorMessage }
: img,
);
saveHistory(updated);
return updated;
});
}
}
} else {
// 标准 OpenAI 图片生成接口
const apiKey = await apiKeyProviderApi.getNextApiKey(
selectedProvider.id,
);
if (!apiKey) {
throw new Error("该 Provider 没有可用的 API Key,请在凭证池中添加");
}
const request: ImageGenRequest = {
model: selectedModelId,
prompt,
n: 1,
size: selectedSize,
n: generationCount,
size: requestSize,
};
const response = await fetch(
@@ -342,29 +380,44 @@ export function useImageGen() {
}
const data = (await response.json()) as ImageGenResponse;
imageUrl = data.data[0]?.url || "";
}
const urls = data.data.map((item) => item.url).filter(Boolean);
if (!imageUrl) {
throw new Error("未返回图片 URL");
}
if (urls.length === 0) {
throw new Error("未返回图片 URL");
}
// 更新图片状态
setImages((prev) => {
const updated = prev.map((img) =>
img.id === imageId
? { ...img, url: imageUrl, status: "complete" as const }
: img,
);
saveHistory(updated);
return updated;
});
setImages((prev) => {
const updated = prev.map((img) => {
const index = generationItems.findIndex(
(item) => item.id === img.id,
);
if (index === -1) return img;
const imageUrl = urls[index];
if (imageUrl) {
return { ...img, url: imageUrl, status: "complete" as const };
}
return {
...img,
status: "error" as const,
error: "服务返回的图片数量少于请求数量",
};
});
saveHistory(updated);
return updated;
});
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
setImages((prev) => {
const updated = prev.map((img) =>
img.id === imageId
generationItems.some((item) => item.id === img.id) &&
img.status === "generating"
? { ...img, status: "error" as const, error: errorMessage }
: img,
);
@@ -376,14 +429,7 @@ export function useImageGen() {
setGenerating(false);
}
},
[
selectedProvider,
selectedModelId,
selectedSize,
saveHistory,
images,
selectedImageId,
],
[selectedProvider, selectedModelId, selectedSize, saveHistory],
);
// 删除图片
@@ -391,14 +437,14 @@ export function useImageGen() {
(id: string) => {
setImages((prev) => {
const updated = prev.filter((img) => img.id !== id);
if (selectedImageId === id) {
setSelectedImageId(updated[0]?.id || null);
}
saveHistory(updated);
return updated;
});
if (selectedImageId === id) {
setSelectedImageId(images[1]?.id || null);
}
},
[images, selectedImageId, saveHistory],
[selectedImageId, saveHistory],
);
// 新建图片(创建一个新的空白图片项)
+13 -7
View File
@@ -28,7 +28,11 @@ const tabs: { id: McpTab; label: string }[] = [
{ id: "config", label: "配置管理" },
];
export function McpPanel() {
interface McpPanelProps {
hideHeader?: boolean;
}
export function McpPanel({ hideHeader = false }: McpPanelProps) {
const [activeTab, setActiveTab] = useState<McpTab>("runtime");
const [callingTool, setCallingTool] = useState<McpToolDefinition | null>(
null,
@@ -74,12 +78,14 @@ export function McpPanel() {
return (
<div className="h-full flex flex-col">
{/* 页面标题 */}
<div className="mb-4">
<h2 className="text-2xl font-bold">MCP 服务器</h2>
<p className="text-muted-foreground">
管理 Model Context Protocol 服务器,浏览工具、提示词和资源
</p>
</div>
{!hideHeader && (
<div className="mb-4">
<h2 className="text-2xl font-bold">MCP 服务器</h2>
<p className="text-muted-foreground">
管理 Model Context Protocol 服务器,浏览工具、提示词和资源
</p>
</div>
)}
{/* Tab 导航 */}
<div className="flex items-center gap-1 mb-4 border-b">
+12 -2
View File
@@ -29,6 +29,10 @@ export interface ProjectSelectorProps {
disabled?: boolean;
/** 自定义类名 */
className?: string;
/** 下拉方向 */
dropdownSide?: "top" | "bottom";
/** 下拉对齐 */
dropdownAlign?: "start" | "end";
}
/**
@@ -43,6 +47,8 @@ export function ProjectSelector({
placeholder = "选择项目",
disabled = false,
className,
dropdownSide = "top",
dropdownAlign = "start",
}: ProjectSelectorProps) {
const { projects, defaultProject, loading, getOrCreateDefault } =
useProjects();
@@ -119,7 +125,11 @@ export function ProjectSelector({
<SelectValue placeholder={placeholder} />
)}
</SelectTrigger>
<SelectContent side="top" className="min-w-[280px] max-w-[400px]">
<SelectContent
side={dropdownSide}
align={dropdownAlign}
className="min-w-[320px] max-w-[520px]"
>
{availableProjects.map((project) => (
<SelectItem key={project.id} value={project.id}>
<div className="flex items-center gap-2 w-full">
@@ -128,7 +138,7 @@ export function ProjectSelector({
) : (
<FolderIcon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
<span className="truncate">{project.name}</span>
<span className="break-all">{project.name}</span>
{project.isDefault && (
<StarIcon className="h-3 w-3 text-yellow-500 fill-yellow-500 flex-shrink-0" />
)}
File diff suppressed because it is too large Load Diff
+130 -44
View File
@@ -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 (
<>
<SettingHeader title="个人资料" />
<PlaceholderPage>
<p>个人资料设置</p>
<p>即将推出...</p>
</PlaceholderPage>
<ProfileSettings />
</>
);
@@ -102,10 +152,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
return (
<>
<SettingHeader title="数据统计" />
<PlaceholderPage>
<p>使用统计信息</p>
<p>即将推出...</p>
</PlaceholderPage>
<StatsSettings />
</>
);
@@ -122,10 +169,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
return (
<>
<SettingHeader title="聊天外观" />
<PlaceholderPage>
<p>聊天气泡样式设置</p>
<p>即将推出...</p>
</PlaceholderPage>
<ChatAppearanceSettings />
</>
);
@@ -141,8 +185,8 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
case SettingsTabs.Providers:
return (
<>
<SettingHeader title="AI 服务商" />
<ConnectionsSettings />
<SettingHeader title="凭证管理" />
<ProviderPoolPage hideHeader />
</>
);
@@ -150,10 +194,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
return (
<>
<SettingHeader title="助理服务" />
<PlaceholderPage>
<p>助理配置</p>
<p>即将推出...</p>
</PlaceholderPage>
<AssistantSettings />
</>
);
@@ -169,10 +210,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
return (
<>
<SettingHeader title="记忆设置" />
<PlaceholderPage>
<p>记忆管理</p>
<p>即将推出...</p>
</PlaceholderPage>
<MemorySettings />
</>
);
@@ -180,10 +218,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
return (
<>
<SettingHeader title="绘画服务" />
<PlaceholderPage>
<p>绘画服务配置</p>
<p>即将推出...</p>
</PlaceholderPage>
<ImageGenSettings />
</>
);
@@ -191,14 +226,27 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
return (
<>
<SettingHeader title="语音服务" />
<PlaceholderPage>
<p>语音服务配置</p>
<p>即将推出...</p>
</PlaceholderPage>
<VoiceSettings />
</>
);
// 系统组
case SettingsTabs.ApiServer:
return (
<>
<SettingHeader title="API Server" />
<ApiServerPage hideHeader />
</>
);
case SettingsTabs.McpServer:
return (
<>
<SettingHeader title="MCP 服务器" />
<McpPanel hideHeader />
</>
);
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>([
SettingsTabs.Providers,
SettingsTabs.ApiServer,
SettingsTabs.McpServer,
]);
export function SettingsLayoutV2({
onNavigate,
initialTab,
}: SettingsLayoutV2Props) {
const [activeTab, setActiveTab] = useState<SettingsTabs>(
SettingsTabs.Appearance,
initialTab || SettingsTabs.Appearance,
);
useEffect(() => {
if (initialTab) {
setActiveTab(initialTab);
}
}, [initialTab]);
const handleBackToHome = () => {
if (onNavigate) {
onNavigate("agent");
}
};
return (
<LayoutContainer>
<SettingsSidebar activeTab={activeTab} onTabChange={setActiveTab} />
<ContentContainer>
<ContentWrapper>{renderSettingsContent(activeTab)}</ContentWrapper>
</ContentContainer>
</LayoutContainer>
<>
{/* 顶部返回栏 */}
<HeaderBar>
<BackButton onClick={handleBackToHome}>
<Home />
返回首页
</BackButton>
</HeaderBar>
{/* 设置内容 */}
<LayoutContainer>
<SettingsSidebar activeTab={activeTab} onTabChange={setActiveTab} />
<ContentContainer>
<ContentWrapper $wide={WIDE_CONTENT_TABS.has(activeTab)}>
{renderSettingsContent(activeTab)}
</ContentWrapper>
</ContentContainer>
</LayoutContainer>
</>
);
}
@@ -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<string, keyof UserProfile> = {
昵称: "nickname",
简介: "bio",
邮箱: "email",
};
const SUGGESTED_TAGS = [
"编程",
"写作",
"设计",
"数据分析",
"产品经理",
"创业者",
"学生",
"研究者",
];
export function ProfileSettings() {
const [config, setConfig] = useState<Config | null>(null);
const [profile, setProfile] = useState<UserProfile>(DEFAULT_USER_PROFILE);
const [_loading, setLoading] = useState(true);
const [editingField, setEditingField] = useState<keyof UserProfile | null>(
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 (
<div className="flex items-center justify-between px-4 py-3 border-b last:border-b-0">
<div className="flex items-center gap-3 min-w-0">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{label}</span>
</div>
<div className="flex items-center gap-2 flex-1 justify-end min-w-0 pl-4">
{editingField === field ? (
multiline ? (
<textarea
value={editValue}
onChange={(e) => {
setEditValue(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSaveEdit();
} else if (e.key === "Escape") {
handleCancelEdit();
}
}}
rows={3}
className="flex-1 max-w-xs px-3 py-2 rounded border bg-background text-sm focus:ring-1 focus:ring-primary/20 focus:border-primary outline-none resize-none"
autoFocus
/>
) : (
<input
type="text"
value={editValue}
onChange={(e) => {
setEditValue(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSaveEdit();
} else if (e.key === "Escape") {
handleCancelEdit();
}
}}
className="flex-1 max-w-xs px-3 py-1.5 rounded border bg-background text-sm focus:ring-1 focus:ring-primary/20 focus:border-primary outline-none"
autoFocus
/>
)
) : (
<span className="text-sm text-muted-foreground truncate max-w-[420px] text-right">
{value || "未设置"}
</span>
)}
{editable && !editingField && (
<button
onClick={() => handleStartEdit(label, value)}
className="p-1.5 rounded hover:bg-muted transition-colors flex-shrink-0"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
)}
{editingField === field && (
<div className="flex gap-1 flex-shrink-0">
<button
onClick={handleSaveEdit}
className="p-1.5 rounded hover:bg-green-100 text-green-600 transition-colors"
>
<CheckCircle2 className="h-3.5 w-3.5" />
</button>
<button
onClick={handleCancelEdit}
className="p-1.5 rounded hover:bg-red-100 text-red-600 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</div>
);
};
return (
<div className="space-y-4 w-full">
{/* 头像和基本信息 */}
<div className="rounded-lg border p-4">
<div className="flex items-center gap-4">
{/* 头像 */}
<div className="relative group">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center overflow-hidden">
{profile.avatar_url ? (
<img
src={profile.avatar_url}
alt="头像"
className="w-full h-full object-cover"
/>
) : (
<User className="h-10 w-10 text-primary/50" />
)}
</div>
<button
onClick={handleUploadAvatar}
className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<Camera className="h-6 w-6 text-white" />
</button>
</div>
{/* 昵称和简介 */}
<div className="flex-1">
<h3 className="text-lg font-semibold">
{profile.nickname || "未设置昵称"}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{profile.bio || "未设置个人简介"}
</p>
{profile.email && (
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
<Mail className="h-3 w-3" />
{profile.email}
</div>
)}
</div>
</div>
</div>
{/* 详细信息编辑 */}
<div className="rounded-lg border overflow-hidden">
<ProfileRow icon={User} label="昵称" value={profile.nickname || ""} />
<ProfileRow
icon={Edit2}
label="简介"
value={profile.bio || ""}
multiline
/>
<ProfileRow
icon={Mail}
label="邮箱"
value={profile.email || ""}
editable={false}
/>
</div>
{/* 偏好标签 */}
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2 mb-3">
<Tag className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">偏好标签</h3>
</div>
{/* 已选择的标签 */}
<div className="flex flex-wrap gap-2 mb-3">
{(profile.tags || []).map((tag) => (
<div
key={tag}
className="flex items-center gap-1 px-3 py-1.5 rounded-full bg-primary/10 text-primary text-sm"
>
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="hover:bg-primary/20 rounded-full p-0.5 transition-colors"
>
<X className="h-3 w-3" />
</button>
</div>
))}
{!(profile.tags || []).length && (
<p className="text-sm text-muted-foreground">
还没有添加标签,选择一些您感兴趣的领域
</p>
)}
</div>
{/* 添加新标签 */}
<div className="flex gap-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTag();
}
}}
placeholder="输入自定义标签"
className="flex-1 px-3 py-2 rounded-lg border bg-background text-sm focus:ring-1 focus:ring-primary/20 focus:border-primary outline-none"
/>
<button
onClick={handleAddTag}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm hover:bg-primary/90"
>
添加
</button>
</div>
{/* 推荐标签 */}
<div className="mt-3">
<p className="text-xs text-muted-foreground mb-2">推荐标签:</p>
<div className="flex flex-wrap gap-2">
{SUGGESTED_TAGS.filter((tag) => !(profile.tags || []).includes(tag))
.slice(0, 6)
.map((tag) => (
<button
key={tag}
onClick={() => {
const newTags = [...(profile.tags || []), tag];
saveProfile("tags", newTags);
}}
className="px-3 py-1.5 rounded-full border text-sm hover:bg-muted transition-colors"
>
+ {tag}
</button>
))}
</div>
</div>
</div>
{/* 提示信息 */}
<div className="flex items-start gap-2 text-xs text-muted-foreground p-3 bg-muted/30 rounded-lg">
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<p>
您的个人资料用于个性化 AI 助理的回复。这些信息会帮助 AI
更好地理解您的需求和偏好。您随时可以修改这些信息。
</p>
</div>
{/* 消息提示 */}
{message && (
<div
className={cn(
"flex items-center gap-2 p-3 rounded-lg",
message.type === "success"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
)}
>
{message.type === "success" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<span className="text-sm">{message.text}</span>
</div>
)}
</div>
);
}
export default ProfileSettings;
@@ -0,0 +1,441 @@
/**
* 数据统计页面组件
*
* 参考 LobeHub 的 stats 实现
* 功能包括:使用统计数据展示、Token 消耗统计等
*/
import { useState, useEffect, useCallback } from "react";
import {
BarChart3,
MessageSquare,
Timer,
Coins,
TrendingUp,
Download,
RefreshCw,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface UsageStats {
/** 总对话数 */
total_conversations: number;
/** 总消息数 */
total_messages: number;
/** 总 Token 消耗 */
total_tokens: number;
/** 总使用时间(分钟) */
total_time_minutes: number;
/** 本月对话数 */
monthly_conversations: number;
/** 本月消息数 */
monthly_messages: number;
/** 本月 Token 消耗 */
monthly_tokens: number;
/** 今日对话数 */
today_conversations: number;
/** 今日消息数 */
today_messages: number;
/** 今日 Token 消耗 */
today_tokens: number;
}
interface ModelUsage {
model: string;
conversations: number;
tokens: number;
percentage: number;
}
interface DailyUsage {
date: string;
conversations: number;
tokens: number;
}
export function StatsSettings() {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<UsageStats | null>(null);
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
const [dailyUsage, setDailyUsage] = useState<DailyUsage[]>([]);
const [timeRange, setTimeRange] = useState<"week" | "month" | "all">("month");
const loadStats = useCallback(async () => {
setLoading(true);
try {
// TODO: 实现获取统计数据 API
// const data = await getUsageStats(timeRange);
// setStats(data.stats);
// setModelUsage(data.modelUsage);
// setDailyUsage(data.dailyUsage);
// 模拟数据
setStats({
total_conversations: 328,
total_messages: 4521,
total_tokens: 1258000,
total_time_minutes: 1840,
monthly_conversations: 67,
monthly_messages: 892,
monthly_tokens: 245000,
today_conversations: 5,
today_messages: 42,
today_tokens: 12000,
});
setModelUsage([
{ model: "GPT-4", conversations: 145, tokens: 580000, percentage: 46 },
{
model: "GPT-3.5",
conversations: 128,
tokens: 420000,
percentage: 33,
},
{
model: "Claude 3",
conversations: 55,
tokens: 258000,
percentage: 21,
},
]);
// 生成模拟的每日数据
const days = timeRange === "week" ? 7 : timeRange === "month" ? 30 : 90;
const mockDaily: DailyUsage[] = [];
for (let i = days - 1; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
mockDaily.push({
date: date.toISOString().split("T")[0],
conversations: Math.floor(Math.random() * 10) + 1,
tokens: Math.floor(Math.random() * 15000) + 2000,
});
}
setDailyUsage(mockDaily);
} catch (e) {
console.error("加载统计数据失败:", e);
} finally {
setLoading(false);
}
}, [timeRange]);
useEffect(() => {
loadStats();
}, [loadStats]);
const formatNumber = (num: number): string => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
};
const formatTime = (minutes: number): string => {
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${mins}m`;
}
return `${minutes}m`;
};
const StatCard = ({
icon: Icon,
label,
value,
subvalue,
trend,
}: {
icon: any;
label: string;
value: string;
subvalue?: string;
trend?: number;
}) => (
<div className="flex-1 min-w-[140px] p-4 rounded-lg border bg-gradient-to-br from-primary/5 to-primary/10">
<div className="flex items-center gap-2 mb-2">
<Icon className="h-4 w-4 text-primary" />
<span className="text-xs text-muted-foreground">{label}</span>
</div>
<div className="text-2xl font-bold text-primary mb-1">{value}</div>
{subvalue && (
<div className="text-xs text-muted-foreground">{subvalue}</div>
)}
{trend !== undefined && (
<div className="flex items-center gap-1 mt-1">
<TrendingUp className="h-3 w-3 text-green-500" />
<span className="text-xs text-green-500">{trend}%</span>
</div>
)}
</div>
);
return (
<div className="space-y-4 max-w-4xl">
{/* 时间范围选择 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">使用统计</h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setTimeRange("week")}
className={cn(
"px-3 py-1.5 rounded text-sm transition-colors",
timeRange === "week"
? "bg-primary text-primary-foreground"
: "hover:bg-muted",
)}
>
本周
</button>
<button
onClick={() => setTimeRange("month")}
className={cn(
"px-3 py-1.5 rounded text-sm transition-colors",
timeRange === "month"
? "bg-primary text-primary-foreground"
: "hover:bg-muted",
)}
>
本月
</button>
<button
onClick={() => setTimeRange("all")}
className={cn(
"px-3 py-1.5 rounded text-sm transition-colors",
timeRange === "all"
? "bg-primary text-primary-foreground"
: "hover:bg-muted",
)}
>
全部
</button>
<button
onClick={loadStats}
disabled={loading}
className="p-2 rounded hover:bg-muted transition-colors"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : stats ? (
<>
{/* 今日统计 */}
<div>
<h3 className="text-sm font-medium mb-3">今日</h3>
<div className="flex gap-3 overflow-x-auto pb-2">
<StatCard
icon={MessageSquare}
label="对话"
value={stats.today_conversations.toString()}
subvalue={`${stats.today_messages} 条消息`}
/>
<StatCard
icon={Coins}
label="Token"
value={formatNumber(stats.today_tokens)}
trend={12}
/>
<StatCard
icon={Timer}
label="时长"
value={formatTime(
Math.round(
(stats.today_tokens / (stats.total_tokens || 1)) *
stats.total_time_minutes,
),
)}
/>
</div>
</div>
{/* 本月统计 */}
<div>
<h3 className="text-sm font-medium mb-3">本月</h3>
<div className="flex gap-3 overflow-x-auto pb-2">
<StatCard
icon={MessageSquare}
label="对话"
value={stats.monthly_conversations.toString()}
subvalue={`${stats.monthly_messages} 条消息`}
trend={8}
/>
<StatCard
icon={Coins}
label="Token"
value={formatNumber(stats.monthly_tokens)}
trend={15}
/>
<StatCard
icon={Timer}
label="时长"
value={formatTime(
Math.round(
(stats.monthly_tokens / (stats.total_tokens || 1)) *
stats.total_time_minutes,
),
)}
/>
</div>
</div>
{/* 总计 */}
<div>
<h3 className="text-sm font-medium mb-3">总计</h3>
<div className="flex gap-3 overflow-x-auto pb-2">
<StatCard
icon={MessageSquare}
label="对话"
value={stats.total_conversations.toString()}
subvalue={`${stats.total_messages} 条消息`}
/>
<StatCard
icon={Coins}
label="Token"
value={formatNumber(stats.total_tokens)}
/>
<StatCard
icon={Timer}
label="时长"
value={formatTime(stats.total_time_minutes)}
/>
</div>
</div>
{/* 模型使用排行 */}
<div className="rounded-lg border p-4">
<h3 className="text-sm font-medium mb-4">模型使用排行</h3>
<div className="space-y-3">
{modelUsage.map((model, index) => (
<div key={model.model} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">
#{index + 1}
</span>
<span className="font-medium">{model.model}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>{model.conversations} 次对话</span>
<span>{formatNumber(model.tokens)} Token</span>
<span className="text-primary font-medium">
{model.percentage}%
</span>
</div>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${model.percentage}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* 每日使用趋势 */}
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">每日使用趋势</h3>
<button className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
<Download className="h-3 w-3" />
导出
</button>
</div>
<div className="h-40 flex items-end gap-1">
{dailyUsage.map((day, _index) => {
const maxTokens = Math.max(...dailyUsage.map((d) => d.tokens));
const height =
maxTokens > 0 ? (day.tokens / maxTokens) * 100 : 0;
return (
<div
key={day.date}
className="flex-1 flex flex-col items-center gap-1 group"
>
<div
className="w-full bg-primary/60 hover:bg-primary rounded-t transition-colors relative"
style={{ height: `${Math.max(height, 4)}%` }}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-popover border px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{formatNumber(day.tokens)} Token
</div>
</div>
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
{new Date(day.date).toLocaleDateString("zh-CN", {
month: "numeric",
day: "numeric",
})}
</div>
</div>
);
})}
</div>
</div>
{/* 使用日历热力图(简化版) */}
<div className="rounded-lg border p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">活跃度日历</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>少</span>
<div className="flex gap-0.5">
<div className="w-3 h-3 rounded-sm bg-primary/10" />
<div className="w-3 h-3 rounded-sm bg-primary/30" />
<div className="w-3 h-3 rounded-sm bg-primary/50" />
<div className="w-3 h-3 rounded-sm bg-primary/70" />
<div className="w-3 h-3 rounded-sm bg-primary" />
</div>
<span>多</span>
</div>
</div>
<div className="grid grid-cols-7 gap-1">
{["日", "一", "二", "三", "四", "五", "六"].map((day) => (
<div
key={day}
className="text-xs text-muted-foreground text-center py-1"
>
{day}
</div>
))}
{Array.from({ length: 35 }).map((_, index) => {
const dayData = dailyUsage[index];
const getIntensity = (tokens: number) => {
if (!dayData) return "bg-muted";
const max = Math.max(...dailyUsage.map((d) => d.tokens));
const ratio = tokens / max;
if (ratio < 0.2) return "bg-primary/10";
if (ratio < 0.4) return "bg-primary/30";
if (ratio < 0.6) return "bg-primary/50";
if (ratio < 0.8) return "bg-primary/70";
return "bg-primary";
};
return (
<div
key={index}
className={cn(
"aspect-square rounded-sm",
dayData ? getIntensity(dayData.tokens) : "bg-muted",
)}
title={
dayData ? `${dayData.date}: ${dayData.tokens} Token` : ""
}
/>
);
})}
</div>
</div>
</>
) : null}
</div>
);
}
export default StatsSettings;
@@ -0,0 +1,443 @@
/**
* 助理服务配置设置组件
*
* 参考 LobeHub 的 agent 实现
* 功能包括:默认助理选择、助理参数配置等
*/
import { useState, useEffect } from "react";
import {
Bot,
Settings2,
Sparkles,
Info,
CheckCircle2,
AlertCircle,
Plus,
Edit2,
Trash2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
interface AssistantProfile {
id: string;
name: string;
description?: string;
model?: string;
system_prompt?: string;
temperature?: number;
max_tokens?: number;
}
interface AssistantConfig {
/** 默认助理 ID */
default_assistant_id?: string;
/** 自定义助理列表 */
custom_assistants?: AssistantProfile[];
/** 启用助理自动选择 */
auto_select?: boolean;
/** 显示助理建议 */
show_suggestions?: boolean;
}
const DEFAULT_ASSISTANT_CONFIG: AssistantConfig = {
default_assistant_id: "default",
custom_assistants: [],
auto_select: false,
show_suggestions: true,
};
const PRESET_ASSISTANTS: AssistantProfile[] = [
{
id: "default",
name: "通用助理",
description: "适合日常对话和通用任务",
model: "gpt-4",
temperature: 0.7,
},
{
id: "coder",
name: "编程助手",
description: "专注于编程和代码相关任务",
model: "gpt-4",
temperature: 0.3,
},
{
id: "writer",
name: "写作助理",
description: "帮助撰写和编辑各类文本",
model: "gpt-4",
temperature: 0.8,
},
{
id: "analyst",
name: "数据分析",
description: "协助处理和分析数据",
model: "gpt-4",
temperature: 0.5,
},
];
export function AssistantSettings() {
const [config, setConfig] = useState<Config | null>(null);
const [assistantConfig, setAssistantConfig] = useState<AssistantConfig>(
DEFAULT_ASSISTANT_CONFIG,
);
const [loading, setLoading] = useState(true);
const [_saving, setSaving] = useState<Record<string, boolean>>({});
const [editingAssistant, setEditingAssistant] =
useState<AssistantProfile | null>(null);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// 加载配置
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
setLoading(true);
try {
const c = await getConfig();
setConfig(c);
setAssistantConfig(c.assistant || DEFAULT_ASSISTANT_CONFIG);
} catch (e) {
console.error("加载助理配置失败:", e);
} finally {
setLoading(false);
}
};
// 保存配置
const saveAssistantConfig = async (
key: keyof AssistantConfig,
value: any,
) => {
if (!config) return;
setSaving((prev) => ({ ...prev, [key]: true }));
try {
const newConfig = {
...assistantConfig,
[key]: value,
};
const updatedFullConfig = {
...config,
assistant: newConfig,
};
await saveConfig(updatedFullConfig);
setConfig(updatedFullConfig);
setAssistantConfig(newConfig);
showMessage("success", "设置已保存");
} catch (e) {
console.error("保存助理配置失败:", e);
showMessage("error", "保存失败");
} finally {
setSaving((prev) => ({ ...prev, [key]: false }));
}
};
// 添加自定义助理
const handleAddAssistant = () => {
const newAssistant: AssistantProfile = {
id: `custom_${Date.now()}`,
name: "新助理",
description: "",
model: "gpt-4",
temperature: 0.7,
};
const updatedAssistants = [
...(assistantConfig.custom_assistants || []),
newAssistant,
];
saveAssistantConfig("custom_assistants", updatedAssistants);
setEditingAssistant(newAssistant);
};
// 编辑助理
const handleEditAssistant = (assistant: AssistantProfile) => {
setEditingAssistant(assistant);
};
// 删除助理
const handleDeleteAssistant = (id: string) => {
const updatedAssistants =
assistantConfig.custom_assistants?.filter((a) => a.id !== id) || [];
saveAssistantConfig("custom_assistants", updatedAssistants);
// 如果删除的是默认助理,重置为系统默认
if (assistantConfig.default_assistant_id === id) {
saveAssistantConfig("default_assistant_id", "default");
}
};
// 保存助理编辑
const handleSaveAssistant = (updatedAssistant: AssistantProfile) => {
const updatedAssistants =
assistantConfig.custom_assistants?.map((a) =>
a.id === updatedAssistant.id ? updatedAssistant : a,
) || [];
saveAssistantConfig("custom_assistants", updatedAssistants);
setEditingAssistant(null);
};
const showMessage = (type: "success" | "error", text: string) => {
setMessage({ type, text });
setTimeout(() => setMessage(null), 3000);
};
const allAssistants = [
...PRESET_ASSISTANTS,
...(assistantConfig.custom_assistants || []),
];
return (
<div className="space-y-4 max-w-2xl">
{/* 默认助理选择 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<Bot className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">默认助理</h3>
<p className="text-xs text-muted-foreground">
选择启动时使用的默认助理
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{allAssistants.map((assistant) => (
<button
key={assistant.id}
onClick={() =>
saveAssistantConfig("default_assistant_id", assistant.id)
}
className={cn(
"px-3 py-2 rounded-lg text-sm transition-colors border text-left",
assistantConfig.default_assistant_id === assistant.id
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
<div className="font-medium">{assistant.name}</div>
<div className="text-xs opacity-80">{assistant.description}</div>
</button>
))}
</div>
</div>
{/* 自定义助理管理 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">自定义助理</h3>
<p className="text-xs text-muted-foreground">
创建和管理您的专属助理
</p>
</div>
</div>
<button
onClick={handleAddAssistant}
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90"
>
<Plus className="h-3.5 w-3.5" />
新建
</button>
</div>
{assistantConfig.custom_assistants &&
assistantConfig.custom_assistants.length > 0 ? (
<div className="space-y-2">
{assistantConfig.custom_assistants.map((assistant) => (
<div
key={assistant.id}
className="flex items-center justify-between p-3 rounded-lg border bg-muted/30"
>
<div className="flex-1">
<div className="text-sm font-medium">{assistant.name}</div>
<div className="text-xs text-muted-foreground">
{assistant.description || "无描述"}
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleEditAssistant(assistant)}
className="p-1.5 rounded hover:bg-muted transition-colors"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDeleteAssistant(assistant.id)}
className="p-1.5 rounded hover:bg-destructive/10 text-destructive transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Bot className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">暂无自定义助理</p>
<p className="text-xs">点击上方按钮创建您的第一个助理</p>
</div>
)}
</div>
{/* 其他选项 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">其他选项</h3>
<p className="text-xs text-muted-foreground">配置助理的其他行为</p>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center justify-between py-1.5 cursor-pointer">
<div>
<span className="text-sm">自动选择助理</span>
<p className="text-xs text-muted-foreground">
根据对话内容自动推荐合适的助理
</p>
</div>
<input
type="checkbox"
checked={assistantConfig.auto_select ?? false}
onChange={(e) =>
saveAssistantConfig("auto_select", e.target.checked)
}
disabled={loading}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
<label className="flex items-center justify-between py-1.5 cursor-pointer border-t">
<div>
<span className="text-sm">显示助理建议</span>
<p className="text-xs text-muted-foreground">
在对话时显示相关助理的切换建议
</p>
</div>
<input
type="checkbox"
checked={assistantConfig.show_suggestions ?? true}
onChange={(e) =>
saveAssistantConfig("show_suggestions", e.target.checked)
}
disabled={loading}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
</div>
</div>
{/* 提示信息 */}
<div className="flex items-start gap-2 text-xs text-muted-foreground p-3 bg-muted/30 rounded-lg">
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<p>
系统助理提供了针对不同场景优化的预设配置。您也可以创建自定义助理来满足特定需求。
自定义助理可以调整模型参数和系统提示词。
</p>
</div>
{/* 消息提示 */}
{message && (
<div
className={cn(
"flex items-center gap-2 p-3 rounded-lg",
message.type === "success"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
)}
>
{message.type === "success" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<span className="text-sm">{message.text}</span>
</div>
)}
{/* 编辑助理对话框(简化版) */}
{editingAssistant && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-background rounded-lg p-4 max-w-md w-full mx-4">
<h3 className="text-lg font-medium mb-4">编辑助理</h3>
<div className="space-y-3">
<div>
<label className="text-sm font-medium mb-1.5 block">名称</label>
<input
type="text"
value={editingAssistant.name}
onChange={(e) =>
setEditingAssistant({
...editingAssistant,
name: e.target.value,
})
}
className="w-full px-3 py-2 rounded border bg-background text-sm focus:ring-1 focus:ring-primary/20 focus:border-primary outline-none"
/>
</div>
<div>
<label className="text-sm font-medium mb-1.5 block">描述</label>
<input
type="text"
value={editingAssistant.description || ""}
onChange={(e) =>
setEditingAssistant({
...editingAssistant,
description: e.target.value,
})
}
className="w-full px-3 py-2 rounded border bg-background text-sm focus:ring-1 focus:ring-primary/20 focus:border-primary outline-none"
/>
</div>
<div>
<label className="text-sm font-medium mb-1.5 block">
系统提示词
</label>
<textarea
value={editingAssistant.system_prompt || ""}
onChange={(e) =>
setEditingAssistant({
...editingAssistant,
system_prompt: e.target.value,
})
}
rows={3}
className="w-full px-3 py-2 rounded border bg-background text-sm focus:ring-1 focus:ring-primary/20 focus:border-primary outline-none"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => setEditingAssistant(null)}
className="px-4 py-2 rounded border text-sm hover:bg-muted"
>
取消
</button>
<button
onClick={() => handleSaveAssistant(editingAssistant)}
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90"
>
保存
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
export default AssistantSettings;
@@ -0,0 +1,398 @@
/**
* 绘画服务集成设置组件
*
* 参考 LobeHub 的 image 实现
* 功能包括:图像生成服务商选择、默认参数配置等
*/
import { useState, useEffect } from "react";
import {
Image as ImageIcon,
Palette,
Settings2,
Info,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
type ImageService = "dall_e" | "midjourney" | "stable_diffusion" | "flux";
interface ImageGenConfig {
/** 默认图像生成服务 */
default_service?: ImageService;
/** 默认图像数量 */
default_count?: number;
/** 默认图像尺寸 */
default_size?:
| "256x256"
| "512x512"
| "1024x1024"
| "1792x1024"
| "1024x1792";
/** 默认图像质量 */
default_quality?: "standard" | "hd";
/** 默认图像风格 */
default_style?: "vivid" | "natural";
/** 启用图像增强 */
enable_enhancement?: boolean;
/** 自动下载生成的图像 */
auto_download?: boolean;
}
const DEFAULT_IMAGE_GEN_CONFIG: ImageGenConfig = {
default_service: "dall_e",
default_count: 1,
default_size: "1024x1024",
default_quality: "standard",
default_style: "vivid",
enable_enhancement: false,
auto_download: false,
};
const IMAGE_SERVICES = [
{
value: "dall_e" as ImageService,
label: "DALL·E",
desc: "OpenAI 的图像生成模型",
},
{
value: "midjourney" as ImageService,
label: "Midjourney",
desc: "高质量艺术图像生成",
},
{
value: "stable_diffusion" as ImageService,
label: "Stable Diffusion",
desc: "开源图像生成模型",
},
{ value: "flux" as ImageService, label: "Flux", desc: "新一代图像生成模型" },
];
const IMAGE_SIZES = [
{ value: "256x256", label: "256×256", desc: "小尺寸" },
{ value: "512x512", label: "512×512", desc: "中等尺寸" },
{ value: "1024x1024", label: "1024×1024", desc: "标准尺寸" },
{ value: "1792x1024", label: "1792×1024", desc: "横向宽屏" },
{ value: "1024x1792", label: "1024×1792", desc: "纵向竖屏" },
];
const IMAGE_QUALITIES = [
{ value: "standard", label: "标准", desc: "标准质量,生成速度快" },
{ value: "hd", label: "高清", desc: "高清质量,细节更丰富" },
];
const IMAGE_STYLES = [
{ value: "vivid", label: "生动", desc: "更鲜艳、更富有表现力" },
{ value: "natural", label: "自然", desc: "更自然、更真实" },
];
export function ImageGenSettings() {
const [config, setConfig] = useState<Config | null>(null);
const [imageConfig, setImageConfig] = useState<ImageGenConfig>(
DEFAULT_IMAGE_GEN_CONFIG,
);
const [loading, setLoading] = useState(true);
const [_saving, setSaving] = useState<Record<string, boolean>>({});
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// 加载配置
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
setLoading(true);
try {
const c = await getConfig();
setConfig(c);
setImageConfig(c.image_gen || DEFAULT_IMAGE_GEN_CONFIG);
} catch (e) {
console.error("加载绘画服务配置失败:", e);
} finally {
setLoading(false);
}
};
// 保存配置
const saveImageConfig = async (key: keyof ImageGenConfig, value: any) => {
if (!config) return;
setSaving((prev) => ({ ...prev, [key]: true }));
try {
const newConfig = {
...imageConfig,
[key]: value,
};
const updatedFullConfig = {
...config,
image_gen: newConfig,
};
await saveConfig(updatedFullConfig);
setConfig(updatedFullConfig);
setImageConfig(newConfig);
showMessage("success", "设置已保存");
} catch (e) {
console.error("保存绘画服务配置失败:", e);
showMessage("error", "保存失败");
} finally {
setSaving((prev) => ({ ...prev, [key]: false }));
}
};
const showMessage = (type: "success" | "error", text: string) => {
setMessage({ type, text });
setTimeout(() => setMessage(null), 3000);
};
return (
<div className="space-y-4 max-w-2xl">
{/* 服务商选择 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">默认图像生成服务</h3>
<p className="text-xs text-muted-foreground">
选择默认使用的图像生成服务商
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{IMAGE_SERVICES.map((service) => (
<button
key={service.value}
onClick={() => saveImageConfig("default_service", service.value)}
className={cn(
"px-3 py-2 rounded-lg text-sm font-medium transition-colors border text-left",
imageConfig.default_service === service.value
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
<div className="font-medium">{service.label}</div>
<div className="text-xs opacity-80">{service.desc}</div>
</button>
))}
</div>
</div>
{/* 默认图像数量 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">默认图像数量</h3>
<p className="text-xs text-muted-foreground">
每次生成的图像数量
</p>
</div>
</div>
<span className="text-sm font-medium text-primary">
{imageConfig.default_count || 1}
</span>
</div>
<div className="grid grid-cols-4 gap-2">
{[1, 2, 3, 4].map((count) => (
<button
key={count}
onClick={() => saveImageConfig("default_count", count)}
className={cn(
"px-3 py-2 rounded-lg text-sm font-medium transition-colors border",
imageConfig.default_count === count
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
{count}
</button>
))}
</div>
</div>
{/* 默认图像尺寸 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium">默认图像尺寸</h3>
<p className="text-xs text-muted-foreground">
选择生成图像的默认尺寸
</p>
</div>
<span className="text-sm font-medium text-primary">
{imageConfig.default_size || "1024x1024"}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{IMAGE_SIZES.map((size) => (
<button
key={size.value}
onClick={() => saveImageConfig("default_size", size.value)}
className={cn(
"px-3 py-2 rounded-lg text-sm transition-colors border text-left",
imageConfig.default_size === size.value
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
<div className="font-medium">{size.label}</div>
<div className="text-xs opacity-80">{size.desc}</div>
</button>
))}
</div>
</div>
{/* 图像质量 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<Palette className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">图像质量</h3>
<p className="text-xs text-muted-foreground">
选择生成图像的质量级别
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{IMAGE_QUALITIES.map((quality) => (
<button
key={quality.value}
onClick={() => saveImageConfig("default_quality", quality.value)}
className={cn(
"px-3 py-2 rounded-lg text-sm transition-colors border text-left",
imageConfig.default_quality === quality.value
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
<div className="font-medium">{quality.label}</div>
<div className="text-xs opacity-80">{quality.desc}</div>
</button>
))}
</div>
</div>
{/* 图像风格 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<Palette className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">图像风格</h3>
<p className="text-xs text-muted-foreground">
选择生成图像的默认风格
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{IMAGE_STYLES.map((style) => (
<button
key={style.value}
onClick={() => saveImageConfig("default_style", style.value)}
className={cn(
"px-3 py-2 rounded-lg text-sm transition-colors border text-left",
imageConfig.default_style === style.value
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
<div className="font-medium">{style.label}</div>
<div className="text-xs opacity-80">{style.desc}</div>
</button>
))}
</div>
</div>
{/* 其他选项 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">其他选项</h3>
<p className="text-xs text-muted-foreground">
配置图像生成的其他行为
</p>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center justify-between py-1.5 cursor-pointer">
<div>
<span className="text-sm">启用图像增强</span>
<p className="text-xs text-muted-foreground">
自动对生成的图像进行增强处理
</p>
</div>
<input
type="checkbox"
checked={imageConfig.enable_enhancement ?? false}
onChange={(e) =>
saveImageConfig("enable_enhancement", e.target.checked)
}
disabled={loading}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
<label className="flex items-center justify-between py-1.5 cursor-pointer border-t">
<div>
<span className="text-sm">自动下载图像</span>
<p className="text-xs text-muted-foreground">
生成完成后自动下载到本地
</p>
</div>
<input
type="checkbox"
checked={imageConfig.auto_download ?? false}
onChange={(e) =>
saveImageConfig("auto_download", e.target.checked)
}
disabled={loading}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
</div>
</div>
{/* 提示信息 */}
<div className="flex items-start gap-2 text-xs text-muted-foreground p-3 bg-muted/30 rounded-lg">
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<p>
不同的图像生成服务商支持的功能和参数可能不同。某些服务商可能不支持特定的尺寸或质量选项。
实际生成的效果取决于所选服务商的能力。
</p>
</div>
{/* 消息提示 */}
{message && (
<div
className={cn(
"flex items-center gap-2 p-3 rounded-lg",
message.type === "success"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
)}
>
{message.type === "success" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<span className="text-sm">{message.text}</span>
</div>
)}
</div>
);
}
export default ImageGenSettings;
@@ -0,0 +1,361 @@
/**
* 记忆管理系统设置组件
*
* 参考 LobeHub 的 memory 实现,扩展更多功能
* 功能包括:记忆启用/禁用、容量管理、清理功能等
*/
import { useState, useEffect } from "react";
import {
BrainCircuit,
Trash2,
Database,
AlertCircle,
Info,
CheckCircle2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
interface MemoryConfig {
/** 是否启用记忆功能 */
enabled: boolean;
/** 最大记忆条数 */
max_entries?: number;
/** 记忆保留天数 */
retention_days?: number;
/** 自动清理过期记忆 */
auto_cleanup?: boolean;
}
interface MemoryStats {
/** 总记忆条数 */
total_entries: number;
/** 已使用的存储空间(字节) */
storage_used: number;
/** 记忆库数量 */
memory_count: number;
}
const DEFAULT_MEMORY_CONFIG: MemoryConfig = {
enabled: true,
max_entries: 1000,
retention_days: 30,
auto_cleanup: true,
};
/**
* 格式化存储大小
*/
function formatStorageSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
export function MemorySettings() {
const [config, setConfig] = useState<Config | null>(null);
const [memoryConfig, setMemoryConfig] = useState<MemoryConfig>(
DEFAULT_MEMORY_CONFIG,
);
const [memoryStats, setMemoryStats] = useState<MemoryStats | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [cleaning, setCleaning] = useState(false);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// 加载配置
useEffect(() => {
loadConfig();
loadMemoryStats();
}, []);
const loadConfig = async () => {
setLoading(true);
try {
const c = await getConfig();
setConfig(c);
setMemoryConfig(c.memory || DEFAULT_MEMORY_CONFIG);
} catch (e) {
console.error("加载记忆配置失败:", e);
} finally {
setLoading(false);
}
};
const loadMemoryStats = async () => {
try {
// TODO: 实现获取记忆统计的 API
// const stats = await getMemoryStats();
// setMemoryStats(stats);
// 模拟数据
setMemoryStats({
total_entries: 156,
storage_used: 256000, // 256 KB
memory_count: 12,
});
} catch (e) {
console.error("加载记忆统计失败:", e);
}
};
// 保存配置
const saveMemoryConfig = async (key: keyof MemoryConfig, value: any) => {
if (!config) return;
setSaving(true);
try {
const newConfig = {
...memoryConfig,
[key]: value,
};
const updatedFullConfig = {
...config,
memory: newConfig,
};
await saveConfig(updatedFullConfig);
setConfig(updatedFullConfig);
setMemoryConfig(newConfig);
showMessage("success", "设置已保存");
} catch (e) {
console.error("保存记忆配置失败:", e);
showMessage("error", "保存失败");
} finally {
setSaving(false);
}
};
// 清理记忆
const handleCleanup = async () => {
setCleaning(true);
try {
// TODO: 实现清理记忆的 API
// await cleanupMemory();
// 模拟清理
await new Promise((resolve) => setTimeout(resolve, 1000));
showMessage("success", "已清理过期记忆");
loadMemoryStats(); // 重新加载统计
} catch (e) {
console.error("清理记忆失败:", e);
showMessage("error", "清理失败");
} finally {
setCleaning(false);
}
};
const showMessage = (type: "success" | "error", text: string) => {
setMessage({ type, text });
setTimeout(() => setMessage(null), 3000);
};
const maxEntriesOptions = [100, 500, 1000, 2000, 5000];
const retentionDaysOptions = [7, 14, 30, 60, 90];
return (
<div className="space-y-4 max-w-2xl">
{/* 记忆统计卡片 */}
{memoryStats && (
<div className="rounded-lg border p-4 bg-gradient-to-br from-primary/5 to-primary/10">
<div className="flex items-center gap-2 mb-3">
<Database className="h-5 w-5 text-primary" />
<h3 className="text-sm font-medium">记忆统计</h3>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-primary">
{memoryStats.total_entries}
</div>
<div className="text-xs text-muted-foreground">记忆条数</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-primary">
{formatStorageSize(memoryStats.storage_used)}
</div>
<div className="text-xs text-muted-foreground">存储空间</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-primary">
{memoryStats.memory_count}
</div>
<div className="text-xs text-muted-foreground">记忆库数</div>
</div>
</div>
</div>
)}
{/* 启用记忆功能 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BrainCircuit className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">启用记忆功能</h3>
<p className="text-xs text-muted-foreground">
让 AI 记住之前的对话内容
</p>
</div>
</div>
<input
type="checkbox"
checked={memoryConfig.enabled}
onChange={(e) => saveMemoryConfig("enabled", e.target.checked)}
disabled={loading || saving}
className="w-4 h-4 rounded border-gray-300"
/>
</div>
</div>
{/* 最大记忆条数 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium">最大记忆条数</h3>
<p className="text-xs text-muted-foreground">
限制保存的记忆条目数量
</p>
</div>
<span className="text-sm font-medium text-primary">
{memoryConfig.max_entries || 1000}
</span>
</div>
<div className="grid grid-cols-5 gap-2">
{maxEntriesOptions.map((option) => (
<button
key={option}
onClick={() => saveMemoryConfig("max_entries", option)}
className={cn(
"px-2 py-1.5 rounded text-xs font-medium transition-colors border",
memoryConfig.max_entries === option
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
{option >= 1000 ? `${option / 1000}k` : option}
</button>
))}
</div>
</div>
{/* 记忆保留天数 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium">记忆保留天数</h3>
<p className="text-xs text-muted-foreground">
自动删除超过指定天数的记忆
</p>
</div>
<span className="text-sm font-medium text-primary">
{memoryConfig.retention_days || 30} 天
</span>
</div>
<div className="grid grid-cols-5 gap-2">
{retentionDaysOptions.map((option) => (
<button
key={option}
onClick={() => saveMemoryConfig("retention_days", option)}
className={cn(
"px-2 py-1.5 rounded text-xs font-medium transition-colors border",
memoryConfig.retention_days === option
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
{option} 天
</button>
))}
</div>
</div>
{/* 自动清理 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">自动清理过期记忆</h3>
<p className="text-xs text-muted-foreground">
定期自动删除过期的记忆条目
</p>
</div>
<input
type="checkbox"
checked={memoryConfig.auto_cleanup ?? true}
onChange={(e) => saveMemoryConfig("auto_cleanup", e.target.checked)}
disabled={loading || saving}
className="w-4 h-4 rounded border-gray-300"
/>
</div>
</div>
{/* 清理按钮 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Trash2 className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">清理记忆</h3>
<p className="text-xs text-muted-foreground">
手动清理所有过期和无效的记忆
</p>
</div>
</div>
<button
onClick={handleCleanup}
disabled={loading || cleaning}
className="flex items-center gap-1.5 px-3 py-1.5 rounded border text-sm hover:bg-muted transition-colors disabled:opacity-50"
>
{cleaning ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
清理中...
</>
) : (
<>
<Trash2 className="h-3.5 w-3.5" />
立即清理
</>
)}
</button>
</div>
</div>
{/* 提示信息 */}
<div className="flex items-start gap-2 text-xs text-muted-foreground p-3 bg-muted/30 rounded-lg">
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<p>
记忆功能会让 AI 在对话中记住之前的上下文信息。禁用后,AI
将无法跨对话记住信息。清理记忆是不可逆操作,请谨慎操作。
</p>
</div>
{/* 消息提示 */}
{message && (
<div
className={cn(
"flex items-center gap-2 p-3 rounded-lg",
message.type === "success"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
)}
>
{message.type === "success" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<span className="text-sm">{message.text}</span>
</div>
)}
</div>
);
}
export default MemorySettings;
@@ -0,0 +1,537 @@
/**
* 语音服务配置设置组件
*
* 参考 LobeHub 的 TTS/STT 实现
* 功能包括:TTS 服务商选择、STT 服务商选择、语音参数配置等
*/
import { useState, useEffect } from "react";
import {
Mic,
Volume2,
Play,
Settings2,
Info,
CheckCircle2,
AlertCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
type TTSService = "openai" | "azure" | "google" | "edge" | "macos";
type STTService = "openai" | "azure" | "google" | "whisper";
interface VoiceConfig {
/** TTS 服务商 */
tts_service?: TTSService;
/** STT 服务商 */
stt_service?: STTService;
/** TTS 语音 */
tts_voice?: string;
/** TTS 语速 (0.1-2.0) */
tts_rate?: number;
/** TTS 音调 (0.1-2.0) */
tts_pitch?: number;
/** TTS 音量 (0-1) */
tts_volume?: number;
/** STT 语言 */
stt_language?: string;
/** 自动停止录音 */
stt_auto_stop?: boolean;
/** 启用语音输入 */
voice_input_enabled?: boolean;
/** 启用语音输出 */
voice_output_enabled?: boolean;
}
const DEFAULT_VOICE_CONFIG: VoiceConfig = {
tts_service: "openai",
stt_service: "openai",
tts_voice: "alloy",
tts_rate: 1.0,
tts_pitch: 1.0,
tts_volume: 1.0,
stt_language: "zh-CN",
stt_auto_stop: true,
voice_input_enabled: false,
voice_output_enabled: false,
};
const TTS_SERVICES = [
{ value: "openai" as TTSService, label: "OpenAI", desc: "使用 OpenAI TTS" },
{ value: "azure" as TTSService, label: "Azure", desc: "使用 Azure TTS" },
{ value: "google" as TTSService, label: "Google", desc: "使用 Google TTS" },
{ value: "edge" as TTSService, label: "Edge", desc: "使用 Edge TTS" },
{ value: "macos" as TTSService, label: "macOS", desc: "使用系统 TTS" },
];
const STT_SERVICES = [
{
value: "openai" as STTService,
label: "OpenAI",
desc: "使用 OpenAI Whisper",
},
{ value: "azure" as STTService, label: "Azure", desc: "使用 Azure Speech" },
{
value: "google" as STTService,
label: "Google",
desc: "使用 Google Speech",
},
{
value: "whisper" as STTService,
label: "Whisper",
desc: "使用本地 Whisper",
},
];
const TTS_VOICES = {
openai: [
{ value: "alloy", label: "Alloy" },
{ value: "echo", label: "Echo" },
{ value: "fable", label: "Fable" },
{ value: "onyx", label: "Onyx" },
{ value: "nova", label: "Nova" },
{ value: "shimmer", label: "Shimmer" },
],
azure: [
{ value: "zh-CN-XiaoxiaoNeural", label: "晓晓 (女)" },
{ value: "zh-CN-YunxiNeural", label: "云希 (男)" },
{ value: "zh-CN-YunyangNeural", label: "云扬 (男)" },
],
google: [
{ value: "zh-CN-Wavenet-A", label: "WaveNet A" },
{ value: "zh-CN-Wavenet-B", label: "WaveNet B" },
{ value: "zh-CN-Standard-A", label: "Standard A" },
],
edge: [
{ value: "zh-CN-XiaoxiaoNeural", label: "晓晓 (女)" },
{ value: "zh-CN-YunxiNeural", label: "云希 (男)" },
],
macos: [
{ value: "Ting-Ting", label: "婷婷" },
{ value: "Mei-Jia", label: "美佳" },
{ value: "Sin-ji", label: "欣怡" },
],
};
const STT_LANGUAGES = [
{ value: "zh-CN", label: "中文 (简体)" },
{ value: "zh-TW", label: "中文 (繁体)" },
{ value: "en-US", label: "英语 (美国)" },
{ value: "en-GB", label: "英语 (英国)" },
{ value: "ja-JP", label: "日语" },
{ value: "ko-KR", label: "韩语" },
];
export function VoiceSettings() {
const [config, setConfig] = useState<Config | null>(null);
const [voiceConfig, setVoiceConfig] =
useState<VoiceConfig>(DEFAULT_VOICE_CONFIG);
const [loading, setLoading] = useState(true);
const [_saving, setSaving] = useState<Record<string, boolean>>({});
const [testingTTS, setTestingTTS] = useState(false);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// 加载配置
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
setLoading(true);
try {
const c = await getConfig();
setConfig(c);
setVoiceConfig(c.voice || DEFAULT_VOICE_CONFIG);
} catch (e) {
console.error("加载语音配置失败:", e);
} finally {
setLoading(false);
}
};
// 保存配置
const saveVoiceConfig = async (key: keyof VoiceConfig, value: any) => {
if (!config) return;
setSaving((prev) => ({ ...prev, [key]: true }));
try {
const newConfig = {
...voiceConfig,
[key]: value,
};
const updatedFullConfig = {
...config,
voice: newConfig,
};
await saveConfig(updatedFullConfig);
setConfig(updatedFullConfig);
setVoiceConfig(newConfig);
showMessage("success", "设置已保存");
} catch (e) {
console.error("保存语音配置失败:", e);
showMessage("error", "保存失败");
} finally {
setSaving((prev) => ({ ...prev, [key]: false }));
}
};
// 测试 TTS
const handleTestTTS = async () => {
setTestingTTS(true);
try {
// TODO: 实现 TTS 测试 API
// await testTTS(voiceConfig.tts_service, voiceConfig.tts_voice);
// 模拟测试
await new Promise((resolve) => setTimeout(resolve, 2000));
showMessage("success", "语音测试成功");
} catch (e) {
console.error("TTS 测试失败:", e);
showMessage("error", "测试失败");
} finally {
setTestingTTS(false);
}
};
const showMessage = (type: "success" | "error", text: string) => {
setMessage({ type, text });
setTimeout(() => setMessage(null), 3000);
};
const availableVoices = TTS_VOICES[voiceConfig.tts_service || "openai"] || [];
return (
<div className="space-y-4 max-w-2xl">
{/* 语音总开关 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">语音功能</h3>
<p className="text-xs text-muted-foreground">
控制语音输入和输出功能
</p>
</div>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center justify-between py-1.5 cursor-pointer">
<span className="text-sm">语音输入 (STT)</span>
<input
type="checkbox"
checked={voiceConfig.voice_input_enabled ?? false}
onChange={(e) =>
saveVoiceConfig("voice_input_enabled", e.target.checked)
}
disabled={loading}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
<label className="flex items-center justify-between py-1.5 cursor-pointer border-t">
<span className="text-sm">语音输出 (TTS)</span>
<input
type="checkbox"
checked={voiceConfig.voice_output_enabled ?? false}
onChange={(e) =>
saveVoiceConfig("voice_output_enabled", e.target.checked)
}
disabled={loading}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
</div>
</div>
{/* TTS 服务商 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<Volume2 className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">文字转语音 (TTS)</h3>
<p className="text-xs text-muted-foreground">
选择语音合成服务商和参数
</p>
</div>
</div>
<div className="space-y-3">
{/* 服务商选择 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
服务商
</label>
<div className="grid grid-cols-2 gap-2">
{TTS_SERVICES.map((service) => (
<button
key={service.value}
onClick={() => saveVoiceConfig("tts_service", service.value)}
className={cn(
"px-3 py-2 rounded-lg text-sm font-medium transition-colors border text-left",
voiceConfig.tts_service === service.value
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
<div className="font-medium">{service.label}</div>
<div className="text-xs opacity-80">{service.desc}</div>
</button>
))}
</div>
</div>
{/* 语音选择 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
语音
</label>
<select
value={voiceConfig.tts_voice || "alloy"}
onChange={(e) => saveVoiceConfig("tts_voice", e.target.value)}
disabled={loading}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-1 focus:ring-primary/20 focus:border-primary outline-none"
>
{availableVoices.map((voice) => (
<option key={voice.value} value={voice.value}>
{voice.label}
</option>
))}
</select>
</div>
{/* 语速 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs text-muted-foreground">语速</label>
<span className="text-xs text-primary">
{voiceConfig.tts_rate?.toFixed(1) || "1.0"}x
</span>
</div>
<input
type="range"
min={0.1}
max={2.0}
step={0.1}
value={voiceConfig.tts_rate || 1.0}
onChange={(e) => {
const value = parseFloat(e.target.value);
setVoiceConfig((prev) => ({ ...prev, tts_rate: value }));
}}
onChangeCapture={(e) => {
saveVoiceConfig(
"tts_rate",
parseFloat((e.target as HTMLInputElement).value),
);
}}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<div className="flex justify-between mt-1 text-xs text-muted-foreground">
<span>0.5x</span>
<span>1.0x</span>
<span>2.0x</span>
</div>
</div>
{/* 音调 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs text-muted-foreground">音调</label>
<span className="text-xs text-primary">
{voiceConfig.tts_pitch?.toFixed(1) || "1.0"}x
</span>
</div>
<input
type="range"
min={0.1}
max={2.0}
step={0.1}
value={voiceConfig.tts_pitch || 1.0}
onChange={(e) => {
const value = parseFloat(e.target.value);
setVoiceConfig((prev) => ({ ...prev, tts_pitch: value }));
}}
onChangeCapture={(e) => {
saveVoiceConfig(
"tts_pitch",
parseFloat((e.target as HTMLInputElement).value),
);
}}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<div className="flex justify-between mt-1 text-xs text-muted-foreground">
<span>低</span>
<span>中</span>
<span>高</span>
</div>
</div>
{/* 音量 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs text-muted-foreground">音量</label>
<span className="text-xs text-primary">
{Math.round((voiceConfig.tts_volume || 1.0) * 100)}%
</span>
</div>
<input
type="range"
min={0}
max={1}
step={0.1}
value={voiceConfig.tts_volume || 1.0}
onChange={(e) => {
const value = parseFloat(e.target.value);
setVoiceConfig((prev) => ({ ...prev, tts_volume: value }));
}}
onChangeCapture={(e) => {
saveVoiceConfig(
"tts_volume",
parseFloat((e.target as HTMLInputElement).value),
);
}}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
{/* 测试按钮 */}
<button
onClick={handleTestTTS}
disabled={
loading ||
testingTTS ||
!(voiceConfig.voice_output_enabled ?? false)
}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg border text-sm hover:bg-muted transition-colors disabled:opacity-50"
>
{testingTTS ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
测试中...
</>
) : (
<>
<Play className="h-4 w-4" />
测试语音
</>
)}
</button>
</div>
</div>
{/* STT 服务商 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<Mic className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">语音转文字 (STT)</h3>
<p className="text-xs text-muted-foreground">
选择语音识别服务商和参数
</p>
</div>
</div>
<div className="space-y-3">
{/* 服务商选择 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
服务商
</label>
<div className="grid grid-cols-2 gap-2">
{STT_SERVICES.map((service) => (
<button
key={service.value}
onClick={() => saveVoiceConfig("stt_service", service.value)}
className={cn(
"px-3 py-2 rounded-lg text-sm font-medium transition-colors border text-left",
voiceConfig.stt_service === service.value
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
<div className="font-medium">{service.label}</div>
<div className="text-xs opacity-80">{service.desc}</div>
</button>
))}
</div>
</div>
{/* 语言选择 */}
<div>
<label className="text-xs text-muted-foreground mb-1.5 block">
识别语言
</label>
<select
value={voiceConfig.stt_language || "zh-CN"}
onChange={(e) => saveVoiceConfig("stt_language", e.target.value)}
disabled={loading}
className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-1 focus:ring-primary/20 focus:border-primary outline-none"
>
{STT_LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.label}
</option>
))}
</select>
</div>
{/* 自动停止 */}
<label className="flex items-center justify-between py-1.5 cursor-pointer border-t">
<div>
<span className="text-sm">自动停止录音</span>
<p className="text-xs text-muted-foreground">
检测到停止说话时自动结束录音
</p>
</div>
<input
type="checkbox"
checked={voiceConfig.stt_auto_stop ?? true}
onChange={(e) =>
saveVoiceConfig("stt_auto_stop", e.target.checked)
}
disabled={loading}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
</div>
</div>
{/* 提示信息 */}
<div className="flex items-start gap-2 text-xs text-muted-foreground p-3 bg-muted/30 rounded-lg">
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<p>
语音功能需要先启用相应的开关。TTS 用于将 AI 的回复转换为语音播放,STT
用于将您的语音转换为文字输入。不同的服务商可能有不同的费用和效果。
</p>
</div>
{/* 消息提示 */}
{message && (
<div
className={cn(
"flex items-center gap-2 p-3 rounded-lg",
message.type === "success"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
)}
>
{message.type === "success" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<span className="text-sm">{message.text}</span>
</div>
)}
</div>
);
}
export default VoiceSettings;
@@ -0,0 +1,398 @@
/**
* 聊天外观设置组件
*
* 参考 LobeHub 的 chat-appearance 实现
* 功能包括:聊天气泡样式、字体大小、过渡模式等
*/
import { useState, useEffect } from "react";
import { Type, Sparkles, MessageSquare, Monitor, Info } from "lucide-react";
import { cn } from "@/lib/utils";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
type TransitionMode = "none" | "fadeIn" | "smooth";
type BubbleStyle = "default" | "minimal" | "colorful";
interface ChatAppearanceConfig {
fontSize?: number; // 12-18
transitionMode?: TransitionMode;
bubbleStyle?: BubbleStyle;
showAvatar?: boolean;
showTimestamp?: boolean;
}
const DEFAULT_CHAT_APPEARANCE: ChatAppearanceConfig = {
fontSize: 14,
transitionMode: "smooth",
bubbleStyle: "default",
showAvatar: true,
showTimestamp: true,
};
/**
* 字体大小预览组件
*/
function FontSizePreview({ fontSize }: { fontSize: number }) {
const sampleText = `这是示例文本
## 标题示例
这是一段普通文本,展示当前的字体大小效果。
- 列表项 1
- 列表项 2
**粗体文本** 和 *斜体文本*
`;
return (
<div
className="p-4 rounded-lg border bg-muted/30 min-h-[120px] prose dark:prose-invert max-w-none"
style={{ fontSize: `${fontSize}px` }}
>
<div className="whitespace-pre-wrap">{sampleText}</div>
</div>
);
}
/**
* 过渡模式预览组件
*/
function TransitionPreview({ mode }: { mode: TransitionMode }) {
const [messages, setMessages] = useState<string[]>([]);
useEffect(() => {
setMessages([]);
const timer = setTimeout(() => {
setMessages(["你好!"]);
}, 300);
return () => clearTimeout(timer);
}, [mode]);
return (
<div className="space-y-2 p-4 rounded-lg border bg-muted/30 min-h-[120px]">
{messages.map((msg, i) => (
<div
key={i}
className={cn(
"inline-block px-3 py-2 rounded-lg bg-primary text-primary-foreground",
mode === "fadeIn" && "animate-in fade-in duration-300",
mode === "smooth" && "transition-all duration-300",
)}
>
{msg}
</div>
))}
</div>
);
}
/**
* 气泡样式预览组件
*/
function BubbleStylePreview({ style }: { style: BubbleStyle }) {
const bubbles = [
{ text: "你好,有什么可以帮助你的吗?", align: "left" },
{ text: "帮我写一段代码", align: "right" },
];
const getBubbleClass = (align: string) => {
const baseClass = "max-w-[70%] px-3 py-2 rounded-lg";
if (style === "minimal") {
return cn(
baseClass,
align === "left"
? "bg-muted text-foreground"
: "bg-primary/20 text-foreground",
);
} else if (style === "colorful") {
return cn(
baseClass,
align === "left"
? "bg-gradient-to-br from-blue-500 to-blue-600 text-white"
: "bg-gradient-to-br from-purple-500 to-purple-600 text-white",
);
}
// default
return cn(
baseClass,
align === "left"
? "bg-muted text-foreground"
: "bg-primary text-primary-foreground",
);
};
return (
<div className="space-y-3 p-4 rounded-lg border bg-muted/30 min-h-[120px]">
{bubbles.map((bubble, i) => (
<div
key={i}
className={cn(
"flex",
bubble.align === "left" ? "justify-start" : "justify-end",
)}
>
<div className={getBubbleClass(bubble.align)}>{bubble.text}</div>
</div>
))}
</div>
);
}
export function ChatAppearanceSettings() {
const [config, setConfig] = useState<Config | null>(null);
const [chatConfig, setChatConfig] = useState<ChatAppearanceConfig>(
DEFAULT_CHAT_APPEARANCE,
);
const [_loading, setLoading] = useState(true);
const [_saving, setSaving] = useState<Record<string, boolean>>({});
// 加载配置
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
setLoading(true);
try {
const c = await getConfig();
setConfig(c);
setChatConfig(c.chat_appearance || DEFAULT_CHAT_APPEARANCE);
} catch (e) {
console.error("加载聊天外观配置失败:", e);
} finally {
setLoading(false);
}
};
// 保存配置
const saveChatConfig = async (
key: keyof ChatAppearanceConfig,
value: any,
) => {
if (!config) return;
setSaving((prev) => ({ ...prev, [key]: true }));
try {
const newConfig = {
...chatConfig,
[key]: value,
};
const updatedFullConfig = {
...config,
chat_appearance: newConfig,
};
await saveConfig(updatedFullConfig);
setConfig(updatedFullConfig);
setChatConfig(newConfig);
} catch (e) {
console.error("保存聊天外观配置失败:", e);
} finally {
setSaving((prev) => ({ ...prev, [key]: false }));
}
};
const transitionModeOptions: {
value: TransitionMode;
label: string;
desc: string;
}[] = [
{
value: "none",
label: "无动画",
desc: "消息立即显示",
},
{
value: "fadeIn",
label: "淡入",
desc: "消息淡入显示",
},
{
value: "smooth",
label: "平滑",
desc: "平滑过渡效果",
},
];
const bubbleStyleOptions: {
value: BubbleStyle;
label: string;
desc: string;
}[] = [
{
value: "default",
label: "默认",
desc: "经典聊天气泡样式",
},
{
value: "minimal",
label: "简约",
desc: "简约气泡风格",
},
{
value: "colorful",
label: "彩色",
desc: "渐变彩色气泡",
},
];
return (
<div className="space-y-4 max-w-2xl">
{/* 字体大小 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Type className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">字体大小</h3>
<p className="text-xs text-muted-foreground">
调整聊天消息的字体大小
</p>
</div>
</div>
<span className="text-sm font-medium text-primary">
{chatConfig.fontSize}px
</span>
</div>
<div className="mb-3">
<input
type="range"
min={12}
max={18}
step={1}
value={chatConfig.fontSize || 14}
onChange={(e) => {
const value = parseInt(e.target.value);
setChatConfig((prev) => ({ ...prev, fontSize: value }));
}}
onChangeCapture={(e) => {
saveChatConfig(
"fontSize",
parseInt((e.target as HTMLInputElement).value),
);
}}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<div className="flex justify-between mt-1 text-xs text-muted-foreground">
<span>小 (12px)</span>
<span>中 (14px)</span>
<span>大 (18px)</span>
</div>
</div>
<FontSizePreview fontSize={chatConfig.fontSize || 14} />
</div>
{/* 过渡模式 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">消息过渡效果</h3>
<p className="text-xs text-muted-foreground">
选择消息显示的动画效果
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-2 mb-3">
{transitionModeOptions.map((option) => (
<button
key={option.value}
onClick={() => saveChatConfig("transitionMode", option.value)}
className={cn(
"px-3 py-2 rounded-lg text-xs font-medium transition-colors border",
chatConfig.transitionMode === option.value
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
{option.label}
</button>
))}
</div>
<TransitionPreview mode={chatConfig.transitionMode || "smooth"} />
</div>
{/* 气泡样式 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">聊天气泡样式</h3>
<p className="text-xs text-muted-foreground">
自定义聊天气泡的视觉风格
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-2 mb-3">
{bubbleStyleOptions.map((option) => (
<button
key={option.value}
onClick={() => saveChatConfig("bubbleStyle", option.value)}
className={cn(
"px-3 py-2 rounded-lg text-xs font-medium transition-colors border",
chatConfig.bubbleStyle === option.value
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
{option.label}
</button>
))}
</div>
<BubbleStylePreview style={chatConfig.bubbleStyle || "default"} />
</div>
{/* 显示选项 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<Monitor className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">显示选项</h3>
<p className="text-xs text-muted-foreground">
控制聊天界面的元素显示
</p>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center justify-between py-1.5 cursor-pointer">
<span className="text-sm">显示头像</span>
<input
type="checkbox"
checked={chatConfig.showAvatar ?? true}
onChange={(e) => saveChatConfig("showAvatar", e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
<label className="flex items-center justify-between py-1.5 cursor-pointer border-t">
<span className="text-sm">显示时间戳</span>
<input
type="checkbox"
checked={chatConfig.showTimestamp ?? true}
onChange={(e) =>
saveChatConfig("showTimestamp", e.target.checked)
}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
</div>
</div>
{/* 提示信息 */}
<div className="flex items-start gap-2 text-xs text-muted-foreground p-3 bg-muted/30 rounded-lg">
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<p>
这些设置会应用到所有聊天对话。部分效果可能需要刷新对话窗口后才能看到。
</p>
</div>
</div>
);
}
export default ChatAppearanceSettings;
@@ -19,6 +19,8 @@ import {
BrainCircuit,
Image,
Mic,
Server,
Plug,
Globe,
Database,
Shield,
@@ -144,6 +146,16 @@ export function useSettingsCategory(): CategoryGroup[] {
key: SettingsGroupKey.System,
title: t("settings.group.system", "系统"),
items: [
{
key: SettingsTabs.ApiServer,
label: t("settings.tab.apiServer", "API Server"),
icon: Server,
},
{
key: SettingsTabs.McpServer,
label: t("settings.tab.mcpServer", "MCP 服务器"),
icon: Plug,
},
{
key: SettingsTabs.Proxy,
label: t("settings.tab.proxy", "网络代理"),
+4 -41
View File
@@ -1,27 +1,12 @@
import { useState } from "react";
import { Plug, MessageSquare, Boxes } from "lucide-react";
import { cn } from "@/lib/utils";
import { McpPage } from "../mcp/McpPage";
import { PromptsPage } from "../prompts/PromptsPage";
import { SkillsPage } from "../skills/SkillsPage";
type Tab = "mcp" | "prompts" | "skills";
const tabs = [
{ id: "mcp" as Tab, label: "MCP", icon: Plug },
{ id: "prompts" as Tab, label: "Prompts", icon: MessageSquare },
{ id: "skills" as Tab, label: "Skills", icon: Boxes },
];
export function ExtensionsSettings() {
const [activeTab, setActiveTab] = useState<Tab>("mcp");
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">扩展管理</h3>
<h3 className="text-lg font-semibold">技能管理</h3>
<p className="text-muted-foreground text-sm">
管理 MCP 服务器、Prompts 和 Skills。实验功能,不影响核心使用,
管理 Skills 实验功能,不影响核心使用,
<a
href="https://github.com/aiclientproxy/proxycast/issues"
target="_blank"
@@ -33,30 +18,8 @@ export function ExtensionsSettings() {
</p>
</div>
{/* Tab 切换 */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</div>
{/* Tab 内容 */}
<div className="pt-2">
{activeTab === "mcp" && <McpPage hideHeader />}
{activeTab === "prompts" && <PromptsPage hideHeader />}
{activeTab === "skills" && <SkillsPage hideHeader />}
<div className="pt-1">
<SkillsPage hideHeader />
</div>
</div>
);
+1 -9
View File
@@ -50,21 +50,13 @@ const ALL_NAV_ITEMS = [
{ id: "agent", label: "AI Agent" },
{ id: "projects", label: "项目" },
{ id: "image-gen", label: "图片生成" },
{ id: "api-server", label: "API Server" },
{ id: "provider-pool", label: "凭证池" },
{ id: "terminal", label: "终端" },
{ id: "tools", label: "工具" },
{ id: "plugins", label: "插件中心" },
] as const;
/** 默认启用的导航模块 */
const DEFAULT_ENABLED_NAV_ITEMS = [
"agent",
"projects",
"image-gen",
"api-server",
"provider-pool",
];
const DEFAULT_ENABLED_NAV_ITEMS = ["agent", "projects", "image-gen"];
export function GeneralSettings() {
const [theme, setTheme] = useState<Theme>("system");
+3
View File
@@ -134,12 +134,14 @@ interface SelectContentProps {
className?: string;
children: React.ReactNode;
side?: "top" | "bottom";
align?: "start" | "end";
}
const SelectContent: React.FC<SelectContentProps> = ({
className,
children,
side = "bottom",
align = "start",
}) => {
const context = useContext(SelectContext);
if (!context) throw new Error("SelectContent must be used within Select");
@@ -153,6 +155,7 @@ const SelectContent: React.FC<SelectContentProps> = ({
className={cn(
"absolute z-50 w-full rounded-md border bg-background shadow-lg",
side === "top" ? "bottom-full mb-1" : "top-full mt-1",
align === "end" ? "right-0" : "left-0",
className,
)}
>
+661
View File
@@ -0,0 +1,661 @@
/**
* @file WorkbenchPage.tsx
* @description 主题工作台页面,按主题管理项目并复用 Agent 对话与画布
* @module components/workspace/WorkbenchPage
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import {
FileText,
FolderOpen,
Home,
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelRightOpen,
Plus,
RefreshCw,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import {
type ContentListItem,
type Project,
type ProjectType,
createContent,
createProject,
formatRelativeTime,
generateProjectName,
getContentTypeLabel,
getDefaultContentTypeForProject,
getDefaultProjectPath,
getProjectTypeLabel,
listContents,
listProjects,
updateContent,
} from "@/lib/api/project";
import type {
Page,
PageParams,
WorkspaceTheme,
WorkspaceViewMode,
} from "@/types/page";
import { toast } from "sonner";
import { AgentChatPage } from "@/components/agent";
import { ProjectDetailPage } from "@/components/projects/ProjectDetailPage";
export interface WorkbenchPageProps {
onNavigate?: (page: Page, params?: PageParams) => void;
projectId?: string;
contentId?: string;
theme: WorkspaceTheme;
viewMode?: WorkspaceViewMode;
}
type WorkspaceMode = WorkspaceViewMode;
export function WorkbenchPage({
onNavigate,
projectId: initialProjectId,
contentId: initialContentId,
theme,
viewMode: initialViewMode,
}: WorkbenchPageProps) {
const [showLeftSidebar, setShowLeftSidebar] = useState(true);
const [showRightSidebar, setShowRightSidebar] = useState(true);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>(
initialViewMode ?? (initialContentId ? "workspace" : "project-management"),
);
const [projects, setProjects] = useState<Project[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
initialProjectId ?? null,
);
const [contents, setContents] = useState<ContentListItem[]>([]);
const [contentsLoading, setContentsLoading] = useState(false);
const [selectedContentId, setSelectedContentId] = useState<string | null>(
initialContentId ?? null,
);
const [projectQuery, setProjectQuery] = useState("");
const [contentQuery, setContentQuery] = useState("");
const selectedProject = useMemo(
() => projects.find((project) => project.id === selectedProjectId) ?? null,
[projects, selectedProjectId],
);
const filteredProjects = useMemo(() => {
const query = projectQuery.trim().toLowerCase();
if (!query) {
return projects;
}
return projects.filter(
(project) =>
project.name.toLowerCase().includes(query) ||
project.tags.some((tag) => tag.toLowerCase().includes(query)),
);
}, [projects, projectQuery]);
const filteredContents = useMemo(() => {
const query = contentQuery.trim().toLowerCase();
if (!query) {
return contents;
}
return contents.filter((content) =>
content.title.toLowerCase().includes(query),
);
}, [contents, contentQuery]);
const handleEnterWorkspace = useCallback((contentId: string) => {
setSelectedContentId(contentId);
setWorkspaceMode("workspace");
setShowRightSidebar(true);
}, []);
const handleOpenProjectDetail = useCallback(() => {
if (!selectedProjectId) {
return;
}
setWorkspaceMode("project-detail");
setShowLeftSidebar(true);
setShowRightSidebar(false);
}, [selectedProjectId]);
const loadProjects = useCallback(async () => {
setProjectsLoading(true);
try {
const allProjects = await listProjects();
const typedProjects = allProjects.filter(
(project) =>
project.workspaceType === (theme as ProjectType) &&
!project.isArchived,
);
setProjects(typedProjects);
setSelectedProjectId((previousId) => {
if (
initialProjectId &&
typedProjects.some((project) => project.id === initialProjectId)
) {
return initialProjectId;
}
if (
previousId &&
typedProjects.some((project) => project.id === previousId)
) {
return previousId;
}
return typedProjects[0]?.id ?? null;
});
} catch (error) {
console.error("加载主题项目失败:", error);
toast.error("加载项目失败");
} finally {
setProjectsLoading(false);
}
}, [initialProjectId, theme]);
const loadContents = useCallback(
async (projectId: string) => {
setContentsLoading(true);
try {
const contentList = await listContents(projectId);
setContents(contentList);
setSelectedContentId((previousId) => {
if (
initialContentId &&
contentList.some((content) => content.id === initialContentId)
) {
return initialContentId;
}
if (
previousId &&
contentList.some((content) => content.id === previousId)
) {
return previousId;
}
return contentList[0]?.id ?? null;
});
} catch (error) {
console.error("加载文稿失败:", error);
toast.error("加载文稿失败");
} finally {
setContentsLoading(false);
}
},
[initialContentId],
);
const handleCreateProject = useCallback(async () => {
try {
const projectName = generateProjectName(theme as ProjectType);
const rootPath = getDefaultProjectPath();
await createProject({
name: projectName,
rootPath,
workspaceType: theme as ProjectType,
});
toast.success("已创建新项目");
await loadProjects();
} catch (error) {
console.error("创建项目失败:", error);
toast.error("创建项目失败");
}
}, [loadProjects, theme]);
const handleCreateContent = useCallback(async () => {
if (!selectedProjectId) {
return;
}
try {
const defaultType = getDefaultContentTypeForProject(theme as ProjectType);
const created = await createContent({
project_id: selectedProjectId,
title: `新${getContentTypeLabel(defaultType)}`,
content_type: defaultType,
});
await loadContents(selectedProjectId);
handleEnterWorkspace(created.id);
toast.success("已创建新文稿");
} catch (error) {
console.error("创建文稿失败:", error);
toast.error("创建文稿失败");
}
}, [handleEnterWorkspace, loadContents, selectedProjectId, theme]);
const handleQuickSaveCurrent = useCallback(async () => {
if (!selectedContentId || !selectedProjectId) {
return;
}
try {
await updateContent(selectedContentId, {
metadata: {
saved_from: "theme-workspace",
saved_at: Date.now(),
},
});
toast.success("已保存当前文稿");
await loadContents(selectedProjectId);
} catch (error) {
console.error("保存失败:", error);
toast.error("保存失败");
}
}, [loadContents, selectedContentId, selectedProjectId]);
useEffect(() => {
const nextMode: WorkspaceMode =
initialViewMode ??
(initialContentId ? "workspace" : "project-management");
setProjectQuery("");
setContentQuery("");
setSelectedProjectId(initialProjectId ?? null);
setSelectedContentId(initialContentId ?? null);
setWorkspaceMode(nextMode);
setShowLeftSidebar(true);
setShowRightSidebar(nextMode === "workspace");
setContents([]);
void loadProjects();
}, [
initialContentId,
initialProjectId,
initialViewMode,
loadProjects,
theme,
]);
useEffect(() => {
if (!selectedProjectId) {
setContents([]);
setSelectedContentId(null);
return;
}
void loadContents(selectedProjectId);
}, [loadContents, selectedProjectId]);
const handleBackHome = useCallback(() => {
onNavigate?.("agent", {
theme: "general",
lockTheme: false,
});
}, [onNavigate]);
const handleBackToProjectManagement = useCallback(() => {
setWorkspaceMode("project-management");
setShowLeftSidebar(true);
setShowRightSidebar(false);
}, []);
const shouldRenderLeftSidebar =
workspaceMode !== "workspace" || showLeftSidebar;
return (
<div className="flex flex-col h-full min-h-0">
<header className="h-12 border-b px-3 flex items-center gap-2 bg-background">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleBackHome}
title="回到首页"
>
<Home className="h-4 w-4" />
</Button>
{workspaceMode === "workspace" && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowLeftSidebar((visible) => !visible)}
title={showLeftSidebar ? "隐藏左侧栏" : "显示左侧栏"}
>
{showLeftSidebar ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeftOpen className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowRightSidebar((visible) => !visible)}
title={showRightSidebar ? "隐藏右侧栏" : "显示右侧栏"}
>
{showRightSidebar ? (
<PanelRightClose className="h-4 w-4" />
) : (
<PanelRightOpen className="h-4 w-4" />
)}
</Button>
</>
)}
{workspaceMode !== "project-management" && (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={handleBackToProjectManagement}
>
项目管理
</Button>
)}
<div className="text-sm font-medium ml-2">
{getProjectTypeLabel(theme)}
</div>
{selectedProject && (
<div className="text-xs text-muted-foreground truncate">
{selectedProject.name}
</div>
)}
</header>
<div className="flex flex-1 min-h-0">
{shouldRenderLeftSidebar && (
<aside className="w-[320px] min-w-[300px] border-r bg-muted/20 flex flex-col">
<div className="px-3 py-3 border-b space-y-2">
<div className="flex items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold">
{getProjectTypeLabel(theme)}
</h2>
<p className="text-xs text-muted-foreground">主题项目管理</p>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
void loadProjects();
}}
disabled={projectsLoading}
>
<RefreshCw
className={cn(
"h-4 w-4",
projectsLoading && "animate-spin",
)}
/>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
void handleCreateProject();
}}
title="新建项目"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<Input
value={projectQuery}
onChange={(event) => setProjectQuery(event.target.value)}
placeholder="搜索项目..."
className="h-8 text-xs"
/>
</div>
<div className="flex-1 min-h-0 flex flex-col">
<div className="min-h-0 basis-1/2 border-b flex flex-col">
<div className="px-3 py-2 text-xs text-muted-foreground">
项目
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{filteredProjects.length === 0 ? (
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
该主题下暂无项目
</div>
) : (
filteredProjects.map((project) => (
<button
key={project.id}
className={cn(
"w-full text-left rounded-md px-2 py-2 transition-colors",
"hover:bg-accent",
selectedProjectId === project.id &&
"bg-accent text-accent-foreground",
)}
onClick={() => {
setSelectedProjectId(project.id);
setContentQuery("");
}}
>
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium truncate">
{project.name}
</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground truncate">
{getProjectTypeLabel(project.workspaceType)}
</div>
</button>
))
)}
</div>
</ScrollArea>
</div>
<div className="min-h-0 basis-1/2 flex flex-col">
<div className="px-3 py-2 flex items-center gap-2">
<div className="text-xs text-muted-foreground flex-1">
文稿
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
void handleCreateContent();
}}
disabled={!selectedProjectId}
title="新建文稿"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="px-2 pb-2">
<Input
value={contentQuery}
onChange={(event) => setContentQuery(event.target.value)}
placeholder="搜索文稿..."
className="h-8 text-xs"
disabled={!selectedProjectId}
/>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{contentsLoading ? (
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
文稿加载中...
</div>
) : filteredContents.length === 0 ? (
<div className="px-2 py-6 text-xs text-muted-foreground text-center">
还没有文稿
</div>
) : (
filteredContents.map((content) => (
<button
key={content.id}
className={cn(
"w-full text-left rounded-md px-2 py-2 transition-colors",
"hover:bg-accent",
selectedContentId === content.id &&
"bg-accent text-accent-foreground",
)}
onClick={() => handleEnterWorkspace(content.id)}
>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium truncate">
{content.title}
</span>
</div>
<div className="mt-1 text-[11px] text-muted-foreground truncate">
{formatRelativeTime(content.updated_at)}
</div>
</button>
))
)}
</div>
</ScrollArea>
</div>
</div>
</aside>
)}
<main className="flex-1 min-w-0 min-h-0 flex flex-col">
{workspaceMode === "project-management" ? (
<div className="h-full rounded-lg border bg-card flex flex-col items-center justify-center gap-3 text-muted-foreground m-4">
<Sparkles className="h-8 w-8 opacity-60" />
<p className="text-sm">先完成项目管理,再进入三栏作业界面</p>
<p className="text-xs text-muted-foreground">
在左侧选择文稿,或先新建文稿
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => {
void handleCreateProject();
}}
>
<FolderOpen className="h-4 w-4 mr-1" />
新建项目
</Button>
<Button
variant="outline"
onClick={() => {
void handleCreateContent();
}}
disabled={!selectedProjectId}
>
<Plus className="h-4 w-4 mr-1" />
新建文稿并进入作业
</Button>
<Button
variant="outline"
onClick={handleOpenProjectDetail}
disabled={!selectedProjectId}
>
<FolderOpen className="h-4 w-4 mr-1" />
项目详情
</Button>
</div>
</div>
) : workspaceMode === "project-detail" ? (
!selectedProjectId ? (
<div className="h-full rounded-lg border bg-card flex flex-col items-center justify-center gap-3 text-muted-foreground m-4">
<Sparkles className="h-8 w-8 opacity-60" />
<p className="text-sm">请先在左侧选择项目</p>
</div>
) : (
<ProjectDetailPage
projectId={selectedProjectId}
onBack={handleBackToProjectManagement}
onNavigateToChat={() => {
setWorkspaceMode("workspace");
setShowRightSidebar(true);
}}
/>
)
) : !selectedProjectId || !selectedContentId ? (
<div className="h-full rounded-lg border bg-card flex flex-col items-center justify-center gap-3 text-muted-foreground m-4">
<Sparkles className="h-8 w-8 opacity-60" />
<p className="text-sm">请先在左侧选择项目并打开文稿</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => {
void handleCreateProject();
}}
>
<FolderOpen className="h-4 w-4 mr-1" />
新建项目
</Button>
<Button
variant="outline"
onClick={() => {
void handleCreateContent();
}}
disabled={!selectedProjectId}
>
<Plus className="h-4 w-4 mr-1" />
新建文稿
</Button>
</div>
</div>
) : (
<div className="flex-1 min-h-0">
<AgentChatPage
onNavigate={onNavigate}
projectId={selectedProjectId}
contentId={selectedContentId}
theme={theme}
lockTheme={true}
/>
</div>
)}
</main>
{workspaceMode === "workspace" && showRightSidebar && (
<aside className="w-[260px] min-w-[260px] border-l bg-muted/10 p-4 flex flex-col gap-3">
<h3 className="text-sm font-semibold">主题工具</h3>
<Button
variant="outline"
className="justify-start"
onClick={() => {
void handleQuickSaveCurrent();
}}
disabled={!selectedContentId}
>
<FileText className="h-4 w-4 mr-2" />
快速保存
</Button>
<Button
variant="outline"
className="justify-start"
onClick={handleOpenProjectDetail}
disabled={!selectedProjectId}
>
<FolderOpen className="h-4 w-4 mr-2" />
项目详情
</Button>
</aside>
)}
</div>
</div>
);
}
export default WorkbenchPage;
+2
View File
@@ -6,3 +6,5 @@
export { WorkspaceSelector } from "./WorkspaceSelector";
export type { WorkspaceSelectorProps } from "./WorkspaceSelector";
export { WorkbenchPage } from "./WorkbenchPage";
export type { WorkbenchPageProps } from "./WorkbenchPage";
+275
View File
@@ -145,6 +145,125 @@ export interface NavigationConfig {
enabled_items: string[];
}
/**
* 聊天外观配置
*/
export interface ChatAppearanceConfig {
/** 字体大小 (12-18) */
fontSize?: number;
/** 消息过渡模式 */
transitionMode?: "none" | "fadeIn" | "smooth";
/** 气泡样式 */
bubbleStyle?: "default" | "minimal" | "colorful";
/** 显示头像 */
showAvatar?: boolean;
/** 显示时间戳 */
showTimestamp?: boolean;
}
/**
* 记忆管理系统配置
*/
export interface MemoryConfig {
/** 是否启用记忆功能 */
enabled: boolean;
/** 最大记忆条数 */
max_entries?: number;
/** 记忆保留天数 */
retention_days?: number;
/** 自动清理过期记忆 */
auto_cleanup?: boolean;
}
/**
* 语音服务配置
*/
export interface VoiceConfig {
/** TTS 服务商 */
tts_service?: "openai" | "azure" | "google" | "edge" | "macos";
/** STT 服务商 */
stt_service?: "openai" | "azure" | "google" | "whisper";
/** TTS 语音 */
tts_voice?: string;
/** TTS 语速 (0.1-2.0) */
tts_rate?: number;
/** TTS 音调 (0.1-2.0) */
tts_pitch?: number;
/** TTS 音量 (0-1) */
tts_volume?: number;
/** STT 语言 */
stt_language?: string;
/** 自动停止录音 */
stt_auto_stop?: boolean;
/** 启用语音输入 */
voice_input_enabled?: boolean;
/** 启用语音输出 */
voice_output_enabled?: boolean;
}
/**
* 图像生成服务配置
*/
export interface ImageGenConfig {
/** 默认图像生成服务 */
default_service?: "dall_e" | "midjourney" | "stable_diffusion" | "flux";
/** 默认图像数量 */
default_count?: number;
/** 默认图像尺寸 */
default_size?:
| "256x256"
| "512x512"
| "1024x1024"
| "1792x1024"
| "1024x1792";
/** 默认图像质量 */
default_quality?: "standard" | "hd";
/** 默认图像风格 */
default_style?: "vivid" | "natural";
/** 启用图像增强 */
enable_enhancement?: boolean;
/** 自动下载生成的图像 */
auto_download?: boolean;
}
/**
* 助理配置
*/
export interface AssistantConfig {
/** 默认助理 ID */
default_assistant_id?: string;
/** 自定义助理列表 */
custom_assistants?: Array<{
id: string;
name: string;
description?: string;
model?: string;
system_prompt?: string;
temperature?: number;
max_tokens?: number;
}>;
/** 启用助理自动选择 */
auto_select?: boolean;
/** 显示助理建议 */
show_suggestions?: boolean;
}
/**
* 用户资料配置
*/
export interface UserProfile {
/** 用户头像 URL */
avatar_url?: string;
/** 昵称 */
nickname?: string;
/** 个人简介 */
bio?: string;
/** 邮箱 */
email?: string;
/** 偏好标签 */
tags?: string[];
}
export interface Config {
server: {
host: string;
@@ -193,6 +312,18 @@ export interface Config {
content_creator?: ContentCreatorConfig;
/** 导航栏配置 */
navigation?: NavigationConfig;
/** 聊天外观配置 */
chat_appearance?: ChatAppearanceConfig;
/** 记忆管理配置 */
memory?: MemoryConfig;
/** 语音服务配置 */
voice?: VoiceConfig;
/** 图像生成服务配置 */
image_gen?: ImageGenConfig;
/** 助理配置 */
assistant?: AssistantConfig;
/** 用户资料 */
user_profile?: UserProfile;
}
export interface LogEntry {
@@ -594,3 +725,147 @@ export async function updateScreenshotShortcut(
): Promise<void> {
return safeInvoke("update_screenshot_shortcut", { newShortcut: shortcut });
}
// ============ 使用统计 API ============
export interface UsageStatsResponse {
total_conversations: number;
total_messages: number;
total_tokens: number;
total_time_minutes: number;
monthly_conversations: number;
monthly_messages: number;
monthly_tokens: number;
today_conversations: number;
today_messages: number;
today_tokens: number;
}
export interface ModelUsage {
model: string;
conversations: number;
tokens: number;
percentage: number;
}
export interface DailyUsage {
date: string;
conversations: number;
tokens: number;
}
/**
* 获取使用统计数据
* @param timeRange 时间范围 (week/month/all)
*/
export async function getUsageStats(
timeRange: string,
): Promise<UsageStatsResponse> {
return safeInvoke("get_usage_stats", { timeRange });
}
/**
* 获取模型使用排行
* @param timeRange 时间范围 (week/month/all)
*/
export async function getModelUsageRanking(
timeRange: string,
): Promise<ModelUsage[]> {
return safeInvoke("get_model_usage_ranking", { timeRange });
}
/**
* 获取每日使用趋势
* @param timeRange 时间范围 (week/month/all)
*/
export async function getDailyUsageTrends(
timeRange: string,
): Promise<DailyUsage[]> {
return safeInvoke("get_daily_usage_trends", { timeRange });
}
// ============ 记忆管理 API ============
export interface MemoryStatsResponse {
total_entries: number;
storage_used: number;
memory_count: number;
}
export interface CleanupMemoryResult {
cleaned_entries: number;
freed_space: number;
}
/**
* 获取记忆统计信息
*/
export async function getMemoryStats(): Promise<MemoryStatsResponse> {
return safeInvoke("get_conversation_memory_stats");
}
/**
* 清理过期记忆
*/
export async function cleanupMemory(): Promise<CleanupMemoryResult> {
return safeInvoke("cleanup_conversation_memory");
}
// ============ 语音测试 API ============
export interface TtsTestResult {
success: boolean;
error: string | null;
audio_path: string | null;
}
export interface VoiceOption {
id: string;
name: string;
language: string;
}
/**
* 测试 TTS 语音合成
* @param service TTS 服务名称
* @param voice 语音 ID
*/
export async function testTts(
service: string,
voice: string,
): Promise<TtsTestResult> {
return safeInvoke("test_tts", { service, voice });
}
/**
* 获取可用的语音列表
* @param service TTS 服务名称
*/
export async function getAvailableVoices(
service: string,
): Promise<VoiceOption[]> {
return safeInvoke("get_available_voices", { service });
}
// ============ 文件上传 API ============
export interface UploadResult {
url: string;
size: number;
}
/**
* 上传用户头像
* @param filePath 文件路径
*/
export async function uploadAvatar(filePath: string): Promise<UploadResult> {
return safeInvoke("upload_avatar", { filePath });
}
/**
* 删除用户头像
* @param url 头像 URL
*/
export async function deleteAvatar(url: string): Promise<void> {
return safeInvoke("delete_avatar", { url });
}
+38 -6
View File
@@ -35,6 +35,7 @@ import DoubaoIcon from "./doubao.svg?react";
import AzureIcon from "./azure.svg?react";
import antigravityIconUrl from "./antigravity.svg?url";
import ProxycastIcon from "./proxycast.svg?react";
import ProxycastHubIcon from "./proxycast-hub.svg?react";
// 新增图标 - 主流 AI
import PerplexityIcon from "./perplexity.svg?react";
@@ -109,12 +110,14 @@ const AntigravityIcon: React.FC<SVGProps<SVGSVGElement>> = ({
width = "1em",
height = "1em",
}) => {
const widthValue = typeof width === "number" ? `${width}px` : width;
const heightValue = typeof height === "number" ? `${height}px` : height;
return (
<img
src={antigravityIconUrl}
width={width}
height={height}
alt="Antigravity"
style={{ width: widthValue, height: heightValue, display: "block" }}
/>
);
};
@@ -148,6 +151,7 @@ const iconComponents: Record<string, ComponentType<SVGProps<SVGSVGElement>>> = {
azure: AzureIcon,
antigravity: AntigravityIcon,
proxycast: ProxycastIcon,
"proxycast-hub": ProxycastHubIcon,
// 主流 AI
perplexity: PerplexityIcon,
@@ -219,6 +223,21 @@ const iconComponents: Record<string, ComponentType<SVGProps<SVGSVGElement>>> = {
custom: CustomIcon,
};
// ============================================================================
// Fallback 色板
// ============================================================================
const FALLBACK_GRADIENTS: Array<[string, string]> = [
["#6366f1", "#8b5cf6"],
["#0ea5e9", "#2563eb"],
["#22c55e", "#16a34a"],
["#f59e0b", "#ea580c"],
["#ec4899", "#db2777"],
["#14b8a6", "#0d9488"],
["#a855f7", "#7e22ce"],
["#f43f5e", "#be123c"],
];
// ============================================================================
// ProviderIcon 组件
// ============================================================================
@@ -257,7 +276,8 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
showFallback = true,
}) => {
const iconName = providerTypeToIcon[providerType] || providerType;
const IconComponent = iconComponents[iconName];
const IconComponent =
iconName === "custom" ? undefined : iconComponents[iconName];
const sizeStyle = useMemo(() => {
const sizeValue = typeof size === "number" ? `${size}px` : size;
@@ -283,7 +303,7 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
);
}
// Fallback:显示首字母
// Fallback:显示彩色首字母图标
if (showFallback) {
const source = fallbackText?.trim() || providerType;
const words = source
@@ -308,14 +328,26 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
const fallbackFontSize =
typeof size === "number" ? `${Math.max(size * 0.5, 12)}px` : "0.5em";
const hash = Array.from(source).reduce(
(accumulator, char) => (accumulator * 31 + char.charCodeAt(0)) >>> 0,
7,
);
const [startColor, endColor] =
FALLBACK_GRADIENTS[hash % FALLBACK_GRADIENTS.length];
return (
<span
className={cn(
"inline-flex items-center justify-center flex-shrink-0 rounded-lg",
"bg-muted text-muted-foreground font-semibold",
"text-white font-semibold",
className,
)}
style={sizeStyle}
style={{
...sizeStyle,
background: `linear-gradient(135deg, ${startColor}, ${endColor})`,
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.22)",
}}
>
<span style={{ fontSize: fallbackFontSize }}>{initials}</span>
</span>
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<defs>
<!-- P字母左侧渐变:蓝色系 -->
<linearGradient id="leftP" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4fc3f7"/>
<stop offset="100%" style="stop-color:#1a237e"/>
</linearGradient>
<!-- P字母右侧渐变:紫色系 -->
<linearGradient id="rightP" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#7c4dff"/>
<stop offset="100%" style="stop-color:#e91e63"/>
</linearGradient>
</defs>
<!-- P 字母形状 -->
<!-- 左竖条 -->
<rect x="4" y="4" width="3" height="16" rx="1" fill="url(#leftP)"/>
<!-- 上横条 -->
<rect x="7" y="4" width="8" height="2.5" rx="1" fill="url(#rightP)"/>
<!-- 中横条 -->
<rect x="7" y="11" width="6.5" height="2.2" rx="0.8" fill="url(#rightP)"/>
<!-- 右竖条(上半部分) -->
<rect x="15" y="6.5" width="2.5" height="6.7" rx="1" fill="url(#rightP)"/>
</svg>

After

Width:  |  Height:  |  Size: 1013 B

+53
View File
@@ -38,6 +38,7 @@ export const availableIcons = [
"azure",
"antigravity",
"proxycast",
"proxycast-hub",
// 新增图标
"perplexity",
"moonshot",
@@ -125,46 +126,78 @@ export const providerTypeToIcon: Record<string, string> = {
copilot: "copilot",
aws: "aws",
proxycast: "proxycast",
"proxycast-hub": "proxycast-hub",
lobehub: "proxycast",
// ===== 主流 AI Provider =====
deepseek: "deepseek",
moonshot: "moonshot",
moonshotai: "moonshot",
groq: "groq",
grok: "grok",
xai: "grok",
mistral: "mistral",
perplexity: "perplexity",
cohere: "cohere",
// ===== 国内 AI Provider =====
zhipu: "zhipu",
zhipuai: "zhipu",
baichuan: "baichuan",
dashscope: "dashscope",
stepfun: "stepfun",
doubao: "doubao",
volcengine: "doubao",
minimax: "minimax",
yi: "yi",
zeroone: "yi",
hunyuan: "hunyuan",
"tencent-cloud-ti": "tencent",
tencentcloud: "tencent",
"baidu-cloud": "baidu",
wenxin: "baidu",
infini: "infini",
infiniai: "infini",
modelscope: "modelscope",
xirang: "xirang",
mimo: "mimo",
xiaomi: "mimo",
xiaomimimo: "mimo",
zhinao: "zhinao",
ai360: "zhinao",
giteeai: "dashscope",
internlm: "dashscope",
sensenova: "dashscope",
spark: "tencent",
taichu: "dashscope",
// ===== 云服务 Provider =====
"azure-openai": "azure",
azure: "azure",
azureai: "azure",
vertexai: "vertexai",
"google-vertex": "vertexai",
"aws-bedrock": "bedrock",
"amazon-bedrock": "bedrock",
github: "github",
"github-models": "github",
"github-copilot": "copilot",
cloudflare: "vercel",
// ===== API 聚合服务 =====
silicon: "silicon",
siliconflow: "silicon",
"siliconflow-cn": "silicon",
siliconcloud: "silicon",
openrouter: "openrouter",
aihubmix: "aihubmix",
"302ai": "302ai",
ai302: "302ai",
together: "together",
togetherai: "together",
fireworks: "fireworks",
"fireworks-ai": "fireworks",
fireworksai: "fireworks",
nvidia: "nvidia",
hyperbolic: "hyperbolic",
cerebras: "cerebras",
@@ -184,13 +217,33 @@ export const providerTypeToIcon: Record<string, string> = {
poe: "poe",
huggingface: "huggingface",
"vercel-gateway": "vercel",
vercelaigateway: "vercel",
ai21: "openai",
akashchat: "openai",
bfl: "openai",
cometapi: "openai",
fal: "openai",
nebius: "openai",
novita: "openai",
replicate: "openai",
sambanova: "openai",
search1api: "openai",
upstage: "openai",
v0: "vercel",
zenmux: "openai",
// ===== 本地服务 Provider =====
ollama: "ollama",
ollamacloud: "ollama",
lmstudio: "lmstudio",
"new-api": "newapi",
newapi: "newapi",
gpustack: "gpustack",
ovms: "ovms",
comfyui: "custom",
higress: "custom",
vllm: "custom",
xinference: "custom",
// ===== 专用服务 Provider =====
jina: "jina",
+99
View File
@@ -8,10 +8,69 @@
* @module types/page
*/
import type { SettingsTabs } from "./settings";
export type WorkspaceTheme =
| "general"
| "social-media"
| "poster"
| "music"
| "knowledge"
| "planning"
| "document"
| "video"
| "novel";
export type ThemeWorkspacePage =
| "workspace-general"
| "workspace-social-media"
| "workspace-poster"
| "workspace-music"
| "workspace-knowledge"
| "workspace-planning"
| "workspace-document"
| "workspace-video"
| "workspace-novel";
export const LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY =
"proxycast:last-theme-workspace-page";
export const THEME_WORKSPACE_PAGE_MAP: Record<
WorkspaceTheme,
ThemeWorkspacePage
> = {
general: "workspace-general",
"social-media": "workspace-social-media",
poster: "workspace-poster",
music: "workspace-music",
knowledge: "workspace-knowledge",
planning: "workspace-planning",
document: "workspace-document",
video: "workspace-video",
novel: "workspace-novel",
};
export const WORKSPACE_PAGE_THEME_MAP: Record<
ThemeWorkspacePage,
WorkspaceTheme
> = {
"workspace-general": "general",
"workspace-social-media": "social-media",
"workspace-poster": "poster",
"workspace-music": "music",
"workspace-knowledge": "knowledge",
"workspace-planning": "planning",
"workspace-document": "document",
"workspace-video": "video",
"workspace-novel": "novel",
};
export type Page =
| "provider-pool"
| "api-server"
| "agent"
| "workspace"
| ThemeWorkspacePage
| "image-gen"
| "mcp"
| "tools"
@@ -26,6 +85,31 @@ export type Page =
| "project-detail"
| `plugin:${string}`;
export function isThemeWorkspacePage(page: Page): page is ThemeWorkspacePage {
return page in WORKSPACE_PAGE_THEME_MAP;
}
export function getThemeWorkspacePage(
theme: WorkspaceTheme,
): ThemeWorkspacePage {
return THEME_WORKSPACE_PAGE_MAP[theme];
}
export function getThemeByWorkspacePage(
page: ThemeWorkspacePage,
): WorkspaceTheme {
return WORKSPACE_PAGE_THEME_MAP[page];
}
export function getDefaultThemeWorkspacePage(): ThemeWorkspacePage {
return THEME_WORKSPACE_PAGE_MAP.general;
}
export type WorkspaceViewMode =
| "project-management"
| "workspace"
| "project-detail";
/**
* Agent 页面参数
* 用于从项目入口跳转到创作界面时传递项目上下文
@@ -33,6 +117,12 @@ export type Page =
export interface AgentPageParams {
projectId?: string;
contentId?: string;
/** 首屏主题(用于左侧导航直达创作主题) */
theme?: string;
/** 是否锁定主题(锁定后不在首屏显示主题切换) */
lockTheme?: boolean;
/** 工作台视图模式(仅主题工作台使用) */
workspaceViewMode?: WorkspaceViewMode;
}
/**
@@ -40,6 +130,14 @@ export interface AgentPageParams {
*/
export interface ProjectDetailPageParams {
projectId: string;
workspaceTheme?: WorkspaceTheme;
}
/**
* 设置页面参数
*/
export interface SettingsPageParams {
tab?: SettingsTabs;
}
/**
@@ -48,4 +146,5 @@ export interface ProjectDetailPageParams {
export type PageParams =
| AgentPageParams
| ProjectDetailPageParams
| SettingsPageParams
| Record<string, unknown>;
+4
View File
@@ -36,6 +36,8 @@ export enum SettingsTabs {
Voice = "voice",
// 系统
ApiServer = "api-server",
McpServer = "mcp-server",
Proxy = "proxy",
Storage = "storage",
Security = "security",
@@ -82,6 +84,8 @@ export const SETTINGS_GROUPS: Record<SettingsGroupKey, SettingsTabs[]> = {
SettingsTabs.Voice,
],
[SettingsGroupKey.System]: [
SettingsTabs.ApiServer,
SettingsTabs.McpServer,
SettingsTabs.Proxy,
SettingsTabs.Storage,
SettingsTabs.Security,