mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: release v0.71.0 with full pending changes
This commit is contained in:
-203
@@ -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)
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢用户反馈,帮助我们发现并修复这个跨平台兼容性问题。
|
||||
@@ -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` |
|
||||
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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})。`,
|
||||
);
|
||||
}
|
||||
Generated
+15
-15
@@ -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",
|
||||
|
||||
@@ -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) = ¶ms.provider_task_id {
|
||||
task.provider_task_id = provider_task_id.clone();
|
||||
}
|
||||
if let Some(status) = params.status {
|
||||
task.status = status;
|
||||
}
|
||||
if let Some(progress) = ¶ms.progress {
|
||||
task.progress = *progress;
|
||||
}
|
||||
if let Some(result_url) = ¶ms.result_url {
|
||||
task.result_url = result_url.clone();
|
||||
}
|
||||
if let Some(error_message) = ¶ms.error_message {
|
||||
task.error_message = error_message.clone();
|
||||
}
|
||||
if let Some(metadata_json) = ¶ms.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::*;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Executable
+70
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -159,6 +159,8 @@ export interface ChatAppearanceConfig {
|
||||
showAvatar?: boolean;
|
||||
/** 显示时间戳 */
|
||||
showTimestamp?: boolean;
|
||||
/** 推荐点击时自动附带当前选中文本上下文 */
|
||||
append_selected_text_to_recommendation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "✅ 资源文件同步完成!"
|
||||
@@ -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. 添加更详细的日志来追踪崩溃点
|
||||
@@ -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 中的详细说明"
|
||||
Reference in New Issue
Block a user