feat: release v0.72.0 with full pending changes

This commit is contained in:
coso
2026-02-26 15:40:22 +08:00
parent 522575b583
commit d973579a25
69 changed files with 1727 additions and 1560 deletions
+39 -9
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.71.0",
"version": "0.72.0",
"type": "module",
"repository": {
"type": "git",
+31 -31
View File
@@ -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]]
+5 -5
View File
@@ -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"
+60
View File
@@ -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>,
}
+3 -11
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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">
+62
View File
@@ -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);
+23 -8
View File
@@ -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>
)}
+20 -5
View File
@@ -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">
+4 -2
View File
@@ -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;
}
/**
+41 -28
View File
@@ -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}
+46 -1
View File
@@ -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)}>
+3 -8
View File
@@ -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">
+16 -1
View File
@@ -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>
+3 -9
View File
@@ -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" />}
+8 -36
View File
@@ -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,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;
@@ -7,7 +7,7 @@
*
* input: 用户配置操作
* output: 更新检查设置 UI
* pos: components/settings 层
* pos: components/settings-v2 层
*/
import { useState, useEffect } from "react";
@@ -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,
@@ -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);
});
});
-479
View File
@@ -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>
);
}
-28
View File
@@ -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` | 通用设置测试文件 |
## 更新提醒
任何文件变更后,请更新此文档和相关的上级文档。
-76
View File
@@ -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>
);
}
-7
View File
@@ -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";
+33 -3
View File
@@ -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>
+6 -2
View File
@@ -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[];
}
/**
+38 -5
View File
@@ -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}
+14 -3
View File
@@ -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>
);
+15 -2
View File
@@ -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>
+6 -14
View File
@@ -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}
+33
View File
@@ -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 {
+33
View File
@@ -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
// ============================================================================