mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: bump version to 0.62.0
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.61.0",
|
||||
"version": "0.62.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+17
-15
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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()?,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use super::types::{ScheduledTask, TaskFilter, TaskStatus};
|
||||
use rusqlite::{params, Connection};
|
||||
use tracing::{error, warn};
|
||||
use tracing::warn;
|
||||
|
||||
pub struct SchedulerDao;
|
||||
|
||||
|
||||
@@ -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
|
||||
///
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换话题
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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: 
|
||||
const base64Match = content.match(
|
||||
/!\[.*?\]\((data:image\/[^;]+;base64,[^)]+)\)/,
|
||||
);
|
||||
if (base64Match) {
|
||||
console.log("[useImageGen] 匹配到 base64 图片");
|
||||
imageUrl = base64Match[1];
|
||||
} else {
|
||||
// 回退匹配 URL: 
|
||||
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],
|
||||
);
|
||||
|
||||
// 新建图片(创建一个新的空白图片项)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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", "网络代理"),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
@@ -6,3 +6,5 @@
|
||||
|
||||
export { WorkspaceSelector } from "./WorkspaceSelector";
|
||||
export type { WorkspaceSelectorProps } from "./WorkspaceSelector";
|
||||
export { WorkbenchPage } from "./WorkbenchPage";
|
||||
export type { WorkbenchPageProps } from "./WorkbenchPage";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user