From d973579a258f05b63fb4eecf71b069c89eec4199 Mon Sep 17 00:00:00 2001 From: coso Date: Thu, 26 Feb 2026 15:40:22 +0800 Subject: [PATCH] feat: release v0.72.0 with full pending changes --- RELEASE_NOTES.md | 48 +- package.json | 2 +- src-tauri/Cargo.lock | 62 +- src-tauri/Cargo.toml | 10 +- src-tauri/crates/core/src/config/types.rs | 60 ++ src-tauri/src/commands/channels_cmd.rs | 14 +- src-tauri/src/commands/mod.rs | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 4 +- src/components/README.md | 2 +- .../ChatModelSelector.integration.test.tsx | 35 +- .../agent/chat/components/ChatSidebar.tsx | 401 ++----------- .../Inputbar/components/CharacterMention.tsx | 219 +++++-- .../Inputbar/components/InputbarCore.tsx | 5 + .../Inputbar/components/SkillBadge.tsx | 18 + .../Inputbar/hooks/useActiveSkill.ts | 18 + .../agent/chat/components/Inputbar/index.tsx | 33 +- src/components/agent/chat/index.test.tsx | 62 ++ src/components/agent/chat/index.tsx | 31 +- src/components/artifact/CanvasAdapter.tsx | 1 + .../content-creator/canvas/CanvasFactory.tsx | 11 + .../canvas/document/DocumentCanvas.tsx | 7 + .../content-creator/canvas/document/types.ts | 2 + .../canvas/music/MusicCanvas.tsx | 8 +- .../content-creator/canvas/music/types.ts | 2 + .../canvas/novel/NovelCanvas.tsx | 8 + .../canvas/poster/PosterCanvas.tsx | 8 +- .../content-creator/canvas/poster/types.ts | 2 + .../canvas/script/ScriptCanvas.tsx | 9 +- .../canvas/shared/CanvasBreadcrumbHeader.tsx | 58 ++ .../canvas/video/PromptInput.tsx | 43 +- .../canvas/video/VideoCanvas.tsx | 38 +- .../canvas/video/VideoWorkspace.tsx | 19 +- .../general-chat/chat/ChatPanel.tsx | 25 +- .../chat/CompactModelSelector.test.tsx | 27 +- .../chat/CompactModelSelector.tsx | 30 +- src/components/general-chat/types.ts | 6 +- src/components/image-gen/ImageGenPage.tsx | 69 ++- src/components/input-kit/ModelSelector.tsx | 47 +- src/components/memory/MemoryPage.tsx | 11 +- src/components/plugins/PluginsPage.tsx | 17 +- src/components/resources/ResourcesPage.tsx | 12 +- src/components/settings-v2/_layout/index.tsx | 44 +- .../agent/skills/index.tsx} | 2 +- .../settings-v2/general/appearance/index.tsx | 5 +- .../settings-v2/legacy-import-guard.test.ts | 48 ++ .../shared/language}/LanguageSelector.tsx | 0 .../system/about/index.tsx} | 0 .../settings-v2/system/channels/index.tsx | 566 +++++++++++++++++- .../system/developer/index.tsx} | 0 .../experimental/UpdateCheckSettings.tsx} | 2 +- .../system/experimental/index.tsx} | 4 +- .../system/external-tools/index.tsx} | 2 +- .../system/proxy/index.tsx} | 0 .../settings/ConnectionsSettings.tsx | 234 -------- .../settings/GeneralSettings.test.ts | 78 --- src/components/settings/GeneralSettings.tsx | 479 --------------- src/components/settings/README.md | 28 - src/components/settings/SettingsPage.tsx | 76 --- src/components/settings/index.ts | 7 - src/components/smart-input/ChatInput.tsx | 36 +- .../smart-input/SmartInputWindow.tsx | 17 +- src/components/smart-input/types.ts | 8 +- .../terminal/ai/TerminalAIInput.tsx | 43 +- .../terminal/ai/TerminalAIPanel.tsx | 17 +- src/components/tools/ToolsPage.tsx | 17 +- src/components/workspace/WorkbenchPage.tsx | 20 +- src/hooks/useTauri.ts | 33 + src/lib/api/channels.ts | 33 + 69 files changed, 1727 insertions(+), 1560 deletions(-) create mode 100644 src/components/agent/chat/components/Inputbar/components/SkillBadge.tsx create mode 100644 src/components/agent/chat/components/Inputbar/hooks/useActiveSkill.ts create mode 100644 src/components/content-creator/canvas/shared/CanvasBreadcrumbHeader.tsx rename src/components/{settings/ExtensionsSettings.tsx => settings-v2/agent/skills/index.tsx} (91%) create mode 100644 src/components/settings-v2/legacy-import-guard.test.ts rename src/components/{settings => settings-v2/shared/language}/LanguageSelector.tsx (100%) rename src/components/{settings/AboutSection.tsx => settings-v2/system/about/index.tsx} (100%) rename src/components/{settings/DeveloperSettings.tsx => settings-v2/system/developer/index.tsx} (100%) rename src/components/{settings/UpdateNotification.tsx => settings-v2/system/experimental/UpdateCheckSettings.tsx} (99%) rename src/components/{settings/ExperimentalSettings.tsx => settings-v2/system/experimental/index.tsx} (98%) rename src/components/{settings/ExternalToolsSettings.tsx => settings-v2/system/external-tools/index.tsx} (99%) rename src/components/{settings/ProxySettings.tsx => settings-v2/system/proxy/index.tsx} (100%) delete mode 100644 src/components/settings/ConnectionsSettings.tsx delete mode 100644 src/components/settings/GeneralSettings.test.ts delete mode 100644 src/components/settings/GeneralSettings.tsx delete mode 100644 src/components/settings/README.md delete mode 100644 src/components/settings/SettingsPage.tsx delete mode 100644 src/components/settings/index.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 553df9979..533c4209a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,15 +1,45 @@ -## ProxyCast v0.71.0 +## ProxyCast v0.72.0 + +发布日期:2026-02-26 ### ✨ 新功能 -- 添加渠道管理功能并修复 workspace_mismatch 兼容性问题 (8fec0f10) -- 改进 Windows 平台兼容性 (0a1243e6) -### 🐛 修复 -- 修复发送第一条消息时的闪退问题 (0f6044d6) +#### 渠道管理重构 +- 重写渠道设置页面:移除旧的「AI 模型提供商」和「消息通知渠道」双 tab 布局,改为 Telegram / Discord / 飞书 三个 Bot 渠道 tab,每个 tab 内联表单配置 +- 新增后端 ChannelsConfig 类型:在 Rust 配置层新增 `ChannelsConfig`、`TelegramBotConfig`、`DiscordBotConfig`、`FeishuBotConfig` 结构体,支持 YAML 序列化/反序列化 +- Telegram Bot 配置:支持 Enable 开关、Bot Token(密码输入+显示切换)、允许的用户 ID 列表、默认模型选择 +- Discord Bot 配置:支持 Enable 开关、Bot Token、允许的服务器 ID 列表、默认模型选择 +- 飞书 Bot 配置:支持 Enable 开关、App ID、App Secret、Verification Token(可选)、Encrypt Key(可选)、默认模型选择 +- 默认模型选择器:复用现有 Provider Pool 数据,下拉列出所有已配置 Provider 的模型 +- 脏状态检测:修改表单后底部固定栏显示「未保存的更改」提示,支持保存和取消操作 + +#### Agent Chat 改进 +- ChatSidebar 精简(减少约 300 行冗余代码) +- CharacterMention 角色提及组件功能增强 +- Inputbar 新增 SkillBadge 组件和相关 hooks +- 新增 Agent Chat 集成测试 + +#### 内容创作增强 +- 新增 `content-creator/canvas/shared/` 共享组件目录 +- Document、Music、Novel、Poster、Script、Video 画布均有功能增强 +- 视频工作区 PromptInput、VideoCanvas、VideoWorkspace 组件优化 ### 🔧 优化与重构 -- 优化 release workflow:自动从 tag 同步版本号、动态生成 release notes (5eef86b5) -- 升级 aster-rust 至 v0.14.5 (9f0ba79c) -### 📦 其他 -- 添加 Windows 闪退修复总结文档 (ccb94778) +#### 设置页面迁移 +- 删除旧版 `src/components/settings/` 下 13 个组件(AboutSection、ConnectionsSettings、DeveloperSettings、ExperimentalSettings、ExtensionsSettings、ExternalToolsSettings、GeneralSettings、LanguageSelector、ProxySettings、SettingsPage、UpdateNotification 等) +- settings-v2 布局和导航结构优化 + +#### 其他改进 +- 通用聊天 ChatPanel 和 CompactModelSelector 组件优化 +- 图像生成 ImageGenPage 功能增强 +- input-kit ModelSelector 组件改进 +- Smart Input ChatInput 和 SmartInputWindow 优化 +- 终端 AI TerminalAIInput 和 TerminalAIPanel 改进 +- 工具页面、工作台、记忆管理、插件系统、资源管理页面更新 +- 外观设置页优化 + +### 📦 技术细节 +- 62 个文件变更,+1551 行,-3217 行(净减少 1666 行代码) +- Rust 后端新增渠道配置类型,前端 TypeScript 类型同步更新 +- 旧版设置页面完全迁移至 settings-v2 架构 diff --git a/package.json b/package.json index adfb839b4..631fc55d5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.71.0", + "version": "0.72.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6a4da804b..817cf61df 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -212,8 +212,8 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster-core" -version = "0.14.5" -source = "git+https://github.com/astercloud/aster-rust?tag=v0.14.5#a77d13ee1275989abcdff045622d9f8458c3b840" +version = "0.15.0" +source = "git+https://github.com/astercloud/aster-rust?tag=v0.15.0#eb0377b71bcc056589cd802c46c98fec8880544f" dependencies = [ "ahash", "anyhow", @@ -305,8 +305,8 @@ dependencies = [ [[package]] name = "aster-models" -version = "0.14.5" -source = "git+https://github.com/astercloud/aster-rust?tag=v0.14.5#a77d13ee1275989abcdff045622d9f8458c3b840" +version = "0.15.0" +source = "git+https://github.com/astercloud/aster-rust?tag=v0.15.0#eb0377b71bcc056589cd802c46c98fec8880544f" dependencies = [ "serde", "serde_json", @@ -2211,7 +2211,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.8.0", + "phf 0.10.1", "proc-macro2", "quote", "smallvec", @@ -2227,7 +2227,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.3", "smallvec", ] @@ -4085,7 +4085,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.57.0", ] [[package]] @@ -5422,7 +5422,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.114", @@ -6145,9 +6145,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", "phf_shared 0.8.0", - "proc-macro-hack", ] [[package]] @@ -6156,7 +6154,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ + "phf_macros 0.10.0", "phf_shared 0.10.0", + "proc-macro-hack", ] [[package]] @@ -6260,12 +6260,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "phf_generator 0.10.0", + "phf_shared 0.10.0", "proc-macro-hack", "proc-macro2", "quote", @@ -6677,7 +6677,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.114", @@ -6685,7 +6685,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.71.0" +version = "0.72.0" dependencies = [ "anyhow", "arboard", @@ -6785,7 +6785,7 @@ dependencies = [ [[package]] name = "proxycast-agent" -version = "0.71.0" +version = "0.72.0" dependencies = [ "aster-core", "async-trait", @@ -6808,7 +6808,7 @@ dependencies = [ [[package]] name = "proxycast-config" -version = "0.71.0" +version = "0.72.0" dependencies = [ "async-trait", "parking_lot", @@ -6824,7 +6824,7 @@ dependencies = [ [[package]] name = "proxycast-core" -version = "0.71.0" +version = "0.72.0" dependencies = [ "aster-models", "async-trait", @@ -6864,7 +6864,7 @@ dependencies = [ [[package]] name = "proxycast-credential" -version = "0.71.0" +version = "0.72.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -6899,7 +6899,7 @@ dependencies = [ [[package]] name = "proxycast-infra" -version = "0.71.0" +version = "0.72.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -6919,7 +6919,7 @@ dependencies = [ [[package]] name = "proxycast-mcp" -version = "0.71.0" +version = "0.72.0" dependencies = [ "async-trait", "glob", @@ -6950,7 +6950,7 @@ dependencies = [ [[package]] name = "proxycast-processor" -version = "0.71.0" +version = "0.72.0" dependencies = [ "async-trait", "parking_lot", @@ -6969,7 +6969,7 @@ dependencies = [ [[package]] name = "proxycast-providers" -version = "0.71.0" +version = "0.72.0" dependencies = [ "anyhow", "async-stream", @@ -7021,7 +7021,7 @@ dependencies = [ [[package]] name = "proxycast-server" -version = "0.71.0" +version = "0.72.0" dependencies = [ "async-stream", "axum 0.7.9", @@ -7063,7 +7063,7 @@ dependencies = [ [[package]] name = "proxycast-server-utils" -version = "0.71.0" +version = "0.72.0" dependencies = [ "axum 0.7.9", "futures", @@ -7078,7 +7078,7 @@ dependencies = [ [[package]] name = "proxycast-services" -version = "0.71.0" +version = "0.72.0" dependencies = [ "anyhow", "aster-core", @@ -7119,7 +7119,7 @@ dependencies = [ [[package]] name = "proxycast-skills" -version = "0.71.0" +version = "0.72.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -7135,7 +7135,7 @@ dependencies = [ [[package]] name = "proxycast-terminal" -version = "0.71.0" +version = "0.72.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7162,7 +7162,7 @@ dependencies = [ [[package]] name = "proxycast-websocket" -version = "0.71.0" +version = "0.72.0" dependencies = [ "axum 0.7.9", "chrono", @@ -8514,7 +8514,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 4.0.0", + "dirs 6.0.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 38a4491da..c36c9b4f6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.71.0" +version = "0.72.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/proxycast" @@ -122,11 +122,11 @@ enigo = "0.3" # Aster Agent Framework # 开发时使用本地 aster-rust,CI/CD 使用远程 GitHub 仓库 # 本地开发: path = "../../../astercloud/aster-rust/crates/aster" (相对 src-tauri/) -# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.14.5" +# CI/CD: git = "https://github.com/astercloud/aster-rust", tag = "v0.15.0" # aster = { package = "aster-core", path = "../../../astercloud/aster-rust/crates/aster" } -aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.14.5" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.15.0" } # 本地开发: aster-models = { path = "../../../astercloud/aster-rust/crates/aster-models" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.14.5" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.15.0" } # MCP (Model Context Protocol) rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] } @@ -189,7 +189,7 @@ version = "2.4" [package] name = "proxycast" -version = "0.71.0" +version = "0.72.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 93ff359a1..e45105eb0 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -438,6 +438,9 @@ pub struct Config { /// 心跳引擎配置 #[serde(default)] pub heartbeat: HeartbeatSettings, + /// 渠道配置(Telegram / Discord / 飞书 Bot) + #[serde(default)] + pub channels: ChannelsConfig, } // ============ Native Agent 配置类型 ============ @@ -1785,6 +1788,7 @@ impl Default for Config { hint_router: HintRouterSettings::default(), pairing: PairingSettings::default(), heartbeat: HeartbeatSettings::default(), + channels: ChannelsConfig::default(), } } } @@ -2613,3 +2617,59 @@ impl Default for PairingSettings { Self { enabled: false } } } + +// ============ 渠道配置类型(Telegram / Discord / 飞书 Bot) ============ + +/// 渠道配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct ChannelsConfig { + #[serde(default)] + pub telegram: TelegramBotConfig, + #[serde(default)] + pub discord: DiscordBotConfig, + #[serde(default)] + pub feishu: FeishuBotConfig, +} + +/// Telegram Bot 配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct TelegramBotConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub bot_token: String, + #[serde(default)] + pub allowed_user_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, +} + +/// Discord Bot 配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct DiscordBotConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub bot_token: String, + #[serde(default)] + pub allowed_server_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, +} + +/// 飞书 Bot 配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct FeishuBotConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub app_id: String, + #[serde(default)] + pub app_secret: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verification_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encrypt_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, +} diff --git a/src-tauri/src/commands/channels_cmd.rs b/src-tauri/src/commands/channels_cmd.rs index eca3be1f0..1edc4a1aa 100644 --- a/src-tauri/src/commands/channels_cmd.rs +++ b/src-tauri/src/commands/channels_cmd.rs @@ -147,9 +147,7 @@ pub struct TestMessageResult { /// 获取所有 AI 渠道 #[tauri::command] -pub async fn get_ai_channels( - _state: State<'_, AppState>, -) -> Result, String> { +pub async fn get_ai_channels(_state: State<'_, AppState>) -> Result, String> { // TODO: 实现 AI 渠道获取逻辑 // 需要从 aster-rust 获取 DeclarativeProviderConfig 列表 tracing::info!("[渠道] 获取 AI 渠道列表"); @@ -158,10 +156,7 @@ pub async fn get_ai_channels( /// 获取单个 AI 渠道 #[tauri::command] -pub async fn get_ai_channel( - id: String, - _state: State<'_, AppState>, -) -> Result { +pub async fn get_ai_channel(id: String, _state: State<'_, AppState>) -> Result { tracing::info!("[渠道] 获取 AI 渠道: {}", id); Err("暂未实现".to_string()) } @@ -191,10 +186,7 @@ pub async fn update_ai_channel( /// 删除 AI 渠道 #[tauri::command] -pub async fn delete_ai_channel( - id: String, - _state: State<'_, AppState>, -) -> Result<(), String> { +pub async fn delete_ai_channel(id: String, _state: State<'_, AppState>) -> Result<(), String> { tracing::info!("[渠道] 删除 AI 渠道: {}", id); // TODO: 实现删除逻辑 Err("暂未实现".to_string()) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4f8c2d96f..524023ffd 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,10 +1,10 @@ pub mod a2ui_form_cmd; pub mod agent_cmd; -pub mod channels_cmd; pub mod api_key_provider_cmd; pub mod asr_cmd; pub mod aster_agent_cmd; pub mod auto_fix_cmd; +pub mod channels_cmd; pub mod config_cmd; pub mod connect_cmd; pub mod connection_cmd; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e503d41ee..24971c85b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.71.0", + "version": "0.72.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 8d674a179..cf1cdc895 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -451,7 +451,7 @@ function AppContent() { - +
void; +} + function createModel(id: string, providerId: string) { return { id, @@ -137,7 +141,8 @@ function createModel(id: string, providerId: string) { }; } -function mount(workspaceId: string): HTMLDivElement { +function mount(workspaceId: string, options: MountOptions = {}): HTMLDivElement { + const { onManageProviders } = options; const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -168,6 +173,7 @@ function mount(workspaceId: string): HTMLDivElement { model={chat.model} setModel={chat.setModel} activeTheme="general" + onManageProviders={onManageProviders} />
{chat.providerType}/{chat.model} @@ -398,4 +404,31 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => { model: "deepseek-chat", }); }); + + it("无 Provider 时应显示配置引导并支持点击配置", async () => { + mockUseConfiguredProviders.mockReturnValue({ + providers: [], + loading: false, + }); + mockUseProviderModels.mockReturnValue({ + modelIds: [], + models: [], + loading: false, + error: null, + }); + + const onManageProviders = vi.fn(); + const container = mount("ws-no-provider-guide", { onManageProviders }); + + await flushEffects(); + + expect(container.textContent).toContain("工具模型未配置"); + + const configButton = findButtonByText("配置"); + await act(async () => { + configButton.click(); + }); + + expect(onManageProviders).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/agent/chat/components/ChatSidebar.tsx b/src/components/agent/chat/components/ChatSidebar.tsx index a786287e6..fe965e061 100644 --- a/src/components/agent/chat/components/ChatSidebar.tsx +++ b/src/components/agent/chat/components/ChatSidebar.tsx @@ -1,17 +1,10 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { Plus, MessageSquare, - MoreHorizontal, - Bot, Trash2, - Download, - Check, - Loader2, } from "lucide-react"; import styled from "styled-components"; -import { skillsApi, type Skill } from "@/lib/api/skills"; -import { toast } from "sonner"; import type { Topic } from "../hooks/useAgentChat"; const SidebarContainer = styled.div` @@ -22,45 +15,6 @@ const SidebarContainer = styled.div` border-right: 1px solid hsl(var(--border)); `; -const TabsContainer = styled.div` - display: flex; - padding: 12px 16px 0; - gap: 20px; - border-bottom: 1px solid hsl(var(--border)); -`; - -const TabItem = styled.div<{ $active: boolean }>` - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - font-weight: 500; - padding-bottom: 10px; - cursor: pointer; - color: ${(props) => - props.$active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}; - border-bottom: 2px solid - ${(props) => (props.$active ? "hsl(var(--primary))" : "transparent")}; - transition: all 0.2s; - - &:hover { - color: hsl(var(--foreground)); - } -`; - -const TabBadge = styled.span` - font-size: 10px; - min-width: 16px; - height: 16px; - padding: 0 4px; - border-radius: 8px; - background-color: hsl(var(--primary)); - color: white; - display: flex; - align-items: center; - justify-content: center; -`; - const Toolbar = styled.div` padding: 12px 12px 8px; `; @@ -135,92 +89,6 @@ const ListItem = styled.div<{ $active: boolean }>` } `; -const SkillCard = styled.div<{ $installed: boolean }>` - display: flex; - align-items: flex-start; - gap: 12px; - padding: 12px; - margin-bottom: 8px; - border-radius: 10px; - background-color: ${(props) => - props.$installed ? "hsl(var(--primary)/0.08)" : "hsl(var(--muted)/0.5)"}; - border: 1px solid - ${(props) => (props.$installed ? "hsl(var(--primary)/0.2)" : "transparent")}; - transition: all 0.15s; - - &:hover { - background-color: ${(props) => - props.$installed ? "hsl(var(--primary)/0.12)" : "hsl(var(--muted))"}; - } -`; - -const SkillInfo = styled.div` - flex: 1; - min-width: 0; -`; - -const SkillName = styled.div` - font-size: 13px; - font-weight: 600; - color: hsl(var(--foreground)); - margin-bottom: 4px; -`; - -const SkillDesc = styled.div` - font-size: 11px; - color: hsl(var(--muted-foreground)); - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -`; - -const SkillAction = styled.button<{ $installed: boolean }>` - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 8px; - border: none; - cursor: pointer; - transition: all 0.15s; - - ${(props) => - props.$installed - ? ` - background-color: hsl(var(--primary)/0.15); - color: hsl(var(--primary)); - &:hover { - background-color: hsl(var(--destructive)/0.15); - color: hsl(var(--destructive)); - } - ` - : ` - background-color: hsl(var(--primary)); - color: white; - &:hover { - background-color: hsl(var(--primary)/0.9); - } - `} - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -`; - -const SectionTitle = styled.div` - font-size: 11px; - font-weight: 600; - color: hsl(var(--muted-foreground)); - text-transform: uppercase; - letter-spacing: 0.5px; - padding: 8px 12px 6px; -`; - const EmptyState = styled.div` text-align: center; padding: 24px 16px; @@ -245,69 +113,10 @@ export const ChatSidebar: React.FC = ({ onDeleteTopic, onRenameTopic, }) => { - const [activeTab, setActiveTab] = useState<"skills" | "topics">("topics"); - const [skills, setSkills] = useState([]); - const [loadingSkills, setLoadingSkills] = useState(false); - const [actionLoading, setActionLoading] = useState(null); const [editingTopicId, setEditingTopicId] = useState(null); const [editTitle, setEditTitle] = useState(""); const editInputRef = React.useRef(null); - const loadSkills = async () => { - setLoadingSkills(true); - try { - const allSkills = await skillsApi.getAll("proxycast"); - setSkills(allSkills); - } catch (error) { - console.error("加载技能列表失败:", error); - toast.error("加载技能列表失败"); - } finally { - setLoadingSkills(false); - } - }; - - useEffect(() => { - loadSkills(); - }, []); - - const handleInstall = async (skill: Skill) => { - setActionLoading(skill.directory); - try { - const result = await skillsApi.install(skill.directory, "proxycast"); - if (result) { - toast.success(`已安装: ${skill.name}`); - await loadSkills(); - } else { - toast.error(`安装失败: ${skill.name}`); - } - } catch (error: unknown) { - const errorMsg = error instanceof Error ? error.message : String(error); - console.error("安装失败:", errorMsg); - toast.error(`安装失败: ${errorMsg}`); - } finally { - setActionLoading(null); - } - }; - - const handleUninstall = async (skill: Skill) => { - setActionLoading(skill.directory); - try { - const result = await skillsApi.uninstall(skill.directory, "proxycast"); - if (result) { - toast.success(`已卸载: ${skill.name}`); - await loadSkills(); - } else { - toast.error(`卸载失败: ${skill.name}`); - } - } catch (error: unknown) { - const errorMsg = error instanceof Error ? error.message : String(error); - console.error("卸载失败:", errorMsg); - toast.error(`卸载失败: ${errorMsg}`); - } finally { - setActionLoading(null); - } - }; - const handleDeleteClick = (e: React.MouseEvent, topicId: string) => { e.stopPropagation(); onDeleteTopic(topicId); @@ -356,173 +165,71 @@ export const ChatSidebar: React.FC = ({ } }, [editingTopicId]); - const installedSkills = skills.filter((s) => s.installed); - const availableSkills = skills.filter((s) => !s.installed); - return ( - - setActiveTab("skills")} - > - 技能 - {installedSkills.length > 0 && ( - {installedSkills.length} - )} - - setActiveTab("topics")} - > - 话题 - {topics.length > 0 && {topics.length}} - - - 新建话题 - - {activeTab === "topics" ? ( - <> - {topics.length === 0 ? ( - 暂无话题,点击上方新建 - ) : ( - topics.map((topic) => ( - { - if (editingTopicId !== topic.id) { - onSwitchTopic(topic.id); - } - }} - onDoubleClick={(e) => - handleStartEdit(e, topic.id, topic.title) - } - > - - {editingTopicId === topic.id ? ( - setEditTitle(e.target.value)} - onKeyDown={handleEditKeyDown} - onBlur={handleSaveEdit} - onClick={(e) => e.stopPropagation()} - style={{ - flex: 1, - fontSize: "13px", - padding: "2px 6px", - border: "1px solid hsl(var(--primary))", - borderRadius: "4px", - outline: "none", - }} - /> - ) : ( - {topic.title} - )} - {editingTopicId !== topic.id && ( - - )} - - )) - )} - + {topics.length === 0 ? ( + 暂无话题,点击上方新建 ) : ( - <> - {/* 默认助手 */} - - - 默认助手 + topics.map((topic) => ( + { + if (editingTopicId !== topic.id) { + onSwitchTopic(topic.id); + } + }} + onDoubleClick={(e) => + handleStartEdit(e, topic.id, topic.title) + } + > + + {editingTopicId === topic.id ? ( + setEditTitle(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={handleSaveEdit} + onClick={(e) => e.stopPropagation()} + style={{ + flex: 1, + fontSize: "13px", + padding: "2px 6px", + border: "1px solid hsl(var(--primary))", + borderRadius: "4px", + outline: "none", + }} + /> + ) : ( + {topic.title} + )} + {editingTopicId !== topic.id && ( + + )} - - {loadingSkills ? ( - - - 加载中... - - ) : skills.length === 0 ? ( - 暂无可用技能 - ) : ( - <> - {installedSkills.length > 0 && ( - <> - 已安装 - {installedSkills.map((skill) => ( - - - {skill.name} - {skill.description && ( - {skill.description} - )} - - handleUninstall(skill)} - disabled={actionLoading === skill.directory} - title="卸载" - > - {actionLoading === skill.directory ? ( - - ) : ( - - )} - - - ))} - - )} - - {availableSkills.length > 0 && ( - <> - 可安装 - {availableSkills.map((skill) => ( - - - {skill.name} - {skill.description && ( - {skill.description} - )} - - handleInstall(skill)} - disabled={actionLoading === skill.directory} - title="安装" - > - {actionLoading === skill.directory ? ( - - ) : ( - - )} - - - ))} - - )} - - )} - + )) )} diff --git a/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx b/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx index 45950feee..03fb387e0 100644 --- a/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx +++ b/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx @@ -1,11 +1,11 @@ /** - * 角色引用组件 + * 角色与技能引用组件 * - * 在输入框中检测 @ 符号,显示角色列表供选择 + * 在输入框中检测 @ 符号,显示角色和技能列表供选择 */ import React, { useState, useEffect, useMemo, useRef } from "react"; -import { User } from "lucide-react"; +import { User, Zap } from "lucide-react"; import { Command, CommandEmpty, @@ -20,10 +20,14 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import type { Character } from "@/lib/api/memory"; +import type { Skill } from "@/lib/api/skills"; +import { toast } from "sonner"; interface CharacterMentionProps { /** 角色列表 */ characters: Character[]; + /** 技能列表 */ + skills?: Skill[]; /** 输入框 ref */ inputRef: React.RefObject; /** 当前输入值 */ @@ -32,19 +36,27 @@ interface CharacterMentionProps { onChange: (value: string) => void; /** 选择角色回调 */ onSelectCharacter?: (character: Character) => void; + /** 选择已安装技能回调 */ + onSelectSkill?: (skill: Skill) => void; + /** 跳转到设置页安装技能 */ + onNavigateToSettings?: () => void; } export function CharacterMention({ characters, + skills = [], inputRef, value, onChange, onSelectCharacter, + onSelectSkill, + onNavigateToSettings, }: CharacterMentionProps) { const [showMentions, setShowMentions] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); const [cursorPosition, setCursorPosition] = useState({ top: 0, left: 0 }); const popoverRef = useRef(null); + const commandRef = useRef(null); // 过滤角色列表 const filteredCharacters = useMemo(() => { @@ -57,6 +69,35 @@ export function CharacterMention({ ); }, [characters, mentionQuery]); + // 过滤已安装技能 + const installedSkills = useMemo(() => { + const installed = skills.filter((s) => s.installed); + if (!mentionQuery) return installed; + const query = mentionQuery.toLowerCase(); + return installed.filter( + (s) => + s.name.toLowerCase().includes(query) || + s.key.toLowerCase().includes(query) || + s.description?.toLowerCase().includes(query), + ); + }, [skills, mentionQuery]); + + // 过滤未安装技能 + const availableSkills = useMemo(() => { + const available = skills.filter((s) => !s.installed); + if (!mentionQuery) return available; + const query = mentionQuery.toLowerCase(); + return available.filter( + (s) => + s.name.toLowerCase().includes(query) || + s.key.toLowerCase().includes(query) || + s.description?.toLowerCase().includes(query), + ); + }, [skills, mentionQuery]); + + const hasItems = + characters.length > 0 || skills.length > 0; + // 检测 @ 符号 useEffect(() => { const textarea = inputRef.current; @@ -75,15 +116,10 @@ export function CharacterMention({ setMentionQuery(textAfterAt); setShowMentions(true); - // 计算弹窗位置 + // 计算弹窗位置 — 锚定到输入框顶部 const rect = textarea.getBoundingClientRect(); - const lineHeight = parseInt( - window.getComputedStyle(textarea).lineHeight, - ); - const lines = textBeforeCursor.split("\n"); - const currentLine = lines.length - 1; - const top = rect.top + currentLine * lineHeight - textarea.scrollTop; - const left = rect.left + 10; // 简单的左边距 + const top = rect.top; + const left = rect.left + 10; setCursorPosition({ top, left }); return; @@ -132,7 +168,47 @@ export function CharacterMention({ }, 0); }; - // 处理键盘事件 + // 选择已安装技能 → 通知父组件,清除 @ 查询文本 + const handleSelectInstalledSkill = (skill: Skill) => { + const textarea = inputRef.current; + if (!textarea) return; + + const cursorPos = textarea.selectionStart; + const textBeforeCursor = value.slice(0, cursorPos); + const textAfterCursor = value.slice(cursorPos); + const lastAtIndex = textBeforeCursor.lastIndexOf("@"); + + // 移除 @ 及查询文本,恢复原有输入 + const newValue = value.slice(0, lastAtIndex) + textAfterCursor; + onChange(newValue.trimEnd() === "" ? "" : newValue); + setShowMentions(false); + + // 通知父组件设置 activeSkill + onSelectSkill?.(skill); + + // 恢复焦点 + setTimeout(() => { + textarea.focus(); + const newCursorPos = Math.max(0, lastAtIndex); + textarea.setSelectionRange(newCursorPos, newCursorPos); + }, 0); + }; + + // 选择未安装技能 → toast 提示 + const handleSelectAvailableSkill = (skill: Skill) => { + setShowMentions(false); + + toast.info(`技能「${skill.name}」尚未安装`, { + action: onNavigateToSettings + ? { + label: "去安装", + onClick: onNavigateToSettings, + } + : undefined, + }); + }; + + // 处理键盘事件:Escape 关闭,ArrowUp/ArrowDown/Enter 转发给 cmdk useEffect(() => { const textarea = inputRef.current; if (!textarea || !showMentions) return; @@ -141,6 +217,23 @@ export function CharacterMention({ if (e.key === "Escape") { setShowMentions(false); e.preventDefault(); + return; + } + + // 转发 ArrowUp/ArrowDown/Enter 给 cmdk Command 根元素 + if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + const cmdkRoot = commandRef.current; + if (cmdkRoot) { + cmdkRoot.dispatchEvent( + new KeyboardEvent("keydown", { + key: e.key, + bubbles: true, + cancelable: true, + }), + ); + } } }; @@ -148,7 +241,12 @@ export function CharacterMention({ return () => textarea.removeEventListener("keydown", handleKeyDown); }, [showMentions, inputRef]); - if (!showMentions || characters.length === 0) return null; + if (!showMentions || !hasItems) return null; + + const hasFilteredResults = + filteredCharacters.length > 0 || + installedSkills.length > 0 || + availableSkills.length > 0; return ( @@ -156,7 +254,7 @@ export function CharacterMention({
e.preventDefault()} > - + - 没有找到角色 - - {filteredCharacters.map((character) => ( - handleSelectCharacter(character)} - className="cursor-pointer" - > - -
-
{character.name}
- {character.description && ( -
- {character.description} -
- )} -
-
- ))} -
+ {!hasFilteredResults && ( + 没有找到匹配项 + )} + {filteredCharacters.length > 0 && ( + + {filteredCharacters.map((character) => ( + handleSelectCharacter(character)} + className="cursor-pointer" + > + +
+
{character.name}
+ {character.description && ( +
+ {character.description} +
+ )} +
+
+ ))} +
+ )} + {installedSkills.length > 0 && ( + + {installedSkills.map((skill) => ( + handleSelectInstalledSkill(skill)} + className="cursor-pointer" + > + +
+
{skill.name}
+ {skill.description && ( +
+ {skill.description} +
+ )} +
+
+ ))} +
+ )} + {availableSkills.length > 0 && ( + + {availableSkills.map((skill) => ( + handleSelectAvailableSkill(skill)} + className="cursor-pointer opacity-60" + > + +
+
{skill.name}
+ {skill.description && ( +
+ {skill.description} +
+ )} +
+
+ ))} +
+ )}
diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx index b0f3a2ec0..6daed5c01 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarCore.tsx @@ -49,6 +49,8 @@ interface InputbarCoreProps { leftExtra?: React.ReactNode; /** 输入框底栏右侧扩展区域 */ rightExtra?: React.ReactNode; + /** 输入框内部顶部扩展区域(textarea 上方) */ + topExtra?: React.ReactNode; } export const InputbarCore: React.FC = ({ @@ -70,6 +72,7 @@ export const InputbarCore: React.FC = ({ textareaRef: externalTextareaRef, leftExtra, rightExtra, + topExtra, }) => { return ( = ({ )} + {topExtra} + void; +} + +export const SkillBadge: React.FC = ({ skill, onClear }) => ( +
+ + {skill.name} + +
+); diff --git a/src/components/agent/chat/components/Inputbar/hooks/useActiveSkill.ts b/src/components/agent/chat/components/Inputbar/hooks/useActiveSkill.ts new file mode 100644 index 000000000..19548223d --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/hooks/useActiveSkill.ts @@ -0,0 +1,18 @@ +import { useState, useCallback } from "react"; +import type { Skill } from "@/lib/api/skills"; + +export function useActiveSkill() { + const [activeSkill, setActiveSkill] = useState(null); + + const wrapTextWithSkill = useCallback( + (text: string) => { + if (!activeSkill) return text; + return `/${activeSkill.key} ${text}`.trim(); + }, + [activeSkill], + ); + + const clearActiveSkill = useCallback(() => setActiveSkill(null), []); + + return { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill }; +} diff --git a/src/components/agent/chat/components/Inputbar/index.tsx b/src/components/agent/chat/components/Inputbar/index.tsx index 0a794e42f..8193c1427 100644 --- a/src/components/agent/chat/components/Inputbar/index.tsx +++ b/src/components/agent/chat/components/Inputbar/index.tsx @@ -5,8 +5,11 @@ import { toast } from "sonner"; import styled from "styled-components"; import type { MessageImage } from "../../types"; import type { Character } from "@/lib/api/memory"; +import type { Skill } from "@/lib/api/skills"; import { TaskFileList, type TaskFile } from "../TaskFiles"; import { FolderOpen, ChevronUp, Code2 } from "lucide-react"; +import { useActiveSkill } from "./hooks/useActiveSkill"; +import { SkillBadge } from "./components/SkillBadge"; import { ChatModelSelector } from "../ChatModelSelector"; import { safeInvoke } from "@/lib/dev-bridge"; import { @@ -159,8 +162,12 @@ interface InputbarProps { onTaskFileClick?: (file: TaskFile) => void; /** 角色列表(用于 @ 引用) */ characters?: Character[]; + /** 技能列表(用于 @ 引用) */ + skills?: Skill[]; /** 选择角色回调 */ onSelectCharacter?: (character: Character) => void; + /** 跳转到设置页安装技能 */ + onNavigateToSettings?: () => void; providerType?: string; setProviderType?: (type: string) => void; model?: string; @@ -189,7 +196,9 @@ export const Inputbar: React.FC = ({ onToggleTaskFiles, onTaskFileClick, characters = [], + skills = [], onSelectCharacter, + onNavigateToSettings, providerType, setProviderType, model, @@ -202,6 +211,7 @@ export const Inputbar: React.FC = ({ const [activeTools, setActiveTools] = useState>({}); const [pendingImages, setPendingImages] = useState([]); const [isFullscreen, setIsFullscreen] = useState(false); + const { activeSkill, setActiveSkill, clearActiveSkill } = useActiveSkill(); const fileInputRef = useRef(null); const textareaRef = useRef(null); @@ -445,15 +455,22 @@ export const Inputbar: React.FC = ({ const strategy = executionStrategy || (activeTools["execution_strategy"] ? "code_orchestrated" : "react"); + + // 如果有 activeSkill,拼接 /skill.key 前缀 + const textOverride = activeSkill + ? `/${activeSkill.key} ${input}`.trim() + : undefined; + onSend( pendingImages.length > 0 ? pendingImages : undefined, webSearch, thinking, - undefined, + textOverride, strategy, ); setPendingImages([]); - }, [activeTools, executionStrategy, input, onSend, pendingImages]); + clearActiveSkill(); + }, [activeSkill, activeTools, clearActiveSkill, executionStrategy, input, onSend, pendingImages]); const handleToggleTaskFiles = useCallback(() => { onToggleTaskFiles?.(); @@ -570,14 +587,17 @@ export const Inputbar: React.FC = ({ style={{ display: "none" }} onChange={handleFileSelect} /> - {/* 角色引用组件 */} - {characters.length > 0 && ( + {/* 角色与技能引用组件 */} + {(characters.length > 0 || skills.length > 0) && ( )} = ({ onPaste={handlePaste} isFullscreen={isFullscreen} isCanvasOpen={isCanvasOpen} + topExtra={ + activeSkill ? ( + + ) : undefined + } leftExtra={ !isFullscreen ? (
diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 49c37e742..571286066 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -462,6 +462,68 @@ describe("AgentChatPage 话题切换项目恢复", () => { }); }); +describe("AgentChatPage 侧栏显示控制", () => { + it("有消息时手动展开侧栏后不应被自动收起", async () => { + mockUseAgentChatUnified.mockImplementation( + ({ workspaceId }: { workspaceId: string }) => { + observedWorkspaceIds.push(workspaceId); + return { + providerType: "kiro", + setProviderType: vi.fn(), + model: "mock-model", + setModel: vi.fn(), + executionStrategy: "auto", + setExecutionStrategy: vi.fn(), + messages: [{ id: "msg-1", role: "user", content: "你好" }], + isSending: false, + sendMessage: sharedSendMessageMock, + stopSending: vi.fn(async () => undefined), + clearMessages: vi.fn(), + deleteMessage: vi.fn(), + editMessage: vi.fn(), + handlePermissionResponse: vi.fn(), + triggerAIGuide: sharedTriggerAIGuideMock, + topics: [ + { + id: "topic-a", + title: "话题 A", + updatedAt: Date.now(), + }, + ], + sessionId: "session-1", + switchTopic: sharedSwitchTopicMock, + deleteTopic: vi.fn(), + renameTopic: vi.fn(), + }; + }, + ); + + const container = renderPage(); + await flushEffects(); + + expect(container.querySelector('[data-testid="chat-sidebar"]')).toBeNull(); + + clickButton(container, "toggle-history"); + await flushEffects(); + expect(container.querySelector('[data-testid="chat-sidebar"]')).not.toBeNull(); + + clickButton(container, "set-project"); + await flushEffects(); + expect(container.querySelector('[data-testid="chat-sidebar"]')).not.toBeNull(); + }); + + it("showChatPanel=false 时应保持侧栏隐藏", async () => { + const container = renderPage({ showChatPanel: false }); + await flushEffects(); + + expect(container.querySelector('[data-testid="chat-sidebar"]')).toBeNull(); + + clickButton(container, "toggle-history"); + await flushEffects(); + expect(container.querySelector('[data-testid="chat-sidebar"]')).toBeNull(); + }); +}); + describe("AgentChatPage 自动引导", () => { it("社媒空文稿应预填引导词且不自动发送", async () => { mockIsContentCreationTheme.mockReturnValue(true); diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 48c538367..bc3035afe 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -1,7 +1,7 @@ /** * AI Agent 聊天页面 * - * 包含聊天区域和侧边栏(话题/技能列表) + * 包含聊天区域和侧边栏(话题列表) * 支持内容创作模式下的布局过渡和步骤引导 * 当主题为 general 时,使用 GeneralChat 组件实现 */ @@ -67,6 +67,7 @@ import { } from "@/lib/api/memory"; import type { Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; +import { skillsApi, type Skill } from "@/lib/api/skills"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { LatestRunStatusBadge } from "@/components/execution/LatestRunStatusBadge"; @@ -335,6 +336,9 @@ export function AgentChatPage({ [], ); + // 技能列表(用于 @ 引用) + const [skills, setSkills] = useState([]); + // 用于追踪已处理的消息 ID,避免重复处理 const processedMessageIds = useRef>(new Set()); const pendingTopicSwitchRef = useRef<{ @@ -386,6 +390,19 @@ export function AgentChatPage({ setLayoutMode("chat-canvas"); }, [artifacts.length, activeTheme]); + // 加载技能列表 + useEffect(() => { + skillsApi + .getAll("proxycast") + .then(setSkills) + .catch((err) => console.error("加载技能列表失败:", err)); + }, []); + + // 跳转到设置页安装技能 + const handleNavigateToSkillSettings = useCallback(() => { + _onNavigate?.("settings", { tab: SettingsTabs.Skills }); + }, [_onNavigate]); + // 加载项目、Memory 和内容 useEffect(() => { const loadData = async () => { @@ -1146,12 +1163,6 @@ export function AgentChatPage({ onHasMessagesChange?.(hasMessages); }, [hasMessages, onHasMessagesChange]); - useEffect(() => { - if (hasMessages) { - setShowSidebar(false); - } - }, [hasMessages]); - // 当有文件时默认在画布中显示最新文件(按更新时间) useEffect(() => { if (taskFiles.length > 0) { @@ -1212,7 +1223,7 @@ export function AgentChatPage({ if (!showChatPanel) { return; } - setShowSidebar(!showSidebar); + setShowSidebar((prev) => !prev); }; const handleToggleNovelChapterList = useCallback(() => { @@ -1948,6 +1959,7 @@ export function AgentChatPage({ onToggleTaskFiles={() => setTaskFilesExpanded(!taskFilesExpanded)} onTaskFileClick={handleTaskFileClick} characters={projectMemory?.characters || []} + skills={skills} onSelectCharacter={(character) => { setMentionedCharacters((prev) => { // 避免重复添加 @@ -1955,6 +1967,7 @@ export function AgentChatPage({ return [...prev, character]; }); }} + onNavigateToSettings={handleNavigateToSkillSettings} /> )} @@ -2018,6 +2031,7 @@ export function AgentChatPage({ theme={mappedTheme} state={canvasState} onStateChange={setCanvasState} + onBackHome={handleBackHome} onClose={handleCloseCanvas} isStreaming={isSending} onSelectionTextChange={handleCanvasSelectionTextChange} @@ -2042,6 +2056,7 @@ export function AgentChatPage({ canvasState, mappedTheme, handleCloseCanvas, + handleBackHome, isSending, handleCanvasSelectionTextChange, artifactViewMode, diff --git a/src/components/artifact/CanvasAdapter.tsx b/src/components/artifact/CanvasAdapter.tsx index 6b71d1d41..f0462785e 100644 --- a/src/components/artifact/CanvasAdapter.tsx +++ b/src/components/artifact/CanvasAdapter.tsx @@ -219,6 +219,7 @@ export const CanvasAdapter: React.FC = memo( theme="general" state={canvasState} onStateChange={handleStateChange} + onBackHome={handleClose} onClose={handleClose} isStreaming={isStreaming} /> diff --git a/src/components/content-creator/canvas/CanvasFactory.tsx b/src/components/content-creator/canvas/CanvasFactory.tsx index a59d55aa6..46231d405 100644 --- a/src/components/content-creator/canvas/CanvasFactory.tsx +++ b/src/components/content-creator/canvas/CanvasFactory.tsx @@ -32,6 +32,8 @@ interface CanvasFactoryProps { onStateChange: (state: CanvasStateUnion) => void; /** 关闭回调 */ onClose: () => void; + /** 返回首页回调 */ + onBackHome?: () => void; /** 是否正在流式输出(仅文档画布使用) */ isStreaming?: boolean; /** 小说画布控制配置(可选) */ @@ -59,10 +61,13 @@ export const CanvasFactory: React.FC = memo( state, onStateChange, onClose, + onBackHome, isStreaming, novelControls, onSelectionTextChange, }) => { + const resolvedBackHome = onBackHome ?? onClose; + // 优先根据 state.type 渲染,这样 general 主题也能显示文档画布 // 只有当 state.type 与 theme 对应的 canvasType 不匹配时才检查 theme const canvasType = useMemo(() => { @@ -80,6 +85,7 @@ export const CanvasFactory: React.FC = memo( void} + onBackHome={resolvedBackHome} onClose={onClose} isStreaming={isStreaming} onSelectionTextChange={onSelectionTextChange} @@ -92,6 +98,7 @@ export const CanvasFactory: React.FC = memo( void} + onBackHome={resolvedBackHome} onClose={onClose} /> ); @@ -102,6 +109,7 @@ export const CanvasFactory: React.FC = memo( void} + onBackHome={resolvedBackHome} onClose={onClose} isStreaming={isStreaming} /> @@ -113,6 +121,7 @@ export const CanvasFactory: React.FC = memo( void} + onBackHome={resolvedBackHome} onClose={onClose} /> ); @@ -123,6 +132,7 @@ export const CanvasFactory: React.FC = memo( void} + onBackHome={resolvedBackHome} onClose={onClose} useExternalToolbar={novelControls?.useExternalToolbar} chapterListCollapsed={novelControls?.chapterListCollapsed} @@ -139,6 +149,7 @@ export const CanvasFactory: React.FC = memo( void} + onBackHome={resolvedBackHome} onClose={onClose} /> ); diff --git a/src/components/content-creator/canvas/document/DocumentCanvas.tsx b/src/components/content-creator/canvas/document/DocumentCanvas.tsx index e9b023096..99c28eed6 100644 --- a/src/components/content-creator/canvas/document/DocumentCanvas.tsx +++ b/src/components/content-creator/canvas/document/DocumentCanvas.tsx @@ -11,12 +11,16 @@ import { DocumentToolbar } from "./DocumentToolbar"; import { DocumentRenderer } from "./DocumentRenderer"; import { NotionEditor } from "./editor"; import { PlatformTabs } from "./PlatformTabs"; +import { CanvasBreadcrumbHeader } from "../shared/CanvasBreadcrumbHeader"; const Container = styled.div` display: flex; flex-direction: column; height: 100%; + min-height: 0; + width: 100%; padding: 16px; + gap: 8px; `; const InnerContainer = styled.div` @@ -61,6 +65,7 @@ export const DocumentCanvas: React.FC = memo( ({ state, onStateChange, + onBackHome, onClose, isStreaming = false, onSelectionTextChange, @@ -191,6 +196,8 @@ export const DocumentCanvas: React.FC = memo( return ( + + void; + /** 返回首页回调 */ + onBackHome?: () => void; /** 关闭画布回调 */ onClose: () => void; /** 是否正在流式输出 */ diff --git a/src/components/content-creator/canvas/music/MusicCanvas.tsx b/src/components/content-creator/canvas/music/MusicCanvas.tsx index de0cea033..8c66765b0 100644 --- a/src/components/content-creator/canvas/music/MusicCanvas.tsx +++ b/src/components/content-creator/canvas/music/MusicCanvas.tsx @@ -14,6 +14,7 @@ import { PianoRollRenderer, } from "./renderers"; import { Copy, Check, Music } from "lucide-react"; +import { CanvasBreadcrumbHeader } from "../shared/CanvasBreadcrumbHeader"; /** 段落类型中文映射 */ const SECTION_DISPLAY_NAMES: Record = { @@ -30,7 +31,10 @@ const Container = styled.div` display: flex; flex-direction: column; height: 100%; + min-height: 0; + width: 100%; padding: 16px; + gap: 8px; `; const InnerContainer = styled.div` @@ -224,7 +228,7 @@ const StatusItem = styled.span` * 音乐画布主组件 */ export const MusicCanvas: React.FC = memo( - ({ state, onStateChange, onClose, isStreaming = false }) => { + ({ state, onStateChange, onBackHome, onClose, isStreaming = false }) => { const [toastMessage, setToastMessage] = useState(""); const [showToast, setShowToast] = useState(false); @@ -409,6 +413,8 @@ export const MusicCanvas: React.FC = memo( return ( + + void; + /** 返回首页回调 */ + onBackHome?: () => void; /** 关闭画布回调 */ onClose: () => void; /** 是否正在流式输出 */ diff --git a/src/components/content-creator/canvas/novel/NovelCanvas.tsx b/src/components/content-creator/canvas/novel/NovelCanvas.tsx index 4977e4249..117750b14 100644 --- a/src/components/content-creator/canvas/novel/NovelCanvas.tsx +++ b/src/components/content-creator/canvas/novel/NovelCanvas.tsx @@ -19,12 +19,16 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { NotionEditor } from "@/components/content-creator/canvas/document/editor"; import type { NovelCanvasState, Chapter } from "./types"; import { countWords } from "./types"; +import { CanvasBreadcrumbHeader } from "../shared/CanvasBreadcrumbHeader"; const Container = styled.div` display: flex; flex-direction: column; height: 100%; + min-height: 0; + width: 100%; padding: 16px; + gap: 8px; `; const InnerContainer = styled.div` @@ -172,6 +176,7 @@ const EmptyEditorState = styled.div` interface NovelCanvasProps { state: NovelCanvasState; onStateChange: (state: NovelCanvasState) => void; + onBackHome?: () => void; onClose: () => void; useExternalToolbar?: boolean; chapterListCollapsed?: boolean; @@ -225,6 +230,7 @@ export const NovelCanvas: React.FC = memo( ({ state, onStateChange, + onBackHome, onClose, useExternalToolbar = false, chapterListCollapsed, @@ -352,6 +358,8 @@ export const NovelCanvas: React.FC = memo( return ( + + {!useExternalToolbar && (
diff --git a/src/components/content-creator/canvas/poster/PosterCanvas.tsx b/src/components/content-creator/canvas/poster/PosterCanvas.tsx index e57810568..9363fb5ac 100644 --- a/src/components/content-creator/canvas/poster/PosterCanvas.tsx +++ b/src/components/content-creator/canvas/poster/PosterCanvas.tsx @@ -10,6 +10,7 @@ import { PosterToolbar } from "./PosterToolbar"; import { ElementToolbar } from "./ElementToolbar"; import { LayerPanel } from "./LayerPanel"; import { PageList } from "./PageList"; +import { CanvasBreadcrumbHeader } from "../shared/CanvasBreadcrumbHeader"; import { useFabricCanvas, useElementOperations, @@ -31,7 +32,10 @@ const Container = styled.div` display: flex; flex-direction: column; height: 100%; + min-height: 0; + width: 100%; padding: 16px; + gap: 8px; `; const InnerContainer = styled.div` @@ -98,7 +102,7 @@ const GridOverlay = styled.div<{ * @returns 海报画布组件 */ export const PosterCanvas: React.FC = memo( - ({ state, onStateChange, onClose }) => { + ({ state, onStateChange, onBackHome, onClose }) => { const canvasRef = useRef(null); const wrapperRef = useRef(null); const fileInputRef = useRef(null); @@ -345,6 +349,8 @@ export const PosterCanvas: React.FC = memo( return ( + + void; + /** 返回首页回调 */ + onBackHome?: () => void; /** 关闭画布回调 */ onClose: () => void; } diff --git a/src/components/content-creator/canvas/script/ScriptCanvas.tsx b/src/components/content-creator/canvas/script/ScriptCanvas.tsx index f26ed39d7..de874938e 100644 --- a/src/components/content-creator/canvas/script/ScriptCanvas.tsx +++ b/src/components/content-creator/canvas/script/ScriptCanvas.tsx @@ -12,12 +12,16 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { ScrollArea } from "@/components/ui/scroll-area"; import type { ScriptCanvasState, Scene, Dialogue } from "./types"; +import { CanvasBreadcrumbHeader } from "../shared/CanvasBreadcrumbHeader"; const Container = styled.div` display: flex; flex-direction: column; height: 100%; + min-height: 0; + width: 100%; padding: 16px; + gap: 8px; `; const InnerContainer = styled.div` @@ -131,11 +135,12 @@ const _CharacterName = styled.div` interface ScriptCanvasProps { state: ScriptCanvasState; onStateChange: (state: ScriptCanvasState) => void; + onBackHome?: () => void; onClose: () => void; } export const ScriptCanvas: React.FC = memo( - ({ state, onStateChange, onClose }) => { + ({ state, onStateChange, onBackHome, onClose }) => { const currentScene = state.scenes.find( (s) => s.id === state.currentSceneId, ); @@ -210,6 +215,8 @@ export const ScriptCanvas: React.FC = memo( return ( + +
剧本 diff --git a/src/components/content-creator/canvas/shared/CanvasBreadcrumbHeader.tsx b/src/components/content-creator/canvas/shared/CanvasBreadcrumbHeader.tsx new file mode 100644 index 000000000..5b0ab258f --- /dev/null +++ b/src/components/content-creator/canvas/shared/CanvasBreadcrumbHeader.tsx @@ -0,0 +1,58 @@ +import React, { memo } from "react"; +import styled from "styled-components"; +import { ChevronRight, Home } from "lucide-react"; + +export interface CanvasBreadcrumbHeaderProps { + label: string; + onBackHome?: () => void; + backTitle?: string; +} + +const Header = styled.div` + height: 26px; + display: flex; + align-items: center; + gap: 4px; + color: hsl(var(--muted-foreground)); + font-size: 12px; + padding: 0 2px; +`; + +const HomeButton = styled.button` + border: none; + background: transparent; + color: hsl(var(--muted-foreground)); + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + + &:hover { + color: hsl(var(--foreground)); + background: hsl(var(--accent)); + } +`; + +const CurrentLabel = styled.span` + font-weight: 600; + color: hsl(var(--muted-foreground)); +`; + +export const CanvasBreadcrumbHeader: React.FC = memo( + ({ label, onBackHome, backTitle = "返回首页" }) => { + return ( +
+ + + + + {label} +
+ ); + }, +); + +CanvasBreadcrumbHeader.displayName = "CanvasBreadcrumbHeader"; diff --git a/src/components/content-creator/canvas/video/PromptInput.tsx b/src/components/content-creator/canvas/video/PromptInput.tsx index d93e45c8f..0bd169ece 100644 --- a/src/components/content-creator/canvas/video/PromptInput.tsx +++ b/src/components/content-creator/canvas/video/PromptInput.tsx @@ -1,12 +1,17 @@ -import React, { memo, KeyboardEvent } from "react"; +import React, { memo, KeyboardEvent, useRef } from "react"; import styled from "styled-components"; import { VideoCanvasState } from "./types"; import { Sparkles } from "lucide-react"; +import { CharacterMention } from "@/components/agent/chat/components/Inputbar/components/CharacterMention"; +import { SkillBadge } from "@/components/agent/chat/components/Inputbar/components/SkillBadge"; +import { useActiveSkill } from "@/components/agent/chat/components/Inputbar/hooks/useActiveSkill"; +import type { Skill } from "@/lib/api/skills"; interface PromptInputProps { state: VideoCanvasState; onStateChange: (state: VideoCanvasState) => void; - onGenerate: () => void; + onGenerate: (textOverride?: string) => void; + skills?: Skill[]; } const PromptWrapper = styled.div` @@ -76,20 +81,48 @@ const GenerateButton = styled.button<{ $generating?: boolean }>` `; export const PromptInput: React.FC = memo( - ({ state, onStateChange, onGenerate }) => { + ({ state, onStateChange, onGenerate, skills = [] }) => { + const textareaRef = useRef(null); + const { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill } = + useActiveSkill(); + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (state.prompt.trim() && state.status !== "generating") { - onGenerate(); + handleGenerate(); } } }; + const handleGenerate = () => { + const text = activeSkill + ? wrapTextWithSkill(state.prompt) + : undefined; + onGenerate(text); + clearActiveSkill(); + }; + return ( + {/* CharacterMention */} + {skills.length > 0 && ( + onStateChange({ ...state, prompt: val })} + onSelectSkill={setActiveSkill} + /> + )} + {/* Skill Badge */} + {activeSkill && ( + + )} { onStateChange({ ...state, prompt: e.target.value }); @@ -104,7 +137,7 @@ export const PromptInput: React.FC = memo( diff --git a/src/components/content-creator/canvas/video/VideoCanvas.tsx b/src/components/content-creator/canvas/video/VideoCanvas.tsx index 65deeb64d..d6a3a9cc3 100644 --- a/src/components/content-creator/canvas/video/VideoCanvas.tsx +++ b/src/components/content-creator/canvas/video/VideoCanvas.tsx @@ -3,7 +3,6 @@ import styled from "styled-components"; import { ChevronLeft, ChevronRight, - Home, LayoutGrid, PanelLeftClose, PanelLeftOpen, @@ -12,6 +11,7 @@ import { VideoCanvasProps } from "./types"; import { VideoSidebar, type VideoProviderOption } from "./VideoSidebar"; import { VideoWorkspace } from "./VideoWorkspace"; import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider"; +import { CanvasBreadcrumbHeader } from "../shared/CanvasBreadcrumbHeader"; const VIDEO_MODEL_PRESETS: Record = { doubao: ["seedance-1-5-pro-251215", "seedance-1-5-lite-250428"], @@ -73,34 +73,6 @@ const Root = styled.div` background: hsl(var(--muted) / 0.28); `; -const Header = styled.div` - height: 26px; - display: flex; - align-items: center; - gap: 4px; - color: hsl(var(--muted-foreground)); - font-size: 12px; - padding: 0 2px; -`; - -const HeaderHome = styled.button` - border: none; - background: transparent; - color: hsl(var(--muted-foreground)); - width: 18px; - height: 18px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 4px; - cursor: pointer; - - &:hover { - color: hsl(var(--foreground)); - background: hsl(var(--accent)); - } -`; - const Body = styled.div` display: flex; flex: 1; @@ -310,13 +282,7 @@ export const VideoCanvas: React.FC = memo( return ( -
- - - - - 视频 -
+ diff --git a/src/components/content-creator/canvas/video/VideoWorkspace.tsx b/src/components/content-creator/canvas/video/VideoWorkspace.tsx index 3c6d138b3..ec1052f15 100644 --- a/src/components/content-creator/canvas/video/VideoWorkspace.tsx +++ b/src/components/content-creator/canvas/video/VideoWorkspace.tsx @@ -16,6 +16,7 @@ import { videoGenerationApi, type VideoGenerationTask, } from "@/lib/api/videoGeneration"; +import { skillsApi, type Skill } from "@/lib/api/skills"; interface VideoWorkspaceProps { state: VideoCanvasState; @@ -231,10 +232,19 @@ function mergeTaskList( export const VideoWorkspace: React.FC = memo( ({ state, projectId, onStateChange }) => { const [tasks, setTasks] = useState([]); + const [skills, setSkills] = useState([]); const pollingGuard = useRef(false); const savingTaskIdsRef = useRef>(new Set()); const materialRefCache = useRef>(new Map()); + // 加载技能列表 + useEffect(() => { + skillsApi + .getAll("proxycast") + .then(setSkills) + .catch((err) => console.error("加载技能列表失败:", err)); + }, []); + useEffect(() => { materialRefCache.current.clear(); }, [projectId]); @@ -486,7 +496,7 @@ export const VideoWorkspace: React.FC = memo( [projectId], ); - const handleGenerate = useCallback(async () => { + const handleGenerate = useCallback(async (textOverride?: string) => { if (!projectId) { toast.error("请先选择项目后再生成视频"); return; @@ -499,7 +509,8 @@ export const VideoWorkspace: React.FC = memo( toast.error("请选择视频模型"); return; } - if (!state.prompt.trim()) { + const promptText = textOverride || state.prompt.trim(); + if (!promptText) { toast.error("请输入视频描述"); return; } @@ -530,7 +541,7 @@ export const VideoWorkspace: React.FC = memo( projectId, providerId: state.providerId, model: state.model, - prompt: state.prompt.trim(), + prompt: promptText, aspectRatio: state.aspectRatio, resolution: state.resolution, duration: state.duration, @@ -573,6 +584,7 @@ export const VideoWorkspace: React.FC = memo( state={state} onStateChange={onStateChange} onGenerate={handleGenerate} + skills={skills} /> ) : ( @@ -634,6 +646,7 @@ export const VideoWorkspace: React.FC = memo( state={state} onStateChange={onStateChange} onGenerate={handleGenerate} + skills={skills} /> )} diff --git a/src/components/general-chat/chat/ChatPanel.tsx b/src/components/general-chat/chat/ChatPanel.tsx index 46f2fa576..d3e285917 100644 --- a/src/components/general-chat/chat/ChatPanel.tsx +++ b/src/components/general-chat/chat/ChatPanel.tsx @@ -6,7 +6,7 @@ * @requirements 3.1, 3.6, 5.4, 5.5, 2.6, 9.5, 10.2 */ -import React, { useState, useMemo, useCallback } from "react"; +import React, { useState, useMemo, useCallback, useEffect } from "react"; import { Settings, AlertTriangle, Loader2 } from "lucide-react"; import { useGeneralChatStore } from "../store/useGeneralChatStore"; import { useProvider } from "../hooks/useProvider"; @@ -19,6 +19,9 @@ import { WorkflowStatusPanel } from "../components/WorkflowStatusPanel"; import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; import type { MessageImage } from "@/components/agent/chat/types"; import { createGeneralInputAdapter } from "@/components/input-kit"; +import { skillsApi, type Skill } from "@/lib/api/skills"; +import type { Page, PageParams } from "@/types/page"; +import { SettingsTabs } from "@/types/settings"; interface ChatPanelProps { /** 当前会话 ID */ @@ -26,7 +29,7 @@ interface ChatPanelProps { /** 打开画布回调 */ onOpenCanvas: (state: CanvasState) => void; /** 页面导航回调 */ - onNavigate?: (page: string) => void; + onNavigate?: (page: Page, params?: PageParams) => void; } /** @@ -89,7 +92,7 @@ const NoProviderPrompt: React.FC = ({ className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-white rounded-lg font-medium hover:bg-accent/90 transition-colors shadow-sm" > - 前往配置 Provider + 前往配置模型 )}

@@ -110,6 +113,15 @@ export const ChatPanel: React.FC = ({ const [retryingMessageId, setRetryingMessageId] = useState( null, ); + const [skills, setSkills] = useState([]); + + // 加载技能列表 + useEffect(() => { + skillsApi + .getAll("proxycast") + .then(setSkills) + .catch((err) => console.error("加载技能列表失败:", err)); + }, []); // 直接从 store 获取状态 const messages = useGeneralChatStore((state) => state.messages); @@ -198,7 +210,7 @@ export const ChatPanel: React.FC = ({ // 处理导航到 Provider 配置页面 const handleNavigateToProviderConfig = useCallback(() => { - onNavigate?.("provider-pool"); + onNavigate?.("settings", { tab: SettingsTabs.Providers }); }, [onNavigate]); // 处理发送消息 @@ -207,11 +219,12 @@ export const ChatPanel: React.FC = ({ images?: MessageImage[], _webSearch?: boolean, _thinking?: boolean, + textOverride?: string, ) => { if (!sessionId || (!input.trim() && (!images || images.length === 0))) return; - const content = input.trim(); + const content = (textOverride || input).trim(); setInput(""); // 将 MessageImage 转换为 File 对象(用于 store) @@ -386,6 +399,7 @@ export const ChatPanel: React.FC = ({ hasAvailableProvider={hasProviderSelection} isLoading={providerSelectionLoading} error={providerSelectionError} + onManageProviders={handleNavigateToProviderConfig} />

@@ -397,6 +411,7 @@ export const ChatPanel: React.FC = ({ onStop={inputAdapter.actions.stop} isLoading={inputAdapter.state.isSending} disabled={inputAdapter.state.disabled} + skills={skills} />
diff --git a/src/components/general-chat/chat/CompactModelSelector.test.tsx b/src/components/general-chat/chat/CompactModelSelector.test.tsx index 6be1277d9..a4c689163 100644 --- a/src/components/general-chat/chat/CompactModelSelector.test.tsx +++ b/src/components/general-chat/chat/CompactModelSelector.test.tsx @@ -128,16 +128,39 @@ describe("CompactModelSelector", () => { expect(props.disabled).toBe(true); }); - it("无可用 Provider 且未加载时应显示提示并不渲染 ModelSelector", () => { + it("无可用 Provider 且未加载时应显示引导并不渲染 ModelSelector", () => { const { container } = renderCompactModelSelector({ hasAvailableProvider: false, isLoading: false, }); - expect(container.textContent).toContain("请先配置 Provider 凭证"); + expect(container.textContent).toContain("工具模型未配置"); expect(mockModelSelector).not.toHaveBeenCalled(); }); + it("无可用 Provider 时点击配置应触发回调", () => { + const onManageProviders = vi.fn(); + const { container } = renderCompactModelSelector({ + hasAvailableProvider: false, + isLoading: false, + onManageProviders, + }); + + const button = Array.from(container.querySelectorAll("button")).find( + (node) => node.textContent?.includes("配置"), + ) as HTMLButtonElement | undefined; + + if (!button) { + throw new Error("未找到配置按钮"); + } + + act(() => { + button.click(); + }); + + expect(onManageProviders).toHaveBeenCalledTimes(1); + }); + it("错误状态应展示异常提示文案", () => { const { container } = renderCompactModelSelector({ hasAvailableProvider: true, diff --git a/src/components/general-chat/chat/CompactModelSelector.tsx b/src/components/general-chat/chat/CompactModelSelector.tsx index 06087f473..de4df2f26 100644 --- a/src/components/general-chat/chat/CompactModelSelector.tsx +++ b/src/components/general-chat/chat/CompactModelSelector.tsx @@ -28,6 +28,8 @@ export interface CompactModelSelectorProps { isLoading?: boolean; /** 错误信息 */ error?: string | null; + /** 跳转到 Provider 配置 */ + onManageProviders?: () => void; } export const CompactModelSelector: React.FC = ({ @@ -40,18 +42,39 @@ export const CompactModelSelector: React.FC = ({ hasAvailableProvider, isLoading = false, error = null, + onManageProviders, }) => { if (!hasAvailableProvider && !isLoading) { return (
- - 请先配置 Provider 凭证 +
+
+ +
+
+ 工具模型未配置 +
+
+ 配置工具模型以获得更好的对话标题和记忆管理。 +
+
+
+ {onManageProviders && ( + + )} +
); } @@ -66,6 +89,7 @@ export const CompactModelSelector: React.FC = ({ compactTrigger popoverSide="top" disabled={disabled || isLoading} + onManageProviders={onManageProviders} /> {error && ( diff --git a/src/components/general-chat/types.ts b/src/components/general-chat/types.ts index f0cefda0e..0a6faec61 100644 --- a/src/components/general-chat/types.ts +++ b/src/components/general-chat/types.ts @@ -9,6 +9,8 @@ * @requirements 1.1, 2.1, 4.1 */ +import type { Page, PageParams } from "@/types/page"; + // ============================================================================ // 会话相关类型 // ============================================================================ @@ -409,7 +411,7 @@ export interface GeneralChatPageProps { /** 初始会话 ID (可选) */ initialSessionId?: string; /** 页面导航回调 */ - onNavigate?: (page: string) => void; + onNavigate?: (page: Page, params?: PageParams) => void; } /** @@ -421,7 +423,7 @@ export interface ChatPanelProps { /** 打开画布回调 */ onOpenCanvas: (content: CanvasState) => void; /** 页面导航回调 */ - onNavigate?: (page: string) => void; + onNavigate?: (page: Page, params?: PageParams) => void; } /** diff --git a/src/components/image-gen/ImageGenPage.tsx b/src/components/image-gen/ImageGenPage.tsx index 027fe3694..31b100b40 100644 --- a/src/components/image-gen/ImageGenPage.tsx +++ b/src/components/image-gen/ImageGenPage.tsx @@ -7,7 +7,6 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import styled from "styled-components"; import { - Home, Image as ImageIcon, ImagePlus, Loader2, @@ -29,6 +28,11 @@ import { setStoredResourceProjectId, } from "@/lib/resourceProjectSelection"; import type { Page } from "@/types/page"; +import { CharacterMention } from "@/components/agent/chat/components/Inputbar/components/CharacterMention"; +import { SkillBadge } from "@/components/agent/chat/components/Inputbar/components/SkillBadge"; +import { useActiveSkill } from "@/components/agent/chat/components/Inputbar/hooks/useActiveSkill"; +import { skillsApi, type Skill } from "@/lib/api/skills"; +import { CanvasBreadcrumbHeader } from "@/components/content-creator/canvas/shared/CanvasBreadcrumbHeader"; interface ImageGenPageProps { onNavigate?: (page: Page) => void; @@ -229,31 +233,10 @@ const PageLayout = styled.div` const HeaderBar = styled.div` display: flex; align-items: center; - padding: 16px 24px; - border-bottom: 1px solid hsl(var(--border)); + padding: 10px 16px 6px; background: hsl(var(--background)); `; -const BackButton = styled.button` - display: inline-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)); - } -`; - const ControlPanel = styled.aside` width: 280px; min-width: 280px; @@ -834,6 +817,10 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) { const { projects, defaultProject, loading: projectsLoading } = useProjects(); const [prompt, setPrompt] = useState(""); + const [skills, setSkills] = useState([]); + const { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill } = + useActiveSkill(); + const promptRef = useRef(null); const [resolutionPreset, setResolutionPreset] = useState("1k"); const [aspectRatio, setAspectRatio] = useState("1:1"); @@ -848,6 +835,14 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) { const fileInputRef = useRef(null); + // 加载技能列表 + useEffect(() => { + skillsApi + .getAll("proxycast") + .then(setSkills) + .catch((err) => console.error("加载技能列表失败:", err)); + }, []); + const availableProjects = useMemo( () => projects.filter((project) => !project.isArchived), [projects], @@ -988,14 +983,19 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) { const handleGenerate = async () => { if (!canGenerate) return; + const finalPrompt = activeSkill + ? wrapTextWithSkill(prompt.trim()) + : prompt.trim(); + try { - await generateImage(prompt.trim(), { + await generateImage(finalPrompt, { imageCount, referenceImages: referenceImages.map((item) => item.url), size: resolvedSize, targetProjectId: targetProjectId || undefined, }); setPrompt(""); + clearActiveSkill(); } catch (error) { console.error("图片生成失败:", error); } @@ -1044,10 +1044,7 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) { return ( - - - 返回首页 - + @@ -1369,7 +1366,23 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) { + {/* CharacterMention */} + {skills.length > 0 && ( + + )} + {/* Skill Badge */} + {activeSkill && ( + + )} setPrompt(event.target.value)} onKeyDown={handlePromptKeyDown} diff --git a/src/components/input-kit/ModelSelector.tsx b/src/components/input-kit/ModelSelector.tsx index 8c2438c8b..f0ba51451 100644 --- a/src/components/input-kit/ModelSelector.tsx +++ b/src/components/input-kit/ModelSelector.tsx @@ -1,5 +1,12 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; -import { ArrowRight, Bot, Check, ChevronDown, Settings2 } from "lucide-react"; +import { + AlertCircle, + ArrowRight, + Bot, + Check, + ChevronDown, + Settings2, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Popover, @@ -175,6 +182,44 @@ export const ModelSelector: React.FC = ({ normalizedTheme !== "general" && !filteredResult.usedFallback && filteredResult.filteredOutCount > 0; + const showNoProviderGuide = + !providersLoading && configuredProviders.length === 0; + + if (showNoProviderGuide) { + return ( +
+
+
+ +
+
+ 工具模型未配置 +
+
+ 配置工具模型以获得更好的对话标题和记忆管理。 +
+
+
+ {onManageProviders && ( + + )} +
+
+ ); + } return (
diff --git a/src/components/memory/MemoryPage.tsx b/src/components/memory/MemoryPage.tsx index 6f1f5f84d..f295348d2 100644 --- a/src/components/memory/MemoryPage.tsx +++ b/src/components/memory/MemoryPage.tsx @@ -32,6 +32,7 @@ import { import { cn } from "@/lib/utils"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import type { Page, PageParams } from "@/types/page"; +import { CanvasBreadcrumbHeader } from "@/components/content-creator/canvas/shared/CanvasBreadcrumbHeader"; import { getConfig, saveConfig, @@ -907,14 +908,8 @@ export function MemoryPage({ onNavigate }: MemoryPageProps) { return (
-
- +
+
diff --git a/src/components/plugins/PluginsPage.tsx b/src/components/plugins/PluginsPage.tsx index bf623aea7..e5cc1761f 100644 --- a/src/components/plugins/PluginsPage.tsx +++ b/src/components/plugins/PluginsPage.tsx @@ -7,11 +7,26 @@ * @module components/plugins/PluginsPage */ +import { CanvasBreadcrumbHeader } from "@/components/content-creator/canvas/shared/CanvasBreadcrumbHeader"; +import { buildHomeAgentParams } from "@/lib/workspace/navigation"; +import type { Page, PageParams } from "@/types/page"; import { PluginManager } from "./PluginManager"; -export function PluginsPage() { +interface PluginsPageProps { + onNavigate?: (page: Page, params?: PageParams) => void; +} + +export function PluginsPage({ onNavigate }: PluginsPageProps) { + const handleBackHome = () => { + onNavigate?.("agent", buildHomeAgentParams()); + }; + return (
+
+ +
+

插件中心

管理和配置 ProxyCast 插件

diff --git a/src/components/resources/ResourcesPage.tsx b/src/components/resources/ResourcesPage.tsx index 4a0f619ed..0bb98bddc 100644 --- a/src/components/resources/ResourcesPage.tsx +++ b/src/components/resources/ResourcesPage.tsx @@ -8,7 +8,6 @@ import { FileText, Folder, FolderPlus, - Home, Image as ImageIcon, Library, MoreHorizontal, @@ -62,6 +61,7 @@ import { import { cn } from "@/lib/utils"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import type { Page, PageParams } from "@/types/page"; +import { CanvasBreadcrumbHeader } from "@/components/content-creator/canvas/shared/CanvasBreadcrumbHeader"; import { fetchDocumentDetail } from "./services/resourceAdapter"; import type { ResourceItem } from "./services/types"; import { resourcesSelectors, useResourcesStore } from "./store"; @@ -631,14 +631,8 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { return (
-
- +
+
{saving && } diff --git a/src/components/settings-v2/_layout/index.tsx b/src/components/settings-v2/_layout/index.tsx index 07dd99517..ec33c27e9 100644 --- a/src/components/settings-v2/_layout/index.tsx +++ b/src/components/settings-v2/_layout/index.tsx @@ -7,32 +7,32 @@ 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 { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { Page, PageParams } from "@/types/page"; +import { CanvasBreadcrumbHeader } from "@/components/content-creator/canvas/shared/CanvasBreadcrumbHeader"; // 外观设置 import { AppearanceSettings } from '../general/appearance'; import { ChatAppearanceSettings } from '../general/chat-appearance'; // 网络代理 -import { ProxySettings } from "../../settings/ProxySettings"; +import { ProxySettings } from "../system/proxy"; // 安全与性能 import { SecurityPerformanceSettings } from "../system/security-performance"; // 心跳引擎 import { HeartbeatSettings } from "../system/heartbeat"; import { ExecutionTrackerSettings } from "../system/execution-tracker"; // 外部工具 -import { ExternalToolsSettings } from "../../settings/ExternalToolsSettings"; +import { ExternalToolsSettings } from "../system/external-tools"; // 实验功能 -import { ExperimentalSettings } from "../../settings/ExperimentalSettings"; +import { ExperimentalSettings } from "../system/experimental"; // 开发者 -import { DeveloperSettings } from "../../settings/DeveloperSettings"; +import { DeveloperSettings } from "../system/developer"; // 关于 -import { AboutSection } from "../../settings/AboutSection"; +import { AboutSection } from "../system/about"; // 扩展设置 -import { ExtensionsSettings } from "../../settings/ExtensionsSettings"; +import { ExtensionsSettings } from "../agent/skills"; // 快捷键设置 import { HotkeysSettings } from "../general/hotkeys"; // 记忆设置 @@ -93,31 +93,6 @@ const HeaderBar = styled.div` 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` display: flex; flex-direction: column; @@ -358,10 +333,7 @@ export function SettingsLayoutV2({ <> {/* 顶部返回栏 */} - - - 返回首页 - + {/* 设置内容 */} diff --git a/src/components/settings/ExtensionsSettings.tsx b/src/components/settings-v2/agent/skills/index.tsx similarity index 91% rename from src/components/settings/ExtensionsSettings.tsx rename to src/components/settings-v2/agent/skills/index.tsx index 0ff82aa81..9744c5d18 100644 --- a/src/components/settings/ExtensionsSettings.tsx +++ b/src/components/settings-v2/agent/skills/index.tsx @@ -1,4 +1,4 @@ -import { SkillsPage } from "../skills/SkillsPage"; +import { SkillsPage } from "@/components/skills/SkillsPage"; export function ExtensionsSettings() { return ( diff --git a/src/components/settings-v2/general/appearance/index.tsx b/src/components/settings-v2/general/appearance/index.tsx index 96ecd66f2..be0a1f63b 100644 --- a/src/components/settings-v2/general/appearance/index.tsx +++ b/src/components/settings-v2/general/appearance/index.tsx @@ -9,7 +9,10 @@ import { Moon, Sun, Monitor, Volume2, RotateCcw } from "lucide-react"; import { cn } from "@/lib/utils"; import { getConfig, saveConfig, Config } from "@/hooks/useTauri"; import { useOnboardingState } from "@/components/onboarding"; -import { LanguageSelector, Language } from "../../../settings/LanguageSelector"; +import { + LanguageSelector, + Language, +} from "../../shared/language/LanguageSelector"; import { useI18nPatch } from "@/i18n/I18nPatchProvider"; import { useSoundContext } from "@/contexts/useSoundContext"; diff --git a/src/components/settings-v2/legacy-import-guard.test.ts b/src/components/settings-v2/legacy-import-guard.test.ts new file mode 100644 index 000000000..15a979a5a --- /dev/null +++ b/src/components/settings-v2/legacy-import-guard.test.ts @@ -0,0 +1,48 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SETTINGS_V2_DIR = join(process.cwd(), "src/components/settings-v2"); + +function collectTsFiles(dir: string): string[] { + const entries = readdirSync(dir); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = join(dir, entry); + const stats = statSync(fullPath); + + if (stats.isDirectory()) { + files.push(...collectTsFiles(fullPath)); + continue; + } + + if (!fullPath.endsWith(".ts") && !fullPath.endsWith(".tsx")) { + continue; + } + + files.push(fullPath); + } + + return files; +} + +describe("settings-v2 legacy import guard", () => { + it("不应引用旧 settings 目录实现", () => { + const files = collectTsFiles(SETTINGS_V2_DIR); + const offenders: string[] = []; + + for (const filePath of files) { + const content = readFileSync(filePath, "utf8"); + + if ( + content.match(/from\s+["'][^"']*\/components\/settings\/[^"']*["']/) || + content.match(/from\s+["'][^"']*(?:\.\.\/)+settings\/[^"']*["']/) + ) { + offenders.push(relative(process.cwd(), filePath)); + } + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/src/components/settings/LanguageSelector.tsx b/src/components/settings-v2/shared/language/LanguageSelector.tsx similarity index 100% rename from src/components/settings/LanguageSelector.tsx rename to src/components/settings-v2/shared/language/LanguageSelector.tsx diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings-v2/system/about/index.tsx similarity index 100% rename from src/components/settings/AboutSection.tsx rename to src/components/settings-v2/system/about/index.tsx diff --git a/src/components/settings-v2/system/channels/index.tsx b/src/components/settings-v2/system/channels/index.tsx index da87b9919..d1cdf0f2b 100644 --- a/src/components/settings-v2/system/channels/index.tsx +++ b/src/components/settings-v2/system/channels/index.tsx @@ -1,17 +1,429 @@ /** * 渠道管理设置页面 * - * 提供两类渠道的管理: - * - AI 模型提供商渠道(OpenAI、Ollama、Anthropic 等) - * - 消息通知渠道(飞书、Telegram、Discord 等) + * Telegram / Discord / 飞书 三个 Bot 渠道的内联表单配置 */ -import React, { useState } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { SettingHeader } from "@/components/settings-v2/features/SettingHeader"; -import { AIChannelsList } from "./AIChannelsList"; -import { NotificationChannelsList } from "./NotificationChannelsList"; +import { + Eye, + EyeOff, + Plus, + X, + Loader2, + Save, + RotateCcw, + AlertCircle, +} from "lucide-react"; +import { + getConfig, + saveConfig, + Config, + ChannelsConfig, + TelegramBotConfig, + DiscordBotConfig, + FeishuBotConfig, +} from "@/hooks/useTauri"; +import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; + +// ============================================================================ +// 默认值 +// ============================================================================ + +const DEFAULT_CHANNELS: ChannelsConfig = { + telegram: { enabled: false, bot_token: "", allowed_user_ids: [], default_model: undefined }, + discord: { enabled: false, bot_token: "", allowed_server_ids: [], default_model: undefined }, + feishu: { enabled: false, app_id: "", app_secret: "", default_model: undefined }, +}; + +type TabKey = "telegram" | "discord" | "feishu"; + +// ============================================================================ +// 模型选择器子组件 +// ============================================================================ + +function DefaultModelSelect({ + value, + onChange, +}: { + value: string | undefined; + onChange: (v: string | undefined) => void; +}) { + const { providers, loading: providersLoading } = useConfiguredProviders(); + + return ( +
+ + +

+ 为此渠道指定默认使用的 AI 模型 +

+
+ ); +} + +// ============================================================================ +// 密码输入组件 +// ============================================================================ + +function PasswordInput({ + label, + value, + onChange, + placeholder, + hint, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + hint?: React.ReactNode; +}) { + const [show, setShow] = useState(false); + return ( +
+ +
+ onChange(e.target.value)} + placeholder={placeholder} + className="w-full px-3 py-2 pr-10 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> + +
+ {hint &&

{hint}

} +
+ ); +} + +// ============================================================================ +// 字符串列表输入组件 +// ============================================================================ + +function StringListInput({ + label, + values, + onChange, + placeholder, + hint, +}: { + label: string; + values: string[]; + onChange: (v: string[]) => void; + placeholder?: string; + hint?: string; +}) { + const [draft, setDraft] = useState(""); + + const addItem = () => { + const trimmed = draft.trim(); + if (trimmed && !values.includes(trimmed)) { + onChange([...values, trimmed]); + setDraft(""); + } + }; + + return ( +
+ +
+ setDraft(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addItem())} + placeholder={placeholder} + className="flex-1 px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> + +
+ {values.length > 0 && ( +
+ {values.map((v) => ( + + {v} + + + ))} +
+ )} + {hint &&

{hint}

} +
+ ); +} + +// ============================================================================ +// Telegram 表单 +// ============================================================================ + +function TelegramForm({ + config, + onChange, +}: { + config: TelegramBotConfig; + onChange: (c: TelegramBotConfig) => void; +}) { + return ( +
+
+
+

启用 Telegram Bot

+

开启后可通过 Telegram Bot 与 AI 对话

+
+ +
+ + onChange({ ...config, bot_token: v })} + placeholder="123456:ABC-DEF..." + hint={ + <> + 从{" "} + + @BotFather + {" "} + 获取 + + } + /> + + onChange({ ...config, allowed_user_ids: v })} + placeholder="输入 Telegram User ID" + hint="留空则允许所有用户" + /> + + onChange({ ...config, default_model: v })} + /> +
+ ); +} + +// ============================================================================ +// Discord 表单 +// ============================================================================ + +function DiscordForm({ + config, + onChange, +}: { + config: DiscordBotConfig; + onChange: (c: DiscordBotConfig) => void; +}) { + return ( +
+
+
+

启用 Discord Bot

+

开启后可通过 Discord Bot 与 AI 对话

+
+ +
+ + onChange({ ...config, bot_token: v })} + placeholder="MTIz..." + hint={ + <> + 从{" "} + + Discord Developer Portal + {" "} + 获取 + + } + /> + + onChange({ ...config, allowed_server_ids: v })} + placeholder="输入 Discord Server ID" + hint="留空则允许所有服务器" + /> + + onChange({ ...config, default_model: v })} + /> +
+ ); +} + +// ============================================================================ +// 飞书表单 +// ============================================================================ + +function FeishuForm({ + config, + onChange, +}: { + config: FeishuBotConfig; + onChange: (c: FeishuBotConfig) => void; +}) { + return ( +
+
+
+

启用飞书 Bot

+

开启后可通过飞书 Bot 与 AI 对话

+
+ +
+ +
+ + onChange({ ...config, app_id: e.target.value })} + placeholder="cli_xxxx" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm font-mono focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+ + onChange({ ...config, app_secret: v })} + placeholder="飞书应用的 App Secret" + /> + +
+ + + onChange({ ...config, verification_token: e.target.value || undefined }) + } + placeholder="事件订阅验证 Token" + className="w-full px-3 py-2 rounded-lg border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + /> +
+ + onChange({ ...config, encrypt_key: v || undefined })} + placeholder="事件加密密钥" + /> + + onChange({ ...config, default_model: v })} + /> +
+ ); +} + +// ============================================================================ +// 主组件 +// ============================================================================ export interface ChannelsSettingsProps { className?: string; @@ -19,36 +431,150 @@ export interface ChannelsSettingsProps { export function ChannelsSettings({ className }: ChannelsSettingsProps) { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState<"ai" | "notification">("ai"); + const [activeTab, setActiveTab] = useState("telegram"); + const [config, setConfig] = useState(null); + const [channels, setChannels] = useState(DEFAULT_CHANNELS); + const [initialJson, setInitialJson] = useState(""); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + + const isDirty = useMemo( + () => JSON.stringify(channels) !== initialJson, + [channels, initialJson], + ); + + const loadConfig = useCallback(async () => { + try { + const c = await getConfig(); + setConfig(c); + const ch = c.channels ?? DEFAULT_CHANNELS; + setChannels(ch); + setInitialJson(JSON.stringify(ch)); + } catch (e) { + console.error("加载配置失败", e); + } + }, []); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + const handleSave = async () => { + if (!config) return; + setSaving(true); + setMessage(null); + try { + await saveConfig({ ...config, channels }); + setInitialJson(JSON.stringify(channels)); + setMessage({ type: "success", text: "渠道配置已保存" }); + setTimeout(() => setMessage(null), 3000); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + setMessage({ type: "error", text: `保存失败: ${msg}` }); + } + setSaving(false); + }; + + const handleCancel = () => { + if (initialJson) { + setChannels(JSON.parse(initialJson)); + } + }; + + if (!config) { + return ( +
+ +
+ ); + } + + const TAB_LABELS: Record = { + telegram: "Telegram", + discord: "Discord", + feishu: "飞书", + }; return (
+ {message && ( +
+ {message.text} +
+ )} + setActiveTab(value as "ai" | "notification")} + onValueChange={(v) => setActiveTab(v as TabKey)} className="w-full" > - - - {t("AI 模型提供商", "AI 模型提供商")} - - - {t("消息通知渠道", "消息通知渠道")} - + + Telegram + Discord + 飞书 - - + + setChannels({ ...channels, telegram: tg })} + /> - - + + setChannels({ ...channels, discord: dc })} + /> + + + + setChannels({ ...channels, feishu: fs })} + /> + + {/* 底部固定栏 */} + {isDirty && ( +
+
+ + 未保存的更改 + + {TAB_LABELS[activeTab]} + +
+
+ + +
+
+ )}
); } -export default ChannelsSettings; +export default ChannelsSettings; \ No newline at end of file diff --git a/src/components/settings/DeveloperSettings.tsx b/src/components/settings-v2/system/developer/index.tsx similarity index 100% rename from src/components/settings/DeveloperSettings.tsx rename to src/components/settings-v2/system/developer/index.tsx diff --git a/src/components/settings/UpdateNotification.tsx b/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx similarity index 99% rename from src/components/settings/UpdateNotification.tsx rename to src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx index 1613f7edf..8ddcebe01 100644 --- a/src/components/settings/UpdateNotification.tsx +++ b/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx @@ -7,7 +7,7 @@ * * input: 用户配置操作 * output: 更新检查设置 UI - * pos: components/settings 层 + * pos: components/settings-v2 层 */ import { useState, useEffect } from "react"; diff --git a/src/components/settings/ExperimentalSettings.tsx b/src/components/settings-v2/system/experimental/index.tsx similarity index 98% rename from src/components/settings/ExperimentalSettings.tsx rename to src/components/settings-v2/system/experimental/index.tsx index d361f950e..9a2e5bda6 100644 --- a/src/components/settings/ExperimentalSettings.tsx +++ b/src/components/settings-v2/system/experimental/index.tsx @@ -1,7 +1,7 @@ /** * @file ExperimentalSettings.tsx * @description 实验室设置页面 - 管理实验性功能的开关和配置 - * @module components/settings/ExperimentalSettings + * @module components/settings-v2/system/experimental * * 需求: 6.1, 6.2, 6.3, 6.5 - 实验室标签页,截图对话功能开关,快捷键设置,权限警告 */ @@ -17,7 +17,7 @@ import { ExperimentalFeatures, } from "@/hooks/useTauri"; import { ShortcutSettings } from "@/components/smart-input/ShortcutSettings"; -import { UpdateCheckSettings } from "./UpdateNotification"; +import { UpdateCheckSettings } from "./UpdateCheckSettings"; import { VoiceSettings } from "@/components/voice"; import { getVoiceInputConfig, diff --git a/src/components/settings/ExternalToolsSettings.tsx b/src/components/settings-v2/system/external-tools/index.tsx similarity index 99% rename from src/components/settings/ExternalToolsSettings.tsx rename to src/components/settings-v2/system/external-tools/index.tsx index c3f3ec011..158db4574 100644 --- a/src/components/settings/ExternalToolsSettings.tsx +++ b/src/components/settings-v2/system/external-tools/index.tsx @@ -4,7 +4,7 @@ * 管理 Codex CLI 等外部命令行工具的状态和配置 * 这些工具有自己的认证系统,不通过 ProxyCast 凭证池管理 * - * @module components/settings/ExternalToolsSettings + * @module components/settings-v2/system/external-tools */ import { useState, useEffect, useCallback } from "react"; diff --git a/src/components/settings/ProxySettings.tsx b/src/components/settings-v2/system/proxy/index.tsx similarity index 100% rename from src/components/settings/ProxySettings.tsx rename to src/components/settings-v2/system/proxy/index.tsx diff --git a/src/components/settings/ConnectionsSettings.tsx b/src/components/settings/ConnectionsSettings.tsx deleted file mode 100644 index b6d24ba2c..000000000 --- a/src/components/settings/ConnectionsSettings.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/** - * 连接管理设置组件 - * - * 提供 Waveterm 风格的 connections.json 编辑界面。 - * 支持直接编辑 JSON 配置文件。 - * - * @module components/settings/ConnectionsSettings - */ - -import { useState, useEffect, useCallback } from "react"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Save, - RefreshCw, - FolderOpen, - AlertCircle, - CheckCircle, -} from "lucide-react"; -import { - getConnectionConfigPath, - getRawConnectionConfig, - saveRawConnectionConfig, -} from "@/lib/connection-api"; - -export function ConnectionsSettings() { - const [configPath, setConfigPath] = useState(""); - const [configContent, setConfigContent] = useState(""); - const [originalContent, setOriginalContent] = useState(""); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - // 加载配置 - const loadConfig = useCallback(async () => { - setLoading(true); - setError(null); - setSuccess(null); - - try { - const [path, content] = await Promise.all([ - getConnectionConfigPath(), - getRawConnectionConfig(), - ]); - setConfigPath(path); - setConfigContent(content); - setOriginalContent(content); - } catch (err) { - console.error("[ConnectionsSettings] 加载配置失败:", err); - setError(String(err)); - } finally { - setLoading(false); - } - }, []); - - // 首次加载 - useEffect(() => { - loadConfig(); - }, [loadConfig]); - - // 保存配置 - const handleSave = async () => { - setSaving(true); - setError(null); - setSuccess(null); - - try { - // 先验证 JSON 格式 - JSON.parse(configContent); - - const result = await saveRawConnectionConfig(configContent); - if (result.success) { - setOriginalContent(configContent); - setSuccess("配置已保存"); - setTimeout(() => setSuccess(null), 3000); - } else { - setError(result.error || "保存失败"); - } - } catch (err) { - if (err instanceof SyntaxError) { - setError(`JSON 格式错误: ${err.message}`); - } else { - setError(String(err)); - } - } finally { - setSaving(false); - } - }; - - // 检查是否有未保存的修改 - const hasChanges = configContent !== originalContent; - - // 格式化 JSON - const handleFormat = () => { - try { - const parsed = JSON.parse(configContent); - setConfigContent(JSON.stringify(parsed, null, 2)); - } catch { - setError("JSON 格式错误,无法格式化"); - } - }; - - return ( -
- - - - 连接配置 -
- - - -
-
- - - - {configPath} - - -
- - {/* 状态提示 */} - {error && ( -
- - {error} -
- )} - {success && ( -
- - {success} -
- )} - - {/* JSON 编辑器 */} -
-