feat: release v0.71.0 with full pending changes

This commit is contained in:
coso
2026-02-24 16:18:57 +08:00
parent 9f0ba79ce2
commit 54611cd3ab
59 changed files with 5786 additions and 1537 deletions
-203
View File
@@ -1,203 +0,0 @@
# Windows 闪退问题修复总结
## 问题概述
用户报告 ProxyCast v0.70 在 Windows 11 上发送第一条消息时崩溃,而 macOS 开发环境正常工作。
## 根本原因分析
### 1. 平台差异
通过 Context7 MCP 分析发现的关键差异:
| 平台 | 渲染引擎 | I/O 模型 | 特点 |
|------|----------|----------|------|
| Windows | Chromium | IOCP | 更严格的资源限制 |
| macOS | WebKit | kqueue | POSIX 风格的文件锁 |
| Linux | WebKit | epoll/io-uring | 灵活的线程池 |
### 2. Tokio Runtime 创建问题
原来的代码:
```rust
tokio::runtime::Runtime::new().unwrap()
```
**问题**:
- `Runtime::new()` 在不同平台上有不同的默认行为
- Windows 上线程池创建可能失败
- IOCP 初始化可能因资源不足失败
### 3. 版本检查
✅ **aster-rust v0.13.0** - 已是最新版本,无需更新
## 修复方案
### 修复 1: 改进 Tokio Runtime 创建
**文件**: `src-tauri/src/app/bootstrap.rs:147`
```rust
// 修改前
tokio::runtime::Runtime::new()
.expect("Failed to create tokio runtime...")
.handle()
.clone()
// 修改后
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2) // 限制线程数,避免 Windows 资源问题
.thread_name("proxycast-runtime")
.enable_io()
.enable_time()
.build()
.expect("Failed to create tokio runtime: 系统资源不足或配置错误")
.handle()
.clone()
```
**优势**:
- 使用 Builder 模式获得更多控制
- 限制工作线程数,避免 Windows 资源问题
- 添加平台特定的日志输出
- 提高错误信息的可读性
### 修复 2: 添加 Windows 数据库验证
```rust
#[cfg(target_os = "windows")]
{
tracing::info!("[Bootstrap] Windows 平台 - 验证数据库文件权限");
match db.lock() {
Ok(conn) => {
if let Err(e) = conn.execute("PRAGMA user_version", []) {
tracing::warn!("[Bootstrap] Windows 数据库验证失败: {}", e);
} else {
tracing::info!("[Bootstrap] Windows 数据库验证成功");
}
}
Err(e) => {
tracing::warn!("[Bootstrap] Windows 数据库锁获取失败: {}", e);
}
}
}
```
### 修复 3: 前端错误处理
**文件**: `src/components/agent/chat/index.tsx`
```typescript
try {
await sendMessage(text, images || [], webSearch, thinking, false, sendExecutionStrategy);
} catch (error) {
console.error("[AgentChat] 发送消息失败:", error);
toast.error(`发送失败: ${error instanceof Error ? error.message : String(error)}`);
setInput(sourceText); // 恢复输入内容
}
```
## 提交记录
### 提交 1: 0f6044d6
```
fix: 修复发送第一条消息时的闪退问题
- 移除危险的 unwrap() 调用
- 添加前端错误处理
- 验证模型过滤逻辑
- 验证加密模块
```
### 提交 2: 0a1243e6
```
feat: 改进 Windows 平台兼容性
- 使用 Builder 模式创建 Tokio Runtime
- 限制工作线程数为 2
- 添加 Windows 数据库验证
- 添加平台特定的日志输出
```
## 文档
创建了完整的文档体系:
1. **WINDOWS_CRASH_ANALYSIS.md**
- 平台差异详细分析
- Context7 MCP 文档引用
- 风险点识别
- 修复建议
2. **WINDOWS_TEST_GUIDE.md**
- Windows 11 测试步骤
- 常见问题排查
- 日志收集方法
- 性能对比
3. **test-crash-fix.md**
- 修复验证清单
- 测试步骤
- 预期结果
4. **test-messaging.sh**
- 自动化测试脚本
## 验证步骤
### 用户验证
1. 拉取最新代码
2. 在 Windows 11 上启动应用
3. 发送第一条消息
4. 查看日志输出
### 预期日志
```
[INFO] [Bootstrap] Windows 平台 - 创建 Tokio Runtime (IOCP)
[INFO] [Bootstrap] Windows 平台 - 验证数据库文件权限
[INFO] [Bootstrap] Windows 数据库验证成功
[INFO] [AsterAgent] 发送流式消息: session=xxx, event=xxx
```
### 如果仍然崩溃
收集以下信息:
1. 启用详细日志:`$env:RUST_LOG=trace`
2. 检查事件查看器
3. 提供完整堆栈跟踪
4. 系统信息:`systeminfo`
## 技术亮点
### Context7 MCP 使用
成功使用 Context7 MCP 查询:
- Tauri 平台差异文档
- Tokio Runtime 跨平台兼容性
- Rust 平台特定代码模式
### 跨平台最佳实践
- 使用条件编译 `#[cfg(target_os = "windows")]`
- 使用 Builder 模式获得更多控制
- 添加平台特定的验证逻辑
- 提供详细的错误上下文
## 下一步
1. **在 Windows 11 上测试**
- 验证启动流程
- 验证消息发送
- 收集性能数据
2. **添加 CI/CD**
- Windows 构建管道
- 自动化测试
- 性能基准测试
3. **持续改进**
- 监控 Windows 特定问题
- 优化线程池配置
- 改进错误处理
## 参考资料
- [Tauri Windows 文档](https://tauri.app/v1/guides/building/windows)
- [Tokio Runtime 文档](https://tokio.rs/tokio/topics/runtime)
- [Rust Windows 平台支持](https://doc.rust-lang.org/rustc/platform-support/windows-pc-gnu-msvc.html)
- [Context7 MCP](https://context7.com)
## 致谢
感谢用户反馈,帮助我们发现并修复这个跨平台兼容性问题。
-31
View File
@@ -1,31 +0,0 @@
# ZeroClaw → ProxyCast/Aster-Rust 借鉴计划 - 实施状态
## 阶段 1:快速胜利 ✅ 完成
| # | 任务 | 层 | 状态 | 文件 |
|---|------|-----|------|------|
| 1-A | 错误分类和智能重试 | Aster | ✅ | `core/retry_logic.rs` |
| 1-B | 统一 Observer Trait | Aster | ✅ | `observability/` |
| 1-C | 请求体大小和超时限制 | ProxyCast | ✅ | `server/middleware/security.rs` |
| 1-D | 滑动窗口速率限制 | ProxyCast | ✅ | `server/middleware/rate_limit.rs` |
| 1-E | 凭证清理 | ProxyCast | ✅ | `core/sanitizer.rs` |
| 1-F | 历史修剪策略 | ProxyCast | ✅ | `processor/conversation_manager.rs` |
## 阶段 2:核心增强 ✅ 完成
| # | 任务 | 层 | 状态 | 文件 |
|---|------|-----|------|------|
| 2-A | 组件监督者模式 | Aster | ✅ | `core/supervisor.rs` |
| 2-B | HeartbeatEngine | Aster | ✅ | `heartbeat/` |
| 2-C | SecurityPolicy Trait | Aster | ✅ | `security/policy.rs` |
| 2-D | 配对认证系统 | ProxyCast | ✅ | `server/auth/pairing.rs` |
| 2-E | 幂等性中间件 | ProxyCast | ✅ | `server/middleware/idempotency.rs` |
| 2-F | 提示路由系统 | ProxyCast | ✅ | `core/router/hint_router.rs` |
## 阶段 3:高级功能 ✅ 完成
| # | 任务 | 层 | 状态 | 文件 |
|---|------|-----|------|------|
| 3-A | ChaCha20-Poly1305 加密 | ProxyCast | ✅ | `credential/encryption.rs` |
| 3-B | 对话摘要功能 | ProxyCast | ✅ | `processor/conversation_summarizer.rs` |
| 3-C | 配置热重载增强 | Aster | ✅ | `config/watcher.rs` |
+28 -17
View File
@@ -14,6 +14,7 @@
ProxyCast 是面向普通创作者的 AI Agent 平台。
你不需要先懂复杂设置,只要带着一个想法进来,就可以在同一处完成:
- 和 Agent 对话定方向
- 生成内容与素材
- 继续迭代修改
@@ -40,43 +41,51 @@ ProxyCast 是面向普通创作者的 AI Agent 平台。
## 📖 创作场景(不止一种)
### 场景 1:社媒日更
- 场景:每天都要稳定发内容,但选题和表达容易重复。
- 动作:先让 Agent 给出 3 个方向,再选一个生成多版文案与配图思路。
- 场景:每天都要稳定发内容,但选题和表达容易重复。
- 动作:先让 Agent 给出 3 个方向,再选一个生成多版文案与配图思路。
- 结果:当天可直接发布,同时保留素材供后续复用。
### 场景 2:短视频起号
- 场景:有想法但脚本总是“有点散”。
- 动作:用主题工作流先拆结构,再生成口播稿和镜头节奏。
- 场景:有想法但脚本总是“有点散”。
- 动作:用主题工作流先拆结构,再生成口播稿和镜头节奏。
- 结果:从模糊创意变成可拍摄脚本,沟通成本显著降低。
### 场景 3:小说连载
- 场景:长期连载容易设定冲突、节奏断档。
- 动作:在同一项目里持续积累世界观、人物设定和章节草稿。
- 场景:长期连载容易设定冲突、节奏断档。
- 动作:在同一项目里持续积累世界观、人物设定和章节草稿。
- 结果:剧情连贯性更强,更新更稳定。
### 场景 4:活动海报与图文
- 场景:活动上线前要快速产出多套视觉方向。
- 动作:先生成文案方向,再出图并按参考图持续迭代。
- 场景:活动上线前要快速产出多套视觉方向。
- 动作:先生成文案方向,再出图并按参考图持续迭代。
- 结果:方案选择更快,历史版本可追溯、可复用。
### 场景 5:歌词创作
- 场景:有旋律或主题,但歌词总卡在中段。
- 动作:让 Agent 先给主副歌框架,再逐段续写与改写。
- 场景:有旋律或主题,但歌词总卡在中段。
- 动作:让 Agent 先给主副歌框架,再逐段续写与改写。
- 结果:成稿速度更快,风格更统一。
### 场景 6:知识内容输出
- 场景:学了很多但难以整理成可分享内容。
- 动作:把资料整理成结构化要点,再输出为卡片或长文。
- 场景:学了很多但难以整理成可分享内容。
- 动作:把资料整理成结构化要点,再输出为卡片或长文。
- 结果:输入和输出形成闭环,知识更容易长期积累。
### 场景 7:计划执行
- 场景:目标很大,但每天不知道先做什么。
- 动作:把目标拆成周计划与日任务,并按进度复盘调整。
- 场景:目标很大,但每天不知道先做什么。
- 动作:把目标拆成周计划与日任务,并按进度复盘调整。
- 结果:执行路径清晰,可持续推进。
### 场景 8:办公写作
- 场景:报告、邮件、方案反复改,耗时高。
- 动作:先生成初稿,再按受众快速改成不同版本。
- 场景:报告、邮件、方案反复改,耗时高。
- 动作:先生成初稿,再按受众快速改成不同版本。
- 结果:沟通更顺,交付更快。
---
@@ -127,6 +136,7 @@ brew install --cask proxycast
## 📚 文档与开发(可选)
如果你是开发者,可查看:
- 项目文档:`docs/aiprompts/`
- Agent 指南:`AGENTS.md`
@@ -138,7 +148,8 @@ npm run tauri:dev
npm run tauri build
```
说明:开发脚本统一使用 `CARGO_TARGET_DIR=src-tauri/target`,避免生成分散的 `target_*` 目录。
说明:开发脚本统一使用 `CARGO_TARGET_DIR=target`(在 `src-tauri/` 下),避免生成分散的 `target_*` 目录。
请务必在仓库根目录执行上述命令;若在 `src-tauri/` 子目录执行,会误生成 `src-tauri/src-tauri/target`。
---
-229
View File
@@ -1,229 +0,0 @@
# Windows vs macOS 平台差异分析报告
## 问题背景
用户报告在 Windows 11 上发送第一条消息时崩溃,而 macOS 开发环境正常工作。
## Context7 MCP 文档分析结果
### 1. Tauri 平台差异
**渲染引擎差异**:
- **Windows**: 使用 Chromium
- **macOS/Linux**: 使用 WebKit
**重要发现**: Tauri 文档明确指出需要根据平台设置不同的构建目标:
```javascript
// Windows
chrome105 // 用于 Windows (Chromium)
// macOS/Linux
safari13 // 用于 macOS 和 Linux (WebKit)
```
### 2. Tokio Runtime 平台差异
**关键问题**: `Runtime::new()` 在不同平台上的行为可能不同
从 Context7 文档中发现:
- Tokio 在不同平台上使用不同的 I/O 驱动
- Linux 使用 `io-uring`(可选)
- macOS 使用 `kqueue`
- Windows 使用 `IOCP` (I/O Completion Ports)
**Windows 特定风险**:
```rust
// 我们的代码 (bootstrap.rs:147)
let rt = tokio::runtime::Handle::try_current().unwrap_or_else(|_| {
tokio::runtime::Runtime::new()
.expect("Failed to create tokio runtime: 系统资源不足或配置错误")
.handle()
.clone()
});
```
**潜在问题**:
1. Windows 上的线程池创建可能更严格
2. Windows 上的 IOCP 初始化可能失败
3. Windows 上的栈大小默认值不同
### 3. Rust 平台特定代码
**条件编译示例**:
```rust
#[cfg(target_os = "windows")]
pub struct WindowsToken;
#[cfg(target_os = "macos")]
pub struct MacosToken;
```
**我们的代码检查结果**:
- ✅ 已正确使用 `#[cfg(target_os = "windows")]` 进行平台特定代码隔离
- ✅ 配置文件路径处理已正确处理 Windows 路径
## aster-rust 版本分析
### 当前使用的版本
```toml
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.13.0" }
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.13.0" }
```
### 版本历史
- **v0.13.0** (2025-02-18): ✅ 当前使用 - 最新版本
- Commit: `4422f761`
- 包含修复: "fix clippy warnings, fmt, bump version"
- **v0.12.0** (2025-02-16): 上一版本
- 主要更新: "feat: add observability, supervisor, heartbeat"
**结论**: ✅ **aster-rust 版本是最新的,不需要更新**
## Windows 特定崩溃点分析
### 高风险点
#### 1. Tokio Runtime 创建 (bootstrap.rs:147)
```rust
tokio::runtime::Runtime::new()
.expect("Failed to create tokio runtime: 系统资源不足或配置错误")
```
**Windows 风险**:
- 线程池创建可能失败
- IOCP 端口创建可能失败
- 栈内存分配可能更严格
**建议修复**:
```rust
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2) // 限制线程数
.thread_name("proxycast-runtime")
.enable_io()
.enable_time()
.build()
.expect("Failed to create tokio runtime")
```
#### 2. 数据库连接 (可能的问题)
```rust
let db = database::init_database()
.map_err(|e| format!("数据库初始化失败: {e}"))?;
```
**Windows 风险**:
- SQLite 在 Windows 上的文件锁行为不同
- 路径长度限制 (MAX_PATH = 260 字符)
- 权限问题更严格
#### 3. 文件系统操作
**Windows 特定限制**:
- 路径分隔符: `\` vs `/`
- 文件名大小写不敏感
- 路径长度限制
- 文件锁更严格
### 中风险点
#### 4. 加密模块初始化
虽然加密模块只在测试中使用,但 Windows 上的加密 API 可能不同。
#### 5. MCP 服务器启动
Windows 上的进程创建和 socket 行为可能不同。
## 建议的修复方案
### 立即修复
#### 1. 改进 Tokio Runtime 创建
```rust
// bootstrap.rs:147
let rt = tokio::runtime::Handle::try_current().unwrap_or_else(|_| {
// 使用 Builder 模式获得更多控制
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name_fn(|| {
static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0);
let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst);
format!("proxycast-runtime-{}", id)
})
.enable_io()
.enable_time()
.build()
.expect("Failed to create tokio runtime: please check system resources and permissions")
.handle()
.clone()
});
```
#### 2. 添加 Windows 特定日志
```rust
#[cfg(target_os = "windows")]
tracing::info!("[Bootstrap] Windows 平台 - 检查 IOCP 和线程池配置");
#[cfg(target_os = "macos")]
tracing::info!("[Bootstrap] macOS 平台 - 检查 kqueue 配置");
```
#### 3. 添加数据库初始化重试
```rust
let db = database::init_database()
.map_err(|e| format!("数据库初始化失败: {e}"))?;
// Windows 特定:验证数据库可写性
#[cfg(target_os = "windows")]
{
use crate::database::dao;
let conn = db.lock().unwrap();
if let Err(e) = dao::test_connection(&conn) {
tracing::error!("[Bootstrap] Windows 数据库连接测试失败: {}", e);
}
}
```
### 长期改进
1. **添加平台特定的集成测试**
2. **在 CI/CD 中添加 Windows 构建**
3. **添加 Windows 事件查看器日志支持**
4. **添加更详细的错误上下文**
## 测试清单
### Windows 特定测试
- [ ] 在 Windows 11 上启动应用
- [ ] 检查事件查看器 (Event Viewer) 中的应用日志
- [ ] 验证数据库文件创建位置
- [ ] 测试长路径支持
- [ ] 测试中文字符路径
- [ ] 验证防火墙权限
### 建议的 Windows 调试命令
```powershell
# 启用详细日志
$env:RUST_LOG=debug
$env:RUST_BACKTRACE=1
.\proxycast.exe
# 检查事件日志
Get-EventLog -LogName Application -Source "ProxyCast" -Newest 50
```
## 结论
### 主要发现
1. ✅ **aster-rust 版本是最新的** - 不需要更新
2. ⚠️ **Tokio Runtime 创建可能在 Windows 上失败** - 需要改进
3. ⚠️ **缺少 Windows 特定的错误处理** - 需要添加
4. ⚠️ **Windows 平台测试不足** - 需要加强
### 下一步行动
1. 实施上述建议的修复方案
2. 在 Windows 11 上测试
3. 添加 Windows CI/CD
4. 收集 Windows 用户的详细错误日志
## 参考资源
- [Tauri Windows 文档](https://tauri.app/v1/guides/building/windows)
- [Tokio Runtime 文档](https://tokio.rs/tokio/topics/runtime)
- [Rust Windows 平台支持](https://doc.rust-lang.org/rustc/platform-support/windows-pc-gnu-msvc.html)
-159
View File
@@ -1,159 +0,0 @@
# Windows 11 测试指南
## 修复说明
本次修复针对 Windows 平台的兼容性问题进行了以下改进:
### 1. 改进 Tokio Runtime 创建
- 使用 `Builder` 模式替代 `Runtime::new()`
- 限制工作线程数为 2(避免 Windows 资源问题)
- 添加平台特定的日志输出
- 提高跨平台兼容性
### 2. 添加 Windows 数据库验证
- 在启动时验证数据库文件权限
- 添加 Windows 特定的诊断日志
## Windows 11 测试步骤
### 准备工作
1. **安装最新代码**
```powershell
git pull origin main
git log --oneline -1
# 应该看到: fix: 改进 Windows 平台兼容性
```
2. **启用详细日志**
```powershell
# 设置环境变量
$env:RUST_LOG=debug
$env:RUST_BACKTRACE=1
# 或者永久设置(管理员权限)
[System.Environment]::SetEnvironmentVariable("RUST_LOG", "debug", "User")
[System.Environment]::SetEnvironmentVariable("RUST_BACKTRACE", "1", "User")
```
### 测试流程
#### 测试 1: 启动测试
1. 双击启动 `ProxyCast.exe`
2. 查看控制台输出,应该看到:
```
[INFO] [Bootstrap] Windows 平台 - 创建 Tokio Runtime (IOCP)
[INFO] [Bootstrap] Windows 平台 - 验证数据库文件权限
[INFO] [Bootstrap] Windows 数据库验证成功
```
3. 应用应该正常启动
#### 测试 2: 发送消息测试
1. 创建新对话
2. 发送第一条消息:"你好"
3. **预期结果**:
- ✅ 消息成功发送
- ✅ 收到 AI 回复
- ✅ 不会崩溃
#### 测试 3: 查看详细日志
如果仍然崩溃,请:
1. 打开 PowerShell
2. 运行:
```powershell
$env:RUST_LOG=debug; $env:RUST_BACKTRACE=1; .\ProxyCast.exe
```
3. 复制所有输出
#### 测试 4: 检查事件查看器
1. 按 `Win + X`,选择"事件查看器"
2. 导航到:Windows 日志 → 应用程序
3. 查找来源为 "ProxyCast" 的错误事件
4. 导出日志(右键 → "将所有事件另存为...")
## 常见问题排查
### 问题 1: 仍然崩溃
**请收集以下信息**:
```powershell
# 1. 系统信息
systeminfo | Select-String /C:"OS Name" /C:"OS Version"
# 2. Rust 版本
rustc --version
# 3. Cargo 版本
cargo --version
# 4. 运行应用(带详细日志)
$env:RUST_LOG=trace; .\ProxyCast.exe > proxycast.log 2>&1
# 5. 检查日志文件
Get-Content proxycast.log | Select-String -Pattern "ERROR|WARN|Bootstrap"
```
### 问题 2: 数据库错误
**症状**:启动时提示"数据库初始化失败"
**解决方案**:
```powershell
# 1. 删除现有数据库(会丢失数据,谨慎操作)
Remove-Item "$env:APPDATA\proxycast\*.db" -Force
# 2. 重新启动应用
.\ProxyCast.exe
```
### 问题 3: 权限错误
**症状**:提示"访问被拒绝"
**解决方案**:
```powershell
# 以管理员身份运行
# 右键 ProxyCast.exe → "以管理员身份运行"
# 或者修改文件夹权限
icacls "$env:APPDATA\proxycast" /grant "$($env:USERNAME):(OI)(CI)F" /T
```
## 预期行为
### 成功启动的日志示例
```
[INFO] [Bootstrap] Windows 平台 - 创建 Tokio Runtime (IOCP)
[INFO] [Bootstrap] Windows 平台 - 验证数据库文件权限
[INFO] [Bootstrap] Windows 数据库验证成功
[INFO] [启动] 插件安装器初始化成功
[INFO] [Bootstrap] 已设置 Aster 全局 session store
```
### 成功发送消息的日志示例
```
[INFO] [AsterAgent] 发送流式消息: session=xxx, event=xxx
[INFO] [AsterAgent] Agent 初始化状态: true
[INFO] [AsterAgent] 收到 provider_config: provider_name=xxx, model_name=xxx
```
## 性能对比
### macOS vs Windows
| 操作 | macOS | Windows |
|------|-------|---------|
| 渲染引擎 | WebKit | Chromium |
| I/O 模型 | kqueue | IOCP |
| 线程数 | 自动 (CPU核心数) | 限制为 2 |
| 文件锁 | POSIX | Windows 锁 |
| 路径格式 | `/` | `\` |
## 联系方式
如果测试后仍有问题,请提供:
1. 完整的启动日志(`$env:RUST_LOG=trace`)
2. 事件查看器中的错误日志
3. 系统信息(`systeminfo`)
4. 重现步骤的详细描述
## 相关文档
- [完整分析报告](./WINDOWS_CRASH_ANALYSIS.md)
- [修复验证清单](./test-crash-fix.md)
+5 -4
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.70.1",
"version": "0.71.0",
"type": "module",
"repository": {
"type": "git",
@@ -9,13 +9,14 @@
},
"homepage": "https://github.com/aiclientproxy/proxycast",
"scripts": {
"predev": "node scripts/ensure-dev-port.mjs",
"dev": "npx vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "CARGO_TARGET_DIR=src-tauri/target tauri dev",
"tauri:dev:headless": "CARGO_TARGET_DIR=src-tauri/target tauri dev --config src-tauri/tauri.conf.headless.json",
"tauri:dev:nowatch": "CARGO_TARGET_DIR=src-tauri/target tauri dev --no-watch",
"tauri:dev": "CARGO_TARGET_DIR=target tauri dev",
"tauri:dev:headless": "CARGO_TARGET_DIR=target tauri dev --config src-tauri/tauri.conf.headless.json",
"tauri:dev:nowatch": "CARGO_TARGET_DIR=target tauri dev --no-watch",
"lint": "eslint src --max-warnings 0",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"prepare": "husky",
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const devPort = process.env.PROXYCAST_DEV_PORT ?? "1420";
const projectRoot = path.resolve(process.cwd());
const repoRootMarker = path.join(projectRoot, "package.json");
const nestedRepoRootMarker = path.join(projectRoot, "..", "package.json");
const runningInsideSrcTauri =
path.basename(projectRoot) === "src-tauri" &&
fs.existsSync(nestedRepoRootMarker);
if (runningInsideSrcTauri) {
console.error("[proxycast] 检测到在 src-tauri 子目录启动开发脚本。");
console.error("[proxycast] 请回到仓库根目录执行:npm run tauri:dev");
console.error(
"[proxycast] 这样可以避免生成 src-tauri/src-tauri/target 目录。",
);
process.exit(1);
}
if (!fs.existsSync(repoRootMarker)) {
console.error(`[proxycast] 当前目录缺少 package.json: ${projectRoot}`);
console.error("[proxycast] 请在 proxycast 仓库根目录执行开发命令。");
process.exit(1);
}
function run(command) {
try {
return execSync(command, { stdio: ["ignore", "pipe", "pipe"] })
.toString("utf8")
.trim();
} catch {
return "";
}
}
function listListenPids(port) {
const output = run(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`);
if (!output) {
return [];
}
return [
...new Set(
output
.split("\n")
.map((item) => item.trim())
.filter(Boolean),
),
];
}
function readCommand(pid) {
return run(`ps -p ${pid} -o command=`).trim();
}
function killPid(pid, signal) {
try {
process.kill(Number(pid), signal);
return true;
} catch {
return false;
}
}
if (process.platform === "win32") {
process.exit(0);
}
const occupiedPids = listListenPids(devPort);
if (occupiedPids.length === 0) {
process.exit(0);
}
const blockedProcesses = [];
const targetPids = [];
for (const pid of occupiedPids) {
const command = readCommand(pid);
const isViteProcess = command.includes("vite");
const inCurrentProject = command.includes(projectRoot);
if (isViteProcess && inCurrentProject) {
targetPids.push(pid);
} else {
blockedProcesses.push({ pid, command: command || "unknown" });
}
}
if (blockedProcesses.length > 0) {
console.error(`[proxycast] 端口 ${devPort} 被其他进程占用,无法自动清理:`);
for (const item of blockedProcesses) {
console.error(`- PID ${item.pid}: ${item.command}`);
}
console.error("[proxycast] 请先结束占用进程后再重试启动。");
process.exit(1);
}
for (const pid of targetPids) {
killPid(pid, "SIGTERM");
}
const stillOccupied = listListenPids(devPort);
for (const pid of stillOccupied) {
if (targetPids.includes(pid)) {
killPid(pid, "SIGKILL");
}
}
const unresolved = listListenPids(devPort);
if (unresolved.length > 0) {
console.error(`[proxycast] 端口 ${devPort} 仍被占用,请手动清理后重试。`);
process.exit(1);
}
if (targetPids.length > 0) {
console.log(
`[proxycast] 已清理 ${targetPids.length} 个残留 vite 进程(端口 ${devPort})。`,
);
}
+15 -15
View File
@@ -6685,7 +6685,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"anyhow",
"arboard",
@@ -6785,7 +6785,7 @@ dependencies = [
[[package]]
name = "proxycast-agent"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"aster-core",
"async-trait",
@@ -6808,7 +6808,7 @@ dependencies = [
[[package]]
name = "proxycast-config"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6824,7 +6824,7 @@ dependencies = [
[[package]]
name = "proxycast-core"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"aster-models",
"async-trait",
@@ -6864,7 +6864,7 @@ dependencies = [
[[package]]
name = "proxycast-credential"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -6899,7 +6899,7 @@ dependencies = [
[[package]]
name = "proxycast-infra"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -6919,7 +6919,7 @@ dependencies = [
[[package]]
name = "proxycast-mcp"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"async-trait",
"glob",
@@ -6950,7 +6950,7 @@ dependencies = [
[[package]]
name = "proxycast-processor"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"async-trait",
"parking_lot",
@@ -6969,7 +6969,7 @@ dependencies = [
[[package]]
name = "proxycast-providers"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"anyhow",
"async-stream",
@@ -7021,7 +7021,7 @@ dependencies = [
[[package]]
name = "proxycast-server"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"async-stream",
"axum 0.7.9",
@@ -7063,7 +7063,7 @@ dependencies = [
[[package]]
name = "proxycast-server-utils"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -7078,7 +7078,7 @@ dependencies = [
[[package]]
name = "proxycast-services"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"anyhow",
"aster-core",
@@ -7119,7 +7119,7 @@ dependencies = [
[[package]]
name = "proxycast-skills"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -7135,7 +7135,7 @@ dependencies = [
[[package]]
name = "proxycast-terminal"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7162,7 +7162,7 @@ dependencies = [
[[package]]
name = "proxycast-websocket"
version = "0.70.1"
version = "0.71.0"
dependencies = [
"axum 0.7.9",
"chrono",
+2 -2
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.70.1"
version = "0.71.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/proxycast"
@@ -189,7 +189,7 @@ version = "2.4"
[package]
name = "proxycast"
version = "0.70.1"
version = "0.71.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -130,6 +130,7 @@ impl SessionConfigBuilder {
max_turns: self.max_turns,
retry_config: None,
system_prompt: self.system_prompt,
include_context_trace: None,
}
}
}
@@ -391,6 +391,10 @@ pub fn convert_agent_event(event: AgentEvent) -> Vec<TauriAgentEvent> {
tracing::debug!("History replaced");
vec![]
}
AgentEvent::ContextTrace { steps } => {
tracing::debug!("Context trace received, steps: {}", steps.len());
vec![]
}
}
}
@@ -1809,6 +1809,9 @@ pub struct ChatAppearanceConfig {
/// 显示时间戳
#[serde(default)]
pub show_timestamp: Option<bool>,
/// 推荐点击时自动附带当前选中文本上下文
#[serde(default)]
pub append_selected_text_to_recommendation: Option<bool>,
}
/// 记忆管理配置
@@ -18,3 +18,4 @@ pub mod providers;
pub mod publish_config_dao;
pub mod skills;
pub mod template_dao;
pub mod video_generation_task_dao;
@@ -0,0 +1,244 @@
//! 视频生成任务数据访问层
//!
//! 提供视频生成任务(`video_generation_tasks`)的 CRUD 操作。
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// 视频生成任务状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VideoGenerationTaskStatus {
Pending,
Processing,
Success,
Error,
Cancelled,
}
impl VideoGenerationTaskStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Processing => "processing",
Self::Success => "success",
Self::Error => "error",
Self::Cancelled => "cancelled",
}
}
pub fn from_db(value: &str) -> Self {
match value {
"pending" => Self::Pending,
"processing" => Self::Processing,
"success" => Self::Success,
"error" => Self::Error,
"cancelled" => Self::Cancelled,
_ => Self::Error,
}
}
}
/// 视频生成任务
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoGenerationTask {
pub id: String,
pub project_id: String,
pub provider_id: String,
pub model: String,
pub prompt: String,
pub request_payload: Option<String>,
pub provider_task_id: Option<String>,
pub status: VideoGenerationTaskStatus,
pub progress: Option<i64>,
pub result_url: Option<String>,
pub error_message: Option<String>,
pub metadata_json: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub finished_at: Option<i64>,
}
/// 创建视频任务参数
#[derive(Debug, Clone)]
pub struct CreateVideoGenerationTaskParams {
pub project_id: String,
pub provider_id: String,
pub model: String,
pub prompt: String,
pub request_payload: Option<String>,
pub metadata_json: Option<String>,
}
/// 更新视频任务状态参数
#[derive(Debug, Clone, Default)]
pub struct UpdateVideoGenerationTaskParams {
pub provider_task_id: Option<Option<String>>,
pub status: Option<VideoGenerationTaskStatus>,
pub progress: Option<Option<i64>>,
pub result_url: Option<Option<String>>,
pub error_message: Option<Option<String>>,
pub metadata_json: Option<Option<String>>,
pub finished_at: Option<Option<i64>>,
}
/// 视频任务 DAO
pub struct VideoGenerationTaskDao;
impl VideoGenerationTaskDao {
/// 创建视频生成任务
pub fn create(
conn: &Connection,
params: &CreateVideoGenerationTaskParams,
) -> Result<VideoGenerationTask, rusqlite::Error> {
let now = chrono::Utc::now().timestamp();
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO video_generation_tasks (
id, project_id, provider_id, model, prompt, request_payload, provider_task_id,
status, progress, result_url, error_message, metadata_json,
created_at, updated_at, finished_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, NULL, NULL, NULL, ?8, ?9, ?10, NULL)",
params![
id,
params.project_id,
params.provider_id,
params.model,
params.prompt,
params.request_payload,
VideoGenerationTaskStatus::Pending.as_str(),
params.metadata_json,
now,
now,
],
)?;
Self::get_by_id(conn, &id).map(|task| task.expect("刚创建的任务必须可读取"))
}
/// 按 ID 获取任务
pub fn get_by_id(
conn: &Connection,
id: &str,
) -> Result<Option<VideoGenerationTask>, rusqlite::Error> {
let mut stmt = conn.prepare(
"SELECT
id, project_id, provider_id, model, prompt, request_payload, provider_task_id,
status, progress, result_url, error_message, metadata_json,
created_at, updated_at, finished_at
FROM video_generation_tasks
WHERE id = ?1",
)?;
stmt.query_row([id], Self::map_row).optional()
}
/// 按项目列出任务(按创建时间倒序)
pub fn list_by_project(
conn: &Connection,
project_id: &str,
limit: i64,
) -> Result<Vec<VideoGenerationTask>, rusqlite::Error> {
let bounded_limit = limit.clamp(1, 200);
let mut stmt = conn.prepare(
"SELECT
id, project_id, provider_id, model, prompt, request_payload, provider_task_id,
status, progress, result_url, error_message, metadata_json,
created_at, updated_at, finished_at
FROM video_generation_tasks
WHERE project_id = ?1
ORDER BY created_at DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![project_id, bounded_limit], Self::map_row)?;
Ok(rows.filter_map(|row| row.ok()).collect())
}
/// 更新任务状态
pub fn update_task(
conn: &Connection,
id: &str,
params: &UpdateVideoGenerationTaskParams,
) -> Result<Option<VideoGenerationTask>, rusqlite::Error> {
let mut task = match Self::get_by_id(conn, id)? {
Some(value) => value,
None => return Ok(None),
};
if let Some(provider_task_id) = &params.provider_task_id {
task.provider_task_id = provider_task_id.clone();
}
if let Some(status) = params.status {
task.status = status;
}
if let Some(progress) = &params.progress {
task.progress = *progress;
}
if let Some(result_url) = &params.result_url {
task.result_url = result_url.clone();
}
if let Some(error_message) = &params.error_message {
task.error_message = error_message.clone();
}
if let Some(metadata_json) = &params.metadata_json {
task.metadata_json = metadata_json.clone();
}
if let Some(finished_at) = params.finished_at {
task.finished_at = finished_at;
}
task.updated_at = chrono::Utc::now().timestamp();
conn.execute(
"UPDATE video_generation_tasks
SET provider_task_id = ?2,
status = ?3,
progress = ?4,
result_url = ?5,
error_message = ?6,
metadata_json = ?7,
updated_at = ?8,
finished_at = ?9
WHERE id = ?1",
params![
task.id,
task.provider_task_id,
task.status.as_str(),
task.progress,
task.result_url,
task.error_message,
task.metadata_json,
task.updated_at,
task.finished_at,
],
)?;
Ok(Some(task))
}
fn map_row(row: &rusqlite::Row<'_>) -> Result<VideoGenerationTask, rusqlite::Error> {
let status_value: String = row.get(7)?;
Ok(VideoGenerationTask {
id: row.get(0)?,
project_id: row.get(1)?,
provider_id: row.get(2)?,
model: row.get(3)?,
prompt: row.get(4)?,
request_payload: row.get(5)?,
provider_task_id: row.get(6)?,
status: VideoGenerationTaskStatus::from_db(&status_value),
progress: row.get(8)?,
result_url: row.get(9)?,
error_message: row.get(10)?,
metadata_json: row.get(11)?,
created_at: row.get(12)?,
updated_at: row.get(13)?,
finished_at: row.get(14)?,
})
}
}
@@ -786,6 +786,45 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
[],
)?;
// ============================================================================
// 视频生成任务表 (VideoGenerationTask)
// 存储视频生成任务状态与结果
// ============================================================================
conn.execute(
"CREATE TABLE IF NOT EXISTS video_generation_tasks (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
model TEXT NOT NULL,
prompt TEXT NOT NULL,
request_payload TEXT,
provider_task_id TEXT,
status TEXT NOT NULL DEFAULT 'pending',
progress INTEGER,
result_url TEXT,
error_message TEXT,
metadata_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER,
FOREIGN KEY (project_id) REFERENCES workspaces(id) ON DELETE CASCADE
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_video_tasks_project_created ON video_generation_tasks(project_id, created_at DESC)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_video_tasks_status ON video_generation_tasks(status)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_video_tasks_provider_task ON video_generation_tasks(provider_task_id)",
[],
)?;
// ============================================================================
// 排版模板表 (Template)
// 存储项目级排版模板,用于控制 AI 输出内容的格式
@@ -187,18 +187,6 @@ pub struct WorkspaceUpdate {
pub tags: Option<Vec<String>>,
}
/// Workspace 创建请求
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceCreateRequest {
/// 显示名称
pub name: String,
/// 根目录路径
pub root_path: String,
/// Workspace 类型(可选,默认 persistent)
#[serde(default)]
pub workspace_type: WorkspaceType,
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -92,3 +92,4 @@ pub mod api_key_provider_service;
pub mod provider_pool_service;
pub mod provider_type_mapping;
pub mod token_cache_service;
pub mod video_generation_service;
@@ -0,0 +1,977 @@
//! 视频生成服务
//!
//! 提供视频生成任务创建、状态轮询与结果管理能力。
use std::time::Duration;
use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use proxycast_core::database::dao::api_key_provider::ApiKeyProvider;
use proxycast_core::database::dao::material_dao::MaterialDao;
use proxycast_core::database::dao::video_generation_task_dao::{
CreateVideoGenerationTaskParams, UpdateVideoGenerationTaskParams, VideoGenerationTask,
VideoGenerationTaskDao, VideoGenerationTaskStatus,
};
use proxycast_core::database::{lock_db, DbConnection};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use crate::api_key_provider_service::ApiKeyProviderService;
const DEFAULT_TIMEOUT_SECS: u64 = 45;
const DEFAULT_VOLCENGINE_HOST: &str = "https://ark.cn-beijing.volces.com/api/v3";
const DEFAULT_DASHSCOPE_HOST: &str = "https://dashscope.aliyuncs.com";
const MATERIAL_URL_PREFIX: &str = "material://";
/// 创建视频任务请求
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateVideoGenerationRequest {
pub project_id: String,
pub provider_id: String,
pub model: String,
pub prompt: String,
pub aspect_ratio: Option<String>,
pub resolution: Option<String>,
pub duration: Option<i64>,
pub image_url: Option<String>,
pub end_image_url: Option<String>,
pub seed: Option<i64>,
pub generate_audio: Option<bool>,
pub camera_fixed: Option<bool>,
}
/// 视频任务状态响应(用于 Provider 轮询)
#[derive(Debug, Clone)]
struct ProviderTaskStatus {
status: VideoGenerationTaskStatus,
progress: Option<i64>,
video_url: Option<String>,
error_message: Option<String>,
}
/// Provider 适配器上下文
#[derive(Debug, Clone)]
struct AdapterContext {
api_host: String,
api_key: String,
}
#[async_trait]
trait VideoProviderAdapter {
async fn submit(
&self,
client: &Client,
context: &AdapterContext,
request: &CreateVideoGenerationRequest,
) -> Result<String, String>;
async fn query(
&self,
client: &Client,
context: &AdapterContext,
provider_task_id: &str,
) -> Result<ProviderTaskStatus, String>;
async fn cancel(
&self,
_client: &Client,
_context: &AdapterContext,
_provider_task_id: &str,
) -> Result<(), String> {
Ok(())
}
}
struct VolcengineVideoAdapter;
struct DashscopeVideoAdapter;
#[async_trait]
impl VideoProviderAdapter for VolcengineVideoAdapter {
async fn submit(
&self,
client: &Client,
context: &AdapterContext,
request: &CreateVideoGenerationRequest,
) -> Result<String, String> {
let base_url = normalize_host(
if context.api_host.trim().is_empty() {
DEFAULT_VOLCENGINE_HOST
} else {
&context.api_host
},
DEFAULT_VOLCENGINE_HOST,
);
let endpoint = format!(
"{}/contents/generations/tasks",
base_url.trim_end_matches('/')
);
let mut content = vec![json!({
"type": "text",
"text": request.prompt
})];
if let Some(image_url) = &request.image_url {
if !image_url.trim().is_empty() {
content.push(json!({
"type": "image_url",
"role": "first_frame",
"image_url": { "url": image_url }
}));
}
}
if let Some(end_image_url) = &request.end_image_url {
if !end_image_url.trim().is_empty() {
content.push(json!({
"type": "image_url",
"role": "last_frame",
"image_url": { "url": end_image_url }
}));
}
}
let mut body = Map::new();
body.insert("model".to_string(), Value::String(request.model.clone()));
body.insert("content".to_string(), Value::Array(content));
body.insert("watermark".to_string(), Value::Bool(false));
if let Some(aspect_ratio) = &request.aspect_ratio {
if !aspect_ratio.trim().is_empty() && aspect_ratio != "adaptive" {
body.insert("ratio".to_string(), Value::String(aspect_ratio.clone()));
}
}
if let Some(duration) = request.duration {
body.insert("duration".to_string(), Value::Number(duration.into()));
}
if let Some(seed) = request.seed {
body.insert("seed".to_string(), Value::Number(seed.into()));
}
if let Some(generate_audio) = request.generate_audio {
body.insert("generate_audio".to_string(), Value::Bool(generate_audio));
}
if let Some(camera_fixed) = request.camera_fixed {
body.insert("camera_fixed".to_string(), Value::Bool(camera_fixed));
}
if let Some(resolution) = &request.resolution {
if !resolution.trim().is_empty() {
body.insert("resolution".to_string(), Value::String(resolution.clone()));
}
}
let response = client
.post(endpoint)
.header(AUTHORIZATION, format!("Bearer {}", context.api_key))
.header(CONTENT_TYPE, "application/json")
.json(&Value::Object(body))
.send()
.await
.map_err(|error| format!("火山视频任务提交失败: {error}"))?;
let status = response.status();
let payload = response
.text()
.await
.map_err(|error| format!("火山视频响应读取失败: {error}"))?;
if !status.is_success() {
return Err(format!(
"火山视频任务提交失败 ({}): {}",
status.as_u16(),
preview_payload(&payload)
));
}
let value: Value = serde_json::from_str(&payload)
.map_err(|error| format!("火山视频响应解析失败: {error}"))?;
find_string_value(&value, &["id", "task_id"])
.ok_or_else(|| "火山视频响应缺少任务 ID".to_string())
}
async fn query(
&self,
client: &Client,
context: &AdapterContext,
provider_task_id: &str,
) -> Result<ProviderTaskStatus, String> {
let base_url = normalize_host(
if context.api_host.trim().is_empty() {
DEFAULT_VOLCENGINE_HOST
} else {
&context.api_host
},
DEFAULT_VOLCENGINE_HOST,
);
let endpoint = format!(
"{}/contents/generations/tasks/{}",
base_url.trim_end_matches('/'),
provider_task_id
);
let response = client
.get(endpoint)
.header(AUTHORIZATION, format!("Bearer {}", context.api_key))
.send()
.await
.map_err(|error| format!("火山视频任务查询失败: {error}"))?;
let status_code = response.status();
let payload = response
.text()
.await
.map_err(|error| format!("火山视频查询响应读取失败: {error}"))?;
if !status_code.is_success() {
return Err(format!(
"火山视频任务查询失败 ({}): {}",
status_code.as_u16(),
preview_payload(&payload)
));
}
let value: Value = serde_json::from_str(&payload)
.map_err(|error| format!("火山视频查询响应解析失败: {error}"))?;
let raw_status = find_string_value(
&value,
&[
"status",
"state",
"task_status",
"taskStatus",
"output.task_status",
],
)
.unwrap_or_else(|| "processing".to_string());
let progress = find_i64_value(
&value,
&[
"progress",
"task_progress",
"output.task_progress",
"output.progress",
],
);
let video_url = extract_video_url(&value);
let normalized_status = normalize_provider_status(&raw_status);
let error_message = if normalized_status == VideoGenerationTaskStatus::Error {
find_string_value(&value, &["error", "error_message", "message", "msg"])
.or_else(|| Some("视频生成失败".to_string()))
} else {
None
};
Ok(ProviderTaskStatus {
status: normalized_status,
progress,
video_url,
error_message,
})
}
}
#[async_trait]
impl VideoProviderAdapter for DashscopeVideoAdapter {
async fn submit(
&self,
client: &Client,
context: &AdapterContext,
request: &CreateVideoGenerationRequest,
) -> Result<String, String> {
let base_url = normalize_host(
if context.api_host.trim().is_empty() {
DEFAULT_DASHSCOPE_HOST
} else {
&context.api_host
},
DEFAULT_DASHSCOPE_HOST,
);
let endpoint = format!(
"{}/api/v1/services/aigc/video-generation/video-synthesis",
base_url.trim_end_matches('/')
);
let mut input = Map::new();
input.insert("prompt".to_string(), Value::String(request.prompt.clone()));
if let Some(image_url) = &request.image_url {
if !image_url.trim().is_empty() {
input.insert("image_url".to_string(), Value::String(image_url.clone()));
}
}
if let Some(end_image_url) = &request.end_image_url {
if !end_image_url.trim().is_empty() {
input.insert(
"end_image_url".to_string(),
Value::String(end_image_url.clone()),
);
}
}
let mut parameters = Map::new();
if let Some(size) = resolve_dashscope_size(
request.resolution.as_deref(),
request.aspect_ratio.as_deref(),
) {
parameters.insert("size".to_string(), Value::String(size));
}
if let Some(duration) = request.duration {
parameters.insert("duration".to_string(), Value::Number(duration.into()));
}
if let Some(seed) = request.seed {
parameters.insert("seed".to_string(), Value::Number(seed.into()));
}
if let Some(camera_fixed) = request.camera_fixed {
parameters.insert("camera_fixed".to_string(), Value::Bool(camera_fixed));
}
let mut body = Map::new();
body.insert("model".to_string(), Value::String(request.model.clone()));
body.insert("input".to_string(), Value::Object(input));
if !parameters.is_empty() {
body.insert("parameters".to_string(), Value::Object(parameters));
}
let response = client
.post(endpoint)
.header(AUTHORIZATION, format!("Bearer {}", context.api_key))
.header(CONTENT_TYPE, "application/json")
.header("X-DashScope-Async", "enable")
.json(&Value::Object(body))
.send()
.await
.map_err(|error| format!("阿里视频任务提交失败: {error}"))?;
let status = response.status();
let payload = response
.text()
.await
.map_err(|error| format!("阿里视频响应读取失败: {error}"))?;
if !status.is_success() {
return Err(format!(
"阿里视频任务提交失败 ({}): {}",
status.as_u16(),
preview_payload(&payload)
));
}
let value: Value = serde_json::from_str(&payload)
.map_err(|error| format!("阿里视频响应解析失败: {error}"))?;
find_string_value(&value, &["output.task_id", "task_id", "id"])
.ok_or_else(|| "阿里视频响应缺少任务 ID".to_string())
}
async fn query(
&self,
client: &Client,
context: &AdapterContext,
provider_task_id: &str,
) -> Result<ProviderTaskStatus, String> {
let base_url = normalize_host(
if context.api_host.trim().is_empty() {
DEFAULT_DASHSCOPE_HOST
} else {
&context.api_host
},
DEFAULT_DASHSCOPE_HOST,
);
let endpoint = format!(
"{}/api/v1/tasks/{}",
base_url.trim_end_matches('/'),
provider_task_id
);
let response = client
.get(endpoint)
.header(AUTHORIZATION, format!("Bearer {}", context.api_key))
.send()
.await
.map_err(|error| format!("阿里视频任务查询失败: {error}"))?;
let status_code = response.status();
let payload = response
.text()
.await
.map_err(|error| format!("阿里视频查询响应读取失败: {error}"))?;
if !status_code.is_success() {
return Err(format!(
"阿里视频任务查询失败 ({}): {}",
status_code.as_u16(),
preview_payload(&payload)
));
}
let value: Value = serde_json::from_str(&payload)
.map_err(|error| format!("阿里视频查询响应解析失败: {error}"))?;
let raw_status = find_string_value(
&value,
&[
"output.task_status",
"task_status",
"status",
"state",
"output.status",
],
)
.unwrap_or_else(|| "processing".to_string());
let progress = find_i64_value(
&value,
&["output.task_progress", "task_progress", "progress"],
);
let video_url = extract_video_url(&value);
let normalized_status = normalize_provider_status(&raw_status);
let error_message = if normalized_status == VideoGenerationTaskStatus::Error {
find_string_value(
&value,
&["output.message", "message", "error_message", "msg"],
)
.or_else(|| Some("视频生成失败".to_string()))
} else {
None
};
Ok(ProviderTaskStatus {
status: normalized_status,
progress,
video_url,
error_message,
})
}
}
fn normalize_host(api_host: &str, fallback: &str) -> String {
let trimmed = api_host.trim();
if trimmed.is_empty() {
return fallback.trim_end_matches('/').to_string();
}
let with_protocol = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
trimmed.to_string()
} else {
format!("https://{trimmed}")
};
with_protocol.trim_end_matches('/').to_string()
}
fn preview_payload(payload: &str) -> String {
if payload.len() <= 280 {
return payload.to_string();
}
format!("{}...", &payload[..280])
}
fn normalize_provider_status(raw_status: &str) -> VideoGenerationTaskStatus {
let normalized = raw_status.trim().to_uppercase();
if normalized.contains("SUCCEED")
|| normalized.contains("SUCCESS")
|| normalized == "DONE"
|| normalized == "COMPLETED"
{
return VideoGenerationTaskStatus::Success;
}
if normalized.contains("FAIL") || normalized.contains("ERROR") {
return VideoGenerationTaskStatus::Error;
}
if normalized.contains("CANCEL") {
return VideoGenerationTaskStatus::Cancelled;
}
if normalized.contains("PENDING")
|| normalized.contains("RUNNING")
|| normalized.contains("PROCESSING")
|| normalized.contains("QUEUE")
|| normalized.contains("SUBMITTED")
{
return VideoGenerationTaskStatus::Processing;
}
VideoGenerationTaskStatus::Processing
}
fn find_value_by_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
let mut current = value;
for segment in path.split('.') {
match current {
Value::Object(map) => {
current = map.get(segment)?;
}
Value::Array(items) => {
let index = segment.parse::<usize>().ok()?;
current = items.get(index)?;
}
_ => return None,
}
}
Some(current)
}
fn find_string_value(value: &Value, paths: &[&str]) -> Option<String> {
for path in paths {
if let Some(candidate) = find_value_by_path(value, path) {
match candidate {
Value::String(text) => {
if !text.trim().is_empty() {
return Some(text.clone());
}
}
Value::Number(number) => {
return Some(number.to_string());
}
_ => {}
}
}
}
None
}
fn find_i64_value(value: &Value, paths: &[&str]) -> Option<i64> {
for path in paths {
if let Some(candidate) = find_value_by_path(value, path) {
match candidate {
Value::Number(number) => {
if let Some(integer) = number.as_i64() {
return Some(integer);
}
}
Value::String(text) => {
if let Ok(parsed) = text.parse::<i64>() {
return Some(parsed);
}
}
_ => {}
}
}
}
None
}
fn extract_video_url(value: &Value) -> Option<String> {
if let Some(url) = find_string_value(
value,
&[
"output.video_url",
"output.url",
"video_url",
"url",
"result.video_url",
"result.url",
"output.video_urls.0",
],
) {
if url.starts_with("http://") || url.starts_with("https://") {
return Some(url);
}
}
if let Some(results) = find_value_by_path(value, "output.results") {
if let Value::Array(items) = results {
for item in items {
if let Some(url) = find_string_value(item, &["url", "video_url"]) {
if url.starts_with("http://") || url.starts_with("https://") {
return Some(url);
}
}
}
}
}
None
}
fn resolve_dashscope_size(resolution: Option<&str>, aspect_ratio: Option<&str>) -> Option<String> {
let ratio = aspect_ratio.unwrap_or("16:9");
let normalized_ratio = if ratio == "adaptive" { "16:9" } else { ratio };
let normalized_resolution = resolution.unwrap_or("720p").to_lowercase();
let value = match (normalized_resolution.as_str(), normalized_ratio) {
("1080p", "16:9") => "1920*1080",
("1080p", "9:16") => "1080*1920",
("1080p", "1:1") => "1536*1536",
("720p", "16:9") => "1280*720",
("720p", "9:16") => "720*1280",
("720p", "1:1") => "1024*1024",
("480p", "16:9") => "854*480",
("480p", "9:16") => "480*854",
("480p", "1:1") => "720*720",
_ => "1280*720",
};
Some(value.to_string())
}
fn infer_mime_type_from_path(path: &str) -> &'static str {
let extension = std::path::Path::new(path)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_lowercase();
match extension.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"svg" => "image/svg+xml",
_ => "application/octet-stream",
}
}
fn build_data_url(mime_type: &str, bytes: &[u8]) -> String {
format!("data:{mime_type};base64,{}", BASE64.encode(bytes))
}
fn resolve_material_reference_url(db: &DbConnection, raw_url: &str) -> Result<String, String> {
if !raw_url.starts_with(MATERIAL_URL_PREFIX) {
return Ok(raw_url.to_string());
}
let material_id = raw_url
.trim_start_matches(MATERIAL_URL_PREFIX)
.trim()
.to_string();
if material_id.is_empty() {
return Err("素材引用 URL 无效:缺少 material id".to_string());
}
let material = {
let conn = lock_db(db)?;
MaterialDao::get(&conn, &material_id).map_err(|error| format!("读取素材失败: {error}"))?
}
.ok_or_else(|| format!("素材不存在: {material_id}"))?;
let file_path = material
.file_path
.ok_or_else(|| format!("素材缺少文件路径: {material_id}"))?;
let bytes = std::fs::read(&file_path).map_err(|error| format!("读取素材文件失败: {error}"))?;
let mime_type = material
.mime_type
.unwrap_or_else(|| infer_mime_type_from_path(&file_path).to_string());
Ok(build_data_url(&mime_type, &bytes))
}
fn resolve_submit_request(
db: &DbConnection,
request: &CreateVideoGenerationRequest,
) -> Result<CreateVideoGenerationRequest, String> {
let mut resolved = request.clone();
if let Some(image_url) = &request.image_url {
if !image_url.trim().is_empty() {
resolved.image_url = Some(resolve_material_reference_url(db, image_url)?);
}
}
if let Some(end_image_url) = &request.end_image_url {
if !end_image_url.trim().is_empty() {
resolved.end_image_url = Some(resolve_material_reference_url(db, end_image_url)?);
}
}
Ok(resolved)
}
fn resolve_adapter(
provider: &ApiKeyProvider,
) -> Result<Box<dyn VideoProviderAdapter + Send + Sync>, String> {
let provider_id = provider.id.to_lowercase();
let api_host = provider.api_host.to_lowercase();
if provider_id.contains("doubao")
|| provider_id.contains("volc")
|| api_host.contains("volces.com")
|| api_host.contains("volcengine.com")
{
return Ok(Box::new(VolcengineVideoAdapter));
}
if provider_id.contains("dashscope")
|| provider_id.contains("alibaba")
|| provider_id.contains("qwen")
|| api_host.contains("dashscope.aliyuncs.com")
{
return Ok(Box::new(DashscopeVideoAdapter));
}
Err(format!(
"当前 Provider 尚未实现视频生成适配: {} (api_host={})",
provider.id, provider.api_host
))
}
/// 视频生成服务
pub struct VideoGenerationService {
client: Client,
}
impl Default for VideoGenerationService {
fn default() -> Self {
Self::new()
}
}
impl VideoGenerationService {
pub fn new() -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
.build()
.unwrap_or_else(|_| Client::new());
Self { client }
}
pub async fn create_task(
&self,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderService,
request: CreateVideoGenerationRequest,
) -> Result<VideoGenerationTask, String> {
let provider_with_keys = api_key_provider_service
.get_provider(db, &request.provider_id)?
.ok_or_else(|| format!("Provider 不存在: {}", request.provider_id))?;
let provider = provider_with_keys.provider;
if !provider.enabled {
return Err(format!("Provider 已禁用: {}", provider.id));
}
let request_payload = serde_json::to_string(&request)
.map_err(|error| format!("视频任务请求序列化失败: {error}"))?;
let mut task = {
let conn = lock_db(db)?;
VideoGenerationTaskDao::create(
&conn,
&CreateVideoGenerationTaskParams {
project_id: request.project_id.clone(),
provider_id: request.provider_id.clone(),
model: request.model.clone(),
prompt: request.prompt.clone(),
request_payload: Some(request_payload),
metadata_json: None,
},
)
.map_err(|error| format!("视频任务创建失败: {error}"))?
};
let (selected_key_id, selected_api_key) = api_key_provider_service
.get_next_api_key_entry(db, &provider.id)?
.ok_or_else(|| format!("Provider 没有可用的 API Key: {}", provider.id))?;
let adapter = resolve_adapter(&provider)?;
let context = AdapterContext {
api_host: provider.api_host.clone(),
api_key: selected_api_key,
};
let submit_request = resolve_submit_request(db, &request)?;
match adapter
.submit(&self.client, &context, &submit_request)
.await
{
Ok(provider_task_id) => {
let updated = {
let conn = lock_db(db)?;
VideoGenerationTaskDao::update_task(
&conn,
&task.id,
&UpdateVideoGenerationTaskParams {
provider_task_id: Some(Some(provider_task_id)),
status: Some(VideoGenerationTaskStatus::Processing),
progress: Some(Some(0)),
result_url: None,
error_message: None,
metadata_json: None,
finished_at: Some(None),
},
)
.map_err(|error| format!("视频任务更新失败: {error}"))?
};
api_key_provider_service.record_usage(db, &selected_key_id)?;
task = updated.ok_or_else(|| "视频任务更新后丢失".to_string())?;
Ok(task)
}
Err(error_message) => {
{
let conn = lock_db(db)?;
let _ = VideoGenerationTaskDao::update_task(
&conn,
&task.id,
&UpdateVideoGenerationTaskParams {
status: Some(VideoGenerationTaskStatus::Error),
error_message: Some(Some(error_message.clone())),
finished_at: Some(Some(chrono::Utc::now().timestamp())),
..Default::default()
},
);
}
let _ = api_key_provider_service.record_error(db, &selected_key_id);
Err(error_message)
}
}
}
pub async fn get_task(
&self,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderService,
task_id: &str,
refresh_status: bool,
) -> Result<Option<VideoGenerationTask>, String> {
let task = {
let conn = lock_db(db)?;
VideoGenerationTaskDao::get_by_id(&conn, task_id)
.map_err(|error| format!("读取视频任务失败: {error}"))?
};
let mut task = match task {
Some(value) => value,
None => return Ok(None),
};
if !refresh_status {
return Ok(Some(task));
}
if task.status != VideoGenerationTaskStatus::Pending
&& task.status != VideoGenerationTaskStatus::Processing
{
return Ok(Some(task));
}
let provider_task_id = match &task.provider_task_id {
Some(value) if !value.trim().is_empty() => value.clone(),
_ => return Ok(Some(task)),
};
let provider_with_keys = api_key_provider_service
.get_provider(db, &task.provider_id)?
.ok_or_else(|| format!("Provider 不存在: {}", task.provider_id))?;
let provider = provider_with_keys.provider;
let (_key_id, api_key) = api_key_provider_service
.get_next_api_key_entry(db, &provider.id)?
.ok_or_else(|| format!("Provider 没有可用的 API Key: {}", provider.id))?;
let adapter = resolve_adapter(&provider)?;
let context = AdapterContext {
api_host: provider.api_host.clone(),
api_key,
};
let status = match adapter
.query(&self.client, &context, &provider_task_id)
.await
{
Ok(value) => value,
Err(error_message) => ProviderTaskStatus {
status: VideoGenerationTaskStatus::Error,
progress: None,
video_url: None,
error_message: Some(error_message),
},
};
let updated_task = {
let conn = lock_db(db)?;
VideoGenerationTaskDao::update_task(
&conn,
&task.id,
&UpdateVideoGenerationTaskParams {
status: Some(status.status),
progress: Some(status.progress),
result_url: Some(status.video_url),
error_message: Some(status.error_message),
finished_at: if matches!(
status.status,
VideoGenerationTaskStatus::Success
| VideoGenerationTaskStatus::Error
| VideoGenerationTaskStatus::Cancelled
) {
Some(Some(chrono::Utc::now().timestamp()))
} else {
Some(None)
},
..Default::default()
},
)
.map_err(|error| format!("更新视频任务状态失败: {error}"))?
};
if let Some(updated) = updated_task {
task = updated;
}
Ok(Some(task))
}
pub fn list_tasks(
&self,
db: &DbConnection,
project_id: &str,
limit: i64,
) -> Result<Vec<VideoGenerationTask>, String> {
let conn = lock_db(db)?;
VideoGenerationTaskDao::list_by_project(&conn, project_id, limit)
.map_err(|error| format!("读取视频任务列表失败: {error}"))
}
pub async fn cancel_task(
&self,
db: &DbConnection,
api_key_provider_service: &ApiKeyProviderService,
task_id: &str,
) -> Result<Option<VideoGenerationTask>, String> {
let task = {
let conn = lock_db(db)?;
VideoGenerationTaskDao::get_by_id(&conn, task_id)
.map_err(|error| format!("读取视频任务失败: {error}"))?
};
let task = match task {
Some(value) => value,
None => return Ok(None),
};
if let Some(provider_task_id) = &task.provider_task_id {
if let Some(provider_with_keys) =
api_key_provider_service.get_provider(db, &task.provider_id)?
{
if let Some((_key_id, api_key)) = api_key_provider_service
.get_next_api_key_entry(db, &provider_with_keys.provider.id)?
{
let adapter = resolve_adapter(&provider_with_keys.provider)?;
let context = AdapterContext {
api_host: provider_with_keys.provider.api_host.clone(),
api_key,
};
let _ = adapter
.cancel(&self.client, &context, provider_task_id)
.await;
}
}
}
let updated = {
let conn = lock_db(db)?;
VideoGenerationTaskDao::update_task(
&conn,
task_id,
&UpdateVideoGenerationTaskParams {
status: Some(VideoGenerationTaskStatus::Cancelled),
finished_at: Some(Some(chrono::Utc::now().timestamp())),
..Default::default()
},
)
.map_err(|error| format!("取消视频任务失败: {error}"))?
};
Ok(updated)
}
}
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
echo "🔧 ProxyCast 本地安装脚本"
echo "================================"
# 1. 更新 Rust
echo "📦 检查 Rust 版本..."
CURRENT_VERSION=$(rustc --version | awk '{print $2}')
echo "当前版本: $CURRENT_VERSION"
if ! rustc --version | grep -q "1.9"; then
echo "⚠️ Rust 版本过低,正在更新..."
rustup update stable
source "$HOME/.cargo/env"
fi
echo "✅ Rust 版本: $(rustc --version | awk '{print $1,$2}')"
# 2. 清理之前的构建
echo ""
echo "🧹 清理之前的构建..."
cargo clean 2>/dev/null || true
# 3. 编译
echo ""
echo "🔨 开始编译 (dev 模式)..."
cargo build 2>&1 | tee /tmp/proxycast_build.log
BUILD_STATUS=${PIPESTATUS[0]}
if [ $BUILD_STATUS -ne 0 ]; then
echo "❌ 编译失败!查看日志: /tmp/proxycast_build.log"
tail -50 /tmp/proxycast_build.log
exit 1
fi
echo "✅ 编译成功"
# 4. 本地安装
echo ""
echo "📦 正在本地安装..."
cargo install --path . --force 2>&1 | tee /tmp/proxycast_install.log
INSTALL_STATUS=${PIPESTATUS[0]}
if [ $INSTALL_STATUS -ne 0 ]; then
echo "❌ 安装失败!查看日志: /tmp/proxycast_install.log"
tail -50 /tmp/proxycast_install.log
exit 1
fi
echo "✅ 安装成功"
# 5. 验证安装
echo ""
echo "🔍 验证安装..."
if command -v proxycast &> /dev/null; then
echo "✅ ProxyCast 已安装到: $(which proxycast)"
else
echo "⚠️ ProxyCast 命令行工具未在 PATH 中"
echo "安装位置: ~/.cargo/bin/proxycast"
echo ""
echo "请将以下内容添加到 ~/.zshrc 或 ~/.bash_profile:"
echo 'export PATH="$HOME/.cargo/bin:$PATH"'
fi
echo ""
echo "🎉 安装完成!"
echo ""
echo "运行应用:"
echo " 开发模式: cd .. && npm run tauri dev"
echo " 构建应用: npm run tauri build"
+5
View File
@@ -1256,6 +1256,11 @@ pub fn run() {
commands::material_cmd::get_material_content,
commands::material_cmd::get_material_count,
commands::material_cmd::get_materials_content,
// Video generation commands
commands::video_generation_cmd::create_video_generation_task,
commands::video_generation_cmd::get_video_generation_task,
commands::video_generation_cmd::list_video_generation_tasks,
commands::video_generation_cmd::cancel_video_generation_task,
// Poster Material commands
commands::poster_material_cmd::create_poster_metadata,
commands::poster_material_cmd::get_poster_metadata,
+1
View File
@@ -59,6 +59,7 @@ pub mod unified_memory_cmd;
pub mod update_cmd;
pub mod usage_cmd;
pub mod usage_stats_cmd;
pub mod video_generation_cmd;
pub mod voice_test_cmd;
pub mod websocket_cmd;
pub mod webview_cmd;
@@ -0,0 +1,90 @@
//! 视频生成命令
//!
//! 提供视频任务创建、轮询、列表和取消命令。
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use tauri::State;
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
use crate::database::DbConnection;
use proxycast_core::database::dao::video_generation_task_dao::VideoGenerationTask;
use proxycast_services::video_generation_service::{
CreateVideoGenerationRequest, VideoGenerationService,
};
static VIDEO_GENERATION_SERVICE: Lazy<VideoGenerationService> =
Lazy::new(VideoGenerationService::new);
/// 获取视频任务请求参数
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetVideoTaskRequest {
pub task_id: String,
pub refresh_status: Option<bool>,
}
/// 列表视频任务请求参数
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListVideoTasksRequest {
pub project_id: String,
pub limit: Option<i64>,
}
/// 取消视频任务请求参数
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CancelVideoTaskRequest {
pub task_id: String,
}
#[tauri::command]
pub async fn create_video_generation_task(
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
request: CreateVideoGenerationRequest,
) -> Result<VideoGenerationTask, String> {
VIDEO_GENERATION_SERVICE
.create_task(&db, &api_key_provider_service.0, request)
.await
}
#[tauri::command]
pub async fn get_video_generation_task(
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
request: GetVideoTaskRequest,
) -> Result<Option<VideoGenerationTask>, String> {
VIDEO_GENERATION_SERVICE
.get_task(
&db,
&api_key_provider_service.0,
&request.task_id,
request.refresh_status.unwrap_or(true),
)
.await
}
#[tauri::command]
pub fn list_video_generation_tasks(
db: State<'_, DbConnection>,
request: ListVideoTasksRequest,
) -> Result<Vec<VideoGenerationTask>, String> {
VIDEO_GENERATION_SERVICE.list_tasks(
&db,
&request.project_id,
request.limit.unwrap_or(50).clamp(1, 200),
)
}
#[tauri::command]
pub async fn cancel_video_generation_task(
db: State<'_, DbConnection>,
api_key_provider_service: State<'_, ApiKeyProviderServiceState>,
request: CancelVideoTaskRequest,
) -> Result<Option<VideoGenerationTask>, String> {
VIDEO_GENERATION_SERVICE
.cancel_task(&db, &api_key_provider_service.0, &request.task_id)
.await
}
+46 -8
View File
@@ -266,6 +266,14 @@ const MAIN_MENU_ITEMS: SidebarNavItem[] = [
params: { theme: "general", lockTheme: false },
isActive: (currentPage) => currentPage === "agent",
},
{
id: "video",
label: "视频",
icon: Video,
page: getThemeWorkspacePage("video"),
params: { workspaceViewMode: "workspace" },
isActive: (currentPage) => currentPage === getThemeWorkspacePage("video"),
},
{ id: "image-gen", label: "绘画", icon: Image, page: "image-gen" },
{ id: "batch", label: "批量任务", icon: Layers, page: "batch" },
{ id: "plugins", label: "插件中心", icon: Compass, page: "plugins" },
@@ -365,7 +373,12 @@ const FOOTER_MENU_ITEMS: SidebarNavItem[] = [
},
];
const DEFAULT_ENABLED_NAV_ITEMS = ["home-general", "image-gen", "plugins"];
const DEFAULT_ENABLED_NAV_ITEMS = [
"home-general",
"video",
"image-gen",
"plugins",
];
function getIconByName(iconName: string): LucideIcon {
const IconComponent = (
@@ -391,6 +404,14 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
const [enabledNavItems, setEnabledNavItems] = useState<string[]>(
DEFAULT_ENABLED_NAV_ITEMS,
);
const [enabledThemes, setEnabledThemes] = useState<string[]>([
"general",
"social-media",
"poster",
"music",
"video",
"novel",
]);
const [sidebarPlugins, setSidebarPlugins] = useState<PluginUIInfo[]>([]);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [_activeThemeKey, setActiveThemeKey] = useState<string>(
@@ -413,21 +434,28 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
} else {
setEnabledNavItems(DEFAULT_ENABLED_NAV_ITEMS);
}
const savedThemes = config.content_creator?.enabled_themes;
if (savedThemes && savedThemes.length > 0) {
setEnabledThemes(savedThemes);
}
} catch (error) {
console.error("加载导航配置失败:", error);
console.error("加载配置失败:", error);
}
};
loadNavConfig();
const handleNavConfigChange = () => {
const handleConfigChange = () => {
loadNavConfig();
};
window.addEventListener("nav-config-changed", handleNavConfigChange);
window.addEventListener("nav-config-changed", handleConfigChange);
window.addEventListener("theme-config-changed", handleConfigChange);
return () => {
window.removeEventListener("nav-config-changed", handleNavConfigChange);
window.removeEventListener("nav-config-changed", handleConfigChange);
window.removeEventListener("theme-config-changed", handleConfigChange);
};
}, []);
@@ -435,6 +463,14 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
return MAIN_MENU_ITEMS.filter((item) => enabledNavItems.includes(item.id));
}, [enabledNavItems]);
const filteredThemeMenuItems = useMemo(() => {
return THEME_MENU_ITEMS.filter((item) => {
// 从 theme-xxx 提取出 xxx
const themeId = item.id.replace("theme-", "");
return enabledThemes.includes(themeId);
});
}, [enabledThemes]);
useEffect(() => {
const loadSidebarPlugins = async () => {
try {
@@ -527,8 +563,10 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
? buildHomeAgentParams(item.params as AgentPageParams | undefined)
: isThemeWorkspacePage(item.page)
? buildWorkspaceResetParams(
item.params as AgentPageParams | undefined,
)
item.params as AgentPageParams | undefined,
(item.params as AgentPageParams | undefined)?.workspaceViewMode ??
"project-management",
)
: item.params;
onNavigate(item.page, params);
@@ -569,7 +607,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
<Section>
<SectionTitle>创作主题</SectionTitle>
{THEME_MENU_ITEMS.map((item) => (
{filteredThemeMenuItems.map((item) => (
<NavButton
key={item.id}
$active={isActive(item)}
@@ -38,11 +38,14 @@ import {
composeEntryPrompt,
createDefaultEntrySlotValues,
formatEntryTaskPreview,
getEntryTaskRecommendations,
getEntryTaskTemplate,
SOCIAL_MEDIA_ENTRY_TASKS,
validateEntryTaskSlots,
} from "../utils/entryPromptComposer";
import {
buildRecommendationPrompt,
getContextualRecommendations,
} from "../utils/contextualRecommendations";
import { ChatModelSelector } from "./ChatModelSelector";
// Import Assets
@@ -378,6 +381,9 @@ interface EmptyStateProps {
strategy: "react" | "code_orchestrated" | "auto",
) => void;
onManageProviders?: () => void;
hasCanvasContent?: boolean;
hasContentId?: boolean;
selectedText?: string;
}
const ENTRY_THEME_ID = "social-media";
@@ -431,134 +437,6 @@ const CREATION_THEMES = [
"novel",
];
/**
* 推荐内容配置
* 格式: [简化标题, 完整 Prompt]
* 简化标题用于显示,完整 Prompt 用于点击发送
*/
const THEME_RECOMMENDATIONS: Record<string, [string, string][]> = {
"social-media": [
[
"爆款标题生成",
"帮我为'春季护肤routine'写10个小红书爆款标题,要求:数字开头、制造悬念、引发共鸣",
],
[
"小红书探店文案",
"写一篇小红书探店文案:周末在杭州发现一家宝藏咖啡店,工业风装修+拉花拿铁,适合拍照出片",
],
[
"公众号排版",
"帮我把这段话排版成公众号风格:每段不超过150字,加入小标题和emoji,重点内容加粗",
],
[
"评论区回复",
"用户评论'这个产品真的好用吗?还是广告?',帮我写一条真诚、有说服力的回复",
],
],
poster: [
[
"海报设计",
"设计一张夏日音乐节海报:主色调渐变蓝紫,中央是剪影吉他和声波元素,底部大标题'夏日音浪'",
],
[
"插画生成",
"生成一幅温馨的卧室插画:暖色调,落地窗透进阳光,书桌上有绿植和笔记本,治愈系风格",
],
[
"UI 界面",
"设计一个健身APP首页:深色模式,顶部显示今日步数,中间是环形进度条,底部四个功能入口",
],
[
"Logo 设计",
"设计一家名为'绿野'的有机食品品牌Logo:简约绿色叶子轮廓,可单独使用,适合多种尺寸",
],
[
"摄影修图",
"人像照片调色建议:肤色通透,背景偏暖,整体日系清新风格,降低对比度提升亮度",
],
],
knowledge: [
[
"解释量子计算",
"用通俗易懂的方式解释量子计算是什么,类比成生活中的例子,适合非理科背景的人理解",
],
[
"总结这篇论文",
"[粘贴论文链接或内容后] 帮我总结这篇论文的核心观点、研究方法和主要结论,输出500字以内的摘要",
],
[
"如何制定OKR",
"详细介绍OKR(目标与关键结果)制定方法,包括设定原则、常见误区和实际案例,适合团队管理者",
],
[
"分析行业趋势",
"分析2024年AI行业发展趋势,从技术突破、商业化进程、监管政策三个维度展开",
],
],
planning: [
[
"日本旅行计划",
"帮我制定一个7天日本关西旅行计划:大阪进京都出,包含主要景点、美食推荐、交通路线和预算估算",
],
[
"年度职业规划",
"制定一名前端开发工程师的2024年职业规划:技能提升、项目经验、人脉积累、求职目标四个维度",
],
[
"婚礼流程表",
"制定一场户外草坪婚礼的流程表:上午10点开始,包含仪式、宴会、互动环节,标注每个环节的时间",
],
[
"健身计划",
"为办公室上班族制定健身计划:每周3次,每次30分钟,无需器械,可在办公室或家中完成",
],
],
music: [
[
"流行情歌",
"创作一首关于'暗恋'的流行情歌:主歌描述图书馆偶遇,副歌表达不敢告白的纠结,温柔的R&B风格",
],
[
"古风歌词",
"创作古风歌词:主题是'江湖离别',意象包括酒、剑、残阳、孤舟,五言句式为主,押韵工整",
],
[
"说唱歌词",
"创作一段励志说唱:主题是'逆风翻盘',讲述从低谷到成功的经历,快节奏,押韵密集,副歌要炸",
],
[
"儿歌创作",
"创作一首儿童安全教育儿歌:主题是'过马路要小心',简单易记,欢快活泼,3-5岁儿童能跟着唱",
],
[
"旋律学习",
"帮我分析《稻香》的旋律特点:调式、和弦进行、节奏型,以及为什么听起来很怀旧温暖",
],
],
novel: [
[
"玄幻小说",
"创作玄幻小说开篇:主角在深山古洞觉醒传承,获得上古剑诀,第一章包含世界观铺垫和悬念设置",
],
[
"都市言情",
"创作都市言情小说开篇:职场新人与高冷上司因工作误会相识,第一章突出女主性格和两人的初次冲突",
],
[
"悬疑推理",
"创作悬疑推理小说开篇:雨夜发生密室杀人案,侦探到达现场发现三条线索,第一章制造悬念和推理伏笔",
],
[
"科幻未来",
"创作科幻小说开篇:2084年人类首次接触外星文明,主角作为语言学家被召唤,第一章描写接触场景和紧张氛围",
],
[
"历史架空",
"创作历史架空小说开篇:三国时期,一个现代人穿越成普通士兵,如何利用现代知识在乱世中生存",
],
],
};
// 主题对应的图标
const THEME_ICONS: Record<string, string> = {
"social-media": "✨",
@@ -625,36 +503,49 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
executionStrategy = "react",
setExecutionStrategy,
onManageProviders,
hasCanvasContent = false,
hasContentId = false,
selectedText = "",
}) => {
// 从配置中读取启用的主题
const [enabledThemes, setEnabledThemes] = useState<string[]>(
DEFAULT_ENABLED_THEMES,
);
const [appendSelectedTextToRecommendation, setAppendSelectedTextToRecommendation] =
useState(true);
// 加载配置
useEffect(() => {
const loadEnabledThemes = async () => {
const loadConfigPreferences = async () => {
try {
const config = await getConfig();
if (config.content_creator?.enabled_themes) {
setEnabledThemes(config.content_creator.enabled_themes);
}
setAppendSelectedTextToRecommendation(
config.chat_appearance?.append_selected_text_to_recommendation ?? true,
);
} catch (e) {
console.error("加载主题配置失败:", e);
}
};
loadEnabledThemes();
loadConfigPreferences();
// 监听主题配置变更事件
const handleThemeConfigChange = () => {
loadEnabledThemes();
// 监听配置变更事件
const handleConfigChange = () => {
loadConfigPreferences();
};
window.addEventListener("theme-config-changed", handleThemeConfigChange);
window.addEventListener("theme-config-changed", handleConfigChange);
window.addEventListener(
"chat-appearance-config-changed",
handleConfigChange,
);
return () => {
window.removeEventListener("theme-config-changed", handleConfigChange);
window.removeEventListener(
"theme-config-changed",
handleThemeConfigChange,
"chat-appearance-config-changed",
handleConfigChange,
);
};
}, []);
@@ -714,12 +605,44 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
[entryTaskType, entrySlotValues],
);
const recommendationSelectedText = appendSelectedTextToRecommendation
? selectedText
: "";
const currentRecommendations = useMemo(() => {
if (isEntryTheme) {
return getEntryTaskRecommendations(entryTaskType);
return getContextualRecommendations({
activeTheme,
input,
creationMode,
entryTaskType,
platform,
hasCanvasContent,
hasContentId,
selectedText: recommendationSelectedText,
});
}, [
activeTheme,
input,
creationMode,
entryTaskType,
platform,
hasCanvasContent,
hasContentId,
recommendationSelectedText,
]);
const selectedTextPreview = useMemo(() => {
const normalized = (recommendationSelectedText || "")
.trim()
.replace(/\s+/g, " ");
if (!normalized) {
return "";
}
return THEME_RECOMMENDATIONS[activeTheme] || [];
}, [activeTheme, entryTaskType, isEntryTheme]);
return normalized.length > 56
? `${normalized.slice(0, 56).trim()}…`
: normalized;
}, [recommendationSelectedText]);
const handleEntrySlotChange = (key: string, value: string) => {
setEntrySlotValues((prev) => ({
@@ -1265,6 +1188,12 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
</InputCard>
{/* Dynamic Inspiration/Tips based on Tab - Styled nicely */}
{selectedTextPreview && (
<div className="w-full max-w-[800px] text-xs text-muted-foreground bg-muted/30 border border-border/70 rounded-lg px-3 py-2">
已检测到选中内容,点击推荐会自动附带上下文:
<span className="ml-1 text-foreground">“{selectedTextPreview}”</span>
</div>
)}
<div className="w-full max-w-[800px] flex flex-wrap gap-3 justify-center">
{currentRecommendations.map(([shortLabel, fullPrompt]) => (
<Badge
@@ -1273,10 +1202,15 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
className="px-4 py-2 text-xs font-normal cursor-pointer hover:bg-muted-foreground/10 transition-colors"
title={fullPrompt}
onClick={() => {
const promptWithSelection = buildRecommendationPrompt(
fullPrompt,
selectedText,
appendSelectedTextToRecommendation,
);
if (onRecommendationClick) {
onRecommendationClick(shortLabel, fullPrompt);
onRecommendationClick(shortLabel, promptWithSelection);
} else {
setInput(fullPrompt);
setInput(promptWithSelection);
}
}}
>
@@ -407,7 +407,13 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
const childProps = child.props as any;
const className = childProps?.className || "";
const match = /language-(\w+)/.exec(className);
const language = match ? match[1] : "";
const language = match ? match[1] : "text";
const codeChildren = childProps?.children;
const codeContent = String(
Array.isArray(codeChildren)
? codeChildren.join("")
: codeChildren || "",
).replace(/\n$/, "");
// 调试:输出检测到的语言
if (language) {
@@ -419,14 +425,6 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
// 如果是 a2ui 代码块,特殊处理
if (language === "a2ui") {
// 获取代码内容 - children 可能是字符串或数组
const codeChildren = childProps?.children;
const codeContent = String(
Array.isArray(codeChildren)
? codeChildren.join("")
: codeChildren || "",
).replace(/\n$/, "");
console.log(
"[MarkdownRenderer] a2ui 代码块内容长度:",
codeContent.length,
@@ -459,28 +457,6 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
}
}
// 其他代码块正常渲染
return <pre {...props}>{children}</pre>;
},
code({ inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || "");
const codeContent = String(children).replace(/\n$/, "");
const language = match ? match[1] : "text";
// Inline code
if (inline) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
// a2ui 已在 pre 组件中处理,这里跳过
if (language === "a2ui") {
return null;
}
// 如果启用了代码块折叠,显示占位符卡片
if (collapseCodeBlocks) {
const lineCount = codeContent.split("\n").length;
@@ -516,13 +492,29 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
background: "transparent",
fontSize: "13px",
}}
{...props}
>
{codeContent}
</SyntaxHighlighter>
</CodeBlockContainer>
);
},
code({ inline, className, children, ...props }: any) {
// Inline code
if (inline) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
// 非 inline code 统一由 pre 组件处理,避免块级元素落入 <p>
return (
<code className={className} {...props}>
{children}
</code>
);
},
// 普通图片渲染(非 base64)
img({ src, alt, ...props }: any) {
// base64 图片已经在上面单独处理了,这里只处理普通 URL 图片
@@ -537,15 +529,13 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
};
return (
<ImageContainer>
<GeneratedImage
src={src}
alt={alt || "Image"}
onClick={handleImageClick}
title="点击查看大图"
{...props}
/>
</ImageContainer>
<GeneratedImage
src={src}
alt={alt || "Image"}
onClick={handleImageClick}
title="点击查看大图"
{...props}
/>
);
},
}}
@@ -1488,6 +1488,26 @@ describe("useAsterAgentChat 兼容接口", () => {
}
});
it("triggerAIGuide 传入引导词时应发送该引导词", async () => {
const harness = mountHook("ws-guide-social");
const prompt = "请先确认社媒平台和目标受众。";
try {
await flushEffects();
await act(async () => {
await harness.getValue().triggerAIGuide(prompt);
});
const value = harness.getValue();
expect(value.messages).toHaveLength(1);
expect(value.messages[0]?.role).toBe("assistant");
expect(mockSendAsterMessageStream).toHaveBeenCalledTimes(1);
expect(mockSendAsterMessageStream.mock.calls[0]?.[0]).toBe(prompt);
} finally {
harness.unmount();
}
});
it("renameTopic 应调用后端并刷新话题标题", async () => {
const createdAt = Math.floor(Date.now() / 1000);
mockListAsterSessions
@@ -1940,9 +1940,12 @@ export function useAsterAgentChat(options: UseAsterAgentChatOptions) {
);
// 兼容 Native 接口:触发 AI 引导(仅生成助手消息,不注入用户气泡)
const triggerAIGuide = useCallback(async () => {
await sendMessage("", [], false, false, true);
}, [sendMessage]);
const triggerAIGuide = useCallback(
async (initialPrompt?: string) => {
await sendMessage(initialPrompt?.trim() || "", [], false, false, true);
},
[sendMessage],
);
// 清空消息(兼容 useAgentChat 的可选参数)
const clearMessages = useCallback(
+85 -7
View File
@@ -14,6 +14,9 @@ const {
mockArtifactsAtom,
mockSelectedArtifactAtom,
mockSelectedArtifactIdAtom,
mockGenerateContentCreationPrompt,
mockIsContentCreationTheme,
mockEmptyState,
} = vi.hoisted(() => ({
mockUseAgentChatUnified: vi.fn(),
mockGetProject: vi.fn(),
@@ -31,6 +34,11 @@ const {
mockArtifactsAtom: { key: "artifacts" },
mockSelectedArtifactAtom: { key: "selectedArtifact" },
mockSelectedArtifactIdAtom: { key: "selectedArtifactId" },
mockGenerateContentCreationPrompt: vi.fn(() => "mock-system-prompt"),
mockIsContentCreationTheme: vi.fn(() => false),
mockEmptyState: vi.fn((props?: { input?: string }) => (
<div data-testid="empty-state">{props?.input || ""}</div>
)),
}));
vi.mock("sonner", () => ({
@@ -132,7 +140,7 @@ vi.mock("./components/Inputbar", () => ({
}));
vi.mock("./components/EmptyState", () => ({
EmptyState: () => <div data-testid="empty-state" />,
EmptyState: (props?: { input?: string }) => mockEmptyState(props),
}));
vi.mock("@/components/content-creator/core/StepGuide/StepProgress", () => ({
@@ -165,8 +173,8 @@ vi.mock("jotai", () => ({
}));
vi.mock("@/components/content-creator/utils/systemPrompt", () => ({
generateContentCreationPrompt: vi.fn(() => "mock-system-prompt"),
isContentCreationTheme: vi.fn(() => false),
generateContentCreationPrompt: mockGenerateContentCreationPrompt,
isContentCreationTheme: mockIsContentCreationTheme,
}));
vi.mock("@/components/content-creator/utils/projectPrompt", () => ({
@@ -215,6 +223,9 @@ interface MountedHarness {
const mountedRoots: MountedHarness[] = [];
const observedWorkspaceIds: string[] = [];
let sharedSwitchTopicMock: ReturnType<typeof vi.fn>;
let sharedSendMessageMock: ReturnType<typeof vi.fn>;
let sharedTriggerAIGuideMock: ReturnType<typeof vi.fn>;
function createProject(id: string, archived = false) {
return {
@@ -301,8 +312,15 @@ beforeEach(() => {
mockGetContent.mockResolvedValue(null);
mockUpdateContent.mockResolvedValue(undefined);
mockGetProjectMemory.mockResolvedValue(null);
mockGenerateContentCreationPrompt.mockReturnValue("mock-system-prompt");
mockIsContentCreationTheme.mockReturnValue(false);
mockEmptyState.mockImplementation((props?: { input?: string }) => (
<div data-testid="empty-state">{props?.input || ""}</div>
));
const mockOriginalSwitchTopic = vi.fn(async () => undefined);
sharedSwitchTopicMock = vi.fn(async () => undefined);
sharedSendMessageMock = vi.fn(async () => undefined);
sharedTriggerAIGuideMock = vi.fn();
mockUseAgentChatUnified.mockImplementation(
({ workspaceId }: { workspaceId: string }) => {
observedWorkspaceIds.push(workspaceId);
@@ -315,13 +333,13 @@ beforeEach(() => {
setExecutionStrategy: vi.fn(),
messages: [],
isSending: false,
sendMessage: vi.fn(async () => undefined),
sendMessage: sharedSendMessageMock,
stopSending: vi.fn(async () => undefined),
clearMessages: vi.fn(),
deleteMessage: vi.fn(),
editMessage: vi.fn(),
handlePermissionResponse: vi.fn(),
triggerAIGuide: vi.fn(),
triggerAIGuide: sharedTriggerAIGuideMock,
topics: [
{
id: "topic-a",
@@ -330,7 +348,7 @@ beforeEach(() => {
},
],
sessionId: "session-1",
switchTopic: mockOriginalSwitchTopic,
switchTopic: sharedSwitchTopicMock,
deleteTopic: vi.fn(),
renameTopic: vi.fn(),
};
@@ -443,3 +461,63 @@ describe("AgentChatPage 话题切换项目恢复", () => {
);
});
});
describe("AgentChatPage 自动引导", () => {
it("社媒空文稿应预填引导词且不自动发送", async () => {
mockIsContentCreationTheme.mockReturnValue(true);
const container = renderPage({
projectId: "project-social",
contentId: "content-social",
theme: "social-media",
lockTheme: true,
});
await flushEffects(10);
expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled();
expect(sharedSendMessageMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("社媒内容创作教练");
});
it("非社媒空文稿应维持原始自动引导调用", async () => {
mockIsContentCreationTheme.mockReturnValue(true);
renderPage({
projectId: "project-document",
contentId: "content-document",
theme: "document",
lockTheme: true,
});
await flushEffects(10);
expect(sharedTriggerAIGuideMock).toHaveBeenCalledTimes(1);
expect(sharedTriggerAIGuideMock).toHaveBeenCalledWith();
});
it("存在 initialUserPrompt 时应优先发送首条意图", async () => {
mockIsContentCreationTheme.mockReturnValue(true);
const onInitialUserPromptConsumed = vi.fn();
const initialUserPrompt = "请先帮我写一篇社媒文案提纲。";
renderPage({
projectId: "project-social-intent",
contentId: "content-social-intent",
theme: "social-media",
lockTheme: true,
initialUserPrompt,
onInitialUserPromptConsumed,
});
await flushEffects(12);
expect(sharedSendMessageMock).toHaveBeenCalledWith(
initialUserPrompt,
[],
false,
false,
false,
undefined,
);
expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1);
expect(sharedTriggerAIGuideMock).not.toHaveBeenCalled();
});
});
+37
View File
@@ -80,6 +80,7 @@ import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
import { getFileToStepMap } from "./utils/workflowMapping";
import { normalizeProjectId } from "./utils/topicProjectResolution";
import { resolveTopicSwitchProject } from "./utils/topicProjectSwitch";
import { getDefaultGuidePromptByTheme } from "./utils/defaultGuidePrompt";
const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
"general",
@@ -280,6 +281,7 @@ export function AgentChatPage({
}) {
const [showSidebar, setShowSidebar] = useState(false);
const [input, setInput] = useState("");
const [selectedText, setSelectedText] = useState("");
// 内容创作相关状态
const [activeTheme, setActiveTheme] = useState<string>(
@@ -998,6 +1000,7 @@ export function AgentChatPage({
const handleClearMessages = useCallback(() => {
clearMessages();
setInput("");
setSelectedText("");
// 重置布局模式
setLayoutMode("chat");
// 恢复侧边栏显示
@@ -1028,6 +1031,7 @@ export function AgentChatPage({
showToast: false,
});
setInput("");
setSelectedText("");
setLayoutMode("chat");
setShowSidebar(true);
setCanvasState(null);
@@ -1062,6 +1066,7 @@ export function AgentChatPage({
showToast: false,
});
setInput("");
setSelectedText("");
setLayoutMode("chat");
setShowSidebar(true);
setCanvasState(null);
@@ -1082,6 +1087,16 @@ export function AgentChatPage({
// 当开始对话时自动折叠侧边栏
const hasMessages = messages.length > 0;
const handleCanvasSelectionTextChange = useCallback((text: string) => {
const normalized = text.trim().replace(/\s+/g, " ");
const nextValue = normalized.length > 500 ? normalized.slice(0, 500) : normalized;
setSelectedText((previous) => (previous === nextValue ? previous : nextValue));
}, []);
useEffect(() => {
setSelectedText("");
}, [activeTheme, contentId]);
useEffect(() => {
if (!canvasState || canvasState.type !== "novel") {
setNovelChapterListCollapsed(false);
@@ -1743,10 +1758,23 @@ export function AgentChatPage({
return;
}
const defaultGuidePrompt = getDefaultGuidePromptByTheme(activeTheme);
if (defaultGuidePrompt) {
console.log("[AgentChatPage] 自动预填主题引导词");
setInput((previous) => {
if (previous.trim()) {
return previous;
}
return defaultGuidePrompt;
});
return;
}
console.log("[AgentChatPage] 自动触发 AI 创作引导");
triggerAIGuideRef.current();
}
}, [
activeTheme,
contentId,
messages.length,
project,
@@ -1880,6 +1908,13 @@ export function AgentChatPage({
}
}}
showThemeTabs={false}
hasCanvasContent={
activeTheme === "general"
? Boolean(generalCanvasState.content?.trim())
: !isCanvasStateEmpty(canvasState)
}
hasContentId={Boolean(contentId)}
selectedText={selectedText}
onRecommendationClick={(shortLabel, fullPrompt) => {
// 直接将推荐提示词放入输入框,不创建项目
setInput(fullPrompt);
@@ -1985,6 +2020,7 @@ export function AgentChatPage({
onStateChange={setCanvasState}
onClose={handleCloseCanvas}
isStreaming={isSending}
onSelectionTextChange={handleCanvasSelectionTextChange}
novelControls={
canvasState.type === "novel"
? {
@@ -2007,6 +2043,7 @@ export function AgentChatPage({
mappedTheme,
handleCloseCanvas,
isSending,
handleCanvasSelectionTextChange,
artifactViewMode,
artifactPreviewSize,
novelChapterListCollapsed,
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
buildRecommendationPrompt,
getContextualRecommendations,
} from "./contextualRecommendations";
describe("getContextualRecommendations", () => {
it("社媒空白场景应返回起稿类推荐", () => {
const recommendations = getContextualRecommendations({
activeTheme: "social-media",
input: "",
creationMode: "guided",
entryTaskType: "direct",
platform: "xiaohongshu",
hasCanvasContent: false,
hasContentId: true,
selectedText: "",
});
expect(recommendations.length).toBeGreaterThan(0);
expect(recommendations[0]?.[0]).toContain("选题");
});
it("社媒有正文时应优先返回改写类推荐", () => {
const recommendations = getContextualRecommendations({
activeTheme: "social-media",
input: "",
creationMode: "hybrid",
entryTaskType: "rewrite",
platform: "wechat",
hasCanvasContent: true,
hasContentId: true,
selectedText: "",
});
expect(recommendations.length).toBeGreaterThan(0);
expect(recommendations[0]?.[0]).toContain("润色");
});
it("社媒有输入时应返回输入相关推荐", () => {
const recommendations = getContextualRecommendations({
activeTheme: "social-media",
input: "春季敏感肌修护",
creationMode: "fast",
entryTaskType: "direct",
platform: "xiaohongshu",
hasCanvasContent: false,
hasContentId: false,
selectedText: "",
});
expect(recommendations.length).toBeGreaterThan(0);
expect(recommendations[0]?.[1]).toContain("春季敏感肌修护");
});
it("非社媒主题应走主题兜底推荐", () => {
const recommendations = getContextualRecommendations({
activeTheme: "planning",
input: "",
creationMode: "guided",
entryTaskType: "direct",
platform: "xiaohongshu",
hasCanvasContent: false,
hasContentId: false,
selectedText: "",
});
expect(recommendations.length).toBeGreaterThan(0);
expect(recommendations[0]?.[0]).toContain("计划");
});
it("通用主题应返回通用对话推荐", () => {
const recommendations = getContextualRecommendations({
activeTheme: "general",
input: "",
creationMode: "guided",
entryTaskType: "direct",
platform: "xiaohongshu",
hasCanvasContent: false,
hasContentId: false,
selectedText: "",
});
expect(recommendations.length).toBeGreaterThan(0);
expect(recommendations[0]?.[0]).toContain("需求");
});
it("文档主题应返回办公文档推荐", () => {
const recommendations = getContextualRecommendations({
activeTheme: "document",
input: "",
creationMode: "guided",
entryTaskType: "direct",
platform: "xiaohongshu",
hasCanvasContent: false,
hasContentId: false,
selectedText: "",
});
expect(recommendations.length).toBeGreaterThan(0);
expect(recommendations[0]?.[0]).toContain("公文");
});
it("社媒有选中文本时应优先返回选区改写推荐", () => {
const recommendations = getContextualRecommendations({
activeTheme: "social-media",
input: "",
creationMode: "guided",
entryTaskType: "rewrite",
platform: "wechat",
hasCanvasContent: true,
hasContentId: true,
selectedText: "这是一段待优化的原文内容。",
});
expect(recommendations.length).toBeGreaterThan(0);
expect(recommendations[0]?.[0]).toContain("选中");
});
it("构建推荐提示词时应注入选中文本上下文", () => {
const prompt = buildRecommendationPrompt("请帮我改写内容。", "这是原文。");
expect(prompt).toContain("请帮我改写内容。");
expect(prompt).toContain("[参考选中内容]");
expect(prompt).toContain("这是原文。");
});
it("无选中文本时应保持原始提示词", () => {
const prompt = buildRecommendationPrompt("请帮我润色。", "");
expect(prompt).toBe("请帮我润色。");
});
it("选中文本过长时应截断注入", () => {
const longSelectedText = "a".repeat(380);
const prompt = buildRecommendationPrompt("请总结。", longSelectedText);
expect(prompt).toContain("[参考选中内容]");
expect(prompt).toContain("…");
});
it("关闭附带选区开关时应忽略选中文本", () => {
const prompt = buildRecommendationPrompt(
"请润色文稿。",
"这是一段选中的文稿内容。",
false,
);
expect(prompt).toBe("请润色文稿。");
});
});
@@ -0,0 +1,338 @@
import type { CreationMode, EntryTaskType } from "../components/types";
import { getEntryTaskRecommendations } from "./entryPromptComposer";
export type RecommendationTuple = [string, string];
const SELECTED_TEXT_MAX_LENGTH = 320;
interface RecommendationContext {
activeTheme: string;
input: string;
creationMode: CreationMode;
entryTaskType: EntryTaskType;
platform: string;
hasCanvasContent: boolean;
hasContentId: boolean;
selectedText?: string;
}
const SOCIAL_PLATFORM_LABELS: Record<string, string> = {
xiaohongshu: "小红书",
wechat: "公众号",
zhihu: "知乎",
toutiao: "头条",
juejin: "掘金",
csdn: "CSDN",
};
const FALLBACK_THEME_RECOMMENDATIONS: Record<string, RecommendationTuple[]> = {
general: [
[
"需求澄清助手",
"请先帮我澄清当前问题:目标是什么、已知条件是什么、缺失信息是什么,并给出下一步提问清单。",
],
[
"方案对比",
"围绕这个问题给我 3 套可执行方案,分别说明优缺点、适用场景和实施成本。",
],
[
"快速总结",
"请把这件事总结成“背景-问题-建议-行动”四段结构,控制在 200 字内。",
],
[
"行动清单",
"请把目标拆成可执行 TODO 列表:按优先级排序,给出预计耗时和验收标准。",
],
],
"social-media": [
[
"爆款标题生成",
"帮我为“春季护肤routine”写10个小红书爆款标题,要求:数字开头、制造悬念、引发共鸣。",
],
[
"小红书探店文案",
"写一篇小红书探店文案:周末在杭州发现一家宝藏咖啡店,工业风装修+拉花拿铁,适合拍照出片。",
],
[
"公众号排版",
"帮我把这段话排版成公众号风格:每段不超过150字,加入小标题和 emoji,重点内容加粗。",
],
[
"评论区回复",
"用户评论“这个产品真的好用吗?还是广告?”,帮我写一条真诚、有说服力的回复。",
],
],
poster: [
[
"海报设计",
"设计一张夏日音乐节海报:主色调渐变蓝紫,中央是剪影吉他和声波元素,底部大标题“夏日音浪”。",
],
[
"插画生成",
"生成一幅温馨的卧室插画:暖色调,落地窗透进阳光,书桌上有绿植和笔记本,治愈系风格。",
],
[
"UI 界面",
"设计一个健身APP首页:深色模式,顶部显示今日步数,中间是环形进度条,底部四个功能入口。",
],
[
"Logo 设计",
"设计一家名为“绿野”的有机食品品牌 Logo:简约绿色叶子轮廓,可单独使用,适合多种尺寸。",
],
[
"摄影修图",
"人像照片调色建议:肤色通透,背景偏暖,整体日系清新风格,降低对比度提升亮度。",
],
],
knowledge: [
[
"解释量子计算",
"用通俗易懂的方式解释量子计算是什么,类比成生活中的例子,适合非理科背景的人理解。",
],
[
"总结这篇论文",
"帮我总结这篇论文的核心观点、研究方法和主要结论,输出 500 字以内摘要。",
],
[
"如何制定OKR",
"详细介绍 OKR 制定方法,包括设定原则、常见误区和实际案例,适合团队管理者。",
],
[
"分析行业趋势",
"分析 2024 年 AI 行业发展趋势,从技术突破、商业化进程、监管政策三个维度展开。",
],
],
planning: [
[
"日本旅行计划",
"帮我制定一个 7 天日本关西旅行计划:大阪进京都出,包含景点、美食、交通路线和预算估算。",
],
[
"年度职业规划",
"制定一名前端开发工程师的年度职业规划:技能提升、项目经验、人脉积累、求职目标四个维度。",
],
[
"婚礼流程表",
"制定一场户外草坪婚礼流程:上午 10 点开始,包含仪式、宴会、互动环节,并标注每个环节时间。",
],
[
"健身计划",
"为办公室上班族制定健身计划:每周 3 次,每次 30 分钟,无需器械,可在办公室或家中完成。",
],
],
music: [
[
"流行情歌",
"创作一首关于“暗恋”的流行情歌:主歌描述图书馆偶遇,副歌表达不敢告白的纠结,温柔 R&B 风格。",
],
[
"古风歌词",
"创作古风歌词:主题“江湖离别”,意象包括酒、剑、残阳、孤舟,五言句式为主,押韵工整。",
],
[
"说唱歌词",
"创作一段励志说唱:主题“逆风翻盘”,讲述从低谷到成功的经历,快节奏、押韵密集,副歌要炸。",
],
[
"儿歌创作",
"创作一首儿童安全教育儿歌:主题“过马路要小心”,简单易记,欢快活泼,3-5 岁儿童可跟唱。",
],
[
"旋律学习",
"分析《稻香》的旋律特点:调式、和弦进行、节奏型,以及为什么听起来怀旧温暖。",
],
],
novel: [
[
"玄幻小说",
"创作玄幻小说开篇:主角在深山古洞觉醒传承,获得上古剑诀,第一章含世界观铺垫与悬念设置。",
],
[
"都市言情",
"创作都市言情开篇:职场新人与高冷上司因工作误会相识,第一章突出女主性格与初次冲突。",
],
[
"悬疑推理",
"创作悬疑推理开篇:雨夜发生密室杀人案,侦探到场发现三条线索,第一章制造悬念与推理伏笔。",
],
[
"科幻未来",
"创作科幻小说开篇:2084 年人类首次接触外星文明,主角作为语言学家被召唤,描写接触场景与紧张氛围。",
],
[
"历史架空",
"创作历史架空开篇:三国时期,一个现代人穿越成普通士兵,如何利用现代知识在乱世中生存。",
],
],
document: [
[
"公文式润色",
"请把当前内容改写成正式办公文档风格,要求语句简洁、结构清晰、术语统一。",
],
[
"会议纪要整理",
"请把内容整理成会议纪要:议题、讨论要点、结论、责任人、截止时间。",
],
[
"汇报提纲",
"请基于当前主题生成一份工作汇报提纲:背景、进展、风险、下一步计划。",
],
[
"邮件草稿",
"请生成一封专业邮件草稿:说明背景、核心诉求、希望对方的下一步动作。",
],
],
video: [
[
"短视频脚本",
"请为这个主题写一条 60 秒短视频脚本,结构为“开场钩子-冲突-解决-行动号召”。",
],
[
"分镜清单",
"请把内容拆成 8-10 个镜头分镜,包含画面描述、旁白、时长和转场建议。",
],
[
"口播优化",
"请将当前文案改成自然口播稿,句子更短、更有节奏,并保留关键信息。",
],
[
"标题与封面",
"请给我 10 个短视频标题和 5 个封面文案,要求突出冲突与收益点。",
],
],
};
const SOCIAL_BLANK_RECOMMENDATIONS: RecommendationTuple[] = [
[
"从选题开始",
"请先帮我做社媒选题:给我 5 个可执行且有传播潜力的选题,并说明各自目标受众与切入角度。",
],
[
"先搭结构",
"先不要写正文,请先给我“标题-开头-主体-结尾-互动引导”的内容结构框架。",
],
[
"平台差异建议",
"同一主题下,小红书、公众号、知乎的写法差异是什么?请给我一份可执行对照清单。",
],
];
const SOCIAL_REWRITE_RECOMMENDATIONS: RecommendationTuple[] = [
[
"正文润色提效",
"请帮我润色当前文稿,保持核心观点不变,增强可读性和节奏感,并标注关键修改点。",
],
[
"结构压缩重排",
"请把当前文稿重排成“问题-观点-方法-案例-行动”结构,删掉重复表达。",
],
[
"平台适配改写",
"请基于当前文稿输出三个版本:小红书版、公众号版、知乎版,保留事实信息,语气与结构各自适配。",
],
];
function normalizeSubject(value: string): string {
const normalized = value.replace(/\s+/g, " ").trim();
if (!normalized) {
return "这个主题";
}
return normalized.length > 24
? `${normalized.slice(0, 24).trim()}...`
: normalized;
}
function normalizePlatform(value: string): string {
return SOCIAL_PLATFORM_LABELS[value] || "社媒平台";
}
function buildSocialRecommendations(
context: RecommendationContext,
): RecommendationTuple[] {
const selectedText = (context.selectedText || "").trim();
if (selectedText) {
return [
[
"按选中内容改写",
"请基于我选中的段落做三版改写:精简版、增强感染力版、专业理性版,并解释适用场景。",
],
[
"选中段落提炼",
"请提炼我选中段落的核心观点,并改成“可直接发布”的社媒表达,控制在 120 字内。",
],
[
"选中段落转风格",
"请把我选中的内容分别改成小红书口语风和公众号深度风,保留事实,不改变结论。",
],
];
}
if (context.hasCanvasContent) {
return SOCIAL_REWRITE_RECOMMENDATIONS;
}
const normalizedInput = context.input.trim();
if (normalizedInput) {
const subject = normalizeSubject(normalizedInput);
const platform = normalizePlatform(context.platform);
return [
[
"补全创作简报",
`基于“${subject}”,请先补全一份社媒创作简报:目标受众、核心卖点、内容结构、语气风格、互动引导。`,
],
[
"直接起 3 个版本",
`围绕“${subject}”,先给我 3 个不同风格的 ${platform} 起稿版本(实用型/故事型/观点型)。`,
],
[
"先出标题开头",
`围绕“${subject}”,先输出 10 个标题和 3 个开头钩子,供我选择后再写正文。`,
],
];
}
if (context.hasContentId || context.creationMode === "guided") {
return SOCIAL_BLANK_RECOMMENDATIONS;
}
const entryRecommendations = getEntryTaskRecommendations(context.entryTaskType);
if (entryRecommendations.length > 0) {
return entryRecommendations;
}
return FALLBACK_THEME_RECOMMENDATIONS["social-media"];
}
export function getContextualRecommendations(
context: RecommendationContext,
): RecommendationTuple[] {
if (context.activeTheme === "social-media") {
return buildSocialRecommendations(context);
}
return FALLBACK_THEME_RECOMMENDATIONS[context.activeTheme] || [];
}
export function buildRecommendationPrompt(
basePrompt: string,
selectedText?: string,
appendSelectedText = true,
): string {
const normalizedPrompt = basePrompt.trim();
if (!appendSelectedText) {
return normalizedPrompt;
}
const normalizedSelected = (selectedText || "").trim();
if (!normalizedSelected) {
return normalizedPrompt;
}
const clippedSelected =
normalizedSelected.length > SELECTED_TEXT_MAX_LENGTH
? `${normalizedSelected.slice(0, SELECTED_TEXT_MAX_LENGTH).trim()}…`
: normalizedSelected;
return `${normalizedPrompt}\n\n[参考选中内容]\n${clippedSelected}`;
}
@@ -0,0 +1,25 @@
const SOCIAL_MEDIA_DEFAULT_GUIDE_PROMPT = `你现在是社媒内容创作教练,请先进入“提问引导”阶段,不要直接成文。
请先用简洁问题逐项确认以下信息:
1. 创作主题(想解决的问题或核心观点)
2. 发布平台(如小红书/公众号/知乎)
3. 目标受众(人群画像)
4. 目标结果(涨粉/互动/转化/品牌认知)
5. 语气风格与篇幅要求
提问规则:
- 一次最多 3 个问题,问题要具体可回答
- 若信息不全,继续追问关键缺失项
- 在用户明确“可以开始写”前,不输出完整稿件
当信息收集完成后,再给出创作执行计划并开始写作。`;
export function getDefaultGuidePromptByTheme(
theme: string,
): string | undefined {
if (theme === "social-media") {
return SOCIAL_MEDIA_DEFAULT_GUIDE_PROMPT;
}
return undefined;
}
@@ -17,6 +17,7 @@ import { createInitialPosterState } from "@/components/content-creator/canvas/po
import { createInitialMusicState } from "@/components/content-creator/canvas/music";
import { createInitialScriptState } from "@/components/content-creator/canvas/script";
import { createInitialNovelState } from "@/components/content-creator/canvas/novel";
import { createInitialVideoState } from "@/components/content-creator/canvas/video";
import type { DocumentCanvasState } from "@/components/content-creator/canvas/document/types";
import type { PosterCanvasState } from "@/components/content-creator/canvas/poster/types";
import type { MusicCanvasState } from "@/components/content-creator/canvas/music/types";
@@ -54,6 +55,7 @@ export const ARTIFACT_TO_CANVAS_TYPE: Record<string, CanvasType> = {
"canvas:music": "music",
"canvas:script": "script",
"canvas:novel": "novel",
"canvas:video": "video",
};
/**
@@ -65,6 +67,7 @@ export const CANVAS_TYPE_LABELS: Record<CanvasType, string> = {
music: "音乐",
script: "剧本",
novel: "小说",
video: "视频",
};
/**
@@ -76,6 +79,7 @@ export const CANVAS_TYPE_ICONS: Record<CanvasType, string> = {
music: "🎵",
script: "🎬",
novel: "📚",
video: "🎞️",
};
// ============================================================================
@@ -145,6 +149,8 @@ export function createCanvasStateFromArtifact(
return createInitialScriptState(content);
case "novel":
return createInitialNovelState(content);
case "video":
return createInitialVideoState(content);
default:
return null;
}
+25 -2
View File
@@ -4,7 +4,7 @@
* @module components/chat/ChatPage
*/
import React, { useState, useCallback, memo } from "react";
import React, { useState, useCallback, useEffect, memo } from "react";
import styled from "styled-components";
import { MessageList, InputBar, ThemeSelector, EmptyState } from "./components";
import { useChat } from "./hooks";
@@ -56,6 +56,25 @@ export const ChatPage: React.FC = memo(() => {
} = useChat();
const [currentTheme, setCurrentTheme] = useState<ThemeType>("general");
const [selectedText, setSelectedText] = useState("");
useEffect(() => {
const handleSelectionChange = () => {
const rawSelection = window.getSelection()?.toString() || "";
const normalized = rawSelection.trim().replace(/\s+/g, " ");
const clipped =
normalized.length > 500
? `${normalized.slice(0, 500).trim()}…`
: normalized;
setSelectedText((prev) => (prev === clipped ? prev : clipped));
};
document.addEventListener("selectionchange", handleSelectionChange);
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
};
}, []);
const hasMessages = messages.length > 0;
@@ -101,7 +120,11 @@ export const ChatPage: React.FC = memo(() => {
onRetryMessage={handleRetryMessage}
/>
) : (
<EmptyState onSuggestionClick={handleSuggestionClick} />
<EmptyState
onSuggestionClick={handleSuggestionClick}
activeTheme={currentTheme}
selectedText={selectedText}
/>
)}
</ChatArea>
+131 -28
View File
@@ -5,7 +5,7 @@
* @requirements 4.1, 4.4
*/
import React, { memo, useState } from "react";
import React, { memo, useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import {
MessageSquare,
@@ -15,6 +15,12 @@ import {
Lightbulb,
} from "lucide-react";
import { ProjectSelector } from "@/components/projects/ProjectSelector";
import { getConfig } from "@/hooks/useTauri";
import type { ThemeType } from "../types";
import {
buildRecommendationPrompt,
getContextualRecommendations,
} from "@/components/agent/chat/utils/contextualRecommendations";
const Container = styled.div`
flex: 1;
@@ -116,32 +122,34 @@ const ProjectSelectorWrapper = styled.div`
max-width: 280px;
`;
const suggestions = [
{
icon: Code,
title: "代码问答",
desc: "解释代码、调试问题、优化建议",
prompt: "帮我解释一下这段代码的作用",
},
{
icon: Lightbulb,
title: "概念解释",
desc: "深入浅出地解释技术概念",
prompt: "用简单的话解释什么是 React Hooks",
},
{
icon: Languages,
title: "翻译润色",
desc: "翻译文本、润色表达",
prompt: "帮我把这段话翻译成英文",
},
{
icon: Sparkles,
title: "头脑风暴",
desc: "创意想法、方案建议",
prompt: "帮我想几个产品名字的创意",
},
];
const SelectionHint = styled.div`
width: 100%;
max-width: 600px;
margin-bottom: 12px;
font-size: 12px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.35);
border: 1px solid hsl(var(--border));
border-radius: 10px;
padding: 8px 10px;
text-align: left;
`;
const CHAT_THEME_TO_RECOMMENDATION_THEME: Record<ThemeType, string> = {
general: "general",
knowledge: "knowledge",
planning: "planning",
"social-media": "social-media",
poster: "poster",
document: "document",
paper: "knowledge",
novel: "novel",
script: "video",
music: "music",
video: "video",
};
const SUGGESTION_ICONS = [Code, Lightbulb, Languages, Sparkles];
interface EmptyStateProps {
/** 点击建议时的回调 */
@@ -150,6 +158,10 @@ interface EmptyStateProps {
selectedProjectId?: string | null;
/** 项目选择变化回调 */
onProjectChange?: (projectId: string) => void;
/** 当前主题 */
activeTheme?: ThemeType;
/** 当前选中的文本(用于推荐上下文) */
selectedText?: string;
}
/**
@@ -158,10 +170,92 @@ interface EmptyStateProps {
* 显示欢迎信息、项目选择器和快捷建议
*/
export const EmptyState: React.FC<EmptyStateProps> = memo(
({ onSuggestionClick, selectedProjectId, onProjectChange }) => {
({
onSuggestionClick,
selectedProjectId,
onProjectChange,
activeTheme = "general",
selectedText = "",
}) => {
const [localProjectId, setLocalProjectId] = useState<string | null>(
selectedProjectId || null,
);
const [appendSelectedTextToRecommendation, setAppendSelectedTextToRecommendation] =
useState(true);
useEffect(() => {
const loadConfigPreferences = async () => {
try {
const config = await getConfig();
setAppendSelectedTextToRecommendation(
config.chat_appearance?.append_selected_text_to_recommendation ??
true,
);
} catch (error) {
console.error("加载聊天外观配置失败:", error);
}
};
loadConfigPreferences();
window.addEventListener(
"chat-appearance-config-changed",
loadConfigPreferences,
);
return () => {
window.removeEventListener(
"chat-appearance-config-changed",
loadConfigPreferences,
);
};
}, []);
const recommendationTheme = CHAT_THEME_TO_RECOMMENDATION_THEME[activeTheme];
const recommendationSelectedText = appendSelectedTextToRecommendation
? selectedText
: "";
const suggestions = useMemo(() => {
const recommendationTuples = getContextualRecommendations({
activeTheme: recommendationTheme,
input: "",
creationMode: "guided",
entryTaskType: "direct",
platform: "xiaohongshu",
hasCanvasContent: false,
hasContentId: false,
selectedText: recommendationSelectedText,
});
return recommendationTuples
.slice(0, 4)
.map(([title, prompt], index) => ({
icon: SUGGESTION_ICONS[index % SUGGESTION_ICONS.length],
title,
desc: prompt,
prompt: buildRecommendationPrompt(
prompt,
selectedText,
appendSelectedTextToRecommendation,
),
}));
}, [
recommendationTheme,
recommendationSelectedText,
selectedText,
appendSelectedTextToRecommendation,
]);
const selectedTextPreview = useMemo(() => {
const normalized = recommendationSelectedText.trim().replace(/\s+/g, " ");
if (!normalized) {
return "";
}
return normalized.length > 60
? `${normalized.slice(0, 60).trim()}…`
: normalized;
}, [recommendationSelectedText]);
const handleProjectChange = (projectId: string) => {
setLocalProjectId(projectId);
@@ -187,6 +281,15 @@ export const EmptyState: React.FC<EmptyStateProps> = memo(
/>
</ProjectSelectorWrapper>
{selectedTextPreview && (
<SelectionHint>
已检测到选中内容,点击推荐会自动附带上下文:
<span style={{ marginLeft: 4, color: "hsl(var(--foreground))" }}>
“{selectedTextPreview}”
</span>
</SelectionHint>
)}
<SuggestionsGrid>
{suggestions.map((item) => (
<SuggestionCard
@@ -16,6 +16,8 @@ import { ScriptCanvas } from "./script";
import type { ScriptCanvasState } from "./script/types";
import { NovelCanvas } from "./novel";
import type { NovelCanvasState } from "./novel/types";
import { VideoCanvas } from "./video";
import type { VideoCanvasState } from "./video/types";
import { getCanvasTypeForTheme, type CanvasStateUnion } from "./canvasUtils";
/**
@@ -41,6 +43,8 @@ interface CanvasFactoryProps {
/** 章节栏折叠状态变更 */
onChapterListCollapsedChange: (collapsed: boolean) => void;
} | null;
/** 画布选中文本变更 */
onSelectionTextChange?: (text: string) => void;
}
/**
@@ -50,7 +54,15 @@ interface CanvasFactoryProps {
* 优先使用 state.type 来决定渲染哪个画布,以支持 general 等主题
*/
export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
({ theme, state, onStateChange, onClose, isStreaming, novelControls }) => {
({
theme,
state,
onStateChange,
onClose,
isStreaming,
novelControls,
onSelectionTextChange,
}) => {
// 优先根据 state.type 渲染,这样 general 主题也能显示文档画布
// 只有当 state.type 与 theme 对应的 canvasType 不匹配时才检查 theme
const canvasType = useMemo(() => {
@@ -70,6 +82,7 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
onStateChange={onStateChange as (s: DocumentCanvasState) => void}
onClose={onClose}
isStreaming={isStreaming}
onSelectionTextChange={onSelectionTextChange}
/>
);
}
@@ -116,6 +129,17 @@ export const CanvasFactory: React.FC<CanvasFactoryProps> = memo(
onChapterListCollapsedChange={
novelControls?.onChapterListCollapsedChange
}
onSelectionTextChange={onSelectionTextChange}
/>
);
}
if (canvasType === "video" && state.type === "video") {
return (
<VideoCanvas
state={state}
onStateChange={onStateChange as (s: VideoCanvasState) => void}
onClose={onClose}
/>
);
}
@@ -15,6 +15,8 @@ import { createInitialScriptState } from "./script";
import type { ScriptCanvasState } from "./script/types";
import { createInitialNovelState } from "./novel";
import type { NovelCanvasState } from "./novel/types";
import { createInitialVideoState } from "./video";
import type { VideoCanvasState } from "./video/types";
/**
* 画布状态联合类型
@@ -24,12 +26,13 @@ export type CanvasStateUnion =
| PosterCanvasState
| MusicCanvasState
| ScriptCanvasState
| NovelCanvasState;
| NovelCanvasState
| VideoCanvasState;
/**
* 画布类型
*/
export type CanvasType = "document" | "poster" | "music" | "script" | "novel";
export type CanvasType = "document" | "poster" | "music" | "script" | "novel" | "video";
/**
* 主题到画布类型的映射
@@ -48,7 +51,7 @@ const THEME_TO_CANVAS_TYPE: Record<ThemeType, CanvasType | null> = {
knowledge: "document", // 知识探索支持文档画布
planning: "document", // 计划规划支持文档画布
document: "document",
video: "script",
video: "video",
novel: "novel",
};
@@ -86,6 +89,8 @@ export function createInitialCanvasState(
return createInitialScriptState(content);
case "novel":
return createInitialNovelState(content);
case "video":
return createInitialVideoState(content);
default:
return null;
}
@@ -4,7 +4,7 @@
* @module components/content-creator/canvas/document/DocumentCanvas
*/
import React, { memo, useMemo, useCallback, useState } from "react";
import React, { memo, useMemo, useCallback, useState, useEffect } from "react";
import styled from "styled-components";
import type { DocumentCanvasProps, ExportFormat, PlatformType } from "./types";
import { DocumentToolbar } from "./DocumentToolbar";
@@ -58,7 +58,13 @@ const Toast = styled.div<{ $visible: boolean }>`
* 文档画布主组件
*/
export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
({ state, onStateChange, onClose, isStreaming = false }) => {
({
state,
onStateChange,
onClose,
isStreaming = false,
onSelectionTextChange,
}) => {
const [editingContent, setEditingContent] = useState("");
const [toastMessage, setToastMessage] = useState("");
const [showToast, setShowToast] = useState(false);
@@ -70,6 +76,10 @@ export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
);
}, [state.versions, state.currentVersionId]);
useEffect(() => {
onSelectionTextChange?.("");
}, [state.currentVersionId, state.isEditing, onSelectionTextChange]);
// 显示提示
const showMessage = useCallback((message: string) => {
setToastMessage(message);
@@ -201,12 +211,14 @@ export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
onChange={setEditingContent}
onSave={handleSave}
onCancel={handleCancel}
onSelectionTextChange={onSelectionTextChange}
/>
) : (
<DocumentRenderer
content={state.content}
platform={state.platform}
isStreaming={isStreaming}
onSelectionTextChange={onSelectionTextChange}
/>
)}
</ContentArea>
@@ -4,7 +4,7 @@
* @module components/content-creator/canvas/document/DocumentRenderer
*/
import React, { memo, useState, useEffect, useRef } from "react";
import React, { memo, useState, useEffect, useRef, useCallback } from "react";
import styled, { keyframes } from "styled-components";
import type { DocumentRendererProps, PlatformType } from "./types";
import {
@@ -95,12 +95,49 @@ const getRenderer = (platform: PlatformType, content: string) => {
* 支持流式显示 - 按段落逐步显示内容
*/
export const DocumentRenderer: React.FC<DocumentRendererProps> = memo(
({ content, platform, isStreaming = false }) => {
({ content, platform, isStreaming = false, onSelectionTextChange }) => {
// 用于流式显示的状态
const [displayContent, setDisplayContent] = useState(content);
const prevContentRef = useRef(content);
const containerRef = useRef<HTMLDivElement>(null);
const notifySelection = useCallback(() => {
if (!onSelectionTextChange) {
return;
}
const container = containerRef.current;
const selection = window.getSelection();
if (!container || !selection) {
onSelectionTextChange("");
return;
}
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
const inContainer =
(!!anchorNode && container.contains(anchorNode)) ||
(!!focusNode && container.contains(focusNode));
if (!inContainer) {
onSelectionTextChange("");
return;
}
const selectedText = selection.toString().trim();
onSelectionTextChange(selectedText);
}, [onSelectionTextChange]);
useEffect(() => {
if (!onSelectionTextChange) {
return;
}
return () => {
onSelectionTextChange("");
};
}, [onSelectionTextChange]);
// 流式显示效果:当内容更新时,平滑过渡
useEffect(() => {
if (!isStreaming) {
@@ -130,7 +167,11 @@ export const DocumentRenderer: React.FC<DocumentRendererProps> = memo(
if (!displayContent || displayContent.trim() === "") {
return (
<Container ref={containerRef}>
<Container
ref={containerRef}
onMouseUp={notifySelection}
onKeyUp={notifySelection}
>
<EmptyState>
<EmptyIcon>📄</EmptyIcon>
<span>暂无内容</span>
@@ -141,7 +182,11 @@ export const DocumentRenderer: React.FC<DocumentRendererProps> = memo(
}
return (
<Container ref={containerRef}>
<Container
ref={containerRef}
onMouseUp={notifySelection}
onKeyUp={notifySelection}
>
<StreamingContainer key={isStreaming ? "streaming" : "static"}>
{getRenderer(platform, displayContent)}
{isStreaming && <StreamingCursor />}
@@ -22,6 +22,7 @@ interface NotionEditorProps {
onChange: (content: string) => void;
onSave: () => void;
onCancel: () => void;
onSelectionTextChange?: (text: string) => void;
}
const EMPTY_SLASH: SlashMenuState = {
@@ -32,7 +33,7 @@ const EMPTY_SLASH: SlashMenuState = {
};
export const NotionEditor: React.FC<NotionEditorProps> = memo(
({ content, onChange, onSave, onCancel }) => {
({ content, onChange, onSave, onCancel, onSelectionTextChange }) => {
const [slashState, setSlashState] = useState<SlashMenuState>(EMPTY_SLASH);
const keyDownRef = useRef<SlashMenuKeyHandler | null>(null);
@@ -83,6 +84,35 @@ export const NotionEditor: React.FC<NotionEditorProps> = memo(
}
}, [editor]);
useEffect(() => {
if (!editor || !onSelectionTextChange) {
return;
}
const handleSelectionUpdate = () => {
const { from, to, empty } = editor.state.selection;
if (empty) {
onSelectionTextChange("");
return;
}
const selectedText = editor.state.doc.textBetween(from, to, "\n").trim();
onSelectionTextChange(selectedText);
};
const handleBlur = () => {
onSelectionTextChange("");
};
editor.on("selectionUpdate", handleSelectionUpdate);
editor.on("blur", handleBlur);
return () => {
editor.off("selectionUpdate", handleSelectionUpdate);
editor.off("blur", handleBlur);
};
}, [editor, onSelectionTextChange]);
if (!editor) return null;
return (
@@ -58,6 +58,8 @@ export interface DocumentCanvasProps {
onClose: () => void;
/** 是否正在流式输出 */
isStreaming?: boolean;
/** 选中文本变更回调 */
onSelectionTextChange?: (text: string) => void;
}
/**
@@ -94,6 +96,8 @@ export interface DocumentRendererProps {
platform: PlatformType;
/** 是否正在流式输出 */
isStreaming?: boolean;
/** 选中文本变更回调 */
onSelectionTextChange?: (text: string) => void;
}
/**
@@ -118,6 +122,8 @@ export interface DocumentEditorProps {
onSave: () => void;
/** 取消回调 */
onCancel: () => void;
/** 选中文本变更回调 */
onSelectionTextChange?: (text: string) => void;
}
/**
@@ -176,6 +176,7 @@ interface NovelCanvasProps {
useExternalToolbar?: boolean;
chapterListCollapsed?: boolean;
onChapterListCollapsedChange?: (collapsed: boolean) => void;
onSelectionTextChange?: (text: string) => void;
}
/**
@@ -228,6 +229,7 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
useExternalToolbar = false,
chapterListCollapsed,
onChapterListCollapsedChange,
onSelectionTextChange,
}) => {
const [internalChapterListCollapsed, setInternalChapterListCollapsed] =
useState(false);
@@ -339,6 +341,10 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
}
}, [currentChapter, state, onStateChange]);
useEffect(() => {
onSelectionTextChange?.("");
}, [state.currentChapterId, onSelectionTextChange]);
const totalWords = state.chapters.reduce((sum, c) => sum + c.wordCount, 0);
const completedCount = state.chapters.filter(
(c) => c.status === "completed",
@@ -454,6 +460,7 @@ export const NovelCanvas: React.FC<NovelCanvasProps> = memo(
onChange={handleUpdateChapter}
onSave={handleToggleStatus}
onCancel={() => {}}
onSelectionTextChange={onSelectionTextChange}
/>
</EditorContainer>
)}
@@ -0,0 +1,117 @@
import React, { memo, KeyboardEvent } from "react";
import styled from "styled-components";
import { VideoCanvasState } from "./types";
import { Sparkles } from "lucide-react";
interface PromptInputProps {
state: VideoCanvasState;
onStateChange: (state: VideoCanvasState) => void;
onGenerate: () => void;
}
const PromptWrapper = styled.div`
width: 100%;
max-width: 920px;
margin: 0 auto;
`;
const InputContainer = styled.div`
display: flex;
align-items: center;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 14px;
padding: 10px 10px 10px 14px;
min-height: 82px;
transition: all 0.2s;
&:focus-within {
border-color: hsl(var(--border));
}
`;
const StyledTextarea = styled.textarea`
flex: 1;
border: none;
background: transparent;
padding: 4px 0;
min-height: 52px;
max-height: 160px;
resize: none;
font-size: 15px;
line-height: 1.6;
color: hsl(var(--foreground));
outline: none;
&::placeholder {
color: hsl(var(--muted-foreground));
}
`;
const GenerateButton = styled.button<{ $generating?: boolean }>`
flex-shrink: 0;
width: 58px;
height: 58px;
margin-left: 10px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: ${(props) =>
props.$generating ? "hsl(var(--muted))" : "hsl(var(--muted) / 0.35)"};
color: ${(props) =>
props.$generating ? "hsl(var(--muted-foreground))" : "hsl(var(--muted-foreground))"};
border: 1px solid hsl(var(--border));
cursor: ${(props) => (props.$generating ? "not-allowed" : "pointer")};
transition: all 0.2s;
&:hover:not(:disabled) {
background: hsl(var(--muted) / 0.52);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
`;
export const PromptInput: React.FC<PromptInputProps> = memo(
({ state, onStateChange, onGenerate }) => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (state.prompt.trim() && state.status !== "generating") {
onGenerate();
}
}
};
return (
<PromptWrapper>
<InputContainer>
<StyledTextarea
value={state.prompt}
onChange={(e) => {
onStateChange({ ...state, prompt: e.target.value });
// Auto resize
e.target.style.height = "auto";
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
}}
onKeyDown={handleKeyDown}
placeholder="描述你想生成的视频内容"
rows={1}
/>
<GenerateButton
disabled={!state.prompt.trim() || state.status === "generating"}
$generating={state.status === "generating"}
onClick={onGenerate}
>
<Sparkles size={20} />
</GenerateButton>
</InputContainer>
</PromptWrapper>
);
},
);
PromptInput.displayName = "PromptInput";
@@ -0,0 +1,377 @@
import React, { memo, useEffect, useMemo, useState } from "react";
import styled from "styled-components";
import {
ChevronLeft,
ChevronRight,
Home,
LayoutGrid,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import { VideoCanvasProps } from "./types";
import { VideoSidebar, type VideoProviderOption } from "./VideoSidebar";
import { VideoWorkspace } from "./VideoWorkspace";
import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider";
const VIDEO_MODEL_PRESETS: Record<string, string[]> = {
doubao: ["seedance-1-5-pro-251215", "seedance-1-5-lite-250428"],
volcengine: ["seedance-1-5-pro-251215", "seedance-1-5-lite-250428"],
dashscope: ["wanx2.1-t2v-turbo", "wanx2.1-kf2v-plus"],
alibaba: ["wanx2.1-t2v-turbo", "wanx2.1-kf2v-plus"],
qwen: ["wanx2.1-t2v-turbo", "wanx2.1-kf2v-plus"],
sora: ["sora-2", "sora-2-pro"],
openai: ["sora-2", "sora-2-pro"],
veo: ["veo-3.1"],
google: ["veo-3.1"],
vertex: ["veo-3.1"],
kling: ["kling-2.6"],
minimax: ["minimax-hailuo-2.3", "minimax-hailuo-02"],
hailuo: ["minimax-hailuo-2.3", "minimax-hailuo-02"],
runway: ["runway-gen-4-turbo"],
};
function isVideoProvider(providerId: string): boolean {
const normalized = providerId.toLowerCase();
return (
normalized.includes("doubao") ||
normalized.includes("volc") ||
normalized.includes("dashscope") ||
normalized.includes("alibaba") ||
normalized.includes("qwen") ||
normalized.includes("video") ||
normalized.includes("runway") ||
normalized.includes("minimax") ||
normalized.includes("kling") ||
normalized.includes("sora") ||
normalized.includes("veo")
);
}
function resolveProviderModels(provider: VideoProviderOption): string[] {
if (provider.customModels.length > 0) {
return provider.customModels;
}
const normalizedId = provider.id.toLowerCase();
for (const [key, models] of Object.entries(VIDEO_MODEL_PRESETS)) {
if (normalizedId.includes(key)) {
return models;
}
}
return [];
}
const Root = styled.div`
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
width: 100%;
padding: 6px 8px 8px;
gap: 6px;
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;
min-height: 0;
width: 100%;
gap: 6px;
`;
const SidebarContainer = styled.div<{ $collapsed: boolean }>`
width: ${({ $collapsed }) => ($collapsed ? "0px" : "304px")};
flex-shrink: 0;
height: 100%;
min-height: 0;
background: hsl(var(--muted) / 0.34);
border-radius: 12px;
border: none;
overflow-y: auto;
overflow-x: hidden;
opacity: ${({ $collapsed }) => ($collapsed ? 0 : 1)};
pointer-events: ${({ $collapsed }) => ($collapsed ? "none" : "auto")};
transition:
width 0.2s ease,
opacity 0.2s ease;
`;
const Splitter = styled.div`
width: 12px;
display: flex;
justify-content: center;
`;
const SplitterButton = styled.button`
margin-top: 8px;
width: 16px;
height: 24px;
border-radius: 6px;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--muted-foreground));
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: hsl(var(--foreground));
border-color: hsl(var(--primary) / 0.4);
}
`;
const MainContainer = styled.div`
flex: 1;
height: 100%;
min-height: 0;
background: hsl(var(--background));
overflow: hidden;
position: relative;
`;
const WorkspaceFrame = styled.div`
flex: 1;
min-height: 0;
display: flex;
border-radius: 12px;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
overflow: hidden;
`;
const TopicPanel = styled.div<{ $collapsed: boolean }>`
position: relative;
width: ${({ $collapsed }) => ($collapsed ? "0px" : "90px")};
min-width: ${({ $collapsed }) => ($collapsed ? "0px" : "90px")};
height: 100%;
border-left: ${({ $collapsed }) =>
$collapsed ? "none" : "1px solid hsl(var(--border))"};
background: hsl(var(--background));
overflow: visible;
transition:
width 0.2s ease,
min-width 0.2s ease;
`;
const TopicPanelHandle = styled.button`
position: absolute;
left: -14px;
top: 50%;
transform: translateY(-50%);
width: 28px;
height: 50px;
border: 1px solid hsl(var(--border));
border-right: none;
border-radius: 12px 0 0 12px;
background: hsl(var(--background));
color: hsl(var(--muted-foreground));
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 6;
&:hover {
color: hsl(var(--foreground));
}
`;
const MainAction = styled.button`
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
width: 20px;
height: 20px;
border-radius: 6px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
display: inline-flex;
align-items: center;
justify-content: center;
cursor: default;
`;
export const VideoCanvas: React.FC<VideoCanvasProps> = memo(
({ state, onStateChange, projectId, onClose: _onClose, onBackHome }) => {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [topicPanelCollapsed, setTopicPanelCollapsed] = useState(false);
const [providers, setProviders] = useState<VideoProviderOption[]>([]);
useEffect(() => {
let active = true;
const loadProviders = async () => {
try {
const allProviders = await apiKeyProviderApi.getProviders();
if (!active) {
return;
}
const availableProviders = allProviders
.filter(
(provider) =>
provider.enabled &&
provider.api_key_count > 0 &&
isVideoProvider(provider.id),
)
.map((provider) => ({
id: provider.id,
name: provider.name,
customModels: provider.custom_models ?? [],
}));
setProviders(availableProviders);
} catch (error) {
console.error("[VideoCanvas] 加载视频 Provider 失败:", error);
if (active) {
setProviders([]);
}
}
};
void loadProviders();
return () => {
active = false;
};
}, []);
const selectedProvider = useMemo(() => {
return (
providers.find((provider) => provider.id === state.providerId) ?? null
);
}, [providers, state.providerId]);
const availableModels = useMemo(() => {
if (!selectedProvider) {
return [];
}
return resolveProviderModels(selectedProvider);
}, [selectedProvider]);
useEffect(() => {
if (providers.length === 0) {
return;
}
if (
!state.providerId ||
!providers.some((provider) => provider.id === state.providerId)
) {
const firstProvider = providers[0];
const firstModel = resolveProviderModels(firstProvider)[0] ?? "";
onStateChange({
...state,
providerId: firstProvider.id,
model: firstModel,
});
return;
}
if (!state.model && availableModels.length > 0) {
onStateChange({
...state,
model: availableModels[0],
});
}
}, [availableModels, onStateChange, providers, state]);
return (
<Root>
<Header>
<HeaderHome onClick={onBackHome} title="返回首页">
<Home size={12} />
</HeaderHome>
<ChevronRight size={12} />
<span>视频</span>
</Header>
<Body>
<SidebarContainer $collapsed={sidebarCollapsed}>
<VideoSidebar
state={state}
providers={providers}
availableModels={availableModels}
onStateChange={onStateChange}
/>
</SidebarContainer>
<Splitter>
<SplitterButton
onClick={() => setSidebarCollapsed((previous) => !previous)}
title={sidebarCollapsed ? "展开侧栏" : "收起侧栏"}
>
{sidebarCollapsed ? (
<PanelLeftOpen size={12} />
) : (
<PanelLeftClose size={12} />
)}
</SplitterButton>
</Splitter>
<WorkspaceFrame>
<MainContainer>
<MainAction>
<LayoutGrid size={12} />
</MainAction>
<VideoWorkspace
state={state}
projectId={projectId}
onStateChange={onStateChange}
/>
</MainContainer>
<TopicPanel $collapsed={topicPanelCollapsed}>
<TopicPanelHandle
type="button"
title={topicPanelCollapsed ? "展开右侧栏" : "收起右侧栏"}
onClick={() =>
setTopicPanelCollapsed((previous) => !previous)
}
>
{topicPanelCollapsed ? (
<ChevronLeft size={12} />
) : (
<ChevronRight size={12} />
)}
</TopicPanelHandle>
</TopicPanel>
</WorkspaceFrame>
</Body>
</Root>
);
},
);
VideoCanvas.displayName = "VideoCanvas";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,645 @@
import React, {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import styled from "styled-components";
import { Video } from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner";
import { VideoCanvasState } from "./types";
import { PromptInput } from "./PromptInput";
import {
videoGenerationApi,
type VideoGenerationTask,
} from "@/lib/api/videoGeneration";
interface VideoWorkspaceProps {
state: VideoCanvasState;
projectId?: string | null;
onStateChange: (state: VideoCanvasState) => void;
}
const WorkspaceWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
height: 100%;
width: 100%;
padding: 28px 32px 24px;
`;
const ContentWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: 920px;
gap: 32px;
`;
const EmptyStateWrapper = styled.div`
width: 100%;
max-width: 920px;
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
margin-top: clamp(80px, 16vh, 180px);
`;
const HeaderIcons = styled.div`
display: flex;
align-items: center;
gap: 14px;
`;
const IconBox = styled.div`
width: 54px;
height: 54px;
background: hsl(var(--foreground));
color: hsl(var(--background));
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
`;
const Title = styled.h1`
font-size: 48px;
line-height: 1;
font-weight: 700;
color: hsl(var(--foreground));
margin: 0;
`;
const VideoPlayerPlaceholder = styled.div`
width: 100%;
aspect-ratio: 16/9;
background: hsl(var(--muted) / 0.3);
border-radius: 12px;
border: 1px solid hsl(var(--border));
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
`;
const TaskList = styled.div`
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
`;
const TaskCard = styled.div`
border: 1px solid hsl(var(--border));
border-radius: 10px;
background: hsl(var(--background));
padding: 12px;
display: flex;
flex-direction: column;
gap: 6px;
`;
const StatusBadge = styled.span<{ $status: string }>`
display: inline-flex;
align-items: center;
justify-content: center;
height: 22px;
border-radius: 999px;
padding: 0 10px;
font-size: 11px;
background: ${({ $status }) =>
$status === "success"
? "hsl(142 71% 45% / 0.12)"
: $status === "error"
? "hsl(0 84% 60% / 0.12)"
: "hsl(var(--primary) / 0.12)"};
color: ${({ $status }) =>
$status === "success"
? "hsl(142 71% 35%)"
: $status === "error"
? "hsl(0 84% 45%)"
: "hsl(var(--primary))"};
`;
const TaskMeta = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
font-size: 12px;
color: hsl(var(--muted-foreground));
`;
const TaskPrompt = styled.div`
font-size: 13px;
color: hsl(var(--foreground));
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
`;
interface ImportMaterialFromUrlRequest {
projectId: string;
name: string;
type: "video" | "image";
url: string;
tags?: string[];
description?: string;
}
interface WorkspaceTask extends VideoGenerationTask {
resourceMaterialId?: string;
resourceSavedAt?: number;
resourceSaveError?: string;
}
const VIDEO_TASK_TAG = "video-gen";
const VIDEO_REFERENCE_TAG = "video-reference";
function isDirectRemoteUrl(url: string): boolean {
return url.startsWith("http://") || url.startsWith("https://");
}
function isMaterialReferenceUrl(url: string): boolean {
return url.startsWith("material://");
}
function buildVideoMaterialName(task: WorkspaceTask): string {
const promptHead = task.prompt.trim().slice(0, 24) || "生成视频";
const date = new Date(task.createdAt);
const stamp = [
date.getFullYear(),
`${date.getMonth() + 1}`.padStart(2, "0"),
`${date.getDate()}`.padStart(2, "0"),
"-",
`${date.getHours()}`.padStart(2, "0"),
`${date.getMinutes()}`.padStart(2, "0"),
`${date.getSeconds()}`.padStart(2, "0"),
].join("");
return `${promptHead}-${stamp}.mp4`;
}
function formatTaskTime(timestamp: number): string {
const date = new Date(timestamp);
return `${date.getHours().toString().padStart(2, "0")}:${date
.getMinutes()
.toString()
.padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
}
function mergeTaskList(
previous: WorkspaceTask[],
updates: WorkspaceTask[],
): WorkspaceTask[] {
const updateMap = new Map(updates.map((task) => [task.id, task]));
const merged = previous.map((task) => {
const updated = updateMap.get(task.id);
if (!updated) {
return task;
}
return {
...task,
...updated,
resourceMaterialId: task.resourceMaterialId ?? updated.resourceMaterialId,
resourceSavedAt: task.resourceSavedAt ?? updated.resourceSavedAt,
resourceSaveError: updated.resourceSaveError ?? task.resourceSaveError,
};
});
for (const task of updates) {
if (!merged.some((item) => item.id === task.id)) {
merged.push(task);
}
}
merged.sort((left, right) => right.createdAt - left.createdAt);
return merged;
}
export const VideoWorkspace: React.FC<VideoWorkspaceProps> = memo(
({ state, projectId, onStateChange }) => {
const [tasks, setTasks] = useState<WorkspaceTask[]>([]);
const pollingGuard = useRef(false);
const savingTaskIdsRef = useRef<Set<string>>(new Set());
const materialRefCache = useRef<Map<string, string>>(new Map());
useEffect(() => {
materialRefCache.current.clear();
}, [projectId]);
const syncPrimaryState = useCallback(
(taskList: WorkspaceTask[]) => {
if (taskList.length === 0) {
return;
}
const latestTask = taskList[0];
if (latestTask.status === "success" && latestTask.resultUrl) {
if (
state.status !== "success" ||
state.videoUrl !== latestTask.resultUrl
) {
onStateChange({
...state,
status: "success",
videoUrl: latestTask.resultUrl,
errorMessage: undefined,
});
}
return;
}
if (latestTask.status === "error") {
const message = latestTask.errorMessage ?? "视频生成失败";
if (state.status !== "error" || state.errorMessage !== message) {
onStateChange({
...state,
status: "error",
errorMessage: message,
});
}
return;
}
if (
latestTask.status === "pending" ||
latestTask.status === "processing"
) {
if (state.status !== "generating") {
onStateChange({
...state,
status: "generating",
errorMessage: undefined,
});
}
}
},
[onStateChange, state],
);
const saveVideoToResource = useCallback(
async (task: WorkspaceTask): Promise<void> => {
if (!projectId || !task.resultUrl || task.resourceMaterialId) {
return;
}
if (savingTaskIdsRef.current.has(task.id)) {
return;
}
savingTaskIdsRef.current.add(task.id);
try {
const request: ImportMaterialFromUrlRequest = {
projectId,
name: buildVideoMaterialName(task),
type: "video",
url: task.resultUrl,
tags: [VIDEO_TASK_TAG],
description: `视频生成自动入库(服务:${task.providerId},模型:${task.model})`,
};
const savedMaterial = await invoke<{ id: string }>(
"import_material_from_url",
{
req: request,
},
);
setTasks((previous) =>
previous.map((item) =>
item.id === task.id
? {
...item,
resourceMaterialId: savedMaterial.id,
resourceSavedAt: Date.now(),
resourceSaveError: undefined,
}
: item,
),
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
setTasks((previous) =>
previous.map((item) =>
item.id === task.id
? { ...item, resourceSaveError: errorMessage }
: item,
),
);
} finally {
savingTaskIdsRef.current.delete(task.id);
}
},
[projectId],
);
useEffect(() => {
if (!projectId) {
setTasks([]);
return;
}
let active = true;
const loadTasks = async () => {
try {
const list = await videoGenerationApi.listTasks(projectId, {
limit: 50,
});
if (!active) {
return;
}
const mapped = list.map((task) => ({ ...task }));
setTasks(mapped);
syncPrimaryState(mapped);
} catch (error) {
console.error("[VideoWorkspace] 加载视频任务失败:", error);
}
};
void loadTasks();
return () => {
active = false;
};
}, [projectId, syncPrimaryState]);
const runningTaskIds = useMemo(
() =>
tasks
.filter(
(task) => task.status === "pending" || task.status === "processing",
)
.map((task) => task.id),
[tasks],
);
useEffect(() => {
if (runningTaskIds.length === 0) {
return;
}
let active = true;
const tick = async () => {
if (!active || pollingGuard.current) {
return;
}
pollingGuard.current = true;
try {
const updates = await Promise.all(
runningTaskIds.map((taskId) =>
videoGenerationApi.getTask(taskId, { refreshStatus: true }),
),
);
if (!active) {
return;
}
const normalizedUpdates = updates.filter(
(task): task is WorkspaceTask => task !== null,
);
if (normalizedUpdates.length === 0) {
return;
}
setTasks((previous) => {
const merged = mergeTaskList(previous, normalizedUpdates);
syncPrimaryState(merged);
return merged;
});
for (const task of normalizedUpdates) {
if (task.status === "success" && task.resultUrl) {
void saveVideoToResource(task);
}
}
} finally {
pollingGuard.current = false;
}
};
void tick();
const timer = window.setInterval(() => {
void tick();
}, 3000);
return () => {
active = false;
window.clearInterval(timer);
};
}, [runningTaskIds, saveVideoToResource, syncPrimaryState]);
const ensureReferenceImageUrl = useCallback(
async (
imageUrl: string | undefined,
frameType: "start" | "end",
): Promise<string | undefined> => {
const normalizedUrl = imageUrl?.trim();
if (!normalizedUrl) {
return undefined;
}
if (
isDirectRemoteUrl(normalizedUrl) ||
isMaterialReferenceUrl(normalizedUrl)
) {
return normalizedUrl;
}
if (!normalizedUrl.startsWith("data:")) {
throw new Error("参考图格式不支持,请重新上传图片");
}
const cached = materialRefCache.current.get(normalizedUrl);
if (cached) {
return cached;
}
if (!projectId) {
throw new Error("未选择项目,无法处理参考图");
}
const request: ImportMaterialFromUrlRequest = {
projectId,
name: frameType === "start" ? "视频首帧参考图" : "视频尾帧参考图",
type: "image",
url: normalizedUrl,
tags: [VIDEO_REFERENCE_TAG, frameType],
description:
frameType === "start"
? "视频生成首帧参考图(自动上传)"
: "视频生成尾帧参考图(自动上传)",
};
const material = await invoke<{ id: string }>("import_material_from_url", {
req: request,
});
const materialUrl = `material://${material.id}`;
materialRefCache.current.set(normalizedUrl, materialUrl);
return materialUrl;
},
[projectId],
);
const handleGenerate = useCallback(async () => {
if (!projectId) {
toast.error("请先选择项目后再生成视频");
return;
}
if (!state.providerId) {
toast.error("请选择视频服务");
return;
}
if (!state.model) {
toast.error("请选择视频模型");
return;
}
if (!state.prompt.trim()) {
toast.error("请输入视频描述");
return;
}
const providerNormalized = state.providerId.trim().toLowerCase();
const supportedProvider =
providerNormalized.includes("doubao") ||
providerNormalized.includes("volc") ||
providerNormalized.includes("dashscope") ||
providerNormalized.includes("alibaba") ||
providerNormalized.includes("qwen");
if (!supportedProvider) {
toast.error("当前仅支持火山或阿里兼容视频服务");
return;
}
onStateChange({
...state,
status: "generating",
errorMessage: undefined,
});
try {
const [resolvedStartImageUrl, resolvedEndImageUrl] = await Promise.all([
ensureReferenceImageUrl(state.startImage, "start"),
ensureReferenceImageUrl(state.endImage, "end"),
]);
const created = await videoGenerationApi.createTask({
projectId,
providerId: state.providerId,
model: state.model,
prompt: state.prompt.trim(),
aspectRatio: state.aspectRatio,
resolution: state.resolution,
duration: state.duration,
imageUrl: resolvedStartImageUrl,
endImageUrl: resolvedEndImageUrl,
seed: state.seed,
generateAudio: state.generateAudio,
cameraFixed: state.cameraFixed,
});
setTasks((previous) => {
const merged = mergeTaskList(previous, [created]);
return merged;
});
toast.success("视频任务已提交,正在生成");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
onStateChange({
...state,
status: "error",
errorMessage: message,
});
toast.error(message);
}
}, [ensureReferenceImageUrl, onStateChange, projectId, state]);
const isGenerated = tasks.length > 0 || state.status !== "idle";
return (
<WorkspaceWrapper>
{!isGenerated ? (
<EmptyStateWrapper>
<HeaderIcons>
<IconBox>
<Video size={28} />
</IconBox>
<Title>视频</Title>
</HeaderIcons>
<PromptInput
state={state}
onStateChange={onStateChange}
onGenerate={handleGenerate}
/>
</EmptyStateWrapper>
) : (
<ContentWrapper
style={{ height: "100%", justifyContent: "flex-start" }}
>
<VideoPlayerPlaceholder>
{state.status === "generating" ? (
<span>正在生成视频中...</span>
) : state.status === "error" ? (
<span>{state.errorMessage ?? "视频生成失败"}</span>
) : state.videoUrl ? (
<video
controls
src={state.videoUrl}
style={{ width: "100%", height: "100%", borderRadius: 12 }}
/>
) : (
<span>等待视频生成结果...</span>
)}
</VideoPlayerPlaceholder>
<TaskList>
{tasks.map((task) => (
<TaskCard key={task.id}>
<TaskMeta>
<StatusBadge $status={task.status}>
{task.status === "success"
? "已完成"
: task.status === "error"
? "失败"
: task.status === "cancelled"
? "已取消"
: "生成中"}
</StatusBadge>
<span>{formatTaskTime(task.createdAt)}</span>
</TaskMeta>
<TaskPrompt>{task.prompt}</TaskPrompt>
<TaskMeta>
<span>
{task.providerId} · {task.model}
</span>
<span>
{task.progress !== undefined && task.progress !== null
? `${task.progress}%`
: "--"}
</span>
</TaskMeta>
{task.errorMessage ? (
<div style={{ fontSize: 12, color: "hsl(0 84% 45%)" }}>
{task.errorMessage}
</div>
) : null}
</TaskCard>
))}
</TaskList>
<PromptInput
state={state}
onStateChange={onStateChange}
onGenerate={handleGenerate}
/>
</ContentWrapper>
)}
</WorkspaceWrapper>
);
},
);
VideoWorkspace.displayName = "VideoWorkspace";
@@ -0,0 +1,2 @@
export * from './types';
export * from './VideoCanvas';
@@ -0,0 +1,52 @@
export type VideoAspectRatio =
| "adaptive"
| "16:9"
| "9:16"
| "1:1"
| "4:3"
| "3:4"
| "21:9";
export type VideoResolution = "480p" | "720p" | "1080p";
export type VideoStatus = "idle" | "generating" | "success" | "error";
export interface VideoCanvasState {
type: "video";
prompt: string;
providerId: string;
model: string;
duration: number;
seed?: number;
generateAudio: boolean;
cameraFixed: boolean;
startImage?: string;
endImage?: string;
aspectRatio: VideoAspectRatio;
resolution: VideoResolution;
status: VideoStatus;
videoUrl?: string;
errorMessage?: string;
}
export interface VideoCanvasProps {
state: VideoCanvasState;
onStateChange: (state: VideoCanvasState) => void;
projectId?: string | null;
onClose?: () => void;
onBackHome?: () => void;
}
export const createInitialVideoState = (
content?: string,
): VideoCanvasState => ({
type: "video",
prompt: content || "",
providerId: "",
model: "",
duration: 5,
seed: undefined,
generateAudio: false,
cameraFixed: false,
aspectRatio: "adaptive",
resolution: "720p",
status: "idle",
});
+4 -5
View File
@@ -13,8 +13,9 @@ import { SettingsTabs } from "@/types/settings";
import { buildHomeAgentParams } from "@/lib/workspace/navigation";
import { Page, PageParams } from "@/types/page";
// 外观设置(迁移自原 GeneralSettings)
import { GeneralSettings } from "../../settings/GeneralSettings";
// 外观设置
import { AppearanceSettings } from '../general/appearance';
import { ChatAppearanceSettings } from '../general/chat-appearance';
// 网络代理
import { ProxySettings } from "../../settings/ProxySettings";
// 安全与性能
@@ -34,8 +35,6 @@ import { AboutSection } from "../../settings/AboutSection";
import { ExtensionsSettings } from "../../settings/ExtensionsSettings";
// 快捷键设置
import { HotkeysSettings } from "../general/hotkeys";
// 聊天外观设置
import { ChatAppearanceSettings } from "../general/chat-appearance";
// 记忆设置
// 语音服务设置
import { VoiceSettings } from "../agent/voice";
@@ -160,7 +159,7 @@ function renderSettingsContent(tab: SettingsTabs): ReactNode {
return (
<>
<SettingHeader title="外观" />
<GeneralSettings />
<AppearanceSettings />
</>
);
@@ -0,0 +1,242 @@
/**
* @file index.tsx
* @description 通用设置 - 外观与语言
*/
import { useState, useEffect, useCallback } from "react";
import styled from "styled-components";
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 { useI18nPatch } from "@/i18n/I18nPatchProvider";
import { useSoundContext } from "@/contexts/useSoundContext";
type Theme = "light" | "dark" | "system";
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 24px;
`;
const Section = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const SectionTitle = styled.h3`
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
padding-bottom: 8px;
border-bottom: 1px solid hsl(var(--border));
`;
const SettingItem = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
`;
const SettingInfo = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const SettingLabel = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: hsl(var(--foreground));
`;
const SettingDescription = styled.div`
font-size: 12px;
color: hsl(var(--muted-foreground));
`;
const ThemeButtonGroup = styled.div`
display: flex;
gap: 4px;
background: hsl(var(--muted));
padding: 4px;
border-radius: 8px;
`;
const ThemeButton = styled.button<{ $active: boolean }>`
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
background: ${({ $active }) => ($active ? "hsl(var(--background))" : "transparent")};
color: ${({ $active }) => ($active ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))")};
box-shadow: ${({ $active }) => ($active ? "0 1px 3px rgba(0,0,0,0.1)" : "none")};
cursor: pointer;
transition: all 0.2s;
&:hover {
color: hsl(var(--foreground));
}
svg {
width: 14px;
height: 14px;
}
`;
export function AppearanceSettings() {
const [theme, setTheme] = useState<Theme>("system");
const [language, setLanguageState] = useState<Language>("zh");
const [config, setConfig] = useState<Config | null>(null);
const { setLanguage: setI18nLanguage } = useI18nPatch();
const { soundEnabled, setSoundEnabled, playToolcallSound } = useSoundContext();
const { resetOnboarding } = useOnboardingState();
useEffect(() => {
const savedTheme = localStorage.getItem("theme") as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
}
loadConfig();
}, []);
const loadConfig = async () => {
try {
const c = await getConfig();
setConfig(c);
setLanguageState((c.language || "zh") as Language);
} catch (e) {
console.error("加载配置失败:", e);
}
};
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 handleLanguageChange = async (newLanguage: Language) => {
if (!config) return;
try {
const newConfig = { ...config, language: newLanguage };
await saveConfig(newConfig);
setConfig(newConfig);
setLanguageState(newLanguage);
setI18nLanguage(newLanguage);
} catch (err) {
console.error("保存语言设置失败:", err);
}
};
const handleResetOnboarding = useCallback(() => {
resetOnboarding();
window.location.reload();
}, [resetOnboarding]);
const themeOptions = [
{ id: "light" as Theme, label: "浅色", icon: Sun },
{ id: "dark" as Theme, label: "深色", icon: Moon },
{ id: "system" as Theme, label: "系统", icon: Monitor },
];
return (
<Container>
<Section>
<SectionTitle>基础外观</SectionTitle>
<SettingItem>
<SettingInfo>
<SettingLabel>主题模式</SettingLabel>
<SettingDescription>选择应用的主题颜色体系</SettingDescription>
</SettingInfo>
<ThemeButtonGroup>
{themeOptions.map((option) => (
<ThemeButton
key={option.id}
$active={theme === option.id}
onClick={() => handleThemeChange(option.id)}
>
<option.icon />
{option.label}
</ThemeButton>
))}
</ThemeButtonGroup>
</SettingItem>
<SettingItem>
<SettingInfo>
<SettingLabel>语言</SettingLabel>
<SettingDescription>选择应用的显示语言</SettingDescription>
</SettingInfo>
<LanguageSelector
currentLanguage={language}
onLanguageChange={handleLanguageChange}
/>
</SettingItem>
<SettingItem>
<SettingInfo>
<SettingLabel>
<Volume2 className="h-4 w-4" />
提示音效
</SettingLabel>
<SettingDescription>在工具调用和消息生成时播放提示音</SettingDescription>
</SettingInfo>
<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"
/>
</SettingItem>
</Section>
<Section>
<SectionTitle>初始化</SectionTitle>
<SettingItem>
<SettingInfo>
<SettingLabel>重置向导设置</SettingLabel>
<SettingDescription>遇到问题或想重新选择启动选项时,可重新运行初始化向导</SettingDescription>
</SettingInfo>
<button
onClick={handleResetOnboarding}
className="flex items-center gap-1.5 px-3 py-1.5 rounded border border-input bg-background hover:bg-accent hover:text-accent-foreground text-sm font-medium transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
重新运行引导
</button>
</SettingItem>
</Section>
</Container>
);
}
export default AppearanceSettings;
@@ -1,397 +1,344 @@
/**
* 聊天外观设置组件
*
* 参考成熟产品的聊天外观实现
* 功能包括:聊天气泡样式、字体大小、过渡模式等
* @file index.tsx
* @description 通用设置 - 聊天外观与模块定制
*/
import { useState, useEffect } from "react";
import { Type, Sparkles, MessageSquare, Monitor, Info } from "lucide-react";
import styled from "styled-components";
import { Palette } from "lucide-react";
import { cn } from "@/lib/utils";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
import { Switch } from "@/components/ui/switch";
type TransitionMode = "none" | "fadeIn" | "smooth";
type BubbleStyle = "default" | "minimal" | "colorful";
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;
interface ChatAppearanceConfig {
fontSize?: number; // 12-18
transitionMode?: TransitionMode;
bubbleStyle?: BubbleStyle;
showAvatar?: boolean;
showTimestamp?: boolean;
}
const DEFAULT_ENABLED_THEMES = [
"general",
"social-media",
"poster",
"music",
"video",
"novel",
];
const DEFAULT_CHAT_APPEARANCE: ChatAppearanceConfig = {
fontSize: 14,
transitionMode: "smooth",
bubbleStyle: "default",
showAvatar: true,
showTimestamp: true,
};
const ALL_NAV_ITEMS = [
{ id: "home-general", label: "首页" },
{ id: "video", label: "视频" },
{ id: "image-gen", label: "绘画" },
{ id: "batch", label: "批量任务" },
{ id: "plugins", label: "插件中心" },
] as const;
/**
* 字体大小预览组件
*/
function FontSizePreview({ fontSize }: { fontSize: number }) {
const sampleText = `这是示例文本
const DEFAULT_ENABLED_NAV_ITEMS = [
"home-general",
"video",
"image-gen",
"plugins",
];
## 标题示例
这是一段普通文本,展示当前的字体大小效果。
- 列表项 1
- 列表项 2
**粗体文本** 和 *斜体文本*
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 24px;
`;
return (
<div
className="p-4 rounded-lg border bg-muted/30 min-h-[120px] prose dark:prose-invert max-w-none"
style={{ fontSize: `${fontSize}px` }}
>
<div className="whitespace-pre-wrap">{sampleText}</div>
</div>
);
}
const Section = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
/**
* 过渡模式预览组件
*/
function TransitionPreview({ mode }: { mode: TransitionMode }) {
const [messages, setMessages] = useState<string[]>([]);
const SectionTitle = styled.h3`
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
padding-bottom: 8px;
border-bottom: 1px solid hsl(var(--border));
`;
useEffect(() => {
setMessages([]);
const timer = setTimeout(() => {
setMessages(["你好!"]);
}, 300);
return () => clearTimeout(timer);
}, [mode]);
const SettingItem = styled.div`
display: flex;
flex-direction: column;
padding: 16px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 8px;
gap: 16px;
`;
return (
<div className="space-y-2 p-4 rounded-lg border bg-muted/30 min-h-[120px]">
{messages.map((msg, i) => (
<div
key={i}
className={cn(
"inline-block px-3 py-2 rounded-lg bg-primary text-primary-foreground",
mode === "fadeIn" && "animate-in fade-in duration-300",
mode === "smooth" && "transition-all duration-300",
)}
>
{msg}
</div>
))}
</div>
);
}
const SettingHeader = styled.div`
display: flex;
align-items: flex-start;
gap: 12px;
`;
/**
* 气泡样式预览组件
*/
function BubbleStylePreview({ style }: { style: BubbleStyle }) {
const bubbles = [
{ text: "你好,有什么可以帮助你的吗?", align: "left" },
{ text: "帮我写一段代码", align: "right" },
];
const SettingIcon = styled.div`
color: hsl(var(--muted-foreground));
padding-top: 2px;
`;
const getBubbleClass = (align: string) => {
const baseClass = "max-w-[70%] px-3 py-2 rounded-lg";
if (style === "minimal") {
return cn(
baseClass,
align === "left"
? "bg-muted text-foreground"
: "bg-primary/20 text-foreground",
);
} else if (style === "colorful") {
return cn(
baseClass,
align === "left"
? "bg-gradient-to-br from-blue-500 to-blue-600 text-white"
: "bg-gradient-to-br from-purple-500 to-purple-600 text-white",
);
const SettingInfo = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const SettingLabel = styled.div`
font-size: 14px;
font-weight: 500;
color: hsl(var(--foreground));
`;
const SettingDescription = styled.div`
font-size: 13px;
color: hsl(var(--muted-foreground));
line-height: 1.5;
`;
const TagsContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-left: 36px;
`;
const ToggleRow = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-left: 36px;
gap: 12px;
`;
const ToggleInfo = styled.div`
font-size: 12px;
color: hsl(var(--muted-foreground));
line-height: 1.5;
`;
const TagButton = styled.button<{ $active: boolean }>`
px: 12px;
py: 6px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
transition: all 0.2s;
${({ $active }) =>
$active
? `
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
`
: `
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
border: 1px solid transparent;
&:hover {
background: hsl(var(--muted)/0.8);
}
// default
return cn(
baseClass,
align === "left"
? "bg-muted text-foreground"
: "bg-primary text-primary-foreground",
);
};
return (
<div className="space-y-3 p-4 rounded-lg border bg-muted/30 min-h-[120px]">
{bubbles.map((bubble, i) => (
<div
key={i}
className={cn(
"flex",
bubble.align === "left" ? "justify-start" : "justify-end",
)}
>
<div className={getBubbleClass(bubble.align)}>{bubble.text}</div>
</div>
))}
</div>
);
}
`}
`;
export function ChatAppearanceSettings() {
const [config, setConfig] = useState<Config | null>(null);
const [chatConfig, setChatConfig] = useState<ChatAppearanceConfig>(
DEFAULT_CHAT_APPEARANCE,
const [enabledThemes, setEnabledThemes] = useState<string[]>(
DEFAULT_ENABLED_THEMES,
);
const [_loading, setLoading] = useState(true);
const [_saving, setSaving] = useState<Record<string, boolean>>({});
const [enabledNavItems, setEnabledNavItems] = useState<string[]>(
DEFAULT_ENABLED_NAV_ITEMS,
);
const [appendSelectedTextToRecommendation, setAppendSelectedTextToRecommendation] =
useState(true);
const [config, setConfig] = useState<Config | null>(null);
// 加载配置
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
setLoading(true);
try {
const c = await getConfig();
setConfig(c);
setChatConfig(c.chat_appearance || DEFAULT_CHAT_APPEARANCE);
setEnabledThemes(
c.content_creator?.enabled_themes || DEFAULT_ENABLED_THEMES,
);
setEnabledNavItems(
c.navigation?.enabled_items || DEFAULT_ENABLED_NAV_ITEMS,
);
setAppendSelectedTextToRecommendation(
c.chat_appearance?.append_selected_text_to_recommendation ?? true,
);
} catch (e) {
console.error("加载聊天外观配置失败:", e);
} finally {
setLoading(false);
console.error("加载配置失败:", e);
}
};
// 保存配置
const saveChatConfig = async (
key: keyof ChatAppearanceConfig,
value: any,
) => {
const handleThemeToggle = async (themeId: string) => {
if (!config) return;
setSaving((prev) => ({ ...prev, [key]: true }));
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 handleRecommendationSelectionToggle = async (checked: boolean) => {
if (!config) return;
const previousValue = appendSelectedTextToRecommendation;
setAppendSelectedTextToRecommendation(checked);
try {
const newConfig = {
...chatConfig,
[key]: value,
};
const updatedFullConfig = {
...config,
chat_appearance: newConfig,
chat_appearance: {
...(config.chat_appearance || {}),
append_selected_text_to_recommendation: checked,
},
};
await saveConfig(updatedFullConfig);
setConfig(updatedFullConfig);
setChatConfig(newConfig);
} catch (e) {
console.error("保存聊天外观配置失败:", e);
} finally {
setSaving((prev) => ({ ...prev, [key]: false }));
await saveConfig(newConfig);
setConfig(newConfig);
window.dispatchEvent(new CustomEvent("chat-appearance-config-changed"));
} catch (err) {
console.error("保存推荐上下文设置失败:", err);
setAppendSelectedTextToRecommendation(previousValue);
}
};
const transitionModeOptions: {
value: TransitionMode;
label: string;
desc: string;
}[] = [
{
value: "none",
label: "无动画",
desc: "消息立即显示",
},
{
value: "fadeIn",
label: "淡入",
desc: "消息淡入显示",
},
{
value: "smooth",
label: "平滑",
desc: "平滑过渡效果",
},
];
const bubbleStyleOptions: {
value: BubbleStyle;
label: string;
desc: string;
}[] = [
{
value: "default",
label: "默认",
desc: "经典聊天气泡样式",
},
{
value: "minimal",
label: "简约",
desc: "简约气泡风格",
},
{
value: "colorful",
label: "彩色",
desc: "渐变彩色气泡",
},
];
return (
<div className="space-y-4 max-w-2xl">
{/* 字体大小 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Type className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">字体大小</h3>
<p className="text-xs text-muted-foreground">
调整聊天消息的字体大小
</p>
</div>
</div>
<span className="text-sm font-medium text-primary">
{chatConfig.fontSize}px
</span>
</div>
<Container>
<Section>
<SectionTitle>工作区定制</SectionTitle>
<div className="mb-3">
<input
type="range"
min={12}
max={18}
step={1}
value={chatConfig.fontSize || 14}
onChange={(e) => {
const value = parseInt(e.target.value);
setChatConfig((prev) => ({ ...prev, fontSize: value }));
}}
onChangeCapture={(e) => {
saveChatConfig(
"fontSize",
parseInt((e.target as HTMLInputElement).value),
);
}}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
/>
<div className="flex justify-between mt-1 text-xs text-muted-foreground">
<span>小 (12px)</span>
<span>中 (14px)</span>
<span>大 (18px)</span>
</div>
</div>
<SettingItem>
<SettingHeader>
<SettingIcon>
<Palette size={20} />
</SettingIcon>
<SettingInfo>
<SettingLabel>创作模式卡片</SettingLabel>
<SettingDescription>选择您希望在创建新项目时可以使用的快捷内容创作模板,它们会在新对话页面展现。</SettingDescription>
</SettingInfo>
</SettingHeader>
<FontSizePreview fontSize={chatConfig.fontSize || 14} />
</div>
<TagsContainer>
{ALL_CONTENT_THEMES.map((t) => (
<TagButton
key={t.id}
$active={enabledThemes.includes(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}
</TagButton>
))}
</TagsContainer>
</SettingItem>
{/* 过渡模式 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">消息过渡效果</h3>
<p className="text-xs text-muted-foreground">
选择消息显示的动画效果
</p>
</div>
</div>
<SettingItem>
<SettingHeader>
<SettingIcon>
<Palette size={20} />
</SettingIcon>
<SettingInfo>
<SettingLabel>左侧边栏导航</SettingLabel>
<SettingDescription>定制主视图左侧边栏启用的常驻导航图标入口,最少须保留一个。</SettingDescription>
</SettingInfo>
</SettingHeader>
<div className="grid grid-cols-3 gap-2 mb-3">
{transitionModeOptions.map((option) => (
<button
key={option.value}
onClick={() => saveChatConfig("transitionMode", option.value)}
className={cn(
"px-3 py-2 rounded-lg text-xs font-medium transition-colors border",
chatConfig.transitionMode === option.value
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
{option.label}
</button>
))}
</div>
<TagsContainer>
{ALL_NAV_ITEMS.map((item) => (
<TagButton
key={item.id}
$active={enabledNavItems.includes(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}
</TagButton>
))}
</TagsContainer>
</SettingItem>
<TransitionPreview mode={chatConfig.transitionMode || "smooth"} />
</div>
<SettingItem>
<SettingHeader>
<SettingIcon>
<Palette size={20} />
</SettingIcon>
<SettingInfo>
<SettingLabel>推荐自动附带选中内容</SettingLabel>
<SettingDescription>开启后,点击推荐提示词会自动追加当前编辑器选中文本作为上下文。</SettingDescription>
</SettingInfo>
</SettingHeader>
{/* 气泡样式 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">聊天气泡样式</h3>
<p className="text-xs text-muted-foreground">
自定义聊天气泡的视觉风格
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-2 mb-3">
{bubbleStyleOptions.map((option) => (
<button
key={option.value}
onClick={() => saveChatConfig("bubbleStyle", option.value)}
className={cn(
"px-3 py-2 rounded-lg text-xs font-medium transition-colors border",
chatConfig.bubbleStyle === option.value
? "bg-primary text-primary-foreground border-primary"
: "hover:bg-muted",
)}
>
{option.label}
</button>
))}
</div>
<BubbleStylePreview style={chatConfig.bubbleStyle || "default"} />
</div>
{/* 显示选项 */}
<div className="rounded-lg border p-3">
<div className="flex items-center gap-2 mb-3">
<Monitor className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">显示选项</h3>
<p className="text-xs text-muted-foreground">
控制聊天界面的元素显示
</p>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center justify-between py-1.5 cursor-pointer">
<span className="text-sm">显示头像</span>
<input
type="checkbox"
checked={chatConfig.showAvatar ?? true}
onChange={(e) => saveChatConfig("showAvatar", e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
<ToggleRow>
<ToggleInfo>建议开启:更贴合当前段落;关闭可避免附加额外上下文。</ToggleInfo>
<Switch
checked={appendSelectedTextToRecommendation}
onCheckedChange={handleRecommendationSelectionToggle}
/>
</label>
</ToggleRow>
</SettingItem>
<label className="flex items-center justify-between py-1.5 cursor-pointer border-t">
<span className="text-sm">显示时间戳</span>
<input
type="checkbox"
checked={chatConfig.showTimestamp ?? true}
onChange={(e) =>
saveChatConfig("showTimestamp", e.target.checked)
}
className="w-4 h-4 rounded border-gray-300"
/>
</label>
</div>
</div>
{/* 提示信息 */}
<div className="flex items-start gap-2 text-xs text-muted-foreground p-3 bg-muted/30 rounded-lg">
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
<p>
这些设置会应用到所有聊天对话。部分效果可能需要刷新对话窗口后才能看到。
</p>
</div>
</div>
</Section>
</Container>
);
}
+10 -6
View File
@@ -47,16 +47,20 @@ const DEFAULT_ENABLED_THEMES = [
/** 所有可用的导航模块 */
const ALL_NAV_ITEMS = [
{ id: "agent", label: "AI Agent" },
{ id: "projects", label: "项目" },
{ id: "image-gen", label: "图片生成" },
{ id: "terminal", label: "终端" },
{ id: "tools", label: "工具" },
{ id: "home-general", label: "首页" },
{ id: "video", label: "视频" },
{ id: "image-gen", label: "绘画" },
{ id: "batch", label: "批量任务" },
{ id: "plugins", label: "插件中心" },
] as const;
/** 默认启用的导航模块 */
const DEFAULT_ENABLED_NAV_ITEMS = ["agent", "projects", "image-gen"];
const DEFAULT_ENABLED_NAV_ITEMS = [
"home-general",
"video",
"image-gen",
"plugins",
];
export function GeneralSettings() {
const [theme, setTheme] = useState<Theme>("system");
+118 -94
View File
@@ -77,6 +77,11 @@ 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 {
VideoCanvas,
createInitialVideoState,
type VideoCanvasState as StandaloneVideoCanvasState,
} from "@/components/content-creator/canvas/video";
import {
buildCreationIntentMetadata,
buildCreationIntentPrompt,
@@ -206,16 +211,22 @@ export function WorkbenchPage({
const [selectedCreationMode, setSelectedCreationMode] =
useState<CreationMode>(DEFAULT_CREATION_MODE);
const [creationIntentValues, setCreationIntentValues] =
useState<CreationIntentFormValues>(() => createInitialCreationIntentValues());
useState<CreationIntentFormValues>(() =>
createInitialCreationIntentValues(),
);
const [creationIntentError, setCreationIntentError] = useState("");
const [pendingInitialPromptsByContentId, setPendingInitialPromptsByContentId] =
useState<Record<string, string>>({});
const [
pendingInitialPromptsByContentId,
setPendingInitialPromptsByContentId,
] = useState<Record<string, string>>({});
const [contentCreationModes, setContentCreationModes] = useState<
Record<string, CreationMode>
>({});
const [resolvedProjectPath, setResolvedProjectPath] = useState("");
const [pathChecking, setPathChecking] = useState(false);
const [pathConflictMessage, setPathConflictMessage] = useState("");
const [videoCanvasState, setVideoCanvasState] =
useState<StandaloneVideoCanvasState>(() => createInitialVideoState());
const selectedProject = useMemo(
() => projects.find((project) => project.id === selectedProjectId) ?? null,
@@ -260,8 +271,9 @@ export function WorkbenchPage({
);
const currentIntentLength = useMemo(
() => validateCreationIntent(creationIntentInput, MIN_CREATION_INTENT_LENGTH)
.length,
() =>
validateCreationIntent(creationIntentInput, MIN_CREATION_INTENT_LENGTH)
.length,
[creationIntentInput],
);
@@ -434,71 +446,65 @@ export function WorkbenchPage({
setCreationIntentError("");
}, []);
const handleCreateContent = useCallback(
async () => {
if (!selectedProjectId) {
return;
}
const handleCreateContent = useCallback(async () => {
if (!selectedProjectId) {
return;
}
const validation = validateCreationIntent(
creationIntentInput,
MIN_CREATION_INTENT_LENGTH,
);
if (!validation.valid) {
setCreationIntentError(validation.message || "请完善创作意图");
return;
}
const initialUserPrompt = buildCreationIntentPrompt(creationIntentInput);
const creationIntentMetadata = buildCreationIntentMetadata(
creationIntentInput,
);
setCreatingContent(true);
try {
const defaultType = getDefaultContentTypeForProject(
theme as ProjectType,
);
const created = await createContent({
project_id: selectedProjectId,
title: `新${getContentTypeLabel(defaultType)}`,
content_type: defaultType,
metadata: {
creationMode: selectedCreationMode,
creationIntent: creationIntentMetadata,
},
});
setContentCreationModes((previous) => ({
...previous,
[created.id]: selectedCreationMode,
}));
setPendingInitialPromptsByContentId((previous) => ({
...previous,
[created.id]: initialUserPrompt,
}));
setCreateContentDialogOpen(false);
resetCreateContentDialogState();
await loadContents(selectedProjectId);
handleEnterWorkspace(created.id, { showChatPanel: true });
toast.success("已创建新文稿");
} catch (error) {
console.error("创建文稿失败:", error);
toast.error("创建文稿失败");
} finally {
setCreatingContent(false);
}
},
[
const validation = validateCreationIntent(
creationIntentInput,
handleEnterWorkspace,
loadContents,
resetCreateContentDialogState,
selectedCreationMode,
selectedProjectId,
theme,
],
);
MIN_CREATION_INTENT_LENGTH,
);
if (!validation.valid) {
setCreationIntentError(validation.message || "请完善创作意图");
return;
}
const initialUserPrompt = buildCreationIntentPrompt(creationIntentInput);
const creationIntentMetadata =
buildCreationIntentMetadata(creationIntentInput);
setCreatingContent(true);
try {
const defaultType = getDefaultContentTypeForProject(theme as ProjectType);
const created = await createContent({
project_id: selectedProjectId,
title: `新${getContentTypeLabel(defaultType)}`,
content_type: defaultType,
metadata: {
creationMode: selectedCreationMode,
creationIntent: creationIntentMetadata,
},
});
setContentCreationModes((previous) => ({
...previous,
[created.id]: selectedCreationMode,
}));
setPendingInitialPromptsByContentId((previous) => ({
...previous,
[created.id]: initialUserPrompt,
}));
setCreateContentDialogOpen(false);
resetCreateContentDialogState();
await loadContents(selectedProjectId);
handleEnterWorkspace(created.id, { showChatPanel: true });
toast.success("已创建新文稿");
} catch (error) {
console.error("创建文稿失败:", error);
toast.error("创建文稿失败");
} finally {
setCreatingContent(false);
}
}, [
creationIntentInput,
handleEnterWorkspace,
loadContents,
resetCreateContentDialogState,
selectedCreationMode,
selectedProjectId,
theme,
]);
const consumePendingInitialPrompt = useCallback((contentId: string) => {
setPendingInitialPromptsByContentId((previous) => {
@@ -723,6 +729,13 @@ export function WorkbenchPage({
}
}, [workspaceMode]);
useEffect(() => {
if (theme !== "video") {
return;
}
setVideoCanvasState(createInitialVideoState());
}, [theme, resetAt]);
useEffect(() => {
if (!workflowProgress || workflowProgress.steps.length === 0) {
setShowWorkflowRail(false);
@@ -1071,6 +1084,15 @@ export function WorkbenchPage({
}}
/>
)
) : workspaceMode === "workspace" && theme === "video" ? (
<div className="flex-1 min-h-0">
<VideoCanvas
state={videoCanvasState}
onStateChange={setVideoCanvasState}
projectId={selectedProjectId}
onBackHome={handleBackHome}
/>
</div>
) : !selectedProjectId || !selectedContentId ? (
<div className="h-full rounded-lg border bg-card flex flex-col items-center justify-center gap-3 text-muted-foreground m-4">
<Sparkles className="h-8 w-8 opacity-60" />
@@ -1128,33 +1150,35 @@ export function WorkbenchPage({
)}
</main>
{workspaceMode === "workspace" && activeRightDrawer === "tools" && (
<aside className="w-[260px] min-w-[260px] border-l bg-muted/10 p-4 flex flex-col gap-3">
<h3 className="text-sm font-semibold">主题工具</h3>
<Button
variant="outline"
className="justify-start"
onClick={() => {
void handleQuickSaveCurrent();
}}
disabled={!selectedContentId}
>
<FileText className="h-4 w-4 mr-2" />
快速保存
</Button>
<Button
variant="outline"
className="justify-start"
onClick={handleOpenProjectDetail}
disabled={!selectedProjectId}
>
<FolderOpen className="h-4 w-4 mr-2" />
项目详情
</Button>
</aside>
)}
{workspaceMode === "workspace" &&
theme !== "video" &&
activeRightDrawer === "tools" && (
<aside className="w-[260px] min-w-[260px] border-l bg-muted/10 p-4 flex flex-col gap-3">
<h3 className="text-sm font-semibold">主题工具</h3>
<Button
variant="outline"
className="justify-start"
onClick={() => {
void handleQuickSaveCurrent();
}}
disabled={!selectedContentId}
>
<FileText className="h-4 w-4 mr-2" />
快速保存
</Button>
<Button
variant="outline"
className="justify-start"
onClick={handleOpenProjectDetail}
disabled={!selectedProjectId}
>
<FolderOpen className="h-4 w-4 mr-2" />
项目详情
</Button>
</aside>
)}
{workspaceMode === "workspace" && (
{workspaceMode === "workspace" && theme !== "video" && (
<aside className="w-14 min-w-14 border-l bg-background/95 flex flex-col items-center py-3 gap-2">
<TooltipProvider>
<Tooltip>
+2
View File
@@ -159,6 +159,8 @@ export interface ChatAppearanceConfig {
showAvatar?: boolean;
/** 显示时间戳 */
showTimestamp?: boolean;
/** 推荐点击时自动附带当前选中文本上下文 */
append_selected_text_to_recommendation?: boolean;
}
/**
+87
View File
@@ -0,0 +1,87 @@
/**
* @file 视频生成 API
* @description 封装视频生成任务相关的 Tauri 命令调用
* @module lib/api/videoGeneration
*/
import { safeInvoke } from "@/lib/dev-bridge";
export type VideoTaskStatus =
| "pending"
| "processing"
| "success"
| "error"
| "cancelled";
export interface CreateVideoGenerationRequest {
projectId: string;
providerId: string;
model: string;
prompt: string;
aspectRatio?: string;
resolution?: string;
duration?: number;
imageUrl?: string;
endImageUrl?: string;
seed?: number;
generateAudio?: boolean;
cameraFixed?: boolean;
}
export interface VideoGenerationTask {
id: string;
projectId: string;
providerId: string;
model: string;
prompt: string;
requestPayload?: string;
providerTaskId?: string;
status: VideoTaskStatus;
progress?: number;
resultUrl?: string;
errorMessage?: string;
metadataJson?: string;
createdAt: number;
updatedAt: number;
finishedAt?: number;
}
export const videoGenerationApi = {
async createTask(
request: CreateVideoGenerationRequest,
): Promise<VideoGenerationTask> {
return safeInvoke("create_video_generation_task", { request });
},
async getTask(
taskId: string,
options?: { refreshStatus?: boolean },
): Promise<VideoGenerationTask | null> {
return safeInvoke("get_video_generation_task", {
request: {
taskId,
refreshStatus: options?.refreshStatus ?? true,
},
});
},
async listTasks(
projectId: string,
options?: { limit?: number },
): Promise<VideoGenerationTask[]> {
return safeInvoke("list_video_generation_tasks", {
request: {
projectId,
limit: options?.limit ?? 50,
},
});
},
async cancelTask(taskId: string): Promise<VideoGenerationTask | null> {
return safeInvoke("cancel_video_generation_task", {
request: {
taskId,
},
});
},
};
+32 -1
View File
@@ -237,6 +237,32 @@ const defaultMocks: Record<string, any> = {
migrate_legacy_api_key_credentials: () => ({ success: true }),
delete_legacy_api_key_credential: () => ({ success: true }),
get_local_kiro_credential_uuid: () => null,
create_video_generation_task: (args: any) => {
const request = args?.request ?? {};
return {
id: "mock-video-task-id",
projectId: request.projectId ?? "mock-project-id",
providerId: request.providerId ?? "doubao",
model: request.model ?? "seedance-1-5-pro-251215",
prompt: request.prompt ?? "mock",
requestPayload: JSON.stringify(request),
providerTaskId: "mock-provider-task-id",
status: "processing",
progress: 0,
resultUrl: null,
errorMessage: null,
metadataJson: null,
createdAt: Date.now(),
updatedAt: Date.now(),
finishedAt: null,
};
},
get_video_generation_task: () => null,
list_video_generation_tasks: () => [],
cancel_video_generation_task: () => null,
import_material_from_url: () => ({
id: "mock-material-id",
}),
// OAuth 凭证相关
add_kiro_oauth_credential: () => ({ success: true }),
@@ -614,7 +640,12 @@ const defaultMocks: Record<string, any> = {
alert_count: 0,
message: "当前无告警,未触发投递",
}),
trigger_heartbeat_now: () => ({ task_count: 0, success_count: 0, failed_count: 0, timeout_count: 0 }),
trigger_heartbeat_now: () => ({
task_count: 0,
success_count: 0,
failed_count: 0,
timeout_count: 0,
}),
get_task_templates: () => [],
apply_task_template: () => ({ success: true }),
generate_content_creator_tasks: () => 0,
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
# 同步资源文件到 target/debug 目录
echo "正在同步资源文件..."
# 创建目标目录
mkdir -p src-tauri/target/debug/resources/models/providers
mkdir -p src-tauri/target/debug/resources/models/aliases
# 复制模型文件
cp -v src-tauri/resources/models/index.json src-tauri/target/debug/resources/models/
cp -v src-tauri/resources/models/providers/*.json src-tauri/target/debug/resources/models/providers/
cp -v src-tauri/resources/models/aliases/*.json src-tauri/target/debug/resources/models/aliases/ 2>/dev/null || true
echo "✅ 资源文件同步完成!"
-112
View File
@@ -1,112 +0,0 @@
# 闪退修复验证清单
## 已完成的修复
### 1. ✅ 移除危险的 unwrap() 调用
**文件**: `src-tauri/src/app/bootstrap.rs:147`
**修改前**:
```rust
let rt = tokio::runtime::Handle::try_current().unwrap_or_else(|_| {
tokio::runtime::Runtime::new().unwrap().handle().clone()
});
```
**修改后**:
```rust
let rt = tokio::runtime::Handle::try_current().unwrap_or_else(|_| {
tokio::runtime::Runtime::new()
.expect("Failed to create tokio runtime: 系统资源不足或配置错误")
.handle()
.clone()
});
```
**效果**: 如果 tokio runtime 创建失败,现在会显示详细的错误信息而不是直接 panic。
### 2. ✅ 模型过滤逻辑验证
**文件**: `src/components/agent/chat/utils/modelThemePolicy.ts`
**验证结果**:
- `filterModelsByTheme` 函数已有完善的回退机制
- 当过滤后没有模型时,会返回原始模型列表并设置 `usedFallback: true`
- `ModelSelector` 组件在模型列表为空时显示"暂无可用模型",不会崩溃
### 3. ✅ 添加前端错误处理
**文件**: `src/components/agent/chat/index.tsx`
**修改**: 在 `handleSend` 函数中添加 try-catch 错误处理:
```typescript
try {
await sendMessage(
text,
images || [],
webSearch,
thinking,
false,
sendExecutionStrategy,
);
} catch (error) {
console.error("[AgentChat] 发送消息失败:", error);
toast.error(`发送失败: ${error instanceof Error ? error.message : String(error)}`);
// 恢复输入内容,让用户可以重试
setInput(sourceText);
}
```
**效果**: 如果 `sendMessage` 抛出异常,现在会捕获错误并显示给用户,而不是让应用崩溃。
### 4. ✅ 验证加密模块
**验证结果**:
- `credential/encryption.rs` 中的 ChaCha20-Poly1305 加密模块仅在测试中使用
- 实际的 API Key 加密使用 `api_key_provider_service.rs` 中的自定义 XOR 加密
- 加密模块初始化不会导致启动时崩溃
## 验证步骤
### 测试 1: 启动测试
1. 启动 ProxyCast 应用
2. 查看日志输出确认无错误
3. 应用应能正常启动
### 测试 2: 对话测试
1. 创建新对话
2. 发送第一条消息(例如:"你好")
3. 确认不会崩溃
4. 如果出现错误,应该能看到具体的错误信息
### 测试 3: 模型选择测试
1. 测试不同主题的对话
2. 验证模型过滤逻辑
3. 确保总有可用模型
### 测试 4: 跨平台测试
1. macOS 测试
2. Windows 11 测试
3. 确认修复在两个平台都有效
## 预期结果
- ✅ 应用能够正常启动
- ✅ 发送第一条消息不会崩溃
- ✅ 如果出现错误,能看到具体的错误信息
- ✅ 用户配置问题不会导致崩溃
## 需要用户确认的问题
如果问题仍然存在,请提供以下信息:
1. **错误消息**: 现在应该能看到具体的错误信息
2. **控制台日志**: 浏览器开发者工具 Console 标签页中的日志
3. **Tauri 日志**: 应用日志文件中的内容
4. **复现步骤**: 如何触发崩溃的详细步骤
## 下一步计划
如果问题仍然存在,需要进一步调查:
1. 检查 Tauri 命令 `aster_agent_chat_stream` 的实现
2. 验证 Agent 初始化流程
3. 检查数据库操作是否有问题
4. 添加更详细的日志来追踪崩溃点
-48
View File
@@ -1,48 +0,0 @@
#!/bin/bash
# ProxyCast 闪退修复验证脚本
echo "🧪 ProxyCast 闪退修复验证测试"
echo "================================"
echo ""
# 检查当前版本
CURRENT_VERSION=$(grep '"version"' package.json | head -1 | cut -d '"' -f 4)
echo "📦 当前版本: $CURRENT_VERSION"
echo ""
# 检查最近的修复提交
echo "📝 最近的修复提交:"
git log --oneline -1
echo ""
# 编译检查
echo "🔧 编译检查..."
echo "正在编译 Rust 代码..."
cd src-tauri
cargo build 2>&1 | tail -5
cd ..
echo ""
# 运行测试
echo "🧪 运行测试..."
npm run test 2>&1 | tail -10
echo ""
# Lint 检查
echo "🔍 Lint 检查..."
npm run lint 2>&1 | tail -5
echo ""
echo "✅ 修复验证完成!"
echo ""
echo "📋 测试清单:"
echo " 1. 启动应用(应该能看到详细错误信息而非直接崩溃)"
echo " 2. 创建新对话"
echo " 3. 发送第一条消息(例如:'你好')"
echo " 4. 检查是否仍然崩溃"
echo ""
echo "如果问题仍然存在,请查看:"
echo " - 浏览器开发者工具 Console 标签页"
echo " - 应用日志文件"
echo " - test-crash-fix.md 中的详细说明"