mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: release v0.72.0 with full pending changes
This commit is contained in:
+39
-9
@@ -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 架构
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.71.0",
|
||||
"version": "0.72.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+31
-31
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
/// 飞书 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub encrypt_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
@@ -147,9 +147,7 @@ pub struct TestMessageResult {
|
||||
|
||||
/// 获取所有 AI 渠道
|
||||
#[tauri::command]
|
||||
pub async fn get_ai_channels(
|
||||
_state: State<'_, AppState>,
|
||||
) -> Result<Vec<AIChannel>, String> {
|
||||
pub async fn get_ai_channels(_state: State<'_, AppState>) -> Result<Vec<AIChannel>, 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<AIChannel, String> {
|
||||
pub async fn get_ai_channel(id: String, _state: State<'_, AppState>) -> Result<AIChannel, String> {
|
||||
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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
-1
@@ -451,7 +451,7 @@ function AppContent() {
|
||||
</PageWrapper>
|
||||
|
||||
<PageWrapper $isActive={currentPage === "plugins"}>
|
||||
<PluginsPage />
|
||||
<PluginsPage onNavigate={handleNavigate} />
|
||||
</PageWrapper>
|
||||
|
||||
<div
|
||||
@@ -508,6 +508,8 @@ function AppContent() {
|
||||
currentPage !== "settings" &&
|
||||
currentPage !== "memory" &&
|
||||
currentPage !== "image-gen" &&
|
||||
currentPage !== "tools" &&
|
||||
currentPage !== "plugins" &&
|
||||
currentPage !== "resources" &&
|
||||
!isThemeWorkspacePage(currentPage) &&
|
||||
!shouldHideSidebarForAgent;
|
||||
|
||||
@@ -24,7 +24,7 @@ React 组件层,包含 UI 组件和业务组件。
|
||||
- `resilience/` - 弹性策略配置组件
|
||||
- `routing/` - 路由规则配置组件
|
||||
- `screenshot-chat/` - 截图对话功能组件(实验室功能)
|
||||
- `settings/` - 设置页面组件
|
||||
- `settings-v2/` - 设置页面组件(当前主实现)
|
||||
- `skills/` - 技能管理组件
|
||||
- `switch/` - 开关控制组件
|
||||
- `terminal/` - 内置终端组件(使用 Tauri Commands)
|
||||
|
||||
@@ -104,6 +104,10 @@ interface MountedHarness {
|
||||
|
||||
const mountedRoots: MountedHarness[] = [];
|
||||
|
||||
interface MountOptions {
|
||||
onManageProviders?: () => 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}
|
||||
/>
|
||||
<div data-testid="current-model">
|
||||
{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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ChatSidebarProps> = ({
|
||||
onDeleteTopic,
|
||||
onRenameTopic,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<"skills" | "topics">("topics");
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [loadingSkills, setLoadingSkills] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const editInputRef = React.useRef<HTMLInputElement>(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<ChatSidebarProps> = ({
|
||||
}
|
||||
}, [editingTopicId]);
|
||||
|
||||
const installedSkills = skills.filter((s) => s.installed);
|
||||
const availableSkills = skills.filter((s) => !s.installed);
|
||||
|
||||
return (
|
||||
<SidebarContainer className="w-64 shrink-0">
|
||||
<TabsContainer>
|
||||
<TabItem
|
||||
$active={activeTab === "skills"}
|
||||
onClick={() => setActiveTab("skills")}
|
||||
>
|
||||
技能
|
||||
{installedSkills.length > 0 && (
|
||||
<TabBadge>{installedSkills.length}</TabBadge>
|
||||
)}
|
||||
</TabItem>
|
||||
<TabItem
|
||||
$active={activeTab === "topics"}
|
||||
onClick={() => setActiveTab("topics")}
|
||||
>
|
||||
话题
|
||||
{topics.length > 0 && <TabBadge>{topics.length}</TabBadge>}
|
||||
</TabItem>
|
||||
</TabsContainer>
|
||||
|
||||
<Toolbar>
|
||||
<NewTopicButton onClick={onNewChat}>
|
||||
<Plus size={16} />
|
||||
<span>新建话题</span>
|
||||
<MoreHorizontal size={14} className="ml-auto opacity-40" />
|
||||
</NewTopicButton>
|
||||
</Toolbar>
|
||||
|
||||
<ListContainer className="custom-scrollbar">
|
||||
{activeTab === "topics" ? (
|
||||
<>
|
||||
{topics.length === 0 ? (
|
||||
<EmptyState>暂无话题,点击上方新建</EmptyState>
|
||||
) : (
|
||||
topics.map((topic) => (
|
||||
<ListItem
|
||||
key={topic.id}
|
||||
$active={topic.id === currentTopicId}
|
||||
onClick={() => {
|
||||
if (editingTopicId !== topic.id) {
|
||||
onSwitchTopic(topic.id);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) =>
|
||||
handleStartEdit(e, topic.id, topic.title)
|
||||
}
|
||||
>
|
||||
<MessageSquare
|
||||
size={15}
|
||||
className={
|
||||
topic.id === currentTopicId
|
||||
? "text-primary"
|
||||
: "opacity-50"
|
||||
}
|
||||
/>
|
||||
{editingTopicId === topic.id ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => 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",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="title">{topic.title}</span>
|
||||
)}
|
||||
{editingTopicId !== topic.id && (
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={(e) => handleDeleteClick(e, topic.id)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
{topics.length === 0 ? (
|
||||
<EmptyState>暂无话题,点击上方新建</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
{/* 默认助手 */}
|
||||
<ListItem $active={true}>
|
||||
<Bot size={15} className="text-primary" />
|
||||
<span className="title">默认助手</span>
|
||||
topics.map((topic) => (
|
||||
<ListItem
|
||||
key={topic.id}
|
||||
$active={topic.id === currentTopicId}
|
||||
onClick={() => {
|
||||
if (editingTopicId !== topic.id) {
|
||||
onSwitchTopic(topic.id);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) =>
|
||||
handleStartEdit(e, topic.id, topic.title)
|
||||
}
|
||||
>
|
||||
<MessageSquare
|
||||
size={15}
|
||||
className={
|
||||
topic.id === currentTopicId
|
||||
? "text-primary"
|
||||
: "opacity-50"
|
||||
}
|
||||
/>
|
||||
{editingTopicId === topic.id ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => 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",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="title">{topic.title}</span>
|
||||
)}
|
||||
{editingTopicId !== topic.id && (
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={(e) => handleDeleteClick(e, topic.id)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</ListItem>
|
||||
|
||||
{loadingSkills ? (
|
||||
<EmptyState>
|
||||
<Loader2 size={20} className="animate-spin mx-auto mb-2" />
|
||||
加载中...
|
||||
</EmptyState>
|
||||
) : skills.length === 0 ? (
|
||||
<EmptyState>暂无可用技能</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
{installedSkills.length > 0 && (
|
||||
<>
|
||||
<SectionTitle>已安装</SectionTitle>
|
||||
{installedSkills.map((skill) => (
|
||||
<SkillCard key={skill.directory} $installed={true}>
|
||||
<SkillInfo>
|
||||
<SkillName>{skill.name}</SkillName>
|
||||
{skill.description && (
|
||||
<SkillDesc>{skill.description}</SkillDesc>
|
||||
)}
|
||||
</SkillInfo>
|
||||
<SkillAction
|
||||
$installed={true}
|
||||
onClick={() => handleUninstall(skill)}
|
||||
disabled={actionLoading === skill.directory}
|
||||
title="卸载"
|
||||
>
|
||||
{actionLoading === skill.directory ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Check size={16} />
|
||||
)}
|
||||
</SkillAction>
|
||||
</SkillCard>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{availableSkills.length > 0 && (
|
||||
<>
|
||||
<SectionTitle>可安装</SectionTitle>
|
||||
{availableSkills.map((skill) => (
|
||||
<SkillCard key={skill.directory} $installed={false}>
|
||||
<SkillInfo>
|
||||
<SkillName>{skill.name}</SkillName>
|
||||
{skill.description && (
|
||||
<SkillDesc>{skill.description}</SkillDesc>
|
||||
)}
|
||||
</SkillInfo>
|
||||
<SkillAction
|
||||
$installed={false}
|
||||
onClick={() => handleInstall(skill)}
|
||||
disabled={actionLoading === skill.directory}
|
||||
title="安装"
|
||||
>
|
||||
{actionLoading === skill.directory ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={16} />
|
||||
)}
|
||||
</SkillAction>
|
||||
</SkillCard>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
))
|
||||
)}
|
||||
</ListContainer>
|
||||
</SidebarContainer>
|
||||
|
||||
@@ -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<HTMLTextAreaElement>;
|
||||
/** 当前输入值 */
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const commandRef = useRef<HTMLDivElement>(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 (
|
||||
<Popover open={showMentions} onOpenChange={setShowMentions}>
|
||||
@@ -156,7 +254,7 @@ export function CharacterMention({
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: cursorPosition.top - 200, // 弹窗在输入框上方
|
||||
top: cursorPosition.top,
|
||||
left: cursorPosition.left,
|
||||
width: 1,
|
||||
height: 1,
|
||||
@@ -166,38 +264,85 @@ export function CharacterMention({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
ref={popoverRef}
|
||||
className="w-80 p-0"
|
||||
className="w-80 p-0 bg-background border shadow-md"
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<Command>
|
||||
<Command ref={commandRef} className="bg-background">
|
||||
<CommandInput
|
||||
placeholder="搜索角色..."
|
||||
placeholder="搜索角色或技能..."
|
||||
value={mentionQuery}
|
||||
onValueChange={setMentionQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>没有找到角色</CommandEmpty>
|
||||
<CommandGroup heading="选择角色">
|
||||
{filteredCharacters.map((character) => (
|
||||
<CommandItem
|
||||
key={character.id}
|
||||
onSelect={() => handleSelectCharacter(character)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{character.name}</div>
|
||||
{character.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{character.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{!hasFilteredResults && (
|
||||
<CommandEmpty>没有找到匹配项</CommandEmpty>
|
||||
)}
|
||||
{filteredCharacters.length > 0 && (
|
||||
<CommandGroup heading="角色">
|
||||
{filteredCharacters.map((character) => (
|
||||
<CommandItem
|
||||
key={character.id}
|
||||
onSelect={() => handleSelectCharacter(character)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{character.name}</div>
|
||||
{character.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{character.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{installedSkills.length > 0 && (
|
||||
<CommandGroup heading="已安装技能">
|
||||
{installedSkills.map((skill) => (
|
||||
<CommandItem
|
||||
key={skill.directory}
|
||||
onSelect={() => handleSelectInstalledSkill(skill)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4 text-primary" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{availableSkills.length > 0 && (
|
||||
<CommandGroup heading="未安装技能">
|
||||
{availableSkills.map((skill) => (
|
||||
<CommandItem
|
||||
key={skill.directory}
|
||||
onSelect={() => handleSelectAvailableSkill(skill)}
|
||||
className="cursor-pointer opacity-60"
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -49,6 +49,8 @@ interface InputbarCoreProps {
|
||||
leftExtra?: React.ReactNode;
|
||||
/** 输入框底栏右侧扩展区域 */
|
||||
rightExtra?: React.ReactNode;
|
||||
/** 输入框内部顶部扩展区域(textarea 上方) */
|
||||
topExtra?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const InputbarCore: React.FC<InputbarCoreProps> = ({
|
||||
@@ -70,6 +72,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
|
||||
textareaRef: externalTextareaRef,
|
||||
leftExtra,
|
||||
rightExtra,
|
||||
topExtra,
|
||||
}) => {
|
||||
return (
|
||||
<BaseComposer
|
||||
@@ -115,6 +118,8 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
|
||||
</ImagePreviewContainer>
|
||||
)}
|
||||
|
||||
{topExtra}
|
||||
|
||||
<StyledTextarea
|
||||
ref={textareaRef}
|
||||
{...textareaProps}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { Zap, X } from "lucide-react";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
interface SkillBadgeProps {
|
||||
skill: Skill;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export const SkillBadge: React.FC<SkillBadgeProps> = ({ skill, onClear }) => (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 mx-1 mt-1 rounded-md bg-primary/15 text-primary text-xs font-medium w-fit">
|
||||
<Zap className="w-3 h-3" />
|
||||
<span>{skill.name}</span>
|
||||
<button onClick={onClear} className="hover:opacity-70 ml-0.5">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
export function useActiveSkill() {
|
||||
const [activeSkill, setActiveSkill] = useState<Skill | null>(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 };
|
||||
}
|
||||
@@ -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<InputbarProps> = ({
|
||||
onToggleTaskFiles,
|
||||
onTaskFileClick,
|
||||
characters = [],
|
||||
skills = [],
|
||||
onSelectCharacter,
|
||||
onNavigateToSettings,
|
||||
providerType,
|
||||
setProviderType,
|
||||
model,
|
||||
@@ -202,6 +211,7 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
const [activeTools, setActiveTools] = useState<Record<string, boolean>>({});
|
||||
const [pendingImages, setPendingImages] = useState<MessageImage[]>([]);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const { activeSkill, setActiveSkill, clearActiveSkill } = useActiveSkill();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -445,15 +455,22 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
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<InputbarProps> = ({
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{/* 角色引用组件 */}
|
||||
{characters.length > 0 && (
|
||||
{/* 角色与技能引用组件 */}
|
||||
{(characters.length > 0 || skills.length > 0) && (
|
||||
<CharacterMention
|
||||
characters={characters}
|
||||
skills={skills}
|
||||
inputRef={textareaRef}
|
||||
value={input}
|
||||
onChange={inputAdapter.actions.setText}
|
||||
onSelectCharacter={onSelectCharacter}
|
||||
onSelectSkill={setActiveSkill}
|
||||
onNavigateToSettings={onNavigateToSettings}
|
||||
/>
|
||||
)}
|
||||
<InputbarCore
|
||||
@@ -600,6 +620,11 @@ export const Inputbar: React.FC<InputbarProps> = ({
|
||||
onPaste={handlePaste}
|
||||
isFullscreen={isFullscreen}
|
||||
isCanvasOpen={isCanvasOpen}
|
||||
topExtra={
|
||||
activeSkill ? (
|
||||
<SkillBadge skill={activeSkill} onClear={clearActiveSkill} />
|
||||
) : undefined
|
||||
}
|
||||
leftExtra={
|
||||
!isFullscreen ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Skill[]>([]);
|
||||
|
||||
// 用于追踪已处理的消息 ID,避免重复处理
|
||||
const processedMessageIds = useRef<Set<string>>(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,
|
||||
|
||||
@@ -219,6 +219,7 @@ export const CanvasAdapter: React.FC<CanvasAdapterProps> = memo(
|
||||
theme="general"
|
||||
state={canvasState}
|
||||
onStateChange={handleStateChange}
|
||||
onBackHome={handleClose}
|
||||
onClose={handleClose}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
|
||||
@@ -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<CanvasFactoryProps> = 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<CanvasFactoryProps> = memo(
|
||||
<DocumentCanvas
|
||||
state={state}
|
||||
onStateChange={onStateChange as (s: DocumentCanvasState) => void}
|
||||
onBackHome={resolvedBackHome}
|
||||
onClose={onClose}
|
||||
isStreaming={isStreaming}
|
||||
onSelectionTextChange={onSelectionTextChange}
|
||||
@@ -92,6 +98,7 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
<PosterCanvas
|
||||
state={state}
|
||||
onStateChange={onStateChange as (s: PosterCanvasState) => void}
|
||||
onBackHome={resolvedBackHome}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
@@ -102,6 +109,7 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
<MusicCanvas
|
||||
state={state}
|
||||
onStateChange={onStateChange as (s: MusicCanvasState) => void}
|
||||
onBackHome={resolvedBackHome}
|
||||
onClose={onClose}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
@@ -113,6 +121,7 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
<ScriptCanvas
|
||||
state={state}
|
||||
onStateChange={onStateChange as (s: ScriptCanvasState) => void}
|
||||
onBackHome={resolvedBackHome}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
@@ -123,6 +132,7 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
<NovelCanvas
|
||||
state={state}
|
||||
onStateChange={onStateChange as (s: NovelCanvasState) => void}
|
||||
onBackHome={resolvedBackHome}
|
||||
onClose={onClose}
|
||||
useExternalToolbar={novelControls?.useExternalToolbar}
|
||||
chapterListCollapsed={novelControls?.chapterListCollapsed}
|
||||
@@ -139,6 +149,7 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
|
||||
<VideoCanvas
|
||||
state={state}
|
||||
onStateChange={onStateChange as (s: VideoCanvasState) => void}
|
||||
onBackHome={resolvedBackHome}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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<DocumentCanvasProps> = memo(
|
||||
({
|
||||
state,
|
||||
onStateChange,
|
||||
onBackHome,
|
||||
onClose,
|
||||
isStreaming = false,
|
||||
onSelectionTextChange,
|
||||
@@ -191,6 +196,8 @@ export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<CanvasBreadcrumbHeader label="文档" onBackHome={onBackHome} />
|
||||
|
||||
<InnerContainer>
|
||||
<DocumentToolbar
|
||||
currentVersion={currentVersion}
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface DocumentCanvasProps {
|
||||
state: DocumentCanvasState;
|
||||
/** 状态变更回调 */
|
||||
onStateChange: (state: DocumentCanvasState) => void;
|
||||
/** 返回首页回调 */
|
||||
onBackHome?: () => void;
|
||||
/** 关闭画布回调 */
|
||||
onClose: () => void;
|
||||
/** 是否正在流式输出 */
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -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<MusicCanvasProps> = 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<MusicCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<CanvasBreadcrumbHeader label="音乐" onBackHome={onBackHome} />
|
||||
|
||||
<InnerContainer>
|
||||
<MusicToolbar
|
||||
spec={state.spec}
|
||||
|
||||
@@ -451,6 +451,8 @@ export interface MusicCanvasProps {
|
||||
state: MusicCanvasState;
|
||||
/** 状态变更回调 */
|
||||
onStateChange: (state: MusicCanvasState) => void;
|
||||
/** 返回首页回调 */
|
||||
onBackHome?: () => void;
|
||||
/** 关闭画布回调 */
|
||||
onClose: () => void;
|
||||
/** 是否正在流式输出 */
|
||||
|
||||
@@ -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<NovelCanvasProps> = memo(
|
||||
({
|
||||
state,
|
||||
onStateChange,
|
||||
onBackHome,
|
||||
onClose,
|
||||
useExternalToolbar = false,
|
||||
chapterListCollapsed,
|
||||
@@ -352,6 +358,8 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<CanvasBreadcrumbHeader label="小说" onBackHome={onBackHome} />
|
||||
|
||||
<InnerContainer>
|
||||
{!useExternalToolbar && (
|
||||
<Header>
|
||||
|
||||
@@ -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<PosterCanvasProps> = memo(
|
||||
({ state, onStateChange, onClose }) => {
|
||||
({ state, onStateChange, onBackHome, onClose }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -345,6 +349,8 @@ export const PosterCanvas: React.FC<PosterCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<CanvasBreadcrumbHeader label="海报" onBackHome={onBackHome} />
|
||||
|
||||
<InnerContainer>
|
||||
<PosterToolbar
|
||||
zoom={zoom}
|
||||
|
||||
@@ -168,6 +168,8 @@ export interface PosterCanvasProps {
|
||||
state: PosterCanvasState;
|
||||
/** 状态变更回调 */
|
||||
onStateChange: (state: PosterCanvasState) => void;
|
||||
/** 返回首页回调 */
|
||||
onBackHome?: () => void;
|
||||
/** 关闭画布回调 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -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<ScriptCanvasProps> = 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<ScriptCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<CanvasBreadcrumbHeader label="剧本" onBackHome={onBackHome} />
|
||||
|
||||
<InnerContainer>
|
||||
<Header>
|
||||
<Title>剧本</Title>
|
||||
|
||||
@@ -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<CanvasBreadcrumbHeaderProps> = memo(
|
||||
({ label, onBackHome, backTitle = "返回首页" }) => {
|
||||
return (
|
||||
<Header>
|
||||
<HomeButton type="button" onClick={onBackHome} title={backTitle}>
|
||||
<Home size={12} />
|
||||
</HomeButton>
|
||||
<ChevronRight size={12} />
|
||||
<CurrentLabel>{label}</CurrentLabel>
|
||||
</Header>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CanvasBreadcrumbHeader.displayName = "CanvasBreadcrumbHeader";
|
||||
@@ -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<PromptInputProps> = memo(
|
||||
({ state, onStateChange, onGenerate }) => {
|
||||
({ state, onStateChange, onGenerate, skills = [] }) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill } =
|
||||
useActiveSkill();
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<PromptWrapper>
|
||||
<InputContainer>
|
||||
{/* CharacterMention */}
|
||||
{skills.length > 0 && (
|
||||
<CharacterMention
|
||||
characters={[]}
|
||||
skills={skills}
|
||||
inputRef={textareaRef}
|
||||
value={state.prompt}
|
||||
onChange={(val) => onStateChange({ ...state, prompt: val })}
|
||||
onSelectSkill={setActiveSkill}
|
||||
/>
|
||||
)}
|
||||
{/* Skill Badge */}
|
||||
{activeSkill && (
|
||||
<SkillBadge skill={activeSkill} onClear={clearActiveSkill} />
|
||||
)}
|
||||
<StyledTextarea
|
||||
ref={textareaRef}
|
||||
value={state.prompt}
|
||||
onChange={(e) => {
|
||||
onStateChange({ ...state, prompt: e.target.value });
|
||||
@@ -104,7 +137,7 @@ export const PromptInput: React.FC<PromptInputProps> = memo(
|
||||
<GenerateButton
|
||||
disabled={!state.prompt.trim() || state.status === "generating"}
|
||||
$generating={state.status === "generating"}
|
||||
onClick={onGenerate}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
<Sparkles size={20} />
|
||||
</GenerateButton>
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
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<VideoCanvasProps> = memo(
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<Header>
|
||||
<HeaderHome onClick={onBackHome} title="返回首页">
|
||||
<Home size={12} />
|
||||
</HeaderHome>
|
||||
<ChevronRight size={12} />
|
||||
<span>视频</span>
|
||||
</Header>
|
||||
<CanvasBreadcrumbHeader label="视频" onBackHome={onBackHome} />
|
||||
|
||||
<Body>
|
||||
<SidebarContainer $collapsed={sidebarCollapsed}>
|
||||
|
||||
@@ -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<VideoWorkspaceProps> = memo(
|
||||
({ state, projectId, onStateChange }) => {
|
||||
const [tasks, setTasks] = useState<WorkspaceTask[]>([]);
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const pollingGuard = useRef(false);
|
||||
const savingTaskIdsRef = useRef<Set<string>>(new Set());
|
||||
const materialRefCache = useRef<Map<string, string>>(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<VideoWorkspaceProps> = 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<VideoWorkspaceProps> = 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<VideoWorkspaceProps> = 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<VideoWorkspaceProps> = memo(
|
||||
state={state}
|
||||
onStateChange={onStateChange}
|
||||
onGenerate={handleGenerate}
|
||||
skills={skills}
|
||||
/>
|
||||
</EmptyStateWrapper>
|
||||
) : (
|
||||
@@ -634,6 +646,7 @@ export const VideoWorkspace: React.FC<VideoWorkspaceProps> = memo(
|
||||
state={state}
|
||||
onStateChange={onStateChange}
|
||||
onGenerate={handleGenerate}
|
||||
skills={skills}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
)}
|
||||
|
||||
@@ -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<NoProviderPromptProps> = ({
|
||||
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"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
前往配置 Provider
|
||||
前往配置模型
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-ink-400 mt-4">
|
||||
@@ -110,6 +113,15 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({
|
||||
const [retryingMessageId, setRetryingMessageId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
|
||||
// 加载技能列表
|
||||
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<ChatPanelProps> = ({
|
||||
|
||||
// 处理导航到 Provider 配置页面
|
||||
const handleNavigateToProviderConfig = useCallback(() => {
|
||||
onNavigate?.("provider-pool");
|
||||
onNavigate?.("settings", { tab: SettingsTabs.Providers });
|
||||
}, [onNavigate]);
|
||||
|
||||
// 处理发送消息
|
||||
@@ -207,11 +219,12 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({
|
||||
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<ChatPanelProps> = ({
|
||||
hasAvailableProvider={hasProviderSelection}
|
||||
isLoading={providerSelectionLoading}
|
||||
error={providerSelectionError}
|
||||
onManageProviders={handleNavigateToProviderConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -397,6 +411,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({
|
||||
onStop={inputAdapter.actions.stop}
|
||||
isLoading={inputAdapter.state.isSending}
|
||||
disabled={inputAdapter.state.disabled}
|
||||
skills={skills}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface CompactModelSelectorProps {
|
||||
isLoading?: boolean;
|
||||
/** 错误信息 */
|
||||
error?: string | null;
|
||||
/** 跳转到 Provider 配置 */
|
||||
onManageProviders?: () => void;
|
||||
}
|
||||
|
||||
export const CompactModelSelector: React.FC<CompactModelSelectorProps> = ({
|
||||
@@ -40,18 +42,39 @@ export const CompactModelSelector: React.FC<CompactModelSelectorProps> = ({
|
||||
hasAvailableProvider,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
onManageProviders,
|
||||
}) => {
|
||||
|
||||
if (!hasAvailableProvider && !isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-amber-600",
|
||||
"w-full rounded-lg border border-amber-200 bg-amber-50/60 px-3 py-2.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>请先配置 Provider 凭证</span>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-amber-900">
|
||||
工具模型未配置
|
||||
</div>
|
||||
<div className="text-xs text-amber-700 leading-5">
|
||||
配置工具模型以获得更好的对话标题和记忆管理。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onManageProviders && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onManageProviders}
|
||||
className="h-8 px-3 rounded-md border border-amber-300 bg-white text-xs font-medium text-amber-800 hover:bg-amber-100 hover:text-amber-900 transition-colors shrink-0"
|
||||
>
|
||||
配置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +89,7 @@ export const CompactModelSelector: React.FC<CompactModelSelectorProps> = ({
|
||||
compactTrigger
|
||||
popoverSide="top"
|
||||
disabled={disabled || isLoading}
|
||||
onManageProviders={onManageProviders}
|
||||
/>
|
||||
{error && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-destructive">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Skill[]>([]);
|
||||
const { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill } =
|
||||
useActiveSkill();
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [resolutionPreset, setResolutionPreset] =
|
||||
useState<ResolutionPreset>("1k");
|
||||
const [aspectRatio, setAspectRatio] = useState("1:1");
|
||||
@@ -848,6 +835,14 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<PageLayout>
|
||||
<HeaderBar>
|
||||
<BackButton onClick={goHome}>
|
||||
<Home size={16} />
|
||||
返回首页
|
||||
</BackButton>
|
||||
<CanvasBreadcrumbHeader label="绘画" onBackHome={goHome} />
|
||||
</HeaderBar>
|
||||
|
||||
<Container>
|
||||
@@ -1369,7 +1366,23 @@ export function ImageGenPage({ onNavigate }: ImageGenPageProps) {
|
||||
</Canvas>
|
||||
|
||||
<PromptDock>
|
||||
{/* CharacterMention */}
|
||||
{skills.length > 0 && (
|
||||
<CharacterMention
|
||||
characters={[]}
|
||||
skills={skills}
|
||||
inputRef={promptRef}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSelectSkill={setActiveSkill}
|
||||
/>
|
||||
)}
|
||||
{/* Skill Badge */}
|
||||
{activeSkill && (
|
||||
<SkillBadge skill={activeSkill} onClear={clearActiveSkill} />
|
||||
)}
|
||||
<PromptInput
|
||||
ref={promptRef}
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
onKeyDown={handlePromptKeyDown}
|
||||
|
||||
@@ -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<ModelSelectorProps> = ({
|
||||
normalizedTheme !== "general" &&
|
||||
!filteredResult.usedFallback &&
|
||||
filteredResult.filteredOutCount > 0;
|
||||
const showNoProviderGuide =
|
||||
!providersLoading && configuredProviders.length === 0;
|
||||
|
||||
if (showNoProviderGuide) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full rounded-lg border border-amber-200 bg-amber-50/60 p-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<AlertCircle className="h-4 w-4 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-amber-900">
|
||||
工具模型未配置
|
||||
</div>
|
||||
<div className="text-xs text-amber-700 leading-5">
|
||||
配置工具模型以获得更好的对话标题和记忆管理。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onManageProviders && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 border-amber-300 bg-white text-amber-800 hover:bg-amber-100 hover:text-amber-900"
|
||||
onClick={onManageProviders}
|
||||
>
|
||||
配置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center", className)}>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<div className="flex items-center border-b bg-background px-6 py-4">
|
||||
<button
|
||||
onClick={handleBackToHome}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-3.5 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
返回首页
|
||||
</button>
|
||||
<div className="flex items-center bg-background px-6 pt-3 pb-2">
|
||||
<CanvasBreadcrumbHeader label="记忆" onBackHome={handleBackToHome} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="pb-1">
|
||||
<CanvasBreadcrumbHeader label="插件中心" onBackHome={handleBackHome} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">插件中心</h1>
|
||||
<p className="text-muted-foreground mt-1">管理和配置 ProxyCast 插件</p>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<div className="flex items-center justify-between border-b bg-background px-6 py-4">
|
||||
<button
|
||||
onClick={handleBackToHome}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-3.5 py-2 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
返回首页
|
||||
</button>
|
||||
<div className="flex items-center justify-between border-b bg-background px-6 py-3">
|
||||
<CanvasBreadcrumbHeader label="资源" onBackHome={handleBackToHome} />
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{saving && <RefreshCw className="h-4 w-4 animate-spin" />}
|
||||
|
||||
@@ -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({
|
||||
<>
|
||||
{/* 顶部返回栏 */}
|
||||
<HeaderBar>
|
||||
<BackButton onClick={handleBackToHome}>
|
||||
<Home />
|
||||
返回首页
|
||||
</BackButton>
|
||||
<CanvasBreadcrumbHeader label="设置" onBackHome={handleBackToHome} />
|
||||
</HeaderBar>
|
||||
{/* 设置内容 */}
|
||||
<LayoutContainer>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { SkillsPage } from "../skills/SkillsPage";
|
||||
import { SkillsPage } from "@/components/skills/SkillsPage";
|
||||
|
||||
export function ExtensionsSettings() {
|
||||
return (
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">默认模型</label>
|
||||
<select
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
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"
|
||||
>
|
||||
<option value="">未指定(使用全局默认)</option>
|
||||
{providersLoading && <option disabled>加载中...</option>}
|
||||
{providers.map((p) => (
|
||||
<optgroup key={p.key} label={p.label}>
|
||||
{p.customModels?.map((m) => (
|
||||
<option key={`${p.key}/${m}`} value={`${p.key}/${m}`}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
为此渠道指定默认使用的 AI 模型
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 密码输入组件
|
||||
// ============================================================================
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">{label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={show ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow(!show)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-muted"
|
||||
>
|
||||
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{hint && <p className="text-xs text-muted-foreground mt-1">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 字符串列表输入组件
|
||||
// ============================================================================
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">{label}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
className="px-3 py-2 rounded-lg border hover:bg-muted text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{values.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{values.map((v) => (
|
||||
<span
|
||||
key={v}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-muted text-sm"
|
||||
>
|
||||
{v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(values.filter((x) => x !== v))}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hint && <p className="text-xs text-muted-foreground mt-1">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Telegram 表单
|
||||
// ============================================================================
|
||||
|
||||
function TelegramForm({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: TelegramBotConfig;
|
||||
onChange: (c: TelegramBotConfig) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 p-4 rounded-lg border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">启用 Telegram Bot</h3>
|
||||
<p className="text-xs text-muted-foreground">开启后可通过 Telegram Bot 与 AI 对话</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={config.enabled}
|
||||
onClick={() => onChange({ ...config, enabled: !config.enabled })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
config.enabled ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
config.enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Bot Token"
|
||||
value={config.bot_token}
|
||||
onChange={(v) => onChange({ ...config, bot_token: v })}
|
||||
placeholder="123456:ABC-DEF..."
|
||||
hint={
|
||||
<>
|
||||
从{" "}
|
||||
<a
|
||||
href="https://t.me/BotFather"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
@BotFather
|
||||
</a>{" "}
|
||||
获取
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<StringListInput
|
||||
label="允许的用户 ID"
|
||||
values={config.allowed_user_ids}
|
||||
onChange={(v) => onChange({ ...config, allowed_user_ids: v })}
|
||||
placeholder="输入 Telegram User ID"
|
||||
hint="留空则允许所有用户"
|
||||
/>
|
||||
|
||||
<DefaultModelSelect
|
||||
value={config.default_model}
|
||||
onChange={(v) => onChange({ ...config, default_model: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Discord 表单
|
||||
// ============================================================================
|
||||
|
||||
function DiscordForm({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: DiscordBotConfig;
|
||||
onChange: (c: DiscordBotConfig) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 p-4 rounded-lg border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">启用 Discord Bot</h3>
|
||||
<p className="text-xs text-muted-foreground">开启后可通过 Discord Bot 与 AI 对话</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={config.enabled}
|
||||
onClick={() => onChange({ ...config, enabled: !config.enabled })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
config.enabled ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
config.enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Bot Token"
|
||||
value={config.bot_token}
|
||||
onChange={(v) => onChange({ ...config, bot_token: v })}
|
||||
placeholder="MTIz..."
|
||||
hint={
|
||||
<>
|
||||
从{" "}
|
||||
<a
|
||||
href="https://discord.com/developers/applications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Discord Developer Portal
|
||||
</a>{" "}
|
||||
获取
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<StringListInput
|
||||
label="允许的服务器 ID"
|
||||
values={config.allowed_server_ids}
|
||||
onChange={(v) => onChange({ ...config, allowed_server_ids: v })}
|
||||
placeholder="输入 Discord Server ID"
|
||||
hint="留空则允许所有服务器"
|
||||
/>
|
||||
|
||||
<DefaultModelSelect
|
||||
value={config.default_model}
|
||||
onChange={(v) => onChange({ ...config, default_model: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 飞书表单
|
||||
// ============================================================================
|
||||
|
||||
function FeishuForm({
|
||||
config,
|
||||
onChange,
|
||||
}: {
|
||||
config: FeishuBotConfig;
|
||||
onChange: (c: FeishuBotConfig) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 p-4 rounded-lg border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">启用飞书 Bot</h3>
|
||||
<p className="text-xs text-muted-foreground">开启后可通过飞书 Bot 与 AI 对话</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={config.enabled}
|
||||
onClick={() => onChange({ ...config, enabled: !config.enabled })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
config.enabled ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
config.enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">App ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.app_id}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="App Secret"
|
||||
value={config.app_secret}
|
||||
onChange={(v) => onChange({ ...config, app_secret: v })}
|
||||
placeholder="飞书应用的 App Secret"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">
|
||||
Verification Token <span className="text-muted-foreground font-normal">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.verification_token || ""}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Encrypt Key(可选)"
|
||||
value={config.encrypt_key || ""}
|
||||
onChange={(v) => onChange({ ...config, encrypt_key: v || undefined })}
|
||||
placeholder="事件加密密钥"
|
||||
/>
|
||||
|
||||
<DefaultModelSelect
|
||||
value={config.default_model}
|
||||
onChange={(v) => onChange({ ...config, default_model: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 主组件
|
||||
// ============================================================================
|
||||
|
||||
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<TabKey>("telegram");
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [channels, setChannels] = useState<ChannelsConfig>(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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TAB_LABELS: Record<TabKey, string> = {
|
||||
telegram: "Telegram",
|
||||
discord: "Discord",
|
||||
feishu: "飞书",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<SettingHeader title={t("渠道管理", "渠道管理")} />
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`rounded-lg border p-3 text-sm mb-4 ${
|
||||
message.type === "error"
|
||||
? "border-destructive bg-destructive/10 text-destructive"
|
||||
: "border-green-500 bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as "ai" | "notification")}
|
||||
onValueChange={(v) => setActiveTab(v as TabKey)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="ai">
|
||||
{t("AI 模型提供商", "AI 模型提供商")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notification">
|
||||
{t("消息通知渠道", "消息通知渠道")}
|
||||
</TabsTrigger>
|
||||
<TabsList className="grid w-full max-w-md grid-cols-3">
|
||||
<TabsTrigger value="telegram">Telegram</TabsTrigger>
|
||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||
<TabsTrigger value="feishu">飞书</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ai" className="mt-6">
|
||||
<AIChannelsList />
|
||||
<TabsContent value="telegram" className="mt-6">
|
||||
<TelegramForm
|
||||
config={channels.telegram}
|
||||
onChange={(tg) => setChannels({ ...channels, telegram: tg })}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notification" className="mt-6">
|
||||
<NotificationChannelsList />
|
||||
<TabsContent value="discord" className="mt-6">
|
||||
<DiscordForm
|
||||
config={channels.discord}
|
||||
onChange={(dc) => setChannels({ ...channels, discord: dc })}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feishu" className="mt-6">
|
||||
<FeishuForm
|
||||
config={channels.feishu}
|
||||
onChange={(fs) => setChannels({ ...channels, feishu: fs })}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 底部固定栏 */}
|
||||
{isDirty && (
|
||||
<div className="sticky bottom-0 mt-6 flex items-center justify-between rounded-lg border bg-background/95 backdrop-blur p-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
<span>未保存的更改</span>
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium">
|
||||
{TAB_LABELS[activeTab]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-muted"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelsSettings;
|
||||
export default ChannelsSettings;
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* input: 用户配置操作
|
||||
* output: 更新检查设置 UI
|
||||
* pos: components/settings 层
|
||||
* pos: components/settings-v2 层
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
+2
-2
@@ -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,
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
* 管理 Codex CLI 等外部命令行工具的状态和配置
|
||||
* 这些工具有自己的认证系统,不通过 ProxyCast 凭证池管理
|
||||
*
|
||||
* @module components/settings/ExternalToolsSettings
|
||||
* @module components/settings-v2/system/external-tools
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
@@ -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<string>("");
|
||||
const [configContent, setConfigContent] = useState<string>("");
|
||||
const [originalContent, setOriginalContent] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(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 (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>连接配置</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadConfig}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFormat}
|
||||
disabled={loading}
|
||||
>
|
||||
格式化
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded">
|
||||
{configPath}
|
||||
</code>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 状态提示 */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 mb-4 text-sm text-red-500 bg-red-500/10 rounded-md">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 mb-4 text-sm text-green-500 bg-green-500/10 rounded-md">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON 编辑器 */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
className="w-full h-[400px] p-4 font-mono text-sm bg-zinc-900 text-zinc-100 border border-zinc-700 rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
value={configContent}
|
||||
onChange={(e) => setConfigContent(e.target.value)}
|
||||
placeholder={loading ? "加载中..." : "// 在此编辑连接配置"}
|
||||
disabled={loading}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{hasChanges && (
|
||||
<div className="absolute top-2 right-2 px-2 py-1 text-xs bg-yellow-500/20 text-yellow-500 rounded">
|
||||
未保存
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 帮助信息 */}
|
||||
<div className="mt-4 p-4 bg-muted/50 rounded-md">
|
||||
<h4 className="font-medium mb-2">配置格式说明</h4>
|
||||
<pre className="text-xs text-muted-foreground overflow-x-auto">
|
||||
{`{
|
||||
"connections": {
|
||||
"my-server": {
|
||||
"type": "ssh",
|
||||
"user": "root",
|
||||
"host": "192.168.1.100",
|
||||
"port": 22,
|
||||
"identityFile": "~/.ssh/id_rsa"
|
||||
},
|
||||
"dev-server": {
|
||||
"type": "ssh",
|
||||
"user": "developer",
|
||||
"host": "dev.example.com"
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</pre>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
支持的字段: <code>type</code> (ssh/local/wsl), <code>user</code>,{" "}
|
||||
<code>host</code>, <code>port</code>, <code>identityFile</code>,{" "}
|
||||
<code>proxyJump</code>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SSH 配置提示 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">SSH 配置</CardTitle>
|
||||
<CardDescription>
|
||||
系统 SSH 配置文件 (~/.ssh/config) 中的 Host 也会自动显示在连接列表中
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
你可以在 ~/.ssh/config 中配置 SSH
|
||||
连接,这些配置会自动被识别并显示在终端连接选择器中。
|
||||
如果需要自定义配置或添加不在 SSH 配置中的连接,请使用上方的 JSON
|
||||
编辑器。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, expect } from "vitest";
|
||||
import { test, fc } from "@fast-check/vitest";
|
||||
import { validateProxyUrl } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Property-Based Tests for validateProxyUrl
|
||||
*
|
||||
* **Feature: network-proxy-settings, Property 2: 无效 URL 格式拒绝**
|
||||
* **Validates: Requirements 1.5**
|
||||
*/
|
||||
describe("validateProxyUrl", () => {
|
||||
// Property 2: 无效 URL 格式拒绝
|
||||
// *For any* 不符合代理 URL 格式的字符串(如缺少协议、包含空格等),验证函数应该返回 false
|
||||
|
||||
test.prop([fc.constant("")])(
|
||||
"empty string should be valid (no proxy)",
|
||||
(url) => {
|
||||
expect(validateProxyUrl(url)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.prop([fc.constant(" ")])(
|
||||
"whitespace-only string should be valid (no proxy)",
|
||||
(url) => {
|
||||
expect(validateProxyUrl(url)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
// Valid proxy URLs should pass
|
||||
test.prop([
|
||||
fc.oneof(fc.constant("http"), fc.constant("https"), fc.constant("socks5")),
|
||||
fc.ipV4(),
|
||||
fc.integer({ min: 1, max: 65535 }),
|
||||
])("valid proxy URL format should pass", (protocol, host, port) => {
|
||||
const url = `${protocol}://${host}:${port}`;
|
||||
expect(validateProxyUrl(url)).toBe(true);
|
||||
});
|
||||
|
||||
// URLs without valid protocol should fail
|
||||
test.prop([
|
||||
fc.constantFrom("ftp", "file", "tcp", "udp", "ws", "wss", "mailto", "tel"),
|
||||
fc.ipV4(),
|
||||
fc.integer({ min: 1, max: 65535 }),
|
||||
])("invalid protocol should fail", (protocol, host, port) => {
|
||||
const url = `${protocol}://${host}:${port}`;
|
||||
expect(validateProxyUrl(url)).toBe(false);
|
||||
});
|
||||
|
||||
// URLs with spaces should fail
|
||||
test.prop([
|
||||
fc.oneof(fc.constant("http"), fc.constant("https"), fc.constant("socks5")),
|
||||
fc.ipV4(),
|
||||
fc.integer({ min: 1, max: 65535 }),
|
||||
])("URL with embedded spaces should fail", (protocol, host, port) => {
|
||||
const url = `${protocol}:// ${host}:${port}`;
|
||||
expect(validateProxyUrl(url)).toBe(false);
|
||||
});
|
||||
|
||||
// URLs missing protocol separator should fail
|
||||
test.prop([fc.ipV4(), fc.integer({ min: 1, max: 65535 })])(
|
||||
"URL without protocol should fail",
|
||||
(host, port) => {
|
||||
const url = `${host}:${port}`;
|
||||
expect(validateProxyUrl(url)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
// Random strings without protocol pattern should fail
|
||||
test.prop([
|
||||
fc
|
||||
.string({ minLength: 1, maxLength: 50 })
|
||||
.filter(
|
||||
(s) => !s.match(/^(https?|socks5?):\/\//i) && s.trim().length > 0,
|
||||
),
|
||||
])("random non-URL strings should fail", (str) => {
|
||||
expect(validateProxyUrl(str)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,479 +0,0 @@
|
||||
/**
|
||||
* @file GeneralSettings.tsx
|
||||
* @description 通用设置页面 - 主题、代理、启动行为配置
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Moon,
|
||||
Sun,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
Info,
|
||||
RotateCcw,
|
||||
Volume2,
|
||||
Palette,
|
||||
} from "lucide-react";
|
||||
import { cn, validateProxyUrl } from "@/lib/utils";
|
||||
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
|
||||
import { useOnboardingState } from "@/components/onboarding";
|
||||
import { LanguageSelector, Language } from "./LanguageSelector";
|
||||
import { useI18nPatch } from "@/i18n/I18nPatchProvider";
|
||||
import { useSoundContext } from "@/contexts/useSoundContext";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
/** 所有可用的内容创作主题 */
|
||||
const ALL_CONTENT_THEMES = [
|
||||
{ id: "general", label: "通用" },
|
||||
{ id: "social-media", label: "社媒内容" },
|
||||
{ id: "poster", label: "图文海报" },
|
||||
{ id: "music", label: "歌词曲谱" },
|
||||
{ id: "video", label: "短视频" },
|
||||
{ id: "novel", label: "小说" },
|
||||
{ id: "knowledge", label: "知识探索" },
|
||||
{ id: "planning", label: "计划规划" },
|
||||
{ id: "document", label: "办公文档" },
|
||||
] as const;
|
||||
|
||||
/** 默认启用的主题 */
|
||||
const DEFAULT_ENABLED_THEMES = [
|
||||
"general",
|
||||
"social-media",
|
||||
"poster",
|
||||
"music",
|
||||
"video",
|
||||
"novel",
|
||||
];
|
||||
|
||||
/** 所有可用的导航模块 */
|
||||
const ALL_NAV_ITEMS = [
|
||||
{ id: "home-general", label: "首页" },
|
||||
{ id: "video", label: "视频" },
|
||||
{ id: "image-gen", label: "绘画" },
|
||||
{ id: "batch", label: "批量任务" },
|
||||
{ id: "plugins", label: "插件中心" },
|
||||
] as const;
|
||||
|
||||
/** 默认启用的导航模块 */
|
||||
const DEFAULT_ENABLED_NAV_ITEMS = [
|
||||
"home-general",
|
||||
"video",
|
||||
"image-gen",
|
||||
"plugins",
|
||||
];
|
||||
|
||||
export function GeneralSettings() {
|
||||
const [theme, setTheme] = useState<Theme>("system");
|
||||
const [launchOnStartup, setLaunchOnStartup] = useState(false);
|
||||
const [minimizeToTray, setMinimizeToTray] = useState(true);
|
||||
const [language, setLanguageState] = useState<Language>("zh");
|
||||
const [enabledThemes, setEnabledThemes] = useState<string[]>(
|
||||
DEFAULT_ENABLED_THEMES,
|
||||
);
|
||||
const [enabledNavItems, setEnabledNavItems] = useState<string[]>(
|
||||
DEFAULT_ENABLED_NAV_ITEMS,
|
||||
);
|
||||
const { resetOnboarding } = useOnboardingState();
|
||||
const { setLanguage: setI18nLanguage } = useI18nPatch();
|
||||
const { soundEnabled, setSoundEnabled, playToolcallSound } =
|
||||
useSoundContext();
|
||||
|
||||
// 重新运行引导
|
||||
const handleResetOnboarding = useCallback(() => {
|
||||
resetOnboarding();
|
||||
window.location.reload();
|
||||
}, [resetOnboarding]);
|
||||
|
||||
// 网络代理状态
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [proxyUrl, setProxyUrl] = useState<string>("");
|
||||
const [proxyError, setProxyError] = useState<string | null>(null);
|
||||
const [proxySaving, setProxySaving] = useState(false);
|
||||
const [proxyMessage, setProxyMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [configLoading, setConfigLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("theme") as Theme | null;
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
setConfigLoading(true);
|
||||
try {
|
||||
const c = await getConfig();
|
||||
setConfig(c);
|
||||
setProxyUrl(c.proxy_url || "");
|
||||
setMinimizeToTray(c.minimize_to_tray ?? true);
|
||||
setLanguageState((c.language || "zh") as Language);
|
||||
setEnabledThemes(
|
||||
c.content_creator?.enabled_themes || DEFAULT_ENABLED_THEMES,
|
||||
);
|
||||
setEnabledNavItems(
|
||||
c.navigation?.enabled_items || DEFAULT_ENABLED_NAV_ITEMS,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("加载配置失败:", e);
|
||||
} finally {
|
||||
setConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeChange = (newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
const root = document.documentElement;
|
||||
if (newTheme === "system") {
|
||||
const systemDark = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches;
|
||||
root.classList.toggle("dark", systemDark);
|
||||
} else {
|
||||
root.classList.toggle("dark", newTheme === "dark");
|
||||
}
|
||||
};
|
||||
|
||||
const handleProxyUrlChange = (value: string) => {
|
||||
setProxyUrl(value);
|
||||
if (value && !validateProxyUrl(value)) {
|
||||
setProxyError("格式无效,请使用 http://、https:// 或 socks5:// 开头");
|
||||
} else {
|
||||
setProxyError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProxy = async () => {
|
||||
if (!config) return;
|
||||
if (proxyUrl && !validateProxyUrl(proxyUrl)) {
|
||||
setProxyError("格式无效,请使用 http://、https:// 或 socks5:// 开头");
|
||||
return;
|
||||
}
|
||||
setProxySaving(true);
|
||||
setProxyMessage(null);
|
||||
try {
|
||||
const newConfig = { ...config, proxy_url: proxyUrl.trim() || null };
|
||||
await saveConfig(newConfig);
|
||||
setConfig(newConfig);
|
||||
setProxyMessage({ type: "success", text: "已保存" });
|
||||
setTimeout(() => setProxyMessage(null), 2000);
|
||||
} catch (e: unknown) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
setProxyMessage({ type: "error", text: `保存失败: ${errorMessage}` });
|
||||
} finally {
|
||||
setProxySaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (newLanguage: Language) => {
|
||||
if (!config) return;
|
||||
try {
|
||||
const newConfig = { ...config, language: newLanguage };
|
||||
await saveConfig(newConfig);
|
||||
setConfig(newConfig);
|
||||
setLanguageState(newLanguage);
|
||||
// Update i18n context to trigger DOM replacement
|
||||
setI18nLanguage(newLanguage);
|
||||
} catch (err) {
|
||||
console.error("保存语言设置失败:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换内容创作主题
|
||||
const handleThemeToggle = async (themeId: string) => {
|
||||
if (!config) return;
|
||||
const newThemes = enabledThemes.includes(themeId)
|
||||
? enabledThemes.filter((t) => t !== themeId)
|
||||
: [...enabledThemes, themeId];
|
||||
|
||||
// 至少保留一个主题
|
||||
if (newThemes.length === 0) return;
|
||||
|
||||
setEnabledThemes(newThemes);
|
||||
try {
|
||||
const newConfig = {
|
||||
...config,
|
||||
content_creator: { enabled_themes: newThemes },
|
||||
};
|
||||
await saveConfig(newConfig);
|
||||
setConfig(newConfig);
|
||||
// 触发内容创作主题更新
|
||||
window.dispatchEvent(new CustomEvent("theme-config-changed"));
|
||||
} catch (err) {
|
||||
console.error("保存主题设置失败:", err);
|
||||
// 回滚
|
||||
setEnabledThemes(enabledThemes);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换导航模块
|
||||
const handleNavItemToggle = async (itemId: string) => {
|
||||
if (!config) return;
|
||||
const newItems = enabledNavItems.includes(itemId)
|
||||
? enabledNavItems.filter((i) => i !== itemId)
|
||||
: [...enabledNavItems, itemId];
|
||||
|
||||
// 至少保留一个模块
|
||||
if (newItems.length === 0) return;
|
||||
|
||||
setEnabledNavItems(newItems);
|
||||
try {
|
||||
const newConfig = {
|
||||
...config,
|
||||
navigation: { enabled_items: newItems },
|
||||
};
|
||||
await saveConfig(newConfig);
|
||||
setConfig(newConfig);
|
||||
// 触发侧边栏更新
|
||||
window.dispatchEvent(new CustomEvent("nav-config-changed"));
|
||||
} catch (err) {
|
||||
console.error("保存导航设置失败:", err);
|
||||
// 回滚
|
||||
setEnabledNavItems(enabledNavItems);
|
||||
}
|
||||
};
|
||||
|
||||
const themeOptions = [
|
||||
{ id: "light" as Theme, label: "浅色", icon: Sun },
|
||||
{ id: "dark" as Theme, label: "深色", icon: Moon },
|
||||
{ id: "system" as Theme, label: "系统", icon: Monitor },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
{/* 网络代理 */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium">全局代理</h3>
|
||||
{proxyMessage && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded",
|
||||
proxyMessage.type === "error"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
)}
|
||||
>
|
||||
{proxyMessage.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{configLoading ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={proxyUrl}
|
||||
onChange={(e) => handleProxyUrlChange(e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
|
||||
className={cn(
|
||||
"flex-1 px-3 py-1.5 rounded border bg-background text-sm focus:ring-1 focus:ring-primary/20 focus:border-primary outline-none",
|
||||
proxyError && "border-destructive",
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveProxy}
|
||||
disabled={proxySaving || !!proxyError}
|
||||
className="px-3 py-1.5 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{proxySaving ? "..." : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
{proxyError ? (
|
||||
<p className="text-xs text-destructive">{proxyError}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Info className="h-3 w-3" />
|
||||
凭证级代理优先于全局代理,留空表示直连
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 主题 */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">主题</h3>
|
||||
<div className="flex gap-1">
|
||||
{themeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => handleThemeChange(option.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1 rounded text-sm transition-colors",
|
||||
theme === option.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<option.icon className="h-3.5 w-3.5" />
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 语言 */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">语言</h3>
|
||||
<LanguageSelector
|
||||
currentLanguage={language}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音效 */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">音效</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
工具调用和打字时播放提示音
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={soundEnabled}
|
||||
onChange={(e) => {
|
||||
setSoundEnabled(e.target.checked);
|
||||
if (e.target.checked) {
|
||||
playToolcallSound();
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 启动行为 */}
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<h3 className="text-sm font-medium">启动行为</h3>
|
||||
|
||||
<label className="flex items-center justify-between py-1.5 cursor-pointer">
|
||||
<span className="text-sm">开机自启动</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={launchOnStartup}
|
||||
onChange={(e) => setLaunchOnStartup(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between py-1.5 cursor-pointer border-t pt-2">
|
||||
<span className="text-sm">关闭时最小化到托盘</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={minimizeToTray}
|
||||
onChange={async (e) => {
|
||||
const newValue = e.target.checked;
|
||||
setMinimizeToTray(newValue);
|
||||
if (config) {
|
||||
try {
|
||||
await saveConfig({ ...config, minimize_to_tray: newValue });
|
||||
setConfig({ ...config, minimize_to_tray: newValue });
|
||||
} catch (err) {
|
||||
console.error("保存最小化到托盘设置失败:", err);
|
||||
setMinimizeToTray(!newValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 内容创作主题 */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">内容创作主题</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择在内容创作模式中显示的主题标签
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ALL_CONTENT_THEMES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => handleThemeToggle(t.id)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
enabledThemes.includes(t.id)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导航模块 */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">导航模块</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择在左侧导航栏中显示的功能模块
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ALL_NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleNavItemToggle(item.id)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
enabledNavItems.includes(item.id)
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 重新运行引导 */}
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">初次设置向导</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
重新运行初次安装向导,重新选择用户群体和安装插件
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleResetOnboarding}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded border text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
重新引导
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
# settings
|
||||
|
||||
<!-- 一旦我所属的文件夹有所变化,请更新我 -->
|
||||
|
||||
## 架构说明
|
||||
|
||||
设置页面组件集合,提供应用配置界面。采用分组设计,每个设置类别独立为一个组件。
|
||||
|
||||
## 文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `index.ts` | 模块导出入口 |
|
||||
| `SettingsPage.tsx` | 设置页面主容器,管理标签页切换 |
|
||||
| `GeneralSettings.tsx` | 通用设置(主题、语言等) |
|
||||
| `ProxySettings.tsx` | 代理服务器设置 |
|
||||
| `ConnectionsSettings.tsx` | 连接管理设置 |
|
||||
| `DeveloperSettings.tsx` | 开发者选项设置 |
|
||||
| `ExperimentalSettings.tsx` | 实验室功能设置(截图对话、自动更新检查等) |
|
||||
| `ExtensionsSettings.tsx` | 扩展管理设置 |
|
||||
| `AboutSection.tsx` | 关于页面(版本信息、更新检查) |
|
||||
| `LanguageSelector.tsx` | 语言选择器组件 |
|
||||
| `UpdateNotification.tsx` | 更新提醒弹窗和更新检查设置组件 |
|
||||
| `GeneralSettings.test.ts` | 通用设置测试文件 |
|
||||
|
||||
## 更新提醒
|
||||
|
||||
任何文件变更后,请更新此文档和相关的上级文档。
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GeneralSettings } from "./GeneralSettings";
|
||||
import { AboutSection } from "./AboutSection";
|
||||
import { ExtensionsSettings } from "./ExtensionsSettings";
|
||||
import { DeveloperSettings } from "./DeveloperSettings";
|
||||
import { ConnectionsSettings } from "./ConnectionsSettings";
|
||||
import { ExperimentalSettings } from "./ExperimentalSettings";
|
||||
import { ExternalToolsSettings } from "./ExternalToolsSettings";
|
||||
|
||||
type SettingsTab =
|
||||
| "general"
|
||||
| "connections"
|
||||
| "tools"
|
||||
| "extensions"
|
||||
| "experimental"
|
||||
| "developer"
|
||||
| "about";
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; experimental?: boolean }[] = [
|
||||
{ id: "general", label: "通用" },
|
||||
{ id: "connections", label: "连接" },
|
||||
{ id: "tools", label: "外部工具" },
|
||||
{ id: "extensions", label: "扩展", experimental: true },
|
||||
{ id: "experimental", label: "实验室", experimental: true },
|
||||
{ id: "developer", label: "开发者" },
|
||||
{ id: "about", label: "关于" },
|
||||
];
|
||||
|
||||
export function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("general");
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold">设置</h2>
|
||||
<p className="text-muted-foreground">配置应用参数和偏好</p>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="flex gap-1 border-b mb-6">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium transition-colors relative",
|
||||
activeTab === tab.id
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.experimental && (
|
||||
<span className="text-[8px] text-red-500 ml-1">(实验)</span>
|
||||
)}
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === "general" && <GeneralSettings />}
|
||||
{activeTab === "connections" && <ConnectionsSettings />}
|
||||
{activeTab === "tools" && <ExternalToolsSettings />}
|
||||
{activeTab === "extensions" && <ExtensionsSettings />}
|
||||
{activeTab === "experimental" && <ExperimentalSettings />}
|
||||
{activeTab === "developer" && <DeveloperSettings />}
|
||||
{activeTab === "about" && <AboutSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
export { GeneralSettings } from "./GeneralSettings";
|
||||
export { ProxySettings } from "./ProxySettings";
|
||||
export { AboutSection } from "./AboutSection";
|
||||
export { DeveloperSettings } from "./DeveloperSettings";
|
||||
export { ExperimentalSettings } from "./ExperimentalSettings";
|
||||
export { ExternalToolsSettings } from "./ExternalToolsSettings";
|
||||
@@ -4,8 +4,11 @@
|
||||
* @module components/smart-input/ChatInput
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { BaseComposer } from "@/components/input-kit";
|
||||
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 { ChatInputProps } from "./types";
|
||||
|
||||
/**
|
||||
@@ -24,20 +27,47 @@ export const ChatInput: React.FC<ChatInputProps> = ({
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
placeholder = "输入问题...",
|
||||
skills = [],
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill } =
|
||||
useActiveSkill();
|
||||
|
||||
const handleSend = () => {
|
||||
const text = activeSkill ? wrapTextWithSkill(value) : undefined;
|
||||
onSend(text);
|
||||
clearActiveSkill();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseComposer
|
||||
text={value}
|
||||
setText={onChange}
|
||||
onSend={onSend}
|
||||
onSend={handleSend}
|
||||
disabled={disabled || isLoading}
|
||||
placeholder={placeholder}
|
||||
textareaRef={textareaRef}
|
||||
autoFocus
|
||||
maxAutoHeight={80}
|
||||
rows={1}
|
||||
>
|
||||
{({ textareaRef, textareaProps, onPrimaryAction, isPrimaryDisabled }) => (
|
||||
{({ textareaProps, onPrimaryAction, isPrimaryDisabled }) => (
|
||||
<div className="smart-input-input-area">
|
||||
{/* CharacterMention */}
|
||||
{skills.length > 0 && (
|
||||
<CharacterMention
|
||||
characters={[]}
|
||||
skills={skills}
|
||||
inputRef={textareaRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSelectSkill={setActiveSkill}
|
||||
/>
|
||||
)}
|
||||
{/* Skill Badge */}
|
||||
{activeSkill && (
|
||||
<SkillBadge skill={activeSkill} onClear={clearActiveSkill} />
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
{...textareaProps}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ChatInput } from "./ChatInput";
|
||||
import { ChatMessages } from "./ChatMessages";
|
||||
import { useSmartInput } from "./useSmartInput";
|
||||
import type { SmartInputWindowProps } from "./types";
|
||||
import { skillsApi, type Skill } from "@/lib/api/skills";
|
||||
import "./smart-input.css";
|
||||
|
||||
/**
|
||||
@@ -28,6 +29,7 @@ export const SmartInputWindow: React.FC<SmartInputWindowProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const {
|
||||
messages,
|
||||
isLoading,
|
||||
@@ -46,6 +48,14 @@ export const SmartInputWindow: React.FC<SmartInputWindowProps> = ({
|
||||
}
|
||||
}, [imagePath, setImagePath]);
|
||||
|
||||
// 加载技能列表
|
||||
useEffect(() => {
|
||||
skillsApi
|
||||
.getAll("proxycast")
|
||||
.then(setSkills)
|
||||
.catch((err) => console.error("加载技能列表失败:", err));
|
||||
}, []);
|
||||
|
||||
// 处理关闭窗口
|
||||
const handleClose = useCallback(async () => {
|
||||
try {
|
||||
@@ -69,9 +79,9 @@ export const SmartInputWindow: React.FC<SmartInputWindowProps> = ({
|
||||
}, [handleClose]);
|
||||
|
||||
// 处理发送消息
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!inputValue.trim()) return;
|
||||
const message = inputValue;
|
||||
const handleSend = useCallback(async (textOverride?: string) => {
|
||||
const message = textOverride || inputValue;
|
||||
if (!message.trim()) return;
|
||||
setInputValue("");
|
||||
await sendMessage(message);
|
||||
}, [inputValue, sendMessage]);
|
||||
@@ -153,6 +163,7 @@ export const SmartInputWindow: React.FC<SmartInputWindowProps> = ({
|
||||
isLoading={isLoading}
|
||||
disabled={!imageBase64}
|
||||
placeholder={imageBase64 ? "输入问题..." : "正在加载图片..."}
|
||||
skills={skills}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
/**
|
||||
* @file types.ts
|
||||
* @description 截图对话模块类型定义
|
||||
@@ -119,14 +121,16 @@ export interface ChatInputProps {
|
||||
value: string;
|
||||
/** 值变化回调 */
|
||||
onChange: (value: string) => void;
|
||||
/** 发送消息回调 */
|
||||
onSend: () => void;
|
||||
/** 发送消息回调(可接受 textOverride) */
|
||||
onSend: (textOverride?: string) => void;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 是否正在加载 */
|
||||
isLoading?: boolean;
|
||||
/** 占位符文本 */
|
||||
placeholder?: string;
|
||||
/** 技能列表 */
|
||||
skills?: Skill[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 参考 Waveterm 的 AIPanelInput 设计
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { Send, Square, Paperclip } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BaseComposer } from "@/components/input-kit";
|
||||
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 TerminalAIInputProps {
|
||||
/** 输入值 */
|
||||
value: string;
|
||||
/** 输入变化回调 */
|
||||
onChange: (value: string) => void;
|
||||
/** 提交回调 */
|
||||
onSubmit: () => void;
|
||||
/** 提交回调(可接受 textOverride) */
|
||||
onSubmit: (textOverride?: string) => void;
|
||||
/** 停止回调 */
|
||||
onStop?: () => void;
|
||||
/** 是否正在发送 */
|
||||
@@ -26,6 +30,8 @@ interface TerminalAIInputProps {
|
||||
disabled?: boolean;
|
||||
/** 占位符 */
|
||||
placeholder?: string;
|
||||
/** 技能列表 */
|
||||
skills?: Skill[];
|
||||
}
|
||||
|
||||
export const TerminalAIInput: React.FC<TerminalAIInputProps> = ({
|
||||
@@ -36,22 +42,49 @@ export const TerminalAIInput: React.FC<TerminalAIInputProps> = ({
|
||||
isSending,
|
||||
disabled = false,
|
||||
placeholder = "Continue...",
|
||||
skills = [],
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { activeSkill, setActiveSkill, wrapTextWithSkill, clearActiveSkill } =
|
||||
useActiveSkill();
|
||||
|
||||
const handleSend = () => {
|
||||
const text = activeSkill ? wrapTextWithSkill(value) : undefined;
|
||||
onSubmit(text);
|
||||
clearActiveSkill();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseComposer
|
||||
text={value}
|
||||
setText={onChange}
|
||||
onSend={onSubmit}
|
||||
onSend={handleSend}
|
||||
onStop={onStop}
|
||||
isLoading={isSending}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
textareaRef={textareaRef}
|
||||
maxAutoHeight={7 * 24}
|
||||
rows={2}
|
||||
>
|
||||
{({ textareaRef, textareaProps, onPrimaryAction, isPrimaryDisabled }) => (
|
||||
{({ textareaProps, onPrimaryAction, isPrimaryDisabled }) => (
|
||||
<div className="border-t border-zinc-700">
|
||||
{/* CharacterMention */}
|
||||
{skills.length > 0 && (
|
||||
<CharacterMention
|
||||
characters={[]}
|
||||
skills={skills}
|
||||
inputRef={textareaRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSelectSkill={setActiveSkill}
|
||||
/>
|
||||
)}
|
||||
<div className="relative">
|
||||
{/* Skill Badge */}
|
||||
{activeSkill && (
|
||||
<SkillBadge skill={activeSkill} onClear={clearActiveSkill} />
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
{...textareaProps}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { TerminalAIInput } from "./TerminalAIInput";
|
||||
import { TerminalAIWelcome } from "./TerminalAIWelcome";
|
||||
import { CommandApprovalList } from "./CommandApproval";
|
||||
import { useTerminalAI } from "./useTerminalAI";
|
||||
import { skillsApi, type Skill } from "@/lib/api/skills";
|
||||
|
||||
// ============================================================================
|
||||
// 类型
|
||||
@@ -47,6 +48,15 @@ export const TerminalAIPanel: React.FC<TerminalAIPanelProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
const [input, setInput] = useState("");
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
|
||||
// 加载技能列表
|
||||
useEffect(() => {
|
||||
skillsApi
|
||||
.getAll("proxycast")
|
||||
.then(setSkills)
|
||||
.catch((err) => console.error("加载技能列表失败:", err));
|
||||
}, []);
|
||||
|
||||
const {
|
||||
messages,
|
||||
@@ -81,9 +91,9 @@ export const TerminalAIPanel: React.FC<TerminalAIPanelProps> = ({
|
||||
/**
|
||||
* 处理发送
|
||||
*/
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!input.trim()) return;
|
||||
const text = input;
|
||||
const handleSend = useCallback(async (textOverride?: string) => {
|
||||
const text = textOverride || input;
|
||||
if (!text.trim()) return;
|
||||
setInput("");
|
||||
await sendMessage(text);
|
||||
}, [input, sendMessage]);
|
||||
@@ -207,6 +217,7 @@ export const TerminalAIPanel: React.FC<TerminalAIPanelProps> = ({
|
||||
onSubmit={handleSend}
|
||||
isSending={isSending}
|
||||
placeholder={hasMessages ? "继续对话..." : "向 Terminal AI 提问..."}
|
||||
skills={skills}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,14 +26,16 @@ import { PluginInstallDialog } from "@/components/plugins/PluginInstallDialog";
|
||||
import { ToolCardContextMenu } from "./ToolCardContextMenu";
|
||||
import { toast } from "sonner";
|
||||
import { ImageAnalysisTool } from "./image-analysis";
|
||||
import type { Page } from "@/types/page";
|
||||
import type { Page, PageParams } from "@/types/page";
|
||||
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
|
||||
import { CanvasBreadcrumbHeader } from "@/components/content-creator/canvas/shared/CanvasBreadcrumbHeader";
|
||||
|
||||
interface ToolsPageProps {
|
||||
/**
|
||||
* 页面导航回调
|
||||
* 支持静态页面和动态插件页面
|
||||
*/
|
||||
onNavigate: (page: Page) => void;
|
||||
onNavigate: (page: Page, params?: PageParams) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,6 +263,9 @@ export function ToolsPage({ onNavigate }: ToolsPageProps) {
|
||||
// 合并内置工具和插件工具
|
||||
const allTools = [...builtinTools, ...pluginTools, ...placeholderTools];
|
||||
const activeToolsCount = builtinTools.length + pluginTools.length;
|
||||
const goHome = useCallback(() => {
|
||||
onNavigate("agent", buildHomeAgentParams());
|
||||
}, [onNavigate]);
|
||||
|
||||
/**
|
||||
* 处理工具卡片点击
|
||||
@@ -300,6 +305,10 @@ export function ToolsPage({ onNavigate }: ToolsPageProps) {
|
||||
if (selectedTool === "image-analysis") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="pb-1">
|
||||
<CanvasBreadcrumbHeader label="工具箱" onBackHome={goHome} />
|
||||
</div>
|
||||
|
||||
{/* 返回按钮 */}
|
||||
<Button variant="ghost" onClick={handleBackToList} className="mb-4">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
@@ -314,6 +323,10 @@ export function ToolsPage({ onNavigate }: ToolsPageProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="pb-1">
|
||||
<CanvasBreadcrumbHeader label="工具箱" onBackHome={goHome} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">工具箱</h1>
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Bot,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Home,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Plus,
|
||||
@@ -77,6 +76,7 @@ import type { WorkflowProgressSnapshot } from "@/components/agent/chat";
|
||||
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
|
||||
import { ProjectDetailPage } from "@/components/projects/ProjectDetailPage";
|
||||
import type { CreationMode } from "@/components/content-creator/types";
|
||||
import { CanvasBreadcrumbHeader } from "@/components/content-creator/canvas/shared/CanvasBreadcrumbHeader";
|
||||
import {
|
||||
VideoCanvas,
|
||||
createInitialVideoState,
|
||||
@@ -763,16 +763,11 @@ export function WorkbenchPage({
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{workspaceMode !== "workspace" && (
|
||||
<header className="h-12 border-b px-3 flex items-center gap-2 bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleBackHome}
|
||||
title="回到首页"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</Button>
|
||||
<header className="border-b px-3 py-2 flex items-center gap-2 bg-background">
|
||||
<CanvasBreadcrumbHeader
|
||||
label={getProjectTypeLabel(theme)}
|
||||
onBackHome={handleBackHome}
|
||||
/>
|
||||
|
||||
{workspaceMode !== "project-management" && (
|
||||
<Button
|
||||
@@ -785,9 +780,6 @@ export function WorkbenchPage({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="text-sm font-medium ml-2">
|
||||
{getProjectTypeLabel(theme)}
|
||||
</div>
|
||||
{selectedProject && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{selectedProject.name}
|
||||
|
||||
@@ -266,6 +266,37 @@ export interface UserProfile {
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
// ============ 渠道配置类型 ============
|
||||
|
||||
export interface TelegramBotConfig {
|
||||
enabled: boolean;
|
||||
bot_token: string;
|
||||
allowed_user_ids: string[];
|
||||
default_model?: string;
|
||||
}
|
||||
|
||||
export interface DiscordBotConfig {
|
||||
enabled: boolean;
|
||||
bot_token: string;
|
||||
allowed_server_ids: string[];
|
||||
default_model?: string;
|
||||
}
|
||||
|
||||
export interface FeishuBotConfig {
|
||||
enabled: boolean;
|
||||
app_id: string;
|
||||
app_secret: string;
|
||||
verification_token?: string;
|
||||
encrypt_key?: string;
|
||||
default_model?: string;
|
||||
}
|
||||
|
||||
export interface ChannelsConfig {
|
||||
telegram: TelegramBotConfig;
|
||||
discord: DiscordBotConfig;
|
||||
feishu: FeishuBotConfig;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
server: {
|
||||
host: string;
|
||||
@@ -326,6 +357,8 @@ export interface Config {
|
||||
assistant?: AssistantConfig;
|
||||
/** 用户资料 */
|
||||
user_profile?: UserProfile;
|
||||
/** 渠道配置(Telegram / Discord / 飞书 Bot) */
|
||||
channels?: ChannelsConfig;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
|
||||
@@ -140,6 +140,39 @@ export interface TestMessageResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bot 渠道配置类型(用于 settings-v2 渠道管理页面)
|
||||
// ============================================================================
|
||||
|
||||
export interface TelegramBotConfig {
|
||||
enabled: boolean;
|
||||
bot_token: string;
|
||||
allowed_user_ids: string[];
|
||||
default_model?: string;
|
||||
}
|
||||
|
||||
export interface DiscordBotConfig {
|
||||
enabled: boolean;
|
||||
bot_token: string;
|
||||
allowed_server_ids: string[];
|
||||
default_model?: string;
|
||||
}
|
||||
|
||||
export interface FeishuBotConfig {
|
||||
enabled: boolean;
|
||||
app_id: string;
|
||||
app_secret: string;
|
||||
verification_token?: string;
|
||||
encrypt_key?: string;
|
||||
default_model?: string;
|
||||
}
|
||||
|
||||
export interface ChannelsConfig {
|
||||
telegram: TelegramBotConfig;
|
||||
discord: DiscordBotConfig;
|
||||
feishu: FeishuBotConfig;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI 渠道 API
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user