mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: release v0.83.0 with full pending changes
✨ 新功能 - 新增 OpenClaw 服务集成和相关命令支持 - 新增 Windows 启动诊断命令和故障排查文档 - 新增样式库管理面板 (StyleLibraryPanel) - 新增 A2UI 任务卡片组件和完整测试覆盖 - 新增输入栏多个子组件:提示路由弹窗、执行策略选择、模型扩展配置等 - 新增工作台创建入口主页和右侧面板扩展视图 - 新增运行时样式控制栏组件 - 新增样式运行时工具模块和测试 - 新增内容审查面板测试 - 新增多个工作台右侧面板能力:音频任务、视频任务、画外音任务、图片任务等 - 新增 Windows 支持包收集脚本 - 新增开发桥健康检查脚本 🐛 修复 - 修复 TypeScript 编译错误 - 修复崩溃诊断逻辑 - 修复 Tauri mock 核心模块 - 修复开发桥 HTTP 客户端 - 修复 webview API 调用 - 修复 Provider 模型列表组件 - 修复多个 Rust clippy 警告 🔧 优化与重构 - 重构输入栏组件架构,拆分为多个独立 hooks 和子组件 - 重构工作台右侧面板,拆分为多个独立模块和配置文件 - 优化 A2UI 组件渲染器和布局系统 - 优化内容创建器文档画布和工具栏 - 优化记忆层指标计算和测试 - 优化项目提示工具 - 优化主题工作台侧边栏 - 优化样式指南面板 - 优化工作台导航和面板渲染 hooks - 优化确认策略创建工具 - 优化主题模块(novel、video)和共享面板渲染器 - 优化 Agent 事件转换器和会话存储 - 优化数据库 DAO 层 - 优化模型注册服务和技能服务 - 优化日志命令和应用运行器 - 优化开发桥调度器 - 更新 Windows 平台 Tauri 配置 - 更新 CI release workflow 📦 其他 - 新增 Windows 启动问题故障排查文档 - 更新 Playwright E2E 测试文档 - 更新构建和运维文档 - 更新常见问题文档 - 更新 README
This commit is contained in:
@@ -38,7 +38,7 @@ jobs:
|
||||
- platform: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
name: macOS-x64
|
||||
- platform: windows-latest
|
||||
- platform: windows-2022
|
||||
target: x86_64-pc-windows-msvc
|
||||
name: Windows-x64
|
||||
- platform: ubuntu-22.04
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
args: --target ${{ matrix.target }}
|
||||
|
||||
- name: Build Tauri app (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
if: matrix.platform == 'windows-2022'
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
# 默认不启用 voice feature(包含 whisper-rs,编译很慢)
|
||||
args: --target ${{ matrix.target }}
|
||||
args: --target ${{ matrix.target }} --config tauri.windows.conf.json
|
||||
|
||||
- name: Build Tauri app (macOS with notarization, attempt 1)
|
||||
id: build_macos_primary
|
||||
|
||||
@@ -121,6 +121,10 @@ brew install --cask proxycast
|
||||
|
||||
从 [Releases](https://github.com/aiclientproxy/proxycast/releases) 下载对应平台安装包。
|
||||
|
||||
- Windows 用户优先下载 `ProxyCast_*_x64-setup.exe`(NSIS 安装器)
|
||||
- 该安装器已内置 WebView2 Offline Installer,弱网/离线环境成功率更高
|
||||
- 如被 SmartScreen 拦截,属于未签名或签名信誉不足的 Windows 常见提示,不代表安装包必然损坏
|
||||
|
||||
---
|
||||
|
||||
## 🧭 适合谁
|
||||
|
||||
+45
-25
@@ -1,34 +1,54 @@
|
||||
## ProxyCast v0.82.0
|
||||
## ProxyCast v0.83.0
|
||||
|
||||
### 🔧 优化与重构
|
||||
|
||||
- 大量 Rust 代码质量改进:为枚举类型添加 `#[derive(Default)]` 属性
|
||||
- 实现 `FromStr` trait 替代手动 `from_str` 方法,提升代码规范性和类型安全性
|
||||
- 修复不必要的 `unwrap()` 调用,改用更安全的 `if let` 模式
|
||||
- 优化代码结构:修复 TypeScript lint 错误,移除未使用的变量和函数
|
||||
- 修复 `react-hooks/exhaustive-deps` 警告,优化 Hook 依赖项
|
||||
- 使用 `vec![]` 宏替代 `vec init then push` 模式,提升代码简洁性
|
||||
### ✨ 新功能
|
||||
- 新增 OpenClaw 服务集成和相关命令支持
|
||||
- 新增 Windows 启动诊断命令和故障排查文档
|
||||
- 新增样式库管理面板 (StyleLibraryPanel)
|
||||
- 新增 A2UI 任务卡片组件和完整测试覆盖
|
||||
- 新增输入栏多个子组件:提示路由弹窗、执行策略选择、模型扩展配置等
|
||||
- 新增工作台创建入口主页和右侧面板扩展视图
|
||||
- 新增运行时样式控制栏组件
|
||||
- 新增样式运行时工具模块和测试
|
||||
- 新增内容审查面板测试
|
||||
- 新增多个工作台右侧面板能力:音频任务、视频任务、画外音任务、图片任务等
|
||||
- 新增 Windows 支持包收集脚本
|
||||
- 新增开发桥健康检查脚本
|
||||
|
||||
### 🐛 修复
|
||||
- 修复 TypeScript 编译错误
|
||||
- 修复崩溃诊断逻辑
|
||||
- 修复 Tauri mock 核心模块
|
||||
- 修复开发桥 HTTP 客户端
|
||||
- 修复 webview API 调用
|
||||
- 修复 Provider 模型列表组件
|
||||
|
||||
- 修复 ThemeWorkbenchSidebar 组件中的 20+ 个 ESLint 错误
|
||||
- 修复 useConfiguredProviders hook 中的依赖项警告
|
||||
- 修复 Rust 代码中的 33 个 clippy 警告
|
||||
- 修复 6 个失败的 Rust 测试:
|
||||
- test_bundled_social_post_with_cover_skill_contract: 支持 SKILL.md 中的中文引号格式
|
||||
- workspace_commands_roundtrip: 使用驼峰命名 workspaceType
|
||||
- should_embed_social_image_tool_contract_in_default_skill: 更新为 **配图说明** 格式
|
||||
- 修复 normalize 相关测试中的配图说明断言
|
||||
- 修复 sticky_manager.rs 中的不必要的 unwrap 调用
|
||||
- 修复 poster_material_dao.rs 中的不必要的 unwrap 调用
|
||||
### 🔧 优化与重构
|
||||
- 重构输入栏组件架构,拆分为多个独立 hooks 和子组件
|
||||
- 重构工作台右侧面板,拆分为多个独立模块和配置文件
|
||||
- 优化 A2UI 组件渲染器和布局系统
|
||||
- 优化内容创建器文档画布和工具栏
|
||||
- 优化记忆层指标计算和测试
|
||||
- 优化项目提示工具
|
||||
- 优化主题工作台侧边栏
|
||||
- 优化样式指南面板
|
||||
- 优化工作台导航和面板渲染 hooks
|
||||
- 优化确认策略创建工具
|
||||
- 优化主题模块(novel、video)和共享面板渲染器
|
||||
- 优化 Agent 事件转换器和会话存储
|
||||
- 优化数据库 DAO 层
|
||||
- 优化模型注册服务和技能服务
|
||||
- 优化日志命令和应用运行器
|
||||
- 优化开发桥调度器
|
||||
- 更新 Windows 平台 Tauri 配置
|
||||
- 更新 CI release workflow
|
||||
|
||||
### 📦 其他
|
||||
|
||||
- AI 代码质量验证全部通过(30 个文件,平均分 96/100)
|
||||
- 所有核心测试通过 (328 passed; 0 failed)
|
||||
- 代码格式化和 lint 检查全部通过
|
||||
- 为未来的 Rust 代码改进打下基础
|
||||
- 新增 Windows 启动问题故障排查文档
|
||||
- 更新 Playwright E2E 测试文档
|
||||
- 更新构建和运维文档
|
||||
- 更新常见问题文档
|
||||
- 更新 README
|
||||
|
||||
---
|
||||
|
||||
**完整变更**: v0.81.0...v0.82.0
|
||||
**完整变更**: v0.82.0...v0.83.0
|
||||
|
||||
@@ -49,6 +49,29 @@ npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.t
|
||||
- 修改了 `src/lib/tauri-mock/`
|
||||
- 修改了浏览器模式 bridge/mock 优先级
|
||||
|
||||
### 桥接健康检查
|
||||
|
||||
```bash
|
||||
npm run bridge:health -- --timeout-ms 120000
|
||||
```
|
||||
|
||||
用途:
|
||||
- 等待 `http://127.0.0.1:3030/health` 就绪
|
||||
- 避免 Playwright MCP 进入页面时,前端早于 DevBridge 启动而产生 `Failed to fetch` 噪音
|
||||
- 首次编译较慢时,比手工反复刷新页面更稳定
|
||||
|
||||
### 已验证的最小冒烟路径(当前仓库)
|
||||
|
||||
1. 终端 A:`npm run tauri:dev:headless`
|
||||
2. 终端 B:`npm run bridge:health -- --timeout-ms 120000`
|
||||
3. Playwright MCP 打开 `http://127.0.0.1:1420/`
|
||||
4. 等待首页从“正在加载...”进入默认首页
|
||||
5. 检查 `browser_console_messages(level=error)` 应为 `0`
|
||||
|
||||
补充说明:
|
||||
- 若首页已可用但仍有 warning,先区分是第三方库 warning 还是 bridge 缺口
|
||||
- 若 `bridge:health` 已通过,但页面仍报未知命令,优先检查 `dispatcher.rs` 是否缺少该命令分发
|
||||
|
||||
## 继续测试的标准流程
|
||||
|
||||
### 1. 先确认当前 Playwright 会话是否可复用
|
||||
|
||||
@@ -97,6 +97,23 @@ navigation:
|
||||
|
||||
详见 [网络与连接问题](/troubleshooting/connection-issues)。
|
||||
|
||||
## Windows 安装后打不开或白屏
|
||||
|
||||
### 常见症状
|
||||
|
||||
- 双击应用无反应
|
||||
- 启动后白屏
|
||||
- 提示缺少运行时或被 SmartScreen 拦截
|
||||
|
||||
### 处理建议
|
||||
|
||||
1. 优先重新下载安装 `ProxyCast_*_x64-setup.exe`
|
||||
2. 确认 `%APPDATA%\proxycast\` 与 `%USERPROFILE%\.proxycast\` 可写
|
||||
3. 如被 SmartScreen 拦截,确认来源可信后再继续
|
||||
4. 如有条件,运行一键收集脚本后再反馈
|
||||
|
||||
详见 [Windows 启动与安装问题](/troubleshooting/windows-startup-issues)。
|
||||
|
||||
## 仍然无法解决
|
||||
|
||||
请整理以下信息后反馈:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Windows 启动与安装问题
|
||||
description: 处理打不开、白屏、缺少运行时与目录权限问题
|
||||
navigation:
|
||||
icon: i-heroicons-computer-desktop
|
||||
---
|
||||
|
||||
# Windows 启动与安装问题
|
||||
|
||||
如果 Windows 用户反馈“打不开”“白屏”“没有任何反应”,请优先按本页顺序排查。
|
||||
|
||||
## 最快处理顺序
|
||||
|
||||
1. 先确认安装包类型
|
||||
2. 再确认运行时与系统拦截
|
||||
3. 再检查本地目录权限
|
||||
4. 最后收集日志反馈
|
||||
|
||||
## 先确认安装包
|
||||
|
||||
推荐优先使用:
|
||||
|
||||
- `ProxyCast_*_x64-setup.exe`
|
||||
|
||||
不建议优先分发:
|
||||
|
||||
- 便携版压缩包
|
||||
- 旧的 `.msi` 安装包
|
||||
|
||||
原因:
|
||||
|
||||
- `setup.exe` 会一并处理 WebView2 Offline Installer
|
||||
- 在弱网、离线或新系统环境下成功率更高
|
||||
|
||||
## 常见症状与处理
|
||||
|
||||
### 双击后无反应
|
||||
|
||||
处理建议:
|
||||
|
||||
1. 确认下载来源可信
|
||||
2. 如果被 SmartScreen 拦截,点击“更多信息”后再确认是否继续
|
||||
3. 重新运行 `setup.exe` 覆盖安装
|
||||
4. 安装后从开始菜单再次启动
|
||||
|
||||
### 启动后白屏
|
||||
|
||||
处理建议:
|
||||
|
||||
1. 优先重装 `setup.exe`,补齐 WebView2 Runtime
|
||||
2. 检查系统是否禁用了 Edge WebView2 Runtime
|
||||
3. 再确认本地目录是否可写
|
||||
|
||||
### 提示缺少运行时
|
||||
|
||||
处理建议:
|
||||
|
||||
1. 不要先手动找旧版运行时
|
||||
2. 先重新运行 `setup.exe`
|
||||
3. 如仍失败,再单独检查 WebView2 Runtime 是否安装完整
|
||||
|
||||
## 目录权限检查
|
||||
|
||||
以下目录至少要保证当前用户可读写:
|
||||
|
||||
- `%APPDATA%\proxycast\`
|
||||
- `%USERPROFILE%\.proxycast\`
|
||||
|
||||
这些目录分别用于:
|
||||
|
||||
- 配置与凭证副本
|
||||
- 数据库、日志、请求日志和部分运行时状态
|
||||
|
||||
如果目录不可写,常见表现包括:
|
||||
|
||||
- 启动后立即退出
|
||||
- 白屏
|
||||
- 功能区能打开但数据无法加载
|
||||
|
||||
## 日志收集
|
||||
|
||||
反馈问题前,建议至少收集以下内容:
|
||||
|
||||
- `%USERPROFILE%\.proxycast\logs\`
|
||||
- `%USERPROFILE%\.proxycast\request_logs\`
|
||||
- 问题出现时间
|
||||
- 安装包文件名
|
||||
- 页面或弹窗提示截图
|
||||
|
||||
### 一键收集(推荐给支持/开发环境)
|
||||
|
||||
如果你有仓库脚本环境,可直接运行:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\windows-collect-support-bundle.ps1
|
||||
```
|
||||
|
||||
脚本会:
|
||||
|
||||
- 打包 `%USERPROFILE%\.proxycast\logs\` 与 `request_logs\`
|
||||
- 收集 WebView2、PowerShell、目录存在性等环境信息
|
||||
- 默认不打包 `config.yaml`、数据库和凭证正文,避免泄露敏感信息
|
||||
|
||||
执行完成后,会在桌面生成 `ProxyCast-Support-时间戳.zip`。
|
||||
|
||||
如果应用本身还能打开,也可以在“设置 / API 服务器 / 诊断接口”区域点击“导出支持包”。
|
||||
|
||||
## 如果页面出现 Windows 启动自检提示
|
||||
|
||||
新版本会在部分场景下提示以下问题:
|
||||
|
||||
- 应用数据目录不可写
|
||||
- 用户目录数据根不可写
|
||||
- 数据库不可访问
|
||||
- 未检测到 WebView2 Runtime
|
||||
- 未检测到 PowerShell 或 `cmd.exe`
|
||||
|
||||
建议按提示顺序处理,不要一开始就同时修改多项系统设置。
|
||||
|
||||
## 仍然无法恢复怎么办
|
||||
|
||||
请整理以下最小信息后反馈:
|
||||
|
||||
1. Windows 版本
|
||||
2. ProxyCast 版本
|
||||
3. 使用的安装包文件名
|
||||
4. 首次出现时间
|
||||
5. 日志目录压缩包
|
||||
@@ -46,7 +46,7 @@ xcode-select --install
|
||||
|
||||
**Windows:**
|
||||
- 安装 Visual Studio Build Tools
|
||||
- 安装 WebView2
|
||||
- 安装 WebView2(开发模式必需;对外分发时建议使用带离线 WebView2 的 NSIS 安装器)
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
@@ -114,7 +114,7 @@ pnpm tauri build --debug
|
||||
| 平台 | 产物位置 |
|
||||
|------|----------|
|
||||
| macOS | `src-tauri/target/release/bundle/dmg/` |
|
||||
| Windows | `src-tauri/target/release/bundle/msi/` |
|
||||
| Windows | `src-tauri/target/release/bundle/nsis/` |
|
||||
| Linux | `src-tauri/target/release/bundle/deb/` |
|
||||
|
||||
### 跨平台构建
|
||||
@@ -135,10 +135,13 @@ pnpm tauri build --target universal-apple-darwin
|
||||
#### Windows 构建
|
||||
|
||||
```bash
|
||||
# 构建 64 位
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc
|
||||
# 构建 64 位 Windows 安装包(NSIS setup.exe,内置离线 WebView2 安装器)
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --config src-tauri/tauri.windows.conf.json
|
||||
```
|
||||
|
||||
> 建议对外分发 `-setup.exe`,不要把默认 `.msi` 作为首选下载项。
|
||||
|
||||
|
||||
#### Linux 构建
|
||||
|
||||
```bash
|
||||
@@ -196,7 +199,7 @@ git push origin v1.0.1
|
||||
|------|------|--------|
|
||||
| macOS | arm64 | macos-latest |
|
||||
| macOS | x64 | macos-13 |
|
||||
| Windows | x64 | windows-latest |
|
||||
| Windows | x64 | windows-2022 |
|
||||
| Linux | x64 | ubuntu-latest |
|
||||
|
||||
## 调试
|
||||
@@ -246,7 +249,7 @@ macOS 构建需要代码签名:
|
||||
export APPLE_SIGNING_IDENTITY="Developer ID Application: ..."
|
||||
```
|
||||
|
||||
Windows 构建可选签名:
|
||||
Windows 构建强烈建议签名:
|
||||
|
||||
```bash
|
||||
# 设置签名证书
|
||||
|
||||
@@ -40,6 +40,14 @@ navigation:
|
||||
4. 如需保留历史日志,恢复 `logs/` 与 `request_logs/`
|
||||
5. 启动应用并验证 `/health` 与关键功能
|
||||
|
||||
## Windows 启动失败排查
|
||||
|
||||
- 优先确认用户安装的是 `ProxyCast_*_x64-setup.exe`,不要默认分发便携包或旧的 `.msi`
|
||||
- 首次启动若提示缺少运行时,优先重新运行 `setup.exe`,它会一并安装 WebView2 Offline Installer
|
||||
- 检查 `%APPDATA%\proxycast\` 与 `%USERPROFILE%\.proxycast\` 是否可写;数据库、日志与部分运行时状态依赖这两个目录
|
||||
- 收集 `%USERPROFILE%\.proxycast\logs\` 与 `%USERPROFILE%\.proxycast\request_logs\` 作为一线排障材料
|
||||
- 若前端出现 Windows 启动自检提示,按提示项优先检查目录权限、数据库可访问性、WebView2 与 Shell 可用性
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 如果升级失败,恢复备份的 `config.yaml` 与 `proxycast.db`
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.82.0",
|
||||
"version": "0.83.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -32,6 +32,7 @@
|
||||
"ai-verify:prompt": "tsx scripts/ai-code-verify.ts --generate-prompt",
|
||||
"ai-verify:file": "tsx scripts/ai-code-verify.ts --files",
|
||||
"bridge:e2e": "node scripts/chrome-bridge-e2e.mjs",
|
||||
"bridge:health": "node scripts/check-dev-bridge-health.mjs",
|
||||
"smoke:social-workbench": "node scripts/social-workbench-e2e-smoke.mjs",
|
||||
"dev:web-bridge": "node scripts/start-web-bridge-dev.mjs"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import process from "node:process";
|
||||
|
||||
const DEFAULTS = {
|
||||
url: "http://127.0.0.1:3030/health",
|
||||
timeoutMs: 60000,
|
||||
intervalMs: 1000,
|
||||
};
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
ProxyCast DevBridge 健康检查
|
||||
|
||||
用法:
|
||||
node scripts/check-dev-bridge-health.mjs [选项]
|
||||
|
||||
选项:
|
||||
--url <health_url> 健康检查地址,默认 http://127.0.0.1:3030/health
|
||||
--timeout-ms <ms> 超时时间,默认 60000
|
||||
--interval-ms <ms> 轮询间隔,默认 1000
|
||||
-h, --help 显示帮助
|
||||
|
||||
示例:
|
||||
npm run bridge:health
|
||||
npm run bridge:health -- --timeout-ms 120000
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { ...DEFAULTS };
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if ((arg === "--help") || (arg === "-h")) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === "--url" && argv[index + 1]) {
|
||||
options.url = argv[index + 1];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--timeout-ms" && argv[index + 1]) {
|
||||
options.timeoutMs = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--interval-ms" && argv[index + 1]) {
|
||||
options.intervalMs = Number(argv[index + 1]);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1000) {
|
||||
throw new Error("--timeout-ms 必须是 >= 1000 的数字");
|
||||
}
|
||||
if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) {
|
||||
throw new Error("--interval-ms 必须是 >= 100 的数字");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function checkOnce(url) {
|
||||
const response = await fetch(url, { method: "GET" });
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (typeof fetch !== "function") {
|
||||
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const startedAt = Date.now();
|
||||
let lastError = null;
|
||||
|
||||
console.log(`[bridge:health] 开始检查: ${options.url}`);
|
||||
|
||||
while (Date.now() - startedAt < options.timeoutMs) {
|
||||
try {
|
||||
const payload = await checkOnce(options.url);
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const status = payload && typeof payload === "object" ? payload.status : undefined;
|
||||
console.log(
|
||||
`[bridge:health] 就绪: ${options.url} (${elapsed}ms)${status ? ` status=${status}` : ""}`
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(options.intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
const detail = lastError instanceof Error ? lastError.message : String(lastError || "unknown error");
|
||||
throw new Error(
|
||||
`[bridge:health] 超时未就绪: ${options.url}。请先启动 npm run tauri:dev:headless,并确认 DevBridge 已监听 3030。最后错误: ${detail}`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
param(
|
||||
[string]$OutputRoot = "",
|
||||
[switch]$KeepExpanded
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host "[ProxyCast Support] $Message"
|
||||
}
|
||||
|
||||
function Ensure-Directory {
|
||||
param([string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WebView2Version {
|
||||
$keys = @(
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
|
||||
"HKLM:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
|
||||
"HKCU:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
|
||||
"HKCU:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
|
||||
)
|
||||
|
||||
foreach ($key in $keys) {
|
||||
try {
|
||||
$value = (Get-ItemProperty -LiteralPath $key -Name "pv" -ErrorAction Stop).pv
|
||||
if ($value -and $value -ne "0.0.0.0") {
|
||||
return [string]$value
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-PathMetadata {
|
||||
param([string]$TargetPath)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $TargetPath)) {
|
||||
return [pscustomobject]@{
|
||||
path = $TargetPath
|
||||
exists = $false
|
||||
type = $null
|
||||
last_write_time = $null
|
||||
size_bytes = $null
|
||||
}
|
||||
}
|
||||
|
||||
$item = Get-Item -LiteralPath $TargetPath -Force
|
||||
$size = $null
|
||||
if ($item.PSIsContainer) {
|
||||
try {
|
||||
$size = (Get-ChildItem -LiteralPath $TargetPath -Recurse -Force -File -ErrorAction Stop |
|
||||
Measure-Object -Property Length -Sum).Sum
|
||||
} catch {
|
||||
$size = $null
|
||||
}
|
||||
} else {
|
||||
$size = $item.Length
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
path = $TargetPath
|
||||
exists = $true
|
||||
type = if ($item.PSIsContainer) { "directory" } else { "file" }
|
||||
last_write_time = $item.LastWriteTime.ToString("o")
|
||||
size_bytes = $size
|
||||
}
|
||||
}
|
||||
|
||||
function Export-PathListing {
|
||||
param(
|
||||
[string]$SourcePath,
|
||||
[string]$OutputFile
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SourcePath)) {
|
||||
"路径不存在: $SourcePath" | Set-Content -LiteralPath $OutputFile -Encoding UTF8
|
||||
return
|
||||
}
|
||||
|
||||
Get-ChildItem -LiteralPath $SourcePath -Recurse -Force -ErrorAction SilentlyContinue |
|
||||
Select-Object FullName, PSIsContainer, Length, LastWriteTime |
|
||||
ConvertTo-Json -Depth 4 |
|
||||
Set-Content -LiteralPath $OutputFile -Encoding UTF8
|
||||
}
|
||||
|
||||
function Copy-DirectoryIfExists {
|
||||
param(
|
||||
[string]$SourcePath,
|
||||
[string]$DestinationPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SourcePath)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
Ensure-Directory -Path (Split-Path -Parent $DestinationPath)
|
||||
Copy-Item -LiteralPath $SourcePath -Destination $DestinationPath -Recurse -Force
|
||||
return $true
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$desktop = [Environment]::GetFolderPath("Desktop")
|
||||
if ([string]::IsNullOrWhiteSpace($OutputRoot)) {
|
||||
$OutputRoot = if ([string]::IsNullOrWhiteSpace($desktop)) { $env:TEMP } else { $desktop }
|
||||
}
|
||||
|
||||
$bundleName = "ProxyCast-Support-$timestamp"
|
||||
$bundleDir = Join-Path $OutputRoot $bundleName
|
||||
$zipPath = "$bundleDir.zip"
|
||||
|
||||
$appDataDir = Join-Path $env:APPDATA "proxycast"
|
||||
$legacyDir = Join-Path $env:USERPROFILE ".proxycast"
|
||||
$configPath = Join-Path $appDataDir "config.yaml"
|
||||
$dbPath = Join-Path $legacyDir "proxycast.db"
|
||||
$logsDir = Join-Path $legacyDir "logs"
|
||||
$requestLogsDir = Join-Path $legacyDir "request_logs"
|
||||
|
||||
Write-Step "输出目录: $bundleDir"
|
||||
Ensure-Directory -Path $bundleDir
|
||||
Ensure-Directory -Path (Join-Path $bundleDir "logs")
|
||||
Ensure-Directory -Path (Join-Path $bundleDir "meta")
|
||||
|
||||
$systemInfo = [ordered]@{
|
||||
collected_at = (Get-Date).ToString("o")
|
||||
computer_name = $env:COMPUTERNAME
|
||||
username = $env:USERNAME
|
||||
windows_version = [System.Environment]::OSVersion.VersionString
|
||||
powershell_version = $PSVersionTable.PSVersion.ToString()
|
||||
webview2_version = Get-WebView2Version
|
||||
appdata_dir = $appDataDir
|
||||
legacy_proxycast_dir = $legacyDir
|
||||
config_path = $configPath
|
||||
database_path = $dbPath
|
||||
shell_paths = @{
|
||||
powershell = (Get-Command powershell.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue)
|
||||
pwsh = (Get-Command pwsh.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue)
|
||||
cmd = $env:ComSpec
|
||||
}
|
||||
path_checks = @(
|
||||
Get-PathMetadata -TargetPath $appDataDir
|
||||
Get-PathMetadata -TargetPath $legacyDir
|
||||
Get-PathMetadata -TargetPath $configPath
|
||||
Get-PathMetadata -TargetPath $dbPath
|
||||
Get-PathMetadata -TargetPath $logsDir
|
||||
Get-PathMetadata -TargetPath $requestLogsDir
|
||||
)
|
||||
}
|
||||
|
||||
$systemInfo | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $bundleDir "meta/system-info.json") -Encoding UTF8
|
||||
|
||||
Export-PathListing -SourcePath $appDataDir -OutputFile (Join-Path $bundleDir "meta/appdata-listing.json")
|
||||
Export-PathListing -SourcePath $legacyDir -OutputFile (Join-Path $bundleDir "meta/legacy-listing.json")
|
||||
|
||||
$copiedLogs = Copy-DirectoryIfExists -SourcePath $logsDir -DestinationPath (Join-Path $bundleDir "logs/logs")
|
||||
$copiedRequestLogs = Copy-DirectoryIfExists -SourcePath $requestLogsDir -DestinationPath (Join-Path $bundleDir "logs/request_logs")
|
||||
|
||||
@(
|
||||
"ProxyCast 支持包已生成。",
|
||||
"",
|
||||
"已收集内容:",
|
||||
"- system-info.json(系统与路径元数据)",
|
||||
"- appdata-listing.json / legacy-listing.json(目录结构摘要)",
|
||||
"- logs/(如果存在)",
|
||||
"- request_logs/(如果存在)",
|
||||
"",
|
||||
"默认未收集内容:",
|
||||
"- config.yaml 正文(避免泄露 API Key / 凭证)",
|
||||
"- proxycast.db 正文(避免泄露会话与敏感数据)",
|
||||
"- credentials/ 目录内容",
|
||||
"",
|
||||
"是否复制 logs: $copiedLogs",
|
||||
"是否复制 request_logs: $copiedRequestLogs",
|
||||
"",
|
||||
"请将生成的 zip 文件发给支持人员。"
|
||||
) | Set-Content -LiteralPath (Join-Path $bundleDir "README.txt") -Encoding UTF8
|
||||
|
||||
if (Test-Path -LiteralPath $zipPath) {
|
||||
Remove-Item -LiteralPath $zipPath -Force
|
||||
}
|
||||
|
||||
Compress-Archive -LiteralPath $bundleDir -DestinationPath $zipPath -Force
|
||||
|
||||
if (-not $KeepExpanded) {
|
||||
Remove-Item -LiteralPath $bundleDir -Recurse -Force
|
||||
}
|
||||
|
||||
Write-Step "支持包已生成: $zipPath"
|
||||
Generated
+16
-16
@@ -6952,7 +6952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -7054,7 +7054,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-agent"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-trait",
|
||||
@@ -7079,7 +7079,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-config"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -7095,7 +7095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-core"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"aster-models",
|
||||
"async-trait",
|
||||
@@ -7135,7 +7135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-credential"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
@@ -7170,7 +7170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-gateway"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
@@ -7191,7 +7191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-infra"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap 5.5.3",
|
||||
@@ -7211,7 +7211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-mcp"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -7243,7 +7243,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-processor"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"parking_lot",
|
||||
@@ -7262,7 +7262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-providers"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -7316,7 +7316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"aster-core",
|
||||
"async-stream",
|
||||
@@ -7361,7 +7361,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-server-utils"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"futures",
|
||||
@@ -7376,7 +7376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-services"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aster-core",
|
||||
@@ -7417,7 +7417,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-skills"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dirs 5.0.1",
|
||||
@@ -7433,7 +7433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-terminal"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -7460,7 +7460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast-websocket"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
dependencies = [
|
||||
"axum 0.7.9",
|
||||
"chrono",
|
||||
|
||||
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
edition = "2021"
|
||||
authors = ["coso"]
|
||||
repository = "https://github.com/aiclientproxy/proxycast"
|
||||
@@ -191,7 +191,7 @@ version = "2.4"
|
||||
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.82.0"
|
||||
version = "0.83.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -306,6 +306,7 @@ hex.workspace = true
|
||||
scopeguard.workspace = true
|
||||
sysinfo.workspace = true
|
||||
whoami.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
# 终端
|
||||
portable-pty.workspace = true
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
fn main() {
|
||||
configure_windows_stack_size();
|
||||
|
||||
// tauri::generate_context! 在编译期会校验 `frontendDist` 路径是否存在。
|
||||
// 开发/CI 场景下可能只跑 `cargo check/test` 而未先构建前端,从而导致宏 panic。
|
||||
// 这里提前创建配置中的 `../dist` 目录,避免无关的编译阻塞。
|
||||
@@ -13,6 +15,15 @@ fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
fn configure_windows_stack_size() {
|
||||
#[cfg(target_os = "windows")]
|
||||
match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
|
||||
Ok("msvc") => println!("cargo:rustc-link-arg=/STACK:8388608"),
|
||||
Ok("gnu") => println!("cargo:rustc-link-arg=-Wl,--stack,8388608"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查 models 资源目录是否存在
|
||||
/// 如果不存在,输出警告提示用户运行下载脚本
|
||||
fn check_models_resources(manifest_dir: &std::path::Path) {
|
||||
|
||||
@@ -8,6 +8,8 @@ use aster::conversation::message::{ActionRequiredData, Message, MessageContent};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const JSON_RECURSION_LIMIT: usize = 50;
|
||||
|
||||
/// 从工具结果中提取文本内容
|
||||
///
|
||||
/// 使用 serde_json 来处理,避免直接依赖 rmcp 类型
|
||||
@@ -47,19 +49,31 @@ fn dedupe_preserve_order(items: Vec<String>) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn collect_tool_result_text(value: &serde_json::Value, target: &mut Vec<String>) {
|
||||
collect_tool_result_text_with_depth(value, target, 0);
|
||||
}
|
||||
|
||||
fn collect_tool_result_text_with_depth(
|
||||
value: &serde_json::Value,
|
||||
target: &mut Vec<String>,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth >= JSON_RECURSION_LIMIT {
|
||||
return;
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::String(text) => push_non_empty(target, Some(text)),
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_tool_result_text(item, target);
|
||||
collect_tool_result_text_with_depth(item, target, depth + 1);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(obj) => {
|
||||
if let Some(content) = obj.get("content") {
|
||||
collect_tool_result_text(content, target);
|
||||
collect_tool_result_text_with_depth(content, target, depth + 1);
|
||||
}
|
||||
if let Some(value) = obj.get("value") {
|
||||
collect_tool_result_text(value, target);
|
||||
collect_tool_result_text_with_depth(value, target, depth + 1);
|
||||
}
|
||||
for key in ["text", "output", "stdout", "stderr", "message", "error"] {
|
||||
push_non_empty(target, obj.get(key).and_then(|v| v.as_str()));
|
||||
@@ -243,6 +257,19 @@ fn collect_tool_result_images(
|
||||
target: &mut Vec<TauriToolImage>,
|
||||
seen_sources: &mut std::collections::HashSet<String>,
|
||||
) {
|
||||
collect_tool_result_images_with_depth(value, target, seen_sources, 0);
|
||||
}
|
||||
|
||||
fn collect_tool_result_images_with_depth(
|
||||
value: &serde_json::Value,
|
||||
target: &mut Vec<TauriToolImage>,
|
||||
seen_sources: &mut std::collections::HashSet<String>,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth >= JSON_RECURSION_LIMIT {
|
||||
return;
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::String(text) => {
|
||||
for data_url in extract_data_urls_from_text(text) {
|
||||
@@ -255,7 +282,7 @@ fn collect_tool_result_images(
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_tool_result_images(item, target, seen_sources);
|
||||
collect_tool_result_images_with_depth(item, target, seen_sources, depth + 1);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(obj) => {
|
||||
@@ -269,7 +296,7 @@ fn collect_tool_result_images(
|
||||
}
|
||||
}
|
||||
for nested in obj.values() {
|
||||
collect_tool_result_images(nested, target, seen_sources);
|
||||
collect_tool_result_images_with_depth(nested, target, seen_sources, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -873,4 +900,15 @@ mod tests {
|
||||
assert!(!filtered.to_ascii_lowercase().contains("<script"));
|
||||
assert!(filtered.contains("正文"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_tool_result_text_should_stop_on_excessive_depth() {
|
||||
let mut nested = serde_json::json!({ "text": "不会到达" });
|
||||
for _ in 0..(JSON_RECURSION_LIMIT + 10) {
|
||||
nested = serde_json::json!({ "value": nested });
|
||||
}
|
||||
|
||||
let text = extract_tool_result_text(&nested);
|
||||
assert_eq!(text, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,23 +146,11 @@ pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result<SessionDe
|
||||
.map(|message| convert_agent_message(&message))
|
||||
.collect();
|
||||
|
||||
// 测试序列化
|
||||
let test_content = vec![
|
||||
TauriMessageContent::Text {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
TauriMessageContent::Thinking {
|
||||
text: "Thinking...".to_string(),
|
||||
},
|
||||
];
|
||||
if let Ok(json) = serde_json::to_string(&test_content) {
|
||||
tracing::info!("[SessionStore] 测试序列化: {}", json);
|
||||
}
|
||||
|
||||
// 调试日志:序列化后的 JSON
|
||||
if let Ok(json) = serde_json::to_string_pretty(&tauri_messages) {
|
||||
tracing::debug!("[SessionStore] 序列化消息 JSON:\n{}", json);
|
||||
}
|
||||
tracing::debug!(
|
||||
"[SessionStore] 会话消息转换完成: session_id={}, messages_count={}",
|
||||
session_id,
|
||||
tauri_messages.len()
|
||||
);
|
||||
|
||||
Ok(SessionDetail {
|
||||
id: session.id,
|
||||
@@ -334,9 +322,9 @@ fn convert_agent_message(message: &AgentMessage) -> TauriMessage {
|
||||
|
||||
// 调试日志
|
||||
tracing::debug!(
|
||||
"[SessionStore] 转换消息: role={}, content={:?}",
|
||||
"[SessionStore] 转换消息: role={}, content_items={}",
|
||||
result.role,
|
||||
result.content
|
||||
result.content.len()
|
||||
);
|
||||
|
||||
result
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::agent::types::{
|
||||
};
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
const JSON_RECURSION_LIMIT: usize = 50;
|
||||
|
||||
/// 解析消息内容 JSON,支持多种格式
|
||||
///
|
||||
/// 支持的格式:
|
||||
@@ -75,17 +77,25 @@ fn dedupe_preserve_order(items: Vec<String>) -> Vec<String> {
|
||||
deduped
|
||||
}
|
||||
|
||||
fn collect_text_candidates(value: &serde_json::Value, target: &mut Vec<String>) {
|
||||
fn collect_text_candidates_with_depth(
|
||||
value: &serde_json::Value,
|
||||
target: &mut Vec<String>,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth >= JSON_RECURSION_LIMIT {
|
||||
return;
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::String(text) => push_non_empty(target, Some(text)),
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_text_candidates(item, target);
|
||||
collect_text_candidates_with_depth(item, target, depth + 1);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(obj) => {
|
||||
if let Some(content) = obj.get("content") {
|
||||
collect_text_candidates(content, target);
|
||||
collect_text_candidates_with_depth(content, target, depth + 1);
|
||||
}
|
||||
|
||||
for key in ["text", "output", "stdout", "stderr", "message"] {
|
||||
@@ -93,7 +103,7 @@ fn collect_text_candidates(value: &serde_json::Value, target: &mut Vec<String>)
|
||||
}
|
||||
|
||||
if let Some(value) = obj.get("value") {
|
||||
collect_text_candidates(value, target);
|
||||
collect_text_candidates_with_depth(value, target, depth + 1);
|
||||
}
|
||||
|
||||
push_non_empty(target, obj.get("error").and_then(|v| v.as_str()));
|
||||
@@ -103,11 +113,22 @@ fn collect_text_candidates(value: &serde_json::Value, target: &mut Vec<String>)
|
||||
}
|
||||
|
||||
fn extract_tool_response_text(value: &serde_json::Value) -> Option<String> {
|
||||
extract_tool_response_text_with_depth(value, 0)
|
||||
}
|
||||
|
||||
fn extract_tool_response_text_with_depth(
|
||||
value: &serde_json::Value,
|
||||
depth: usize,
|
||||
) -> Option<String> {
|
||||
if depth >= JSON_RECURSION_LIMIT {
|
||||
return None;
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::Array(items) => {
|
||||
let mut segments = Vec::new();
|
||||
for item in items {
|
||||
if let Some(text) = extract_tool_response_text(item) {
|
||||
if let Some(text) = extract_tool_response_text_with_depth(item, depth + 1) {
|
||||
push_non_empty(&mut segments, Some(&text));
|
||||
}
|
||||
}
|
||||
@@ -141,11 +162,11 @@ fn extract_tool_response_text(value: &serde_json::Value) -> Option<String> {
|
||||
.or_else(|| obj.get("toolResponse"))
|
||||
.or_else(|| obj.get("tool_response"))
|
||||
{
|
||||
collect_text_candidates(inner, &mut segments);
|
||||
collect_text_candidates_with_depth(inner, &mut segments, depth + 1);
|
||||
}
|
||||
|
||||
if let Some(tool_result) = obj.get("toolResult").or_else(|| obj.get("tool_result")) {
|
||||
collect_text_candidates(tool_result, &mut segments);
|
||||
collect_text_candidates_with_depth(tool_result, &mut segments, depth + 1);
|
||||
}
|
||||
|
||||
push_non_empty(&mut segments, obj.get("output").and_then(|v| v.as_str()));
|
||||
@@ -163,6 +184,17 @@ fn extract_tool_response_text(value: &serde_json::Value) -> Option<String> {
|
||||
}
|
||||
|
||||
fn parse_content_parts_from_json(value: &serde_json::Value) -> Vec<ContentPart> {
|
||||
parse_content_parts_from_json_with_depth(value, 0)
|
||||
}
|
||||
|
||||
fn parse_content_parts_from_json_with_depth(
|
||||
value: &serde_json::Value,
|
||||
depth: usize,
|
||||
) -> Vec<ContentPart> {
|
||||
if depth >= JSON_RECURSION_LIMIT {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::Array(items) => items
|
||||
.iter()
|
||||
@@ -670,7 +702,7 @@ impl AgentDao {
|
||||
mod tests {
|
||||
use crate::agent::types::MessageContent;
|
||||
|
||||
use super::{parse_message_content, parse_tool_calls};
|
||||
use super::{parse_message_content, parse_tool_calls, JSON_RECURSION_LIMIT};
|
||||
|
||||
#[test]
|
||||
fn parse_tool_calls_should_compat_with_legacy_missing_type() {
|
||||
@@ -750,4 +782,22 @@ mod tests {
|
||||
let parsed = parse_message_content(tool_response);
|
||||
assert_eq!(parsed.as_text(), "-32603: Tool not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_message_content_should_stop_on_excessive_depth() {
|
||||
let mut nested = serde_json::json!({ "text": "不会到达" });
|
||||
for _ in 0..(JSON_RECURSION_LIMIT + 10) {
|
||||
nested = serde_json::json!({ "value": nested });
|
||||
}
|
||||
|
||||
let payload = serde_json::json!([
|
||||
{
|
||||
"type": "toolResponse",
|
||||
"toolResult": nested
|
||||
}
|
||||
]);
|
||||
|
||||
let parsed = parse_message_content(&payload.to_string());
|
||||
assert_eq!(parsed.as_text(), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,6 +461,9 @@ mod tests {
|
||||
let resolved = storage
|
||||
.resolve_file_path("test-session-5", "demo.md")
|
||||
.unwrap();
|
||||
assert!(resolved.ends_with("/test-session-5/files/demo.md"));
|
||||
let expected_suffix = std::path::Path::new("test-session-5")
|
||||
.join("files")
|
||||
.join("demo.md");
|
||||
assert!(std::path::Path::new(&resolved).ends_with(&expected_suffix));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ impl McpClientManager {
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `emitter` - 事件发射器,用于发送事件到前端。
|
||||
/// 如果为 None,则不会发送事件。
|
||||
/// 如果为 None,则不会发送事件。
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
|
||||
@@ -29,8 +29,6 @@ pub fn parse_error_status_code(error_message: &str) -> StatusCode {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
} else if error_message.contains("502") {
|
||||
StatusCode::BAD_GATEWAY
|
||||
} else if error_message.contains("500") {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 创作主题类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ThemeType {
|
||||
/// 通用对话
|
||||
#[default]
|
||||
General,
|
||||
/// 知识探索
|
||||
Knowledge,
|
||||
@@ -33,12 +34,6 @@ pub enum ThemeType {
|
||||
Video,
|
||||
}
|
||||
|
||||
impl Default for ThemeType {
|
||||
fn default() -> Self {
|
||||
Self::General
|
||||
}
|
||||
}
|
||||
|
||||
/// 创作模式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
|
||||
@@ -905,6 +905,7 @@ impl ModelRegistryService {
|
||||
// 构建 API URL
|
||||
let api_url = Self::build_models_api_url(api_host);
|
||||
tracing::info!("[ModelRegistry] API URL: {}", api_url);
|
||||
let diagnostic_hint = Self::build_models_api_hint(provider_id, api_host, &api_url);
|
||||
|
||||
// 尝试从 API 获取
|
||||
match self.call_models_api(&api_url, api_key).await {
|
||||
@@ -922,6 +923,8 @@ impl ModelRegistryService {
|
||||
models,
|
||||
source: ModelFetchSource::Api,
|
||||
error: None,
|
||||
request_url: Some(api_url),
|
||||
diagnostic_hint: None,
|
||||
})
|
||||
}
|
||||
Err(api_error) => {
|
||||
@@ -945,12 +948,16 @@ impl ModelRegistryService {
|
||||
models: vec![],
|
||||
source: ModelFetchSource::LocalFallback,
|
||||
error: Some(format!("API 获取失败: {api_error}, 本地也无数据")),
|
||||
request_url: Some(api_url),
|
||||
diagnostic_hint,
|
||||
})
|
||||
} else {
|
||||
Ok(FetchModelsResult {
|
||||
models: local_models,
|
||||
source: ModelFetchSource::LocalFallback,
|
||||
error: Some(format!("API 获取失败: {api_error}, 已使用本地数据")),
|
||||
request_url: Some(api_url),
|
||||
diagnostic_hint,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1287,17 +1294,67 @@ impl ModelRegistryService {
|
||||
fn build_models_api_url(api_host: &str) -> String {
|
||||
let host = api_host.trim_end_matches('/');
|
||||
|
||||
if host.ends_with("/models") {
|
||||
return host.to_string();
|
||||
}
|
||||
|
||||
// 检查是否已经包含 /v1 路径
|
||||
if host.ends_with("/v1") || host.ends_with("/v1/") {
|
||||
format!("{}/models", host.trim_end_matches('/'))
|
||||
} else if host.contains("/v1/") {
|
||||
// 如果路径中间有 /v1/,直接追加 models
|
||||
format!("{}models", host.trim_end_matches('/').to_string() + "/")
|
||||
} else if Self::has_versioned_api_suffix(host) {
|
||||
format!("{host}/models")
|
||||
} else {
|
||||
format!("{host}/v1/models")
|
||||
}
|
||||
}
|
||||
|
||||
fn has_versioned_api_suffix(api_host: &str) -> bool {
|
||||
let path = api_host
|
||||
.split_once("://")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(api_host)
|
||||
.split_once('/')
|
||||
.map(|(_, path)| path)
|
||||
.unwrap_or("");
|
||||
|
||||
let segments: Vec<&str> = path
|
||||
.split('/')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect();
|
||||
if segments.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let version = segments[segments.len() - 1];
|
||||
let api_segment = segments[segments.len() - 2];
|
||||
api_segment.eq_ignore_ascii_case("api")
|
||||
&& version.starts_with('v')
|
||||
&& version
|
||||
.strip_prefix('v')
|
||||
.map(|suffix| !suffix.is_empty() && suffix.chars().all(|ch| ch.is_ascii_digit()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn build_models_api_hint(provider_id: &str, api_host: &str, api_url: &str) -> Option<String> {
|
||||
let host = api_host.to_lowercase();
|
||||
let provider = provider_id.to_lowercase();
|
||||
|
||||
if provider.contains("doubao")
|
||||
|| provider.contains("volc")
|
||||
|| host.contains("volces.com")
|
||||
|| host.contains("volcengine")
|
||||
{
|
||||
return Some(format!(
|
||||
"豆包 / 火山方舟通常应使用 Base URL `https://ark.cn-beijing.volces.com/api/v3`。当前模型列表请求为 `{api_url}`,如果出现 404,请优先检查 Base URL 是否配置为该地址。"
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 调用 /v1/models API
|
||||
async fn call_models_api(
|
||||
&self,
|
||||
@@ -1323,7 +1380,12 @@ impl ModelRegistryService {
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "无法读取响应体".to_string());
|
||||
return Err(format!("API 返回错误 {status}: {body}"));
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(format!(
|
||||
"API 返回错误 {status}: {body}(请求地址: {url})。这通常表示 Base URL 路径不兼容,请检查 Provider Base URL 是否已经包含版本路径,或是否应直接使用 /models 端点。"
|
||||
));
|
||||
}
|
||||
return Err(format!("API 返回错误 {status}: {body}(请求地址: {url})"));
|
||||
}
|
||||
|
||||
let body = response
|
||||
@@ -1424,6 +1486,10 @@ pub struct FetchModelsResult {
|
||||
pub source: ModelFetchSource,
|
||||
/// 错误信息(如果有)
|
||||
pub error: Option<String>,
|
||||
/// 实际请求 URL(如果有)
|
||||
pub request_url: Option<String>,
|
||||
/// 面向用户的诊断建议(如果有)
|
||||
pub diagnostic_hint: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1449,6 +1515,28 @@ mod tests {
|
||||
ModelRegistryService::build_models_api_url("https://open.bigmodel.cn/api/anthropic"),
|
||||
"https://open.bigmodel.cn/api/anthropic/v1/models"
|
||||
);
|
||||
assert_eq!(
|
||||
ModelRegistryService::build_models_api_url("https://ark.cn-beijing.volces.com/api/v3/"),
|
||||
"https://ark.cn-beijing.volces.com/api/v3/models"
|
||||
);
|
||||
assert_eq!(
|
||||
ModelRegistryService::build_models_api_url("https://example.com/proxy/api/v9"),
|
||||
"https://example.com/proxy/api/v9/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_api_hint_for_doubao() {
|
||||
let hint = ModelRegistryService::build_models_api_hint(
|
||||
"doubao",
|
||||
"https://ark.cn-beijing.volces.com/api/v3",
|
||||
"https://ark.cn-beijing.volces.com/api/v3/models",
|
||||
);
|
||||
|
||||
assert!(hint.is_some());
|
||||
assert!(hint
|
||||
.unwrap()
|
||||
.contains("https://ark.cn-beijing.volces.com/api/v3"));
|
||||
}
|
||||
|
||||
fn create_service_with_resource_dir(resource_dir: std::path::PathBuf) -> ModelRegistryService {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 提供跨平台的屏幕截图功能,支持交互式区域选择
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -90,7 +90,7 @@ pub async fn start_capture() -> Result<PathBuf, CaptureError> {
|
||||
|
||||
/// macOS 截图实现
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn capture_macos(output_path: &PathBuf) -> Result<(), CaptureError> {
|
||||
async fn capture_macos(output_path: &Path) -> Result<(), CaptureError> {
|
||||
use std::process::Command;
|
||||
|
||||
debug!("使用 macOS screencapture 命令");
|
||||
|
||||
@@ -1,17 +1,77 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use reqwest::Client;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use proxycast_core::models::{AppType, Skill, SkillMetadata, SkillRepo, SkillState};
|
||||
|
||||
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const REMOTE_SKILLS_CACHE_TTL: Duration = Duration::from_secs(300);
|
||||
const REMOTE_SKILLS_ERROR_CACHE_TTL: Duration = Duration::from_secs(120);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct RepoCacheKey {
|
||||
owner: String,
|
||||
name: String,
|
||||
branch: String,
|
||||
}
|
||||
|
||||
impl From<&SkillRepo> for RepoCacheKey {
|
||||
fn from(value: &SkillRepo) -> Self {
|
||||
Self {
|
||||
owner: value.owner.clone(),
|
||||
name: value.name.clone(),
|
||||
branch: value.branch.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum RepoCacheValue {
|
||||
Skills(Vec<Skill>),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RepoCacheEntry {
|
||||
value: RepoCacheValue,
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
impl RepoCacheEntry {
|
||||
fn success(skills: Vec<Skill>) -> Self {
|
||||
Self {
|
||||
value: RepoCacheValue::Skills(skills),
|
||||
fetched_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn error(message: String) -> Self {
|
||||
Self {
|
||||
value: RepoCacheValue::Error(message),
|
||||
fetched_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_fresh(&self) -> bool {
|
||||
let ttl = match self.value {
|
||||
RepoCacheValue::Skills(_) => REMOTE_SKILLS_CACHE_TTL,
|
||||
RepoCacheValue::Error(_) => REMOTE_SKILLS_ERROR_CACHE_TTL,
|
||||
};
|
||||
|
||||
self.fetched_at.elapsed() < ttl
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SkillService {
|
||||
client: Client,
|
||||
repo_cache: RwLock<HashMap<RepoCacheKey, RepoCacheEntry>>,
|
||||
inflight_fetches: Mutex<HashMap<RepoCacheKey, Arc<tokio::sync::Notify>>>,
|
||||
}
|
||||
|
||||
impl SkillService {
|
||||
@@ -21,7 +81,11 @@ impl SkillService {
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
Ok(Self { client })
|
||||
Ok(Self {
|
||||
client,
|
||||
repo_cache: RwLock::new(HashMap::new()),
|
||||
inflight_fetches: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取技能安装目录
|
||||
@@ -51,14 +115,18 @@ impl SkillService {
|
||||
let enabled_repos: Vec<_> = repos.iter().filter(|r| r.enabled).collect();
|
||||
|
||||
for repo in enabled_repos {
|
||||
match timeout(
|
||||
DOWNLOAD_TIMEOUT,
|
||||
self.fetch_skills_from_repo(repo, app_type, installed_states),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(skills)) => {
|
||||
for skill in skills {
|
||||
match timeout(DOWNLOAD_TIMEOUT, self.fetch_skills_from_repo_cached(repo)).await {
|
||||
Ok(Ok(remote_skills)) => {
|
||||
for mut skill in remote_skills {
|
||||
let app_key = format!(
|
||||
"{}:{}",
|
||||
app_type.to_string().to_lowercase(),
|
||||
skill.directory
|
||||
);
|
||||
skill.installed = installed_states
|
||||
.get(&app_key)
|
||||
.map(|state| state.installed)
|
||||
.unwrap_or(false);
|
||||
all_skills.insert(skill.key.clone(), skill);
|
||||
}
|
||||
}
|
||||
@@ -130,19 +198,109 @@ impl SkillService {
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
async fn fetch_skills_from_repo_cached(&self, repo: &SkillRepo) -> Result<Vec<Skill>> {
|
||||
let cache_key = RepoCacheKey::from(repo);
|
||||
|
||||
if let Some(cached) = self.read_cached_repo_result(&cache_key) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let (notify, is_leader) = {
|
||||
let mut inflight = self.inflight_fetches.lock();
|
||||
if let Some(existing) = inflight.get(&cache_key) {
|
||||
(existing.clone(), false)
|
||||
} else {
|
||||
let notify = Arc::new(tokio::sync::Notify::new());
|
||||
inflight.insert(cache_key.clone(), notify.clone());
|
||||
(notify, true)
|
||||
}
|
||||
};
|
||||
|
||||
if !is_leader {
|
||||
notify.notified().await;
|
||||
if let Some(cached) = self.read_cached_repo_result(&cache_key) {
|
||||
return cached;
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"技能仓库缓存同步失败: {}/{}@{}",
|
||||
repo.owner,
|
||||
repo.name,
|
||||
repo.branch
|
||||
));
|
||||
}
|
||||
|
||||
let result = self
|
||||
.fetch_skills_from_repo_uncached(repo)
|
||||
.await
|
||||
.map_err(|error| error.to_string());
|
||||
|
||||
{
|
||||
let mut cache = self.repo_cache.write();
|
||||
let entry = match &result {
|
||||
Ok(skills) => RepoCacheEntry::success(skills.clone()),
|
||||
Err(error) => RepoCacheEntry::error(error.clone()),
|
||||
};
|
||||
cache.insert(cache_key.clone(), entry);
|
||||
}
|
||||
|
||||
self.inflight_fetches.lock().remove(&cache_key);
|
||||
notify.notify_waiters();
|
||||
|
||||
result.map_err(|error| anyhow!(error))
|
||||
}
|
||||
|
||||
fn read_cached_repo_result(&self, cache_key: &RepoCacheKey) -> Option<Result<Vec<Skill>>> {
|
||||
let cached = self.repo_cache.read().get(cache_key).cloned()?;
|
||||
if !cached.is_fresh() {
|
||||
self.repo_cache.write().remove(cache_key);
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(match cached.value {
|
||||
RepoCacheValue::Skills(skills) => Ok(skills),
|
||||
RepoCacheValue::Error(error) => Err(anyhow!(error)),
|
||||
})
|
||||
}
|
||||
|
||||
/// 从仓库获取技能列表
|
||||
async fn fetch_skills_from_repo(
|
||||
&self,
|
||||
repo: &SkillRepo,
|
||||
app_type: &AppType,
|
||||
installed_states: &HashMap<String, SkillState>,
|
||||
) -> Result<Vec<Skill>> {
|
||||
async fn fetch_skills_from_repo_uncached(&self, repo: &SkillRepo) -> Result<Vec<Skill>> {
|
||||
let mut last_error = None;
|
||||
|
||||
for branch in Self::build_branch_candidates(&repo.branch) {
|
||||
match self.fetch_skills_from_branch(repo, &branch).await {
|
||||
Ok(skills) => return Ok(skills),
|
||||
Err(error) => {
|
||||
if branch != repo.branch {
|
||||
tracing::warn!(
|
||||
"[SkillService] 仓库 {}/{} 分支 {} 不可用,回退 {} 仍失败: {}",
|
||||
repo.owner,
|
||||
repo.name,
|
||||
repo.branch,
|
||||
branch,
|
||||
error
|
||||
);
|
||||
}
|
||||
last_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
anyhow!(
|
||||
"Failed to fetch skills from {}/{}@{}",
|
||||
repo.owner,
|
||||
repo.name,
|
||||
repo.branch
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
async fn fetch_skills_from_branch(&self, repo: &SkillRepo, branch: &str) -> Result<Vec<Skill>> {
|
||||
let zip_url = format!(
|
||||
"https://github.com/{}/{}/archive/refs/heads/{}.zip",
|
||||
repo.owner, repo.name, repo.branch
|
||||
repo.owner, repo.name, branch
|
||||
);
|
||||
|
||||
// 下载 ZIP
|
||||
let response = self
|
||||
.client
|
||||
.get(&zip_url)
|
||||
@@ -155,8 +313,6 @@ impl SkillService {
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await.context("Failed to read response")?;
|
||||
|
||||
// 解压并扫描
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor).context("Failed to open ZIP archive")?;
|
||||
|
||||
@@ -176,7 +332,6 @@ impl SkillService {
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// 读取并解析 SKILL.md
|
||||
let mut content = String::new();
|
||||
use std::io::Read;
|
||||
file.read_to_string(&mut content)
|
||||
@@ -185,21 +340,16 @@ impl SkillService {
|
||||
let metadata = self.parse_skill_metadata_from_content(&content)?;
|
||||
let name = metadata.name.unwrap_or_else(|| directory.clone());
|
||||
let description = metadata.description.unwrap_or_default();
|
||||
|
||||
let key = format!("{repo_key_prefix}{directory}");
|
||||
let app_key = format!("{}:{}", app_type.to_string().to_lowercase(), directory);
|
||||
let installed = installed_states
|
||||
.get(&app_key)
|
||||
.map(|state| state.installed)
|
||||
.unwrap_or(false);
|
||||
|
||||
let readme_url = Some(format!(
|
||||
"https://github.com/{}/{}/blob/{}/{}/SKILL.md",
|
||||
repo.owner,
|
||||
repo.name,
|
||||
repo.branch,
|
||||
path.parent().unwrap().to_str().unwrap_or("")
|
||||
));
|
||||
let readme_url = path.parent().map(|parent| {
|
||||
format!(
|
||||
"https://github.com/{}/{}/blob/{}/{}/SKILL.md",
|
||||
repo.owner,
|
||||
repo.name,
|
||||
branch,
|
||||
parent.to_str().unwrap_or("")
|
||||
)
|
||||
});
|
||||
|
||||
skills.push(Skill {
|
||||
key,
|
||||
@@ -207,10 +357,10 @@ impl SkillService {
|
||||
description,
|
||||
directory,
|
||||
readme_url,
|
||||
installed,
|
||||
installed: false,
|
||||
repo_owner: Some(repo.owner.clone()),
|
||||
repo_name: Some(repo.name.clone()),
|
||||
repo_branch: Some(repo.branch.clone()),
|
||||
repo_branch: Some(branch.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -218,6 +368,17 @@ impl SkillService {
|
||||
Ok(skills)
|
||||
}
|
||||
|
||||
fn build_branch_candidates(branch: &str) -> Vec<String> {
|
||||
let normalized = branch.trim();
|
||||
if normalized.eq_ignore_ascii_case("main") {
|
||||
vec!["main".to_string(), "master".to_string()]
|
||||
} else if normalized.eq_ignore_ascii_case("master") {
|
||||
vec!["master".to_string(), "main".to_string()]
|
||||
} else {
|
||||
vec![normalized.to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
/// 安装技能
|
||||
pub async fn install_skill(
|
||||
&self,
|
||||
@@ -362,3 +523,24 @@ impl SkillService {
|
||||
Ok(meta)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SkillService;
|
||||
|
||||
#[test]
|
||||
fn build_branch_candidates_should_include_main_master_fallback() {
|
||||
assert_eq!(
|
||||
SkillService::build_branch_candidates("main"),
|
||||
vec!["main".to_string(), "master".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
SkillService::build_branch_candidates("master"),
|
||||
vec!["master".to_string(), "main".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
SkillService::build_branch_candidates("release"),
|
||||
vec!["release".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +273,6 @@ impl Default for UpdateCheckService {
|
||||
// .show()
|
||||
// .map_err(|e| format!("发送通知失败: {}", e))
|
||||
// }
|
||||
|
||||
/// 更新检查服务状态包装器(用于 Tauri 状态管理)
|
||||
pub struct UpdateCheckServiceState(pub Arc<RwLock<UpdateCheckService>>);
|
||||
|
||||
|
||||
@@ -2080,7 +2080,7 @@ pub trait SSHAuthCallback: Send + Sync {
|
||||
/// 请求密钥密码
|
||||
///
|
||||
/// 当私钥需要密码时调用。
|
||||
fn request_passphrase(&self, key_path: &PathBuf) -> Option<String>;
|
||||
fn request_passphrase(&self, key_path: &Path) -> Option<String>;
|
||||
|
||||
/// 请求密码
|
||||
///
|
||||
|
||||
@@ -72,7 +72,7 @@ pub struct WSLDistro {
|
||||
}
|
||||
|
||||
/// WSL 发行版状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum WSLDistroState {
|
||||
/// 已停止
|
||||
@@ -82,15 +82,10 @@ pub enum WSLDistroState {
|
||||
/// 正在安装
|
||||
Installing,
|
||||
/// 未知状态
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Default for WSLDistroState {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WSLDistroState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
|
||||
@@ -30,7 +30,7 @@ use crate::error::TerminalError;
|
||||
use crate::events::event_names;
|
||||
|
||||
/// Shell 类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ShellType {
|
||||
/// Bash shell
|
||||
@@ -42,6 +42,7 @@ pub enum ShellType {
|
||||
/// PowerShell
|
||||
Pwsh,
|
||||
/// 未知 Shell
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
@@ -74,14 +75,8 @@ impl ShellType {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShellType {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Shell 集成状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ShellIntegrationStatus {
|
||||
/// 就绪状态(等待用户输入)
|
||||
@@ -89,15 +84,10 @@ pub enum ShellIntegrationStatus {
|
||||
/// 正在执行命令
|
||||
RunningCommand,
|
||||
/// 未知状态
|
||||
#[default]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Default for ShellIntegrationStatus {
|
||||
fn default() -> Self {
|
||||
Self::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// 命令执行信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommandInfo {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use parking_lot::RwLock;
|
||||
@@ -58,7 +58,7 @@ impl BlockFile {
|
||||
/// - `Err(TerminalError)`: 创建失败
|
||||
///
|
||||
/// _Requirements: 3.1, 3.3_
|
||||
pub fn new(block_id: &str, base_dir: &PathBuf, max_size: usize) -> Result<Self, TerminalError> {
|
||||
pub fn new(block_id: &str, base_dir: &Path, max_size: usize) -> Result<Self, TerminalError> {
|
||||
let file_path = base_dir.join(format!("{block_id}.block"));
|
||||
|
||||
// 确保目录存在
|
||||
@@ -82,6 +82,7 @@ impl BlockFile {
|
||||
// 打开或创建文件
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&file_path)
|
||||
|
||||
@@ -187,9 +187,9 @@ impl PtySession {
|
||||
return None;
|
||||
}
|
||||
|
||||
let expanded = if cleaned.starts_with("~/") {
|
||||
let expanded = if let Some(stripped) = cleaned.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
home.join(&cleaned[2..])
|
||||
home.join(stripped)
|
||||
} else {
|
||||
PathBuf::from(&cleaned)
|
||||
}
|
||||
|
||||
+1053
-18
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,12 @@ use super::bootstrap::{self, AppStates};
|
||||
use super::commands as app_commands;
|
||||
use super::types::{AppState, TrayManagerState};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
|
||||
fn should_minimize_to_tray(window_label: &str, minimize_to_tray: bool) -> bool {
|
||||
minimize_to_tray && window_label == MAIN_WINDOW_LABEL
|
||||
}
|
||||
|
||||
/// 运行 Tauri 应用
|
||||
///
|
||||
/// 这是应用的主入口点,负责:
|
||||
@@ -171,10 +177,12 @@ pub fn run() {
|
||||
.manage(proxycast_gateway::discord::DiscordGatewayState::default())
|
||||
.manage(proxycast_gateway::feishu::FeishuGatewayState::default())
|
||||
.manage(gateway_tunnel_state)
|
||||
.manage(crate::services::openclaw_service::OpenClawServiceState::default())
|
||||
.manage(commands::telegram_remote_cmd::TelegramRemoteState::default())
|
||||
.on_window_event(move |window, event| {
|
||||
// 处理窗口关闭事件
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
let window_label = window.label().to_string();
|
||||
// 获取配置,检查是否启用最小化到托盘
|
||||
let app_handle = window.app_handle();
|
||||
if let Some(app_state) = app_handle.try_state::<AppState>() {
|
||||
@@ -184,7 +192,7 @@ pub fn run() {
|
||||
state.config.minimize_to_tray
|
||||
});
|
||||
|
||||
if minimize_to_tray {
|
||||
if should_minimize_to_tray(&window_label, minimize_to_tray) {
|
||||
// 阻止默认关闭行为
|
||||
api.prevent_close();
|
||||
// 隐藏窗口而不是关闭
|
||||
@@ -208,6 +216,11 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
crate::commands::windows_startup_cmd::maybe_show_windows_startup_notice(&app.handle());
|
||||
}
|
||||
|
||||
// TODO: 重新实现 TerminalTool 和 TermScrollbackTool 的 AppHandle 设置
|
||||
// 当前暂时注释掉,等待适配 aster-rust 工具系统
|
||||
// crate::agent::tools::set_terminal_tool_app_handle(app.handle().clone());
|
||||
@@ -276,6 +289,7 @@ pub fn run() {
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let app_handle = app.handle().clone();
|
||||
let server_state = state_clone.clone();
|
||||
let logs = logs_clone.clone();
|
||||
let db = Some(db_clone.clone());
|
||||
@@ -288,6 +302,7 @@ pub fn run() {
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match crate::dev_bridge::DevBridgeServer::start(
|
||||
app_handle,
|
||||
server_state,
|
||||
logs,
|
||||
db,
|
||||
@@ -920,7 +935,10 @@ pub fn run() {
|
||||
// Log commands (from app::commands)
|
||||
app_commands::get_logs,
|
||||
app_commands::get_persisted_logs_tail,
|
||||
app_commands::get_log_storage_diagnostics,
|
||||
app_commands::export_support_bundle,
|
||||
app_commands::clear_logs,
|
||||
app_commands::clear_diagnostic_log_history,
|
||||
app_commands::report_frontend_crash,
|
||||
// API test commands (from app::commands)
|
||||
app_commands::test_api,
|
||||
@@ -959,6 +977,23 @@ pub fn run() {
|
||||
commands::config_cmd::open_auth_dir,
|
||||
commands::config_cmd::check_for_updates,
|
||||
commands::config_cmd::download_update,
|
||||
// OpenClaw commands
|
||||
commands::openclaw_cmd::openclaw_check_installed,
|
||||
commands::openclaw_cmd::openclaw_check_node_version,
|
||||
commands::openclaw_cmd::openclaw_check_git_available,
|
||||
commands::openclaw_cmd::openclaw_get_node_download_url,
|
||||
commands::openclaw_cmd::openclaw_get_git_download_url,
|
||||
commands::openclaw_cmd::openclaw_install,
|
||||
commands::openclaw_cmd::openclaw_uninstall,
|
||||
commands::openclaw_cmd::openclaw_start_gateway,
|
||||
commands::openclaw_cmd::openclaw_stop_gateway,
|
||||
commands::openclaw_cmd::openclaw_restart_gateway,
|
||||
commands::openclaw_cmd::openclaw_get_status,
|
||||
commands::openclaw_cmd::openclaw_check_health,
|
||||
commands::openclaw_cmd::openclaw_get_dashboard_url,
|
||||
commands::openclaw_cmd::openclaw_get_channels,
|
||||
commands::openclaw_cmd::openclaw_sync_provider_config,
|
||||
commands::openclaw_cmd::openclaw_install_event,
|
||||
// MCP commands
|
||||
commands::mcp_cmd::get_mcp_servers,
|
||||
commands::mcp_cmd::add_mcp_server,
|
||||
@@ -1211,6 +1246,7 @@ pub fn run() {
|
||||
commands::machine_id_cmd::copy_machine_id_to_clipboard,
|
||||
commands::machine_id_cmd::paste_machine_id_from_clipboard,
|
||||
commands::machine_id_cmd::get_system_info,
|
||||
commands::windows_startup_cmd::get_windows_startup_diagnostics,
|
||||
// Kiro Local commands
|
||||
commands::kiro_local::switch_kiro_to_local,
|
||||
commands::kiro_local::get_kiro_fingerprint_info,
|
||||
@@ -1675,3 +1711,15 @@ pub fn run() {
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::should_minimize_to_tray;
|
||||
|
||||
#[test]
|
||||
fn should_only_minimize_main_window_to_tray() {
|
||||
assert!(should_minimize_to_tray("main", true));
|
||||
assert!(!should_minimize_to_tray("openclaw-dashboard", true));
|
||||
assert!(!should_minimize_to_tray("main", false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ pub mod music_cmd;
|
||||
pub mod network_cmd;
|
||||
pub mod novel_cmd;
|
||||
pub mod oauth_cmd;
|
||||
pub mod openclaw_cmd;
|
||||
pub mod orchestrator_cmd;
|
||||
pub mod persona_cmd;
|
||||
pub mod plugin_cmd;
|
||||
@@ -73,4 +74,5 @@ pub mod voice_test_cmd;
|
||||
pub mod websocket_cmd;
|
||||
pub mod webview_cmd;
|
||||
pub mod window_cmd;
|
||||
pub mod windows_startup_cmd;
|
||||
pub mod workspace_cmd;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
|
||||
use crate::database::DbConnection;
|
||||
use crate::services::openclaw_service::{
|
||||
openclaw_install_event_name, ActionResult, BinaryAvailabilityStatus, BinaryInstallStatus,
|
||||
ChannelInfo, GatewayStatusInfo, HealthInfo, NodeCheckResult, OpenClawServiceState,
|
||||
SyncModelEntry,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenClawSyncConfigRequest {
|
||||
pub provider_id: String,
|
||||
pub primary_model_id: String,
|
||||
#[serde(default)]
|
||||
pub models: Vec<SyncModelEntry>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_check_installed(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<BinaryInstallStatus, String> {
|
||||
let service = service.0.lock().await;
|
||||
service.check_installed().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_check_node_version(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<NodeCheckResult, String> {
|
||||
let service = service.0.lock().await;
|
||||
service.check_node_version().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_check_git_available(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<BinaryAvailabilityStatus, String> {
|
||||
let service = service.0.lock().await;
|
||||
service.check_git_available().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_get_node_download_url(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<String, String> {
|
||||
let service = service.0.lock().await;
|
||||
Ok(service.get_node_download_url())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_get_git_download_url(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<String, String> {
|
||||
let service = service.0.lock().await;
|
||||
Ok(service.get_git_download_url())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_install(
|
||||
app: AppHandle,
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<ActionResult, String> {
|
||||
let service = service.0.lock().await;
|
||||
service.install(&app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_uninstall(
|
||||
app: AppHandle,
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<ActionResult, String> {
|
||||
let mut service = service.0.lock().await;
|
||||
service.uninstall(&app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_start_gateway(
|
||||
app: AppHandle,
|
||||
port: Option<u16>,
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<ActionResult, String> {
|
||||
let mut service = service.0.lock().await;
|
||||
service.start_gateway(Some(&app), port).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_stop_gateway(
|
||||
app: AppHandle,
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<ActionResult, String> {
|
||||
let mut service = service.0.lock().await;
|
||||
service.stop_gateway(Some(&app)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_restart_gateway(
|
||||
app: AppHandle,
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<ActionResult, String> {
|
||||
let mut service = service.0.lock().await;
|
||||
service.restart_gateway(&app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_get_status(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<GatewayStatusInfo, String> {
|
||||
let mut service = service.0.lock().await;
|
||||
service.get_status().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_check_health(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<HealthInfo, String> {
|
||||
let mut service = service.0.lock().await;
|
||||
service.check_health().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_get_dashboard_url(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<String, String> {
|
||||
let mut service = service.0.lock().await;
|
||||
Ok(service.get_dashboard_url())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_get_channels(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<Vec<ChannelInfo>, String> {
|
||||
let mut service = service.0.lock().await;
|
||||
service.get_channels().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_sync_provider_config(
|
||||
request: OpenClawSyncConfigRequest,
|
||||
db: State<'_, DbConnection>,
|
||||
api_key_service: State<'_, ApiKeyProviderServiceState>,
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<ActionResult, String> {
|
||||
let provider = api_key_service
|
||||
.0
|
||||
.get_provider(&db, &request.provider_id)?
|
||||
.ok_or_else(|| "未找到指定 Provider。".to_string())?;
|
||||
|
||||
if !provider.provider.enabled {
|
||||
return Ok(ActionResult {
|
||||
success: false,
|
||||
message: "该 Provider 已被禁用。".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let api_key = api_key_service
|
||||
.0
|
||||
.get_next_api_key(&db, &request.provider_id)?
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut service = service.0.lock().await;
|
||||
service.sync_provider_config(
|
||||
&provider.provider,
|
||||
&api_key,
|
||||
&request.primary_model_id,
|
||||
&request.models,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn openclaw_install_event() -> String {
|
||||
openclaw_install_event_name().to_string()
|
||||
}
|
||||
@@ -399,6 +399,8 @@ pub async fn create_webview_panel(
|
||||
tracing::warn!("[Webview] 已存在窗口导航失败: {}", e);
|
||||
}
|
||||
let _ = window.set_title(&title);
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
|
||||
let mut manager = state.0.write().await;
|
||||
@@ -1975,8 +1977,26 @@ pub async fn resize_webview_panel(
|
||||
/// 获取所有活跃的浏览器窗口
|
||||
#[tauri::command]
|
||||
pub async fn get_webview_panels(
|
||||
app: AppHandle,
|
||||
state: tauri::State<'_, WebviewManagerWrapper>,
|
||||
) -> Result<Vec<WebviewPanelInfo>, String> {
|
||||
let stale_panel_ids = {
|
||||
let manager = state.0.read().await;
|
||||
manager
|
||||
.panels
|
||||
.keys()
|
||||
.filter(|panel_id| app.get_webview_window(panel_id).is_none())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
if !stale_panel_ids.is_empty() {
|
||||
let mut manager = state.0.write().await;
|
||||
for panel_id in stale_panel_ids {
|
||||
manager.panels.remove(&panel_id);
|
||||
}
|
||||
}
|
||||
|
||||
let manager = state.0.read().await;
|
||||
Ok(manager.panels.values().cloned().collect())
|
||||
}
|
||||
@@ -1985,6 +2005,8 @@ pub async fn get_webview_panels(
|
||||
#[tauri::command]
|
||||
pub async fn focus_webview_panel(app: AppHandle, panel_id: String) -> Result<bool, String> {
|
||||
if let Some(window) = app.get_webview_window(&panel_id) {
|
||||
let _ = window.unminimize();
|
||||
window.show().map_err(|e| format!("显示窗口失败: {e}"))?;
|
||||
window.set_focus().map_err(|e| format!("聚焦失败: {e}"))?;
|
||||
Ok(true)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::io::Write;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::path::Path;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::process::Command;
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
#[cfg(target_os = "windows")]
|
||||
use winreg::{enums::*, RegKey};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct WindowsStartupCheck {
|
||||
pub key: String,
|
||||
pub status: String,
|
||||
pub message: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct WindowsStartupDiagnostics {
|
||||
pub platform: String,
|
||||
pub app_data_dir: Option<String>,
|
||||
pub legacy_proxycast_dir: Option<String>,
|
||||
pub db_path: Option<String>,
|
||||
pub webview2_version: Option<String>,
|
||||
pub current_exe: Option<String>,
|
||||
pub current_dir: Option<String>,
|
||||
pub resource_dir: Option<String>,
|
||||
pub home_dir: Option<String>,
|
||||
pub shell_env: Option<String>,
|
||||
pub comspec_env: Option<String>,
|
||||
pub resolved_terminal_shell: Option<String>,
|
||||
pub installation_kind_guess: Option<String>,
|
||||
pub checks: Vec<WindowsStartupCheck>,
|
||||
pub has_blocking_issues: bool,
|
||||
pub has_warnings: bool,
|
||||
pub summary_message: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_windows_startup_diagnostics(
|
||||
app: AppHandle,
|
||||
) -> Result<WindowsStartupDiagnostics, String> {
|
||||
Ok(collect_windows_startup_diagnostics(&app))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn maybe_show_windows_startup_notice(app: &AppHandle) {
|
||||
let diagnostics = collect_windows_startup_diagnostics(app);
|
||||
|
||||
for check in &diagnostics.checks {
|
||||
match check.status.as_str() {
|
||||
"error" => tracing::error!(
|
||||
"[WindowsStartup] {}: {} {}",
|
||||
check.key,
|
||||
check.message,
|
||||
check.detail.as_deref().unwrap_or("")
|
||||
),
|
||||
"warning" => tracing::warn!(
|
||||
"[WindowsStartup] {}: {} {}",
|
||||
check.key,
|
||||
check.message,
|
||||
check.detail.as_deref().unwrap_or("")
|
||||
),
|
||||
_ => tracing::info!(
|
||||
"[WindowsStartup] {}: {} {}",
|
||||
check.key,
|
||||
check.message,
|
||||
check.detail.as_deref().unwrap_or("")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if !diagnostics.has_blocking_issues {
|
||||
return;
|
||||
}
|
||||
|
||||
let message = diagnostics.summary_message.clone().unwrap_or_else(|| {
|
||||
"检测到 Windows 启动环境存在阻塞问题,请查看日志并优先使用 setup.exe 安装包重新安装。"
|
||||
.to_string()
|
||||
});
|
||||
|
||||
app.dialog()
|
||||
.message(message)
|
||||
.title("ProxyCast Windows 启动自检")
|
||||
.kind(MessageDialogKind::Error)
|
||||
.buttons(MessageDialogButtons::OkCustom("我知道了".to_string()))
|
||||
.show(|_| {});
|
||||
}
|
||||
|
||||
pub fn collect_windows_startup_diagnostics(app: &AppHandle) -> WindowsStartupDiagnostics {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = app;
|
||||
return WindowsStartupDiagnostics {
|
||||
platform: std::env::consts::OS.to_string(),
|
||||
app_data_dir: None,
|
||||
legacy_proxycast_dir: None,
|
||||
db_path: None,
|
||||
webview2_version: None,
|
||||
current_exe: None,
|
||||
current_dir: None,
|
||||
resource_dir: None,
|
||||
home_dir: None,
|
||||
shell_env: None,
|
||||
comspec_env: None,
|
||||
resolved_terminal_shell: None,
|
||||
installation_kind_guess: None,
|
||||
checks: vec![],
|
||||
has_blocking_issues: false,
|
||||
has_warnings: false,
|
||||
summary_message: None,
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let mut checks = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
let app_data_dir = app.path().app_data_dir().ok();
|
||||
let home_dir = dirs::home_dir();
|
||||
let legacy_proxycast_dir = home_dir.clone().map(|home| home.join(".proxycast"));
|
||||
let db_path = crate::database::get_db_path().ok();
|
||||
let webview2_version = detect_webview2_runtime_version();
|
||||
let current_exe = std::env::current_exe().ok();
|
||||
let current_dir = std::env::current_dir().ok();
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
let shell_env = get_env_path_value("SHELL");
|
||||
let comspec_env = get_env_path_value("COMSPEC");
|
||||
let resolved_terminal_shell =
|
||||
resolve_terminal_shell(shell_env.as_deref(), comspec_env.as_deref());
|
||||
let installation_kind_guess = current_exe
|
||||
.as_ref()
|
||||
.map(|path| guess_installation_kind(path).to_string());
|
||||
|
||||
match &app_data_dir {
|
||||
Some(path) => match ensure_dir_writable(path) {
|
||||
Ok(()) => checks.push(ok_check(
|
||||
"app_data_dir",
|
||||
format!("应用数据目录可写: {}", path.display()),
|
||||
)),
|
||||
Err(error) => {
|
||||
warnings.push(format!("应用数据目录不可写: {}", path.display()));
|
||||
checks.push(warn_check(
|
||||
"app_data_dir",
|
||||
format!("应用数据目录不可写: {}", path.display()),
|
||||
Some(error),
|
||||
));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
warnings.push("无法解析应用数据目录".to_string());
|
||||
checks.push(warn_check(
|
||||
"app_data_dir",
|
||||
"无法解析应用数据目录".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match &legacy_proxycast_dir {
|
||||
Some(path) => match ensure_dir_writable(path) {
|
||||
Ok(()) => checks.push(ok_check(
|
||||
"legacy_proxycast_dir",
|
||||
format!("用户目录数据根可写: {}", path.display()),
|
||||
)),
|
||||
Err(error) => {
|
||||
errors.push(format!("用户目录数据根不可写: {}", path.display()));
|
||||
checks.push(error_check(
|
||||
"legacy_proxycast_dir",
|
||||
format!("用户目录数据根不可写: {}", path.display()),
|
||||
Some(error),
|
||||
));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
errors.push("无法解析用户 Home 目录".to_string());
|
||||
checks.push(error_check(
|
||||
"legacy_proxycast_dir",
|
||||
"无法解析用户 Home 目录".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match &db_path {
|
||||
Some(path) => match check_database_file(path) {
|
||||
Ok(()) => checks.push(ok_check(
|
||||
"database",
|
||||
format!("数据库可访问: {}", path.display()),
|
||||
)),
|
||||
Err(error) => {
|
||||
errors.push(format!("数据库不可访问: {}", path.display()));
|
||||
checks.push(error_check(
|
||||
"database",
|
||||
format!("数据库不可访问: {}", path.display()),
|
||||
Some(error),
|
||||
));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
errors.push("无法解析数据库路径".to_string());
|
||||
checks.push(error_check(
|
||||
"database",
|
||||
"无法解析数据库路径".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match &webview2_version {
|
||||
Some(version) => checks.push(ok_check(
|
||||
"webview2",
|
||||
format!("检测到 WebView2 Runtime: {version}"),
|
||||
)),
|
||||
None => {
|
||||
warnings.push("未检测到 WebView2 Runtime 注册表项".to_string());
|
||||
checks.push(warn_check(
|
||||
"webview2",
|
||||
"未检测到 WebView2 Runtime 注册表项".to_string(),
|
||||
Some(
|
||||
"如果用户通过便携版启动失败,请优先改用 setup.exe 安装包重新安装。"
|
||||
.to_string(),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match detect_shell_availability() {
|
||||
Some(shell) => checks.push(ok_check("shell", format!("检测到可用 Shell: {shell}"))),
|
||||
None => {
|
||||
warnings.push("未检测到 PowerShell 或 cmd.exe".to_string());
|
||||
checks.push(warn_check(
|
||||
"shell",
|
||||
"未检测到 PowerShell 或 cmd.exe".to_string(),
|
||||
Some("Agent、终端与部分系统命令可能无法使用。".to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match ¤t_exe {
|
||||
Some(path) if path.exists() => checks.push(ok_check(
|
||||
"current_exe",
|
||||
format!("当前可执行文件: {}", path.display()),
|
||||
)),
|
||||
Some(path) => {
|
||||
warnings.push(format!("当前可执行文件不存在: {}", path.display()));
|
||||
checks.push(warn_check(
|
||||
"current_exe",
|
||||
format!("当前可执行文件不存在: {}", path.display()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
None => {
|
||||
warnings.push("无法解析当前可执行文件路径".to_string());
|
||||
checks.push(warn_check(
|
||||
"current_exe",
|
||||
"无法解析当前可执行文件路径".to_string(),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match &resource_dir {
|
||||
Some(path) if path.exists() => checks.push(ok_check(
|
||||
"resource_dir",
|
||||
format!("资源目录已解析: {}", path.display()),
|
||||
)),
|
||||
Some(path) => {
|
||||
warnings.push(format!("资源目录不存在: {}", path.display()));
|
||||
checks.push(warn_check(
|
||||
"resource_dir",
|
||||
format!("资源目录不存在: {}", path.display()),
|
||||
Some("安装包资源缺失时,模型索引与内置资源初始化可能失败。".to_string()),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
warnings.push("无法解析资源目录".to_string());
|
||||
checks.push(warn_check(
|
||||
"resource_dir",
|
||||
"无法解析资源目录".to_string(),
|
||||
Some("便携运行或安装不完整时较常见。".to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(shell_value) = &shell_env {
|
||||
if shell_value.trim_start().starts_with('/') {
|
||||
warnings.push(format!("检测到 Unix 风格 SHELL 环境变量: {shell_value}"));
|
||||
checks.push(warn_check(
|
||||
"shell_env",
|
||||
format!("检测到 Unix 风格 SHELL 环境变量: {shell_value}"),
|
||||
Some(
|
||||
"旧版本 Windows 终端实现可能错误使用该值并触发 /bin/bash 启动失败。"
|
||||
.to_string(),
|
||||
),
|
||||
));
|
||||
} else {
|
||||
checks.push(ok_check(
|
||||
"shell_env",
|
||||
format!("SHELL 环境变量: {shell_value}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(comspec_value) = &comspec_env {
|
||||
let path = PathBuf::from(comspec_value);
|
||||
if path.exists() {
|
||||
checks.push(ok_check(
|
||||
"comspec_env",
|
||||
format!("COMSPEC 环境变量: {comspec_value}"),
|
||||
));
|
||||
} else {
|
||||
warnings.push(format!("COMSPEC 指向的路径不存在: {comspec_value}"));
|
||||
checks.push(warn_check(
|
||||
"comspec_env",
|
||||
format!("COMSPEC 指向的路径不存在: {comspec_value}"),
|
||||
Some("终端默认 shell 可能回退到 cmd.exe。".to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
match &resolved_terminal_shell {
|
||||
Some(shell) => checks.push(ok_check(
|
||||
"resolved_terminal_shell",
|
||||
format!("终端默认 Shell 解析结果: {shell}"),
|
||||
)),
|
||||
None => {
|
||||
warnings.push("无法解析终端默认 Shell".to_string());
|
||||
checks.push(warn_check(
|
||||
"resolved_terminal_shell",
|
||||
"无法解析终端默认 Shell".to_string(),
|
||||
Some(
|
||||
"如终端/Agent 创建失败,请重点检查 SHELL 与 COMSPEC 环境变量。".to_string(),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let summary_message = if !errors.is_empty() {
|
||||
Some(format!(
|
||||
"检测到 {} 个阻塞问题:{}。建议先检查目录权限,并优先使用带 WebView2 的 Windows setup.exe 安装包。",
|
||||
errors.len(),
|
||||
errors.join(";")
|
||||
))
|
||||
} else if !warnings.is_empty() {
|
||||
Some(format!(
|
||||
"检测到 {} 个 Windows 环境提示:{}。如用户反馈启动失败,请优先收集日志并确认使用 setup.exe 安装包。",
|
||||
warnings.len(),
|
||||
warnings.join(";")
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
WindowsStartupDiagnostics {
|
||||
platform: "windows".to_string(),
|
||||
app_data_dir: app_data_dir.map(path_to_string),
|
||||
legacy_proxycast_dir: legacy_proxycast_dir.map(path_to_string),
|
||||
db_path: db_path.map(path_to_string),
|
||||
webview2_version,
|
||||
current_exe: current_exe.map(path_to_string),
|
||||
current_dir: current_dir.map(path_to_string),
|
||||
resource_dir: resource_dir.map(path_to_string),
|
||||
home_dir: home_dir.map(path_to_string),
|
||||
shell_env,
|
||||
comspec_env,
|
||||
resolved_terminal_shell,
|
||||
installation_kind_guess,
|
||||
checks,
|
||||
has_blocking_issues: !errors.is_empty(),
|
||||
has_warnings: !warnings.is_empty(),
|
||||
summary_message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ok_check(key: &str, message: String) -> WindowsStartupCheck {
|
||||
WindowsStartupCheck {
|
||||
key: key.to_string(),
|
||||
status: "ok".to_string(),
|
||||
message,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn warn_check(key: &str, message: String, detail: Option<String>) -> WindowsStartupCheck {
|
||||
WindowsStartupCheck {
|
||||
key: key.to_string(),
|
||||
status: "warning".to_string(),
|
||||
message,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn error_check(key: &str, message: String, detail: Option<String>) -> WindowsStartupCheck {
|
||||
WindowsStartupCheck {
|
||||
key: key.to_string(),
|
||||
status: "error".to_string(),
|
||||
message,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn path_to_string(path: PathBuf) -> String {
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ensure_dir_writable(path: &Path) -> Result<(), String> {
|
||||
std::fs::create_dir_all(path).map_err(|e| format!("创建目录失败 {}: {e}", path.display()))?;
|
||||
|
||||
let probe = path.join("proxycast-write-test.tmp");
|
||||
let mut file = std::fs::File::create(&probe)
|
||||
.map_err(|e| format!("创建测试文件失败 {}: {e}", probe.display()))?;
|
||||
file.write_all(b"proxycast")
|
||||
.map_err(|e| format!("写入测试文件失败 {}: {e}", probe.display()))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("刷新测试文件失败 {}: {e}", probe.display()))?;
|
||||
std::fs::remove_file(&probe)
|
||||
.map_err(|e| format!("删除测试文件失败 {}: {e}", probe.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_database_file(path: &Path) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_dir_writable(parent)?;
|
||||
}
|
||||
|
||||
let conn = rusqlite::Connection::open(path)
|
||||
.map_err(|e| format!("打开数据库失败 {}: {e}", path.display()))?;
|
||||
conn.execute("PRAGMA user_version", [])
|
||||
.map_err(|e| format!("执行数据库探测失败 {}: {e}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn detect_webview2_runtime_version() -> Option<String> {
|
||||
const VALUE_NAME: &str = "pv";
|
||||
let key_paths = [
|
||||
"SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
|
||||
"SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
|
||||
];
|
||||
|
||||
for root in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] {
|
||||
let hive = RegKey::predef(root);
|
||||
for key_path in key_paths {
|
||||
if let Ok(key) = hive.open_subkey_with_flags(key_path, KEY_READ) {
|
||||
let version: Result<String, _> = key.get_value(VALUE_NAME);
|
||||
if let Ok(version) = version {
|
||||
let trimmed = version.trim();
|
||||
if !trimmed.is_empty() && trimmed != "0.0.0.0" {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_env_path_value(key: &str) -> Option<String> {
|
||||
let value = std::env::var(key).ok()?;
|
||||
let cleaned = value
|
||||
.split('\0')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
(!cleaned.is_empty()).then_some(cleaned)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_valid_windows_shell(candidate: &str) -> bool {
|
||||
let cleaned = candidate.trim();
|
||||
if cleaned.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if cleaned.starts_with('/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let path = Path::new(cleaned);
|
||||
if path.is_absolute() {
|
||||
if !path.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.to_ascii_lowercase());
|
||||
|
||||
return matches!(ext.as_deref(), Some("exe" | "cmd" | "bat" | "com"));
|
||||
}
|
||||
|
||||
if cleaned.contains('/') || cleaned.contains('\\') {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve_terminal_shell(shell_env: Option<&str>, comspec_env: Option<&str>) -> Option<String> {
|
||||
if let Some(shell) = shell_env {
|
||||
if is_valid_windows_shell(shell) {
|
||||
return Some(shell.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(comspec) = comspec_env {
|
||||
if is_valid_windows_shell(comspec) {
|
||||
return Some(comspec.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Some("cmd.exe".to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn guess_installation_kind(path: &Path) -> &'static str {
|
||||
let lowered = path.to_string_lossy().to_ascii_lowercase();
|
||||
if lowered.contains("\\downloads\\")
|
||||
|| lowered.contains("\\desktop\\")
|
||||
|| lowered.contains("\\temp\\")
|
||||
|| lowered.contains("\\appdata\\local\\temp\\")
|
||||
{
|
||||
return "portable-like";
|
||||
}
|
||||
|
||||
if lowered.contains("\\program files\\")
|
||||
|| lowered.contains("\\program files (x86)\\")
|
||||
|| lowered.contains("\\appdata\\local\\programs\\")
|
||||
{
|
||||
return "installed-like";
|
||||
}
|
||||
|
||||
"unknown"
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn detect_shell_availability() -> Option<String> {
|
||||
let windir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".to_string());
|
||||
let powershell = PathBuf::from(&windir)
|
||||
.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe");
|
||||
if powershell.exists() {
|
||||
return Some(powershell.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
if let Ok(comspec) = std::env::var("COMSPEC") {
|
||||
let path = PathBuf::from(comspec.trim());
|
||||
if path.exists() {
|
||||
return Some(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let pwsh_check = Command::new("pwsh").args(["-v"]).output();
|
||||
if pwsh_check
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some("pwsh".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
@@ -33,6 +33,8 @@ use proxycast_services::{
|
||||
api_key_provider_service::ApiKeyProviderService, model_registry_service::ModelRegistryService,
|
||||
provider_pool_service::ProviderPoolService, skill_service::SkillService,
|
||||
};
|
||||
#[cfg(debug_assertions)]
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -52,6 +54,7 @@ pub struct InvokeResponse {
|
||||
#[cfg(debug_assertions)]
|
||||
#[derive(Clone)]
|
||||
pub struct DevBridgeState {
|
||||
pub app_handle: Option<AppHandle>,
|
||||
pub server: app::AppState,
|
||||
pub logs: app::LogState,
|
||||
pub db: Option<DbConnection>,
|
||||
@@ -95,6 +98,7 @@ impl DevBridgeServer {
|
||||
///
|
||||
/// 服务器会在后台持续运行,直到应用退出。
|
||||
pub async fn start(
|
||||
app_handle: AppHandle,
|
||||
server: app::AppState,
|
||||
logs: app::LogState,
|
||||
db: Option<DbConnection>,
|
||||
@@ -108,6 +112,7 @@ impl DevBridgeServer {
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = config.unwrap_or_default();
|
||||
let bridge_state = DevBridgeState {
|
||||
app_handle: Some(app_handle),
|
||||
server,
|
||||
logs,
|
||||
db,
|
||||
|
||||
@@ -375,21 +375,29 @@ pub async fn handle_command(
|
||||
.clamp(20, 1000);
|
||||
|
||||
let logs = state.logs.read().await;
|
||||
let entries = logs.get_logs();
|
||||
let limit = entries.len().min(requested);
|
||||
let recent: Vec<_> = entries
|
||||
.into_iter()
|
||||
.rev()
|
||||
.take(limit)
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"timestamp": e.timestamp,
|
||||
"level": e.level,
|
||||
"message": e.message,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::to_value(recent)?)
|
||||
let entries = crate::app::commands::read_persisted_logs_tail_from_path(
|
||||
logs.get_log_file_path(),
|
||||
requested,
|
||||
)?;
|
||||
Ok(serde_json::to_value(entries)?)
|
||||
}
|
||||
|
||||
"get_log_storage_diagnostics" => {
|
||||
let logs = state.logs.read().await;
|
||||
let diagnostics = crate::app::commands::get_log_storage_diagnostics_from_path(
|
||||
logs.get_log_file_path(),
|
||||
logs.get_logs().len(),
|
||||
);
|
||||
Ok(serde_json::to_value(diagnostics)?)
|
||||
}
|
||||
|
||||
"get_windows_startup_diagnostics" => {
|
||||
let app_handle = state
|
||||
.app_handle
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?;
|
||||
let diagnostics = crate::commands::windows_startup_cmd::collect_windows_startup_diagnostics(app_handle);
|
||||
Ok(serde_json::to_value(diagnostics)?)
|
||||
}
|
||||
|
||||
"clear_logs" => {
|
||||
@@ -397,6 +405,13 @@ pub async fn handle_command(
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
||||
"clear_diagnostic_log_history" => {
|
||||
let log_file_path = { state.logs.read().await.get_log_file_path() };
|
||||
state.logs.write().await.clear();
|
||||
crate::app::commands::clear_diagnostic_log_artifacts_from_path(log_file_path)?;
|
||||
Ok(serde_json::json!({ "success": true }))
|
||||
}
|
||||
|
||||
// ========== Provider Pool ==========
|
||||
"get_provider_pool_overview" => {
|
||||
if let Some(db) = &state.db {
|
||||
@@ -1397,6 +1412,7 @@ mod tests {
|
||||
let config = Config::default();
|
||||
|
||||
DevBridgeState {
|
||||
app_handle: None,
|
||||
server: Arc::new(RwLock::new(proxycast_server::ServerState::new(
|
||||
config.clone(),
|
||||
))),
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod memory_profile_prompt_service;
|
||||
pub mod memory_rules_loader_service;
|
||||
pub mod memory_source_resolver_service;
|
||||
pub mod novel_service;
|
||||
pub mod openclaw_service;
|
||||
pub mod request_tool_policy_prompt_service;
|
||||
pub mod sysinfo_service;
|
||||
pub mod update_check_service;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.82.0",
|
||||
"version": "0.83.0",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"bundle": {
|
||||
"targets": ["nsis"],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "offlineInstaller",
|
||||
"silent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
-5
@@ -18,10 +18,12 @@ import { SettingsPageV2 } from "./components/settings-v2";
|
||||
import { ToolsPage } from "./components/tools/ToolsPage";
|
||||
import { ResourcesPage } from "./components/resources";
|
||||
import { MemoryPage } from "./components/memory";
|
||||
import { StylePage } from "./components/style";
|
||||
import { AgentChatPage } from "./components/agent";
|
||||
import { PluginsPage } from "./components/plugins/PluginsPage";
|
||||
import { ImageGenPage } from "./components/image-gen";
|
||||
import { BatchPage } from "./components/batch";
|
||||
import { OpenClawPage } from "./components/openclaw";
|
||||
import { RecentImageInsertFloating } from "./components/image-gen/RecentImageInsertFloating";
|
||||
import { CreateProjectDialog } from "./components/projects/CreateProjectDialog";
|
||||
import { WorkbenchPage } from "./components/workspace";
|
||||
@@ -51,10 +53,13 @@ import {
|
||||
getThemeWorkspacePage,
|
||||
isThemeWorkspacePage,
|
||||
LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY,
|
||||
MemoryPageParams,
|
||||
OpenClawPageParams,
|
||||
Page,
|
||||
PageParams,
|
||||
ProjectDetailPageParams,
|
||||
SettingsPageParams,
|
||||
StylePageParams,
|
||||
ThemeWorkspacePage,
|
||||
WorkspaceTheme,
|
||||
} from "./types/page";
|
||||
@@ -107,6 +112,42 @@ const THEME_WORKSPACE_PAGES: ThemeWorkspacePage[] = [
|
||||
"workspace-novel",
|
||||
];
|
||||
|
||||
interface WindowsStartupDiagnostics {
|
||||
platform: string;
|
||||
app_data_dir?: string | null;
|
||||
legacy_proxycast_dir?: string | null;
|
||||
db_path?: string | null;
|
||||
webview2_version?: string | null;
|
||||
checks: Array<{
|
||||
key: string;
|
||||
status: string;
|
||||
message: string;
|
||||
detail?: string | null;
|
||||
}>;
|
||||
has_blocking_issues: boolean;
|
||||
has_warnings: boolean;
|
||||
summary_message?: string | null;
|
||||
}
|
||||
|
||||
function isTauriDesktopEnvironment(): boolean {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tauri = (window as any).__TAURI__;
|
||||
return !!(tauri?.core?.invoke || tauri?.invoke);
|
||||
}
|
||||
|
||||
function isWindowsNavigatorPlatform(): boolean {
|
||||
if (typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const platform = navigator.platform || "";
|
||||
const userAgent = navigator.userAgent || "";
|
||||
return /win/i.test(platform) || /windows/i.test(userAgent);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const [showSplash, setShowSplash] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState<Page>("agent");
|
||||
@@ -148,6 +189,16 @@ function AppContent() {
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(page: Page, params?: PageParams) => {
|
||||
if (
|
||||
page === "memory" &&
|
||||
(params as { section?: string } | undefined)?.section ===
|
||||
"style-library"
|
||||
) {
|
||||
setCurrentPage("style");
|
||||
setPageParams({ section: "library" } as StylePageParams);
|
||||
return;
|
||||
}
|
||||
|
||||
if (page === "workspace") {
|
||||
setCurrentPage("agent");
|
||||
setPageParams(
|
||||
@@ -227,6 +278,10 @@ function AppContent() {
|
||||
? { projectId: projectParams.projectId }
|
||||
: {}),
|
||||
workspaceViewMode,
|
||||
workspaceOpenProjectStyleGuide:
|
||||
projectParams?.openProjectStyleGuide ?? false,
|
||||
workspaceOpenProjectStyleGuideSourceEntryId:
|
||||
projectParams?.openProjectStyleGuideSourceEntryId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -326,6 +381,39 @@ function AppContent() {
|
||||
}
|
||||
}, [registryError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriDesktopEnvironment() || !isWindowsNavigatorPlatform()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void safeInvoke<WindowsStartupDiagnostics>(
|
||||
"get_windows_startup_diagnostics",
|
||||
)
|
||||
.then((diagnostics) => {
|
||||
if (!diagnostics.summary_message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (diagnostics.has_blocking_issues) {
|
||||
toast.error("Windows 启动自检发现阻塞问题", {
|
||||
description: diagnostics.summary_message,
|
||||
duration: 12000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (diagnostics.has_warnings) {
|
||||
toast.warning("Windows 环境检测提示", {
|
||||
description: diagnostics.summary_message,
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[App] 获取 Windows 启动诊断失败:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void safeInvoke<{
|
||||
workspaceId: string;
|
||||
@@ -388,8 +476,19 @@ function AppContent() {
|
||||
theme={theme}
|
||||
viewMode={(pageParams as AgentPageParams).workspaceViewMode}
|
||||
resetAt={(pageParams as AgentPageParams).workspaceResetAt}
|
||||
initialCreatePrompt={(pageParams as AgentPageParams).workspaceCreatePrompt}
|
||||
initialCreateSource={(pageParams as AgentPageParams).workspaceCreateSource}
|
||||
initialStyleGuideDialogOpen={
|
||||
(pageParams as AgentPageParams).workspaceOpenProjectStyleGuide
|
||||
}
|
||||
initialStyleGuideSourceEntryId={
|
||||
(pageParams as AgentPageParams)
|
||||
.workspaceOpenProjectStyleGuideSourceEntryId
|
||||
}
|
||||
initialCreatePrompt={
|
||||
(pageParams as AgentPageParams).workspaceCreatePrompt
|
||||
}
|
||||
initialCreateSource={
|
||||
(pageParams as AgentPageParams).workspaceCreateSource
|
||||
}
|
||||
initialCreateFallbackTitle={
|
||||
(pageParams as AgentPageParams).workspaceCreateFallbackTitle
|
||||
}
|
||||
@@ -490,6 +589,20 @@ function AppContent() {
|
||||
<PluginsPage onNavigate={handleNavigate} />
|
||||
</PageWrapper>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: currentPage === "style" ? "flex" : "none",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<StylePage
|
||||
onNavigate={handleNavigate}
|
||||
pageParams={pageParams as StylePageParams}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -498,7 +611,24 @@ function AppContent() {
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<MemoryPage onNavigate={handleNavigate} />
|
||||
<MemoryPage
|
||||
onNavigate={handleNavigate}
|
||||
pageParams={pageParams as MemoryPageParams}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: currentPage === "openclaw" ? "flex" : "none",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<OpenClawPage
|
||||
onNavigate={handleNavigate}
|
||||
pageParams={pageParams as OpenClawPageParams}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -550,14 +680,19 @@ function AppContent() {
|
||||
!isThemeWorkspacePage(currentPage) &&
|
||||
!shouldHideSidebarForAgent;
|
||||
|
||||
const shouldAddMainContentGap = shouldShowAppSidebar && currentPage === "agent";
|
||||
const shouldAddMainContentGap =
|
||||
shouldShowAppSidebar && currentPage === "agent";
|
||||
|
||||
return (
|
||||
<SoundProvider>
|
||||
<ComponentDebugProvider>
|
||||
<AppContainer>
|
||||
{shouldShowAppSidebar && (
|
||||
<AppSidebar currentPage={currentPage} onNavigate={handleNavigate} />
|
||||
<AppSidebar
|
||||
currentPage={currentPage}
|
||||
currentPageParams={pageParams}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
)}
|
||||
<MainContent $withSidebarGap={shouldAddMainContentGap}>
|
||||
{renderAllPages()}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Library,
|
||||
Wrench,
|
||||
BrainCircuit,
|
||||
Palette,
|
||||
PenTool,
|
||||
Video,
|
||||
Music,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
Activity,
|
||||
Layers,
|
||||
Terminal,
|
||||
Bot,
|
||||
LucideIcon,
|
||||
} from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
@@ -36,6 +38,7 @@ import {
|
||||
AgentPageParams,
|
||||
getThemeWorkspacePage,
|
||||
LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY,
|
||||
OpenClawPageParams,
|
||||
Page,
|
||||
PageParams,
|
||||
ThemeWorkspacePage,
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
|
||||
interface AppSidebarProps {
|
||||
currentPage: Page;
|
||||
currentPageParams?: PageParams;
|
||||
onNavigate: (page: Page, params?: PageParams) => void;
|
||||
}
|
||||
|
||||
@@ -57,7 +61,7 @@ interface SidebarNavItem {
|
||||
icon: LucideIcon;
|
||||
page: Page;
|
||||
params?: PageParams;
|
||||
isActive?: (currentPage: Page) => boolean;
|
||||
isActive?: (currentPage: Page, currentParams?: PageParams) => boolean;
|
||||
}
|
||||
|
||||
const Container = styled.aside`
|
||||
@@ -345,6 +349,14 @@ const THEME_MENU_ITEMS: SidebarNavItem[] = [
|
||||
];
|
||||
|
||||
const FOOTER_MENU_ITEMS: SidebarNavItem[] = [
|
||||
{
|
||||
id: "openclaw",
|
||||
label: "OpenClaw",
|
||||
icon: Bot,
|
||||
page: "openclaw",
|
||||
params: { subpage: "runtime" } as OpenClawPageParams,
|
||||
isActive: (currentPage) => currentPage === "openclaw",
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "设置",
|
||||
@@ -366,6 +378,14 @@ const FOOTER_MENU_ITEMS: SidebarNavItem[] = [
|
||||
page: "tools",
|
||||
isActive: (currentPage) => currentPage === "tools",
|
||||
},
|
||||
{
|
||||
id: "style-library",
|
||||
label: "我的风格",
|
||||
icon: Palette,
|
||||
page: "style",
|
||||
params: { section: "overview" },
|
||||
isActive: (currentPage) => currentPage === "style",
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
label: "记忆",
|
||||
@@ -375,11 +395,7 @@ const FOOTER_MENU_ITEMS: SidebarNavItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_ENABLED_NAV_ITEMS = [
|
||||
"home-general",
|
||||
"video",
|
||||
"image-gen",
|
||||
];
|
||||
const DEFAULT_ENABLED_NAV_ITEMS = ["home-general", "video", "image-gen"];
|
||||
|
||||
const ALL_NAV_ITEM_IDS = [
|
||||
...MAIN_MENU_ITEMS.map((item) => item.id),
|
||||
@@ -436,7 +452,11 @@ function isThemeWorkspacePage(page: Page): page is ThemeWorkspacePage {
|
||||
return typeof page === "string" && page.startsWith("workspace-");
|
||||
}
|
||||
|
||||
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
export function AppSidebar({
|
||||
currentPage,
|
||||
currentPageParams,
|
||||
onNavigate,
|
||||
}: AppSidebarProps) {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return document.documentElement.classList.contains("dark")
|
||||
@@ -590,7 +610,7 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
}
|
||||
|
||||
if (item.isActive) {
|
||||
return item.isActive(currentPage);
|
||||
return item.isActive(currentPage, currentPageParams);
|
||||
}
|
||||
|
||||
return currentPage === item.page;
|
||||
@@ -607,10 +627,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)?.workspaceViewMode ??
|
||||
"project-management",
|
||||
)
|
||||
item.params as AgentPageParams | undefined,
|
||||
(item.params as AgentPageParams | undefined)?.workspaceViewMode ??
|
||||
"project-management",
|
||||
)
|
||||
: item.params;
|
||||
|
||||
onNavigate(item.page, params);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
A2UITaskCard,
|
||||
A2UITaskLoadingCard,
|
||||
} from "./A2UITaskCard";
|
||||
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
|
||||
import {
|
||||
cleanupMountedRoots,
|
||||
clickButtonByText,
|
||||
flushEffects,
|
||||
mountHarness,
|
||||
setupReactActEnvironment,
|
||||
type MountedRoot,
|
||||
} from "@/components/workspace/hooks/testUtils";
|
||||
import {
|
||||
buildCreateConfirmationA2UI,
|
||||
type PendingCreateConfirmation,
|
||||
} from "@/components/workspace/utils/createConfirmationPolicy";
|
||||
|
||||
setupReactActEnvironment();
|
||||
|
||||
describe("A2UITaskCard", () => {
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
const pendingConfirmation: PendingCreateConfirmation = {
|
||||
projectId: "project-1",
|
||||
source: "workspace_prompt",
|
||||
creationMode: "guided",
|
||||
initialUserPrompt: "帮我继续这篇内容",
|
||||
createdAt: 1_700_000_000_000,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMountedRoots(mountedRoots);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应渲染统一任务卡头部与提交区域", async () => {
|
||||
const submitSpy = vi.fn();
|
||||
const { container } = mountHarness(
|
||||
A2UITaskCard,
|
||||
{
|
||||
response: buildCreateConfirmationA2UI(pendingConfirmation),
|
||||
onSubmit: submitSpy,
|
||||
preset: CHAT_A2UI_TASK_CARD_PRESET,
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
expect(container.querySelector("[data-testid='agent-a2ui-task-card']")).not.toBeNull();
|
||||
expect(container.textContent).toContain("补充信息");
|
||||
expect(container.textContent).toContain("待完成 1 / 1");
|
||||
|
||||
clickButtonByText(container, "新写一篇内容");
|
||||
await flushEffects();
|
||||
clickButtonByText(container, "开始处理");
|
||||
await flushEffects();
|
||||
|
||||
expect(submitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create_confirmation_option: ["new_post"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("应渲染统一加载卡片", () => {
|
||||
const { container } = mountHarness(
|
||||
A2UITaskLoadingCard,
|
||||
{
|
||||
title: "补充信息",
|
||||
subtitle: "正在解析结构化问题,请稍等。",
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector("[data-testid='agent-a2ui-task-loading-card']"),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain("正在解析结构化问题,请稍等。");
|
||||
expect(container.textContent).toContain("表单加载中...");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { A2UIRenderer } from "@/components/content-creator/a2ui/components";
|
||||
import type {
|
||||
A2UIFormData,
|
||||
A2UIResponse,
|
||||
} from "@/components/content-creator/a2ui/types";
|
||||
import {
|
||||
DEFAULT_A2UI_TASK_CARD_PRESET,
|
||||
type A2UITaskCardPreset,
|
||||
} from "@/components/content-creator/a2ui/taskCardPresets";
|
||||
import {
|
||||
A2UITaskCardBody,
|
||||
A2UITaskCardHeader,
|
||||
A2UITaskCardLoadingBody,
|
||||
A2UITaskCardShell,
|
||||
} from "@/components/content-creator/a2ui/taskCardPrimitives";
|
||||
|
||||
export interface A2UITaskCardProps {
|
||||
response: A2UIResponse;
|
||||
onSubmit?: (formData: A2UIFormData) => void;
|
||||
onFormStateChange?: (formData: A2UIFormData) => void;
|
||||
formId?: string;
|
||||
initialFormData?: A2UIFormData;
|
||||
onFormChange?: (formId: string, formData: A2UIFormData) => void;
|
||||
submitDisabled?: boolean;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
preset?: A2UITaskCardPreset;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
statusLabel?: string;
|
||||
footerText?: string;
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
interface A2UITaskLoadingCardProps {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
preset?: A2UITaskCardPreset;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
statusLabel?: string;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
function getCardCopy(
|
||||
compact: boolean,
|
||||
preset: A2UITaskCardPreset,
|
||||
title?: string,
|
||||
subtitle?: string,
|
||||
) {
|
||||
return {
|
||||
title: title || preset.title,
|
||||
subtitle:
|
||||
subtitle ||
|
||||
(compact ? preset.subtitle.replace("当前对话。", "。") : preset.subtitle),
|
||||
};
|
||||
}
|
||||
|
||||
export function A2UITaskCard({
|
||||
response,
|
||||
onSubmit,
|
||||
onFormStateChange,
|
||||
formId,
|
||||
initialFormData,
|
||||
onFormChange,
|
||||
submitDisabled = false,
|
||||
className,
|
||||
compact = false,
|
||||
preset = DEFAULT_A2UI_TASK_CARD_PRESET,
|
||||
title,
|
||||
subtitle,
|
||||
statusLabel = preset.statusLabel,
|
||||
footerText,
|
||||
preview = false,
|
||||
}: A2UITaskCardProps) {
|
||||
const copy = getCardCopy(compact, preset, title, subtitle);
|
||||
|
||||
return (
|
||||
<A2UITaskCardShell
|
||||
compact={compact}
|
||||
className={className}
|
||||
preview={preview}
|
||||
testId="agent-a2ui-task-card"
|
||||
>
|
||||
<A2UITaskCardHeader
|
||||
title={copy.title}
|
||||
subtitle={copy.subtitle}
|
||||
compact={compact}
|
||||
statusLabel={statusLabel}
|
||||
/>
|
||||
|
||||
<A2UITaskCardBody compact={compact}>
|
||||
<A2UIRenderer
|
||||
response={response}
|
||||
onSubmit={onSubmit}
|
||||
onFormStateChange={onFormStateChange}
|
||||
formId={formId}
|
||||
initialFormData={initialFormData}
|
||||
onFormChange={onFormChange}
|
||||
submitDisabled={submitDisabled}
|
||||
submitButtonClassName="w-full"
|
||||
className={compact ? "space-y-3" : "space-y-4"}
|
||||
/>
|
||||
</A2UITaskCardBody>
|
||||
|
||||
{footerText ? (
|
||||
<div className="mt-3 text-xs text-slate-500">{footerText}</div>
|
||||
) : null}
|
||||
</A2UITaskCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function A2UITaskLoadingCard({
|
||||
className,
|
||||
compact = false,
|
||||
preset = DEFAULT_A2UI_TASK_CARD_PRESET,
|
||||
title,
|
||||
subtitle,
|
||||
statusLabel = preset.statusLabel,
|
||||
loadingText = preset.loadingText || DEFAULT_A2UI_TASK_CARD_PRESET.loadingText,
|
||||
}: A2UITaskLoadingCardProps) {
|
||||
const copy = getCardCopy(compact, preset, title, subtitle);
|
||||
|
||||
return (
|
||||
<A2UITaskCardShell
|
||||
compact={compact}
|
||||
className={className}
|
||||
testId="agent-a2ui-task-loading-card"
|
||||
>
|
||||
<A2UITaskCardHeader
|
||||
title={copy.title}
|
||||
subtitle={copy.subtitle}
|
||||
compact={compact}
|
||||
statusLabel={statusLabel}
|
||||
/>
|
||||
|
||||
<A2UITaskCardLoadingBody
|
||||
compact={compact}
|
||||
text={loadingText || ""}
|
||||
/>
|
||||
</A2UITaskCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default A2UITaskCard;
|
||||
@@ -1,9 +1,10 @@
|
||||
import styled from "styled-components";
|
||||
import { A2UIRenderer } from "@/components/content-creator/a2ui";
|
||||
import type {
|
||||
A2UIFormData,
|
||||
A2UIResponse,
|
||||
} from "@/components/content-creator/a2ui/types";
|
||||
import { CHAT_FLOATING_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
|
||||
import { A2UITaskCard } from "../../A2UITaskCard";
|
||||
|
||||
interface A2UIFloatingFormProps {
|
||||
response: A2UIResponse;
|
||||
@@ -13,38 +14,13 @@ interface A2UIFloatingFormProps {
|
||||
const Card = styled.div`
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
padding: 12px;
|
||||
background: hsl(var(--background) / 0.97);
|
||||
border: 1px solid hsl(var(--border) / 0.95);
|
||||
border-radius: 12px;
|
||||
max-width: 100%;
|
||||
max-height: min(44vh, 420px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
box-shadow:
|
||||
0 14px 36px hsl(var(--foreground) / 0.10),
|
||||
0 0 0 1px hsl(var(--background) / 0.72);
|
||||
backdrop-filter: blur(14px);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: sticky;
|
||||
display: block;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -12px;
|
||||
height: 16px;
|
||||
margin: 0 -12px -12px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
hsl(var(--background) / 0) 0%,
|
||||
hsl(var(--background) / 0.9) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
@@ -53,52 +29,6 @@ const Card = styled.div`
|
||||
background: hsl(var(--border));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.a2ui-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.a2ui-container > * + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.a2ui-container .text-sm,
|
||||
.a2ui-container label,
|
||||
.a2ui-container [class*="text-sm"] {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.a2ui-container .text-xs,
|
||||
.a2ui-container p,
|
||||
.a2ui-container [class*="text-xs"] {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.a2ui-container input,
|
||||
.a2ui-container textarea {
|
||||
padding: 7px 9px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
border-color: hsl(var(--border) / 0.95);
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.a2ui-container textarea {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.a2ui-container button {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
box-shadow: 0 1px 0 hsl(var(--background) / 0.35);
|
||||
}
|
||||
`;
|
||||
|
||||
export function A2UIFloatingForm({
|
||||
@@ -107,7 +37,13 @@ export function A2UIFloatingForm({
|
||||
}: A2UIFloatingFormProps) {
|
||||
return (
|
||||
<Card>
|
||||
<A2UIRenderer response={response} onSubmit={onSubmit} />
|
||||
<A2UITaskCard
|
||||
response={response}
|
||||
onSubmit={onSubmit}
|
||||
compact={true}
|
||||
preset={CHAT_FLOATING_A2UI_TASK_CARD_PRESET}
|
||||
className="m-0"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import styled from "styled-components";
|
||||
import type { HintRouteItem } from "../hooks/useHintRoutes";
|
||||
|
||||
interface HintRoutePopupProps {
|
||||
routes: HintRouteItem[];
|
||||
activeIndex: number;
|
||||
onSelect: (hint: string) => void;
|
||||
}
|
||||
|
||||
const Popup = styled.div`
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 8px;
|
||||
margin-bottom: 4px;
|
||||
background: hsl(var(--popover));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
`;
|
||||
|
||||
const Item = styled.button<{ $active?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: ${(props) =>
|
||||
props.$active ? "hsl(var(--accent))" : "transparent"};
|
||||
color: hsl(var(--foreground));
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const Model = styled.span`
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
export function HintRoutePopup({
|
||||
routes,
|
||||
activeIndex,
|
||||
onSelect,
|
||||
}: HintRoutePopupProps) {
|
||||
if (routes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup>
|
||||
{routes.map((route, index) => (
|
||||
<Item
|
||||
key={route.hint}
|
||||
$active={index === activeIndex}
|
||||
onClick={() => onSelect(route.hint)}
|
||||
>
|
||||
<Label>[{route.hint}]</Label>
|
||||
<Model>
|
||||
{route.provider} / {route.model}
|
||||
</Model>
|
||||
</Item>
|
||||
))}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React from "react";
|
||||
import type { ChatInputAdapter } from "@/components/input-kit/adapters/types";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
import type { MessageImage } from "../../../types";
|
||||
import { CharacterMention } from "./CharacterMention";
|
||||
import { InputbarCore } from "./InputbarCore";
|
||||
import { ThemeWorkbenchStatusPanel } from "./ThemeWorkbenchStatusPanel";
|
||||
import { InputbarModelExtra } from "./InputbarModelExtra";
|
||||
import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect";
|
||||
import type {
|
||||
ThemeWorkbenchGateState,
|
||||
ThemeWorkbenchQuickAction,
|
||||
ThemeWorkbenchWorkflowStep,
|
||||
} from "../hooks/useThemeWorkbenchInputState";
|
||||
|
||||
interface InputbarComposerSectionProps {
|
||||
renderThemeWorkbenchGeneratingPanel: boolean;
|
||||
themeWorkbenchGate?: ThemeWorkbenchGateState | null;
|
||||
themeWorkbenchQuickActions: ThemeWorkbenchQuickAction[];
|
||||
themeWorkbenchQueueItems: ThemeWorkbenchWorkflowStep[];
|
||||
inputAdapter: ChatInputAdapter;
|
||||
characters: Character[];
|
||||
skills: Skill[];
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
input: string;
|
||||
onSelectCharacter?: (character: Character) => void;
|
||||
onSelectSkill: (skill: Skill) => void;
|
||||
onNavigateToSettings?: () => void;
|
||||
onSend: () => void;
|
||||
onToolClick: (tool: string) => void;
|
||||
activeTools: Record<string, boolean>;
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
pendingImages: MessageImage[];
|
||||
onRemoveImage: (index: number) => void;
|
||||
onPaste: (event: React.ClipboardEvent) => void;
|
||||
isFullscreen: boolean;
|
||||
isCanvasOpen: boolean;
|
||||
isThemeWorkbenchVariant: boolean;
|
||||
activeTheme?: string;
|
||||
onManageProviders?: () => void;
|
||||
setExecutionStrategy?: (
|
||||
strategy: "react" | "code_orchestrated" | "auto",
|
||||
) => void;
|
||||
topExtra?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const InputbarComposerSection: React.FC<
|
||||
InputbarComposerSectionProps
|
||||
> = ({
|
||||
renderThemeWorkbenchGeneratingPanel,
|
||||
themeWorkbenchGate,
|
||||
themeWorkbenchQuickActions,
|
||||
themeWorkbenchQueueItems,
|
||||
inputAdapter,
|
||||
characters,
|
||||
skills,
|
||||
textareaRef,
|
||||
input,
|
||||
onSelectCharacter,
|
||||
onSelectSkill,
|
||||
onNavigateToSettings,
|
||||
onSend,
|
||||
onToolClick,
|
||||
activeTools,
|
||||
executionStrategy,
|
||||
pendingImages,
|
||||
onRemoveImage,
|
||||
onPaste,
|
||||
isFullscreen,
|
||||
isCanvasOpen,
|
||||
isThemeWorkbenchVariant,
|
||||
activeTheme,
|
||||
onManageProviders,
|
||||
setExecutionStrategy,
|
||||
topExtra,
|
||||
}) => {
|
||||
if (renderThemeWorkbenchGeneratingPanel) {
|
||||
return (
|
||||
<ThemeWorkbenchStatusPanel
|
||||
gate={themeWorkbenchGate}
|
||||
quickActions={themeWorkbenchQuickActions}
|
||||
queueItems={themeWorkbenchQueueItems}
|
||||
renderGeneratingPanel
|
||||
onQuickAction={inputAdapter.actions.setText}
|
||||
onStop={inputAdapter.actions.stop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThemeWorkbenchStatusPanel
|
||||
gate={themeWorkbenchGate}
|
||||
quickActions={themeWorkbenchQuickActions}
|
||||
queueItems={themeWorkbenchQueueItems}
|
||||
renderGeneratingPanel={false}
|
||||
onQuickAction={inputAdapter.actions.setText}
|
||||
onStop={inputAdapter.actions.stop}
|
||||
/>
|
||||
<CharacterMention
|
||||
characters={characters}
|
||||
skills={skills}
|
||||
inputRef={textareaRef}
|
||||
value={input}
|
||||
onChange={inputAdapter.actions.setText}
|
||||
onSelectCharacter={onSelectCharacter}
|
||||
onSelectSkill={onSelectSkill}
|
||||
onNavigateToSettings={onNavigateToSettings}
|
||||
/>
|
||||
<InputbarCore
|
||||
textareaRef={textareaRef}
|
||||
text={inputAdapter.state.text}
|
||||
setText={inputAdapter.actions.setText}
|
||||
onSend={onSend}
|
||||
onStop={inputAdapter.actions.stop}
|
||||
isLoading={inputAdapter.state.isSending}
|
||||
disabled={inputAdapter.state.disabled}
|
||||
onToolClick={onToolClick}
|
||||
activeTools={activeTools}
|
||||
executionStrategy={executionStrategy}
|
||||
showExecutionStrategy={false}
|
||||
pendingImages={
|
||||
(inputAdapter.state.attachments as MessageImage[] | undefined) ||
|
||||
pendingImages
|
||||
}
|
||||
onRemoveImage={onRemoveImage}
|
||||
onPaste={onPaste}
|
||||
isFullscreen={isFullscreen}
|
||||
isCanvasOpen={isCanvasOpen}
|
||||
placeholder={
|
||||
isThemeWorkbenchVariant
|
||||
? themeWorkbenchGate?.status === "waiting"
|
||||
? "说说你的选择,剩下的交给我"
|
||||
: "试着输入任何指令,剩下的交给我"
|
||||
: undefined
|
||||
}
|
||||
toolMode={isThemeWorkbenchVariant ? "attach-only" : "default"}
|
||||
showTranslate={!isThemeWorkbenchVariant}
|
||||
showDragHandle={!isThemeWorkbenchVariant}
|
||||
visualVariant={isThemeWorkbenchVariant ? "floating" : "default"}
|
||||
topExtra={topExtra}
|
||||
leftExtra={
|
||||
<InputbarModelExtra
|
||||
isFullscreen={isFullscreen}
|
||||
isThemeWorkbenchVariant={isThemeWorkbenchVariant}
|
||||
providerType={inputAdapter.model?.providerType}
|
||||
setProviderType={inputAdapter.actions.setProviderType}
|
||||
model={inputAdapter.model?.model}
|
||||
setModel={inputAdapter.actions.setModel}
|
||||
activeTheme={activeTheme}
|
||||
onManageProviders={onManageProviders}
|
||||
/>
|
||||
}
|
||||
rightExtra={
|
||||
<InputbarExecutionStrategySelect
|
||||
isFullscreen={isFullscreen}
|
||||
isThemeWorkbenchVariant={isThemeWorkbenchVariant}
|
||||
executionStrategy={executionStrategy}
|
||||
setExecutionStrategy={setExecutionStrategy}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import { Code2 } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface InputbarExecutionStrategySelectProps {
|
||||
isFullscreen?: boolean;
|
||||
isThemeWorkbenchVariant?: boolean;
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
setExecutionStrategy?: (
|
||||
strategy: "react" | "code_orchestrated" | "auto",
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const InputbarExecutionStrategySelect: React.FC<
|
||||
InputbarExecutionStrategySelectProps
|
||||
> = ({
|
||||
isFullscreen = false,
|
||||
isThemeWorkbenchVariant = false,
|
||||
executionStrategy,
|
||||
setExecutionStrategy,
|
||||
}) => {
|
||||
if (isFullscreen || isThemeWorkbenchVariant || !setExecutionStrategy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedExecutionStrategy = executionStrategy || "react";
|
||||
const executionStrategyLabel =
|
||||
resolvedExecutionStrategy === "auto"
|
||||
? "Auto"
|
||||
: resolvedExecutionStrategy === "code_orchestrated"
|
||||
? "Plan"
|
||||
: "ReAct";
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={resolvedExecutionStrategy}
|
||||
onValueChange={(value) =>
|
||||
setExecutionStrategy(value as "react" | "code_orchestrated" | "auto")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs bg-background border shadow-sm min-w-[116px] px-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Code2 className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="whitespace-nowrap">{executionStrategyLabel}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" className="p-1 w-[176px]">
|
||||
<SelectItem value="react">
|
||||
<div className="flex items-center gap-2 whitespace-nowrap">
|
||||
<Code2 className="w-3.5 h-3.5" />
|
||||
ReAct
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="code_orchestrated">
|
||||
<div className="flex items-center gap-2 whitespace-nowrap">
|
||||
<Code2 className="w-3.5 h-3.5" />
|
||||
Plan
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="auto">
|
||||
<div className="flex items-center gap-2 whitespace-nowrap">
|
||||
<Code2 className="w-3.5 h-3.5" />
|
||||
Auto
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { ChatModelSelector } from "../../ChatModelSelector";
|
||||
|
||||
interface InputbarModelExtraProps {
|
||||
isFullscreen?: boolean;
|
||||
isThemeWorkbenchVariant?: boolean;
|
||||
providerType?: string;
|
||||
setProviderType?: (type: string) => void;
|
||||
model?: string;
|
||||
setModel?: (model: string) => void;
|
||||
activeTheme?: string;
|
||||
onManageProviders?: () => void;
|
||||
}
|
||||
|
||||
const NOOP_SET_PROVIDER_TYPE = (_type: string) => {};
|
||||
const NOOP_SET_MODEL = (_model: string) => {};
|
||||
|
||||
export const InputbarModelExtra: React.FC<InputbarModelExtraProps> = ({
|
||||
isFullscreen = false,
|
||||
isThemeWorkbenchVariant = false,
|
||||
providerType,
|
||||
setProviderType,
|
||||
model,
|
||||
setModel,
|
||||
activeTheme,
|
||||
onManageProviders,
|
||||
}) => {
|
||||
if (isFullscreen || isThemeWorkbenchVariant || !providerType || !model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ChatModelSelector
|
||||
providerType={providerType}
|
||||
setProviderType={setProviderType || NOOP_SET_PROVIDER_TYPE}
|
||||
model={model}
|
||||
setModel={setModel || NOOP_SET_MODEL}
|
||||
activeTheme={activeTheme}
|
||||
compactTrigger
|
||||
popoverSide="top"
|
||||
onManageProviders={onManageProviders}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { type ChangeEvent, type RefObject } from "react";
|
||||
import type { TaskFile } from "../../TaskFiles";
|
||||
import type { A2UIResponse, A2UIFormData } from "@/components/content-creator/a2ui/types";
|
||||
import {
|
||||
A2UISubmissionNotice,
|
||||
type A2UISubmissionNoticeData,
|
||||
} from "./A2UISubmissionNotice";
|
||||
import { A2UIFloatingForm } from "./A2UIFloatingForm";
|
||||
import { HintRoutePopup } from "./HintRoutePopup";
|
||||
import { TaskFilesPanel } from "./TaskFilesPanel";
|
||||
import type { HintRouteItem } from "../hooks/useHintRoutes";
|
||||
|
||||
interface InputbarOverlayShellProps {
|
||||
showHintPopup: boolean;
|
||||
hintRoutes: HintRouteItem[];
|
||||
hintIndex: number;
|
||||
onHintSelect: (hint: string) => void;
|
||||
taskFiles: TaskFile[];
|
||||
selectedFileId?: string;
|
||||
taskFilesExpanded?: boolean;
|
||||
onToggleTaskFiles?: () => void;
|
||||
onTaskFileClick?: (file: TaskFile) => void;
|
||||
submissionNotice?: A2UISubmissionNoticeData | null;
|
||||
isSubmissionNoticeVisible: boolean;
|
||||
pendingA2UIForm?: A2UIResponse | null;
|
||||
onA2UISubmit?: (formData: A2UIFormData) => void;
|
||||
fileInputRef: RefObject<HTMLInputElement>;
|
||||
onFileSelect: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
|
||||
showHintPopup,
|
||||
hintRoutes,
|
||||
hintIndex,
|
||||
onHintSelect,
|
||||
taskFiles,
|
||||
selectedFileId,
|
||||
taskFilesExpanded = false,
|
||||
onToggleTaskFiles,
|
||||
onTaskFileClick,
|
||||
submissionNotice,
|
||||
isSubmissionNoticeVisible,
|
||||
pendingA2UIForm,
|
||||
onA2UISubmit,
|
||||
fileInputRef,
|
||||
onFileSelect,
|
||||
}) => (
|
||||
<>
|
||||
{showHintPopup ? (
|
||||
<HintRoutePopup
|
||||
routes={hintRoutes}
|
||||
activeIndex={hintIndex}
|
||||
onSelect={onHintSelect}
|
||||
/>
|
||||
) : null}
|
||||
<TaskFilesPanel
|
||||
files={taskFiles}
|
||||
selectedFileId={selectedFileId}
|
||||
expanded={taskFilesExpanded}
|
||||
onToggle={onToggleTaskFiles}
|
||||
onFileClick={onTaskFileClick}
|
||||
/>
|
||||
{submissionNotice ? (
|
||||
<A2UISubmissionNotice
|
||||
notice={submissionNotice}
|
||||
visible={isSubmissionNoticeVisible}
|
||||
/>
|
||||
) : null}
|
||||
{pendingA2UIForm && onA2UISubmit ? (
|
||||
<A2UIFloatingForm response={pendingA2UIForm} onSubmit={onA2UISubmit} />
|
||||
) : null}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={onFileSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
|
||||
interface InputbarSurfaceProps {
|
||||
isFullscreen: boolean;
|
||||
onDragOver: (event: React.DragEvent) => void;
|
||||
onDrop: (event: React.DragEvent) => void;
|
||||
onKeyDown: (event: React.KeyboardEvent) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const InputbarSurface: React.FC<InputbarSurfaceProps> = ({
|
||||
isFullscreen,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onKeyDown,
|
||||
children,
|
||||
}) => (
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onKeyDown={onKeyDown}
|
||||
className={
|
||||
isFullscreen ? "fixed inset-0 z-50 bg-background p-4 flex flex-col" : ""
|
||||
}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
import styled from "styled-components";
|
||||
import { FolderOpen, ChevronUp } from "lucide-react";
|
||||
import { TaskFileList, type TaskFile } from "../../TaskFiles";
|
||||
|
||||
interface TaskFilesPanelProps {
|
||||
files: TaskFile[];
|
||||
selectedFileId?: string;
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
onFileClick?: (file: TaskFile) => void;
|
||||
}
|
||||
|
||||
const Area = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 8px 8px 8px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const TriggerButton = styled.button<{
|
||||
$expanded?: boolean;
|
||||
$hasFiles?: boolean;
|
||||
}>`
|
||||
display: ${(props) => (props.$hasFiles ? "flex" : "none")};
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--primary) / 0.5);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$expanded &&
|
||||
`
|
||||
border-color: hsl(var(--primary));
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--primary) / 0.05);
|
||||
`}
|
||||
`;
|
||||
|
||||
const FileCount = styled.span`
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
const ChevronIcon = styled.span<{ $expanded?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transform: ${(props) =>
|
||||
props.$expanded ? "rotate(0deg)" : "rotate(180deg)"};
|
||||
transition: transform 0.2s;
|
||||
`;
|
||||
|
||||
export function TaskFilesPanel({
|
||||
files,
|
||||
selectedFileId,
|
||||
expanded = false,
|
||||
onToggle,
|
||||
onFileClick,
|
||||
}: TaskFilesPanelProps) {
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Area>
|
||||
<Wrapper>
|
||||
<TaskFileList
|
||||
files={files}
|
||||
selectedFileId={selectedFileId}
|
||||
onFileClick={onFileClick}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(nextExpanded) => {
|
||||
if (nextExpanded !== expanded) {
|
||||
onToggle?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TriggerButton
|
||||
$hasFiles={files.length > 0}
|
||||
$expanded={expanded}
|
||||
onClick={onToggle}
|
||||
data-task-files-trigger
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
任务文件
|
||||
<FileCount>({files.length})</FileCount>
|
||||
<ChevronIcon $expanded={expanded}>
|
||||
<ChevronUp size={14} />
|
||||
</ChevronIcon>
|
||||
</TriggerButton>
|
||||
</Wrapper>
|
||||
</Area>
|
||||
);
|
||||
}
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, Loader2, Clock3, AlertCircle, Sparkles } from "lucide-react";
|
||||
import styled from "styled-components";
|
||||
import type {
|
||||
ThemeWorkbenchGateState,
|
||||
ThemeWorkbenchQuickAction,
|
||||
ThemeWorkbenchWorkflowStep,
|
||||
} from "../hooks/useThemeWorkbenchInputState";
|
||||
|
||||
interface ThemeWorkbenchStatusPanelProps {
|
||||
gate?: ThemeWorkbenchGateState | null;
|
||||
quickActions?: ThemeWorkbenchQuickAction[];
|
||||
queueItems?: ThemeWorkbenchWorkflowStep[];
|
||||
renderGeneratingPanel: boolean;
|
||||
onQuickAction: (prompt: string) => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
const GateStrip = styled.div`
|
||||
margin: 0 12px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid hsl(var(--border) / 0.92);
|
||||
background: hsl(var(--muted) / 0.78);
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: hsl(222 18% 14% / 0.96);
|
||||
border-color: hsl(217 18% 24% / 0.95);
|
||||
}
|
||||
`;
|
||||
|
||||
const GateMeta = styled.div`
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const GateIcon = styled.span`
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--muted-foreground));
|
||||
border: 1px solid hsl(var(--border) / 0.9);
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const GateTitle = styled.span`
|
||||
font-size: 12px;
|
||||
color: hsl(var(--foreground) / 0.86);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
|
||||
const GateStatus = styled.span<{
|
||||
$status: "running" | "waiting" | "idle";
|
||||
}>`
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
color: ${({ $status }) =>
|
||||
$status === "waiting"
|
||||
? "hsl(var(--destructive))"
|
||||
: $status === "running"
|
||||
? "hsl(var(--primary))"
|
||||
: "hsl(var(--muted-foreground))"};
|
||||
background: ${({ $status }) =>
|
||||
$status === "waiting"
|
||||
? "hsl(var(--destructive) / 0.08)"
|
||||
: $status === "running"
|
||||
? "hsl(var(--primary) / 0.1)"
|
||||
: "hsl(var(--muted) / 0.7)"};
|
||||
`;
|
||||
|
||||
const QuickActions = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
const QuickButton = styled.button`
|
||||
border: 1px solid hsl(var(--border) / 0.88);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground) / 0.82);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--primary) / 0.22);
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
`;
|
||||
|
||||
const GeneratingWrap = styled.div`
|
||||
margin: 0 10px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const TaskCard = styled.div`
|
||||
border: 1px solid hsl(var(--border) / 0.78);
|
||||
border-radius: 15px;
|
||||
background: hsl(var(--background));
|
||||
box-shadow: 0 8px 20px hsl(var(--foreground) / 0.05);
|
||||
padding: 11px 12px 10px;
|
||||
`;
|
||||
|
||||
const TaskHead = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const TaskHeadButton = styled.button`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const TaskHeadChevron = styled.span<{ $collapsed: boolean }>`
|
||||
display: inline-flex;
|
||||
transition: transform 0.2s ease;
|
||||
transform: ${({ $collapsed }) =>
|
||||
$collapsed ? "rotate(-90deg)" : "rotate(0deg)"};
|
||||
`;
|
||||
|
||||
const TaskList = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const TaskRow = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 34px;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const TaskIcon = styled.span<{ $kind: "active" | "pending" | "error" }>`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${({ $kind }) =>
|
||||
$kind === "active"
|
||||
? "hsl(var(--primary) / 0.12)"
|
||||
: $kind === "error"
|
||||
? "hsl(var(--destructive) / 0.1)"
|
||||
: "hsl(38 100% 92%)"};
|
||||
color: ${({ $kind }) =>
|
||||
$kind === "active"
|
||||
? "hsl(var(--primary))"
|
||||
: $kind === "error"
|
||||
? "hsl(var(--destructive))"
|
||||
: "hsl(30 90% 42%)"};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const TaskText = styled.span`
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const TaskStatus = styled.span<{ $kind: "active" | "pending" | "error" }>`
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
color: ${(props) =>
|
||||
props.$kind === "active"
|
||||
? "hsl(var(--primary))"
|
||||
: props.$kind === "error"
|
||||
? "hsl(var(--destructive))"
|
||||
: "hsl(35 95% 35%)"};
|
||||
background: ${(props) =>
|
||||
props.$kind === "active"
|
||||
? "hsl(var(--primary) / 0.14)"
|
||||
: props.$kind === "error"
|
||||
? "hsl(var(--destructive) / 0.12)"
|
||||
: "hsl(36 100% 90%)"};
|
||||
`;
|
||||
|
||||
const RunningBar = styled.div`
|
||||
min-height: 44px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 11px;
|
||||
background: hsl(var(--background));
|
||||
box-shadow: 0 4px 14px hsl(var(--foreground) / 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 10px;
|
||||
`;
|
||||
|
||||
const RunningIcon = styled.span`
|
||||
color: hsl(var(--primary));
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const RunningSub = styled.span`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const RunningMain = styled.span`
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 600;
|
||||
margin-right: 2px;
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const StopButton = styled.button`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.28);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--destructive));
|
||||
border-color: hsl(var(--destructive) / 0.5);
|
||||
background: hsl(var(--destructive) / 0.06);
|
||||
}
|
||||
`;
|
||||
|
||||
const StopGlyph = styled.span`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
}
|
||||
`;
|
||||
|
||||
export function ThemeWorkbenchStatusPanel({
|
||||
gate,
|
||||
quickActions = [],
|
||||
queueItems = [],
|
||||
renderGeneratingPanel,
|
||||
onQuickAction,
|
||||
onStop,
|
||||
}: ThemeWorkbenchStatusPanelProps) {
|
||||
const [queueCollapsed, setQueueCollapsed] = useState(false);
|
||||
|
||||
if (renderGeneratingPanel) {
|
||||
return (
|
||||
<GeneratingWrap>
|
||||
<TaskCard>
|
||||
<TaskHead>
|
||||
<TaskHeadButton
|
||||
type="button"
|
||||
onClick={() => setQueueCollapsed((prev) => !prev)}
|
||||
aria-label={queueCollapsed ? "展开待办列表" : "折叠待办列表"}
|
||||
>
|
||||
<span>当前待办</span>
|
||||
<TaskHeadChevron $collapsed={queueCollapsed}>
|
||||
<ChevronDown size={14} />
|
||||
</TaskHeadChevron>
|
||||
</TaskHeadButton>
|
||||
</TaskHead>
|
||||
{!queueCollapsed ? (
|
||||
<TaskList>
|
||||
{queueItems.length === 0 ? (
|
||||
<TaskRow>
|
||||
<TaskIcon $kind="active">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
</TaskIcon>
|
||||
<TaskText>正在编排任务节点...</TaskText>
|
||||
<TaskStatus $kind="active">进行中</TaskStatus>
|
||||
</TaskRow>
|
||||
) : (
|
||||
queueItems.map((item) => {
|
||||
const statusKind =
|
||||
item.status === "active"
|
||||
? "active"
|
||||
: item.status === "error"
|
||||
? "error"
|
||||
: "pending";
|
||||
return (
|
||||
<TaskRow key={item.id}>
|
||||
<TaskIcon $kind={statusKind}>
|
||||
{statusKind === "active" ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : statusKind === "error" ? (
|
||||
<AlertCircle size={14} />
|
||||
) : (
|
||||
<Clock3 size={14} />
|
||||
)}
|
||||
</TaskIcon>
|
||||
<TaskText>{item.title}</TaskText>
|
||||
<TaskStatus $kind={statusKind}>
|
||||
{statusKind === "active"
|
||||
? "进行中"
|
||||
: statusKind === "error"
|
||||
? "异常"
|
||||
: "待处理"}
|
||||
</TaskStatus>
|
||||
</TaskRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TaskList>
|
||||
) : null}
|
||||
</TaskCard>
|
||||
<RunningBar>
|
||||
<RunningIcon>
|
||||
<Sparkles size={13} />
|
||||
</RunningIcon>
|
||||
<RunningMain>正在生成中 • • •</RunningMain>
|
||||
<RunningSub>切换项目或关闭网页将中断任务</RunningSub>
|
||||
<StopButton
|
||||
type="button"
|
||||
data-testid="theme-workbench-stop"
|
||||
onClick={() => onStop?.()}
|
||||
aria-label="停止生成"
|
||||
>
|
||||
<StopGlyph />
|
||||
</StopButton>
|
||||
</RunningBar>
|
||||
</GeneratingWrap>
|
||||
);
|
||||
}
|
||||
|
||||
if (!gate || gate.status === "idle") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GateStrip>
|
||||
<GateMeta>
|
||||
<GateIcon>
|
||||
<Sparkles size={13} />
|
||||
</GateIcon>
|
||||
<GateTitle>{gate.title}</GateTitle>
|
||||
<GateStatus $status={gate.status}>
|
||||
{gate.status === "waiting"
|
||||
? "等待决策"
|
||||
: gate.status === "running"
|
||||
? "自动执行中"
|
||||
: "待启动"}
|
||||
</GateStatus>
|
||||
</GateMeta>
|
||||
{quickActions.length > 0 ? (
|
||||
<QuickActions>
|
||||
{quickActions.map((action) => (
|
||||
<QuickButton
|
||||
key={action.id}
|
||||
type="button"
|
||||
onClick={() => onQuickAction(action.prompt)}
|
||||
>
|
||||
{action.label}
|
||||
</QuickButton>
|
||||
))}
|
||||
</QuickActions>
|
||||
) : null}
|
||||
</GateStrip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { A2UISubmissionNoticeData } from "../components/A2UISubmissionNotice";
|
||||
|
||||
interface UseA2UISubmissionNoticeParams {
|
||||
notice?: A2UISubmissionNoticeData | null;
|
||||
enabled: boolean;
|
||||
fadeOutMs?: number;
|
||||
}
|
||||
|
||||
export function useA2UISubmissionNotice({
|
||||
notice,
|
||||
enabled,
|
||||
fadeOutMs = 180,
|
||||
}: UseA2UISubmissionNoticeParams) {
|
||||
const [visibleNotice, setVisibleNotice] =
|
||||
useState<A2UISubmissionNoticeData | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (enabled && notice) {
|
||||
setVisibleNotice(notice);
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
setIsVisible(true);
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}
|
||||
|
||||
setIsVisible(false);
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
setVisibleNotice(null);
|
||||
hideTimerRef.current = null;
|
||||
}, fadeOutMs);
|
||||
|
||||
return () => {
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, fadeOutMs, notice]);
|
||||
|
||||
return {
|
||||
visibleNotice,
|
||||
isVisible,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { safeInvoke } from "@/lib/dev-bridge";
|
||||
|
||||
export interface HintRouteItem {
|
||||
hint: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface UseHintRoutesParams {
|
||||
setInput: (value: string) => void;
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
export function useHintRoutes({
|
||||
setInput,
|
||||
textareaRef,
|
||||
}: UseHintRoutesParams) {
|
||||
const [showHintPopup, setShowHintPopup] = useState(false);
|
||||
const [hintRoutes, setHintRoutes] = useState<HintRouteItem[]>([]);
|
||||
const [hintIndex, setHintIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
safeInvoke<HintRouteItem[]>("get_hint_routes")
|
||||
.then((routes) => {
|
||||
if (routes?.length > 0) {
|
||||
setHintRoutes(routes);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSetInput = useCallback(
|
||||
(value: string) => {
|
||||
setInput(value);
|
||||
if (hintRoutes.length > 0 && value === "[") {
|
||||
setShowHintPopup(true);
|
||||
setHintIndex(0);
|
||||
} else if (!value.startsWith("[") || value.includes("]")) {
|
||||
setShowHintPopup(false);
|
||||
}
|
||||
},
|
||||
[hintRoutes.length, setInput],
|
||||
);
|
||||
|
||||
const handleHintSelect = useCallback(
|
||||
(hint: string) => {
|
||||
setInput(`[${hint}] `);
|
||||
setShowHintPopup(false);
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
[setInput, textareaRef],
|
||||
);
|
||||
|
||||
const handleHintKeyDown = useCallback(
|
||||
(e: ReactKeyboardEvent) => {
|
||||
const nativeEvent = e.nativeEvent as KeyboardEvent & {
|
||||
isComposing?: boolean;
|
||||
};
|
||||
if (
|
||||
nativeEvent.isComposing ||
|
||||
nativeEvent.key === "Process" ||
|
||||
nativeEvent.keyCode === 229
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!showHintPopup || hintRoutes.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHintIndex((i) => (i + 1) % hintRoutes.length);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHintIndex((i) => (i - 1 + hintRoutes.length) % hintRoutes.length);
|
||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
handleHintSelect(hintRoutes[hintIndex].hint);
|
||||
} else if (e.key === "Escape") {
|
||||
setShowHintPopup(false);
|
||||
}
|
||||
},
|
||||
[handleHintSelect, hintIndex, hintRoutes, showHintPopup],
|
||||
);
|
||||
|
||||
return {
|
||||
showHintPopup,
|
||||
hintRoutes,
|
||||
hintIndex,
|
||||
handleSetInput,
|
||||
handleHintSelect,
|
||||
handleHintKeyDown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type ClipboardEvent,
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { MessageImage } from "../../../types";
|
||||
|
||||
function readImageAsBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result;
|
||||
if (typeof result !== "string") {
|
||||
reject(new Error("invalid_result"));
|
||||
return;
|
||||
}
|
||||
|
||||
const [, base64Data = ""] = result.split(",");
|
||||
resolve(base64Data);
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(reader.error ?? new Error("read_failed"));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function useImageAttachments() {
|
||||
const [pendingImages, setPendingImages] = useState<MessageImage[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const appendImageFile = useCallback(
|
||||
async (file: File, successMessage?: string) => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.info(`暂不支持该文件类型: ${file.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64Data = await readImageAsBase64(file);
|
||||
setPendingImages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
data: base64Data,
|
||||
mediaType: file.type,
|
||||
},
|
||||
]);
|
||||
toast.success(successMessage ?? `已添加图片: ${file.name}`);
|
||||
} catch {
|
||||
toast.error(`图片读取失败: ${file.name}`);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const appendImageFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
Array.from(files).forEach((file) => {
|
||||
void appendImageFile(file);
|
||||
});
|
||||
},
|
||||
[appendImageFile],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
appendImageFiles(files);
|
||||
event.target.value = "";
|
||||
},
|
||||
[appendImageFiles],
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(event: ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.type.startsWith("image/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
void appendImageFile(file, "已粘贴图片");
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[appendImageFile],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const files = event.dataTransfer.files;
|
||||
if (!files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
appendImageFiles(files);
|
||||
},
|
||||
[appendImageFiles],
|
||||
);
|
||||
|
||||
const handleRemoveImage = useCallback((index: number) => {
|
||||
setPendingImages((prev) => prev.filter((_, currentIndex) => currentIndex !== index));
|
||||
}, []);
|
||||
|
||||
const clearPendingImages = useCallback(() => {
|
||||
setPendingImages([]);
|
||||
}, []);
|
||||
|
||||
const openFileDialog = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pendingImages,
|
||||
fileInputRef,
|
||||
handleFileSelect,
|
||||
handlePaste,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handleRemoveImage,
|
||||
clearPendingImages,
|
||||
openFileDialog,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from "react";
|
||||
import { createAgentInputAdapter } from "@/components/input-kit";
|
||||
import type { MessageImage } from "../../../types";
|
||||
|
||||
interface UseInputbarAdapterParams {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
providerType?: string;
|
||||
setProviderType?: (type: string) => void;
|
||||
model?: string;
|
||||
setModel?: (model: string) => void;
|
||||
handleSend: () => void;
|
||||
onStop?: () => void;
|
||||
pendingImages: MessageImage[];
|
||||
setExecutionStrategy?: (
|
||||
strategy: "react" | "code_orchestrated" | "auto",
|
||||
) => void;
|
||||
}
|
||||
|
||||
const NOOP_SET_PROVIDER_TYPE = (_type: string) => {};
|
||||
const NOOP_SET_MODEL = (_model: string) => {};
|
||||
|
||||
export function useInputbarAdapter({
|
||||
input,
|
||||
setInput,
|
||||
isLoading,
|
||||
disabled,
|
||||
providerType,
|
||||
setProviderType,
|
||||
model,
|
||||
setModel,
|
||||
handleSend,
|
||||
onStop,
|
||||
pendingImages,
|
||||
setExecutionStrategy,
|
||||
}: UseInputbarAdapterParams) {
|
||||
return useMemo(
|
||||
() =>
|
||||
createAgentInputAdapter({
|
||||
text: input,
|
||||
setText: setInput,
|
||||
isSending: isLoading,
|
||||
disabled,
|
||||
providerType: providerType || "",
|
||||
model: model || "",
|
||||
setProviderType: setProviderType || NOOP_SET_PROVIDER_TYPE,
|
||||
setModel: setModel || NOOP_SET_MODEL,
|
||||
send: () => handleSend(),
|
||||
stop: onStop,
|
||||
attachments: pendingImages,
|
||||
showExecutionStrategy: Boolean(setExecutionStrategy),
|
||||
}),
|
||||
[
|
||||
disabled,
|
||||
handleSend,
|
||||
input,
|
||||
isLoading,
|
||||
model,
|
||||
onStop,
|
||||
pendingImages,
|
||||
providerType,
|
||||
setExecutionStrategy,
|
||||
setInput,
|
||||
setModel,
|
||||
setProviderType,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import React, { useRef } from "react";
|
||||
import type { A2UISubmissionNoticeData } from "../components/A2UISubmissionNotice";
|
||||
import { SkillBadge } from "../components/SkillBadge";
|
||||
import { useActiveSkill } from "./useActiveSkill";
|
||||
import { useHintRoutes } from "./useHintRoutes";
|
||||
import { useImageAttachments } from "./useImageAttachments";
|
||||
import { useInputbarAdapter } from "./useInputbarAdapter";
|
||||
import { useInputbarDisplayState } from "./useInputbarDisplayState";
|
||||
import { useInputbarSend } from "./useInputbarSend";
|
||||
import {
|
||||
useInputbarToolState,
|
||||
type InputbarToolStates,
|
||||
} from "./useInputbarToolState";
|
||||
import type {
|
||||
ThemeWorkbenchGateState,
|
||||
ThemeWorkbenchWorkflowStep,
|
||||
} from "./useThemeWorkbenchInputState";
|
||||
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
|
||||
import type { MessageImage } from "../../../types";
|
||||
|
||||
interface UseInputbarControllerParams {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
onSend: (
|
||||
images?: MessageImage[],
|
||||
webSearch?: boolean,
|
||||
thinking?: boolean,
|
||||
textOverride?: string,
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto",
|
||||
) => void;
|
||||
onStop?: () => void;
|
||||
isLoading: boolean;
|
||||
disabled?: boolean;
|
||||
onClearMessages?: () => void;
|
||||
onToggleCanvas?: () => void;
|
||||
providerType?: string;
|
||||
setProviderType?: (type: string) => void;
|
||||
model?: string;
|
||||
setModel?: (model: string) => void;
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
setExecutionStrategy?: (
|
||||
strategy: "react" | "code_orchestrated" | "auto",
|
||||
) => void;
|
||||
toolStates?: Partial<InputbarToolStates>;
|
||||
onToolStatesChange?: (states: InputbarToolStates) => void;
|
||||
activeTheme?: string;
|
||||
variant?: "default" | "theme_workbench";
|
||||
themeWorkbenchGate?: ThemeWorkbenchGateState | null;
|
||||
workflowSteps?: ThemeWorkbenchWorkflowStep[];
|
||||
themeWorkbenchRunState?: "idle" | "auto_running" | "await_user_decision";
|
||||
pendingA2UIForm?: A2UIResponse | null;
|
||||
a2uiSubmissionNotice?: A2UISubmissionNoticeData | null;
|
||||
}
|
||||
|
||||
export function useInputbarController({
|
||||
input,
|
||||
setInput,
|
||||
onSend,
|
||||
onStop,
|
||||
isLoading,
|
||||
disabled,
|
||||
onClearMessages,
|
||||
onToggleCanvas,
|
||||
providerType,
|
||||
setProviderType,
|
||||
model,
|
||||
setModel,
|
||||
executionStrategy,
|
||||
setExecutionStrategy,
|
||||
toolStates,
|
||||
onToolStatesChange,
|
||||
activeTheme,
|
||||
variant = "default",
|
||||
themeWorkbenchGate,
|
||||
workflowSteps = [],
|
||||
themeWorkbenchRunState,
|
||||
pendingA2UIForm,
|
||||
a2uiSubmissionNotice,
|
||||
}: UseInputbarControllerParams) {
|
||||
const { activeSkill, setActiveSkill, clearActiveSkill } = useActiveSkill();
|
||||
const {
|
||||
pendingImages,
|
||||
fileInputRef,
|
||||
handleFileSelect,
|
||||
handlePaste,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handleRemoveImage,
|
||||
clearPendingImages,
|
||||
openFileDialog,
|
||||
} = useImageAttachments();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isThemeWorkbenchVariant = variant === "theme_workbench";
|
||||
|
||||
const {
|
||||
activeTools,
|
||||
handleToolClick,
|
||||
isFullscreen,
|
||||
thinkingEnabled,
|
||||
webSearchEnabled,
|
||||
} = useInputbarToolState({
|
||||
toolStates,
|
||||
onToolStatesChange,
|
||||
executionStrategy,
|
||||
setExecutionStrategy,
|
||||
setInput,
|
||||
onClearMessages,
|
||||
onToggleCanvas,
|
||||
clearPendingImages,
|
||||
openFileDialog,
|
||||
});
|
||||
|
||||
const {
|
||||
showHintPopup,
|
||||
hintRoutes,
|
||||
hintIndex,
|
||||
handleSetInput,
|
||||
handleHintSelect,
|
||||
handleHintKeyDown,
|
||||
} = useHintRoutes({
|
||||
setInput,
|
||||
textareaRef,
|
||||
});
|
||||
|
||||
const handleSend = useInputbarSend({
|
||||
input,
|
||||
pendingImages,
|
||||
webSearchEnabled,
|
||||
thinkingEnabled,
|
||||
executionStrategy,
|
||||
activeTools,
|
||||
activeSkill,
|
||||
activeTheme,
|
||||
onSend,
|
||||
clearPendingImages,
|
||||
clearActiveSkill,
|
||||
});
|
||||
|
||||
const inputAdapter = useInputbarAdapter({
|
||||
input,
|
||||
setInput: handleSetInput,
|
||||
isLoading,
|
||||
disabled,
|
||||
providerType,
|
||||
setProviderType,
|
||||
model,
|
||||
setModel,
|
||||
handleSend,
|
||||
onStop,
|
||||
pendingImages,
|
||||
setExecutionStrategy,
|
||||
});
|
||||
|
||||
const {
|
||||
themeWorkbenchQuickActions,
|
||||
themeWorkbenchQueueItems,
|
||||
renderThemeWorkbenchGeneratingPanel,
|
||||
visibleA2UISubmissionNotice,
|
||||
isA2UISubmissionNoticeVisible,
|
||||
} = useInputbarDisplayState({
|
||||
isThemeWorkbenchVariant,
|
||||
themeWorkbenchGate,
|
||||
workflowSteps,
|
||||
themeWorkbenchRunState,
|
||||
isSending: inputAdapter.state.isSending,
|
||||
pendingA2UIForm: Boolean(pendingA2UIForm),
|
||||
a2uiSubmissionNotice,
|
||||
});
|
||||
|
||||
const topExtra = activeSkill
|
||||
? React.createElement(SkillBadge, {
|
||||
skill: activeSkill,
|
||||
onClear: clearActiveSkill,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
textareaRef,
|
||||
isThemeWorkbenchVariant,
|
||||
pendingImages,
|
||||
fileInputRef,
|
||||
handleFileSelect,
|
||||
handlePaste,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handleRemoveImage,
|
||||
showHintPopup,
|
||||
hintRoutes,
|
||||
hintIndex,
|
||||
handleHintSelect,
|
||||
handleHintKeyDown,
|
||||
activeTools,
|
||||
handleToolClick,
|
||||
isFullscreen,
|
||||
handleSend,
|
||||
inputAdapter,
|
||||
topExtra,
|
||||
themeWorkbenchQuickActions,
|
||||
themeWorkbenchQueueItems,
|
||||
renderThemeWorkbenchGeneratingPanel,
|
||||
visibleA2UISubmissionNotice,
|
||||
isA2UISubmissionNoticeVisible,
|
||||
setActiveSkill,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { A2UISubmissionNoticeData } from "../components/A2UISubmissionNotice";
|
||||
import { useA2UISubmissionNotice } from "./useA2UISubmissionNotice";
|
||||
import {
|
||||
useThemeWorkbenchInputState,
|
||||
type ThemeWorkbenchGateState,
|
||||
type ThemeWorkbenchWorkflowStep,
|
||||
} from "./useThemeWorkbenchInputState";
|
||||
|
||||
interface UseInputbarDisplayStateParams {
|
||||
isThemeWorkbenchVariant: boolean;
|
||||
themeWorkbenchGate?: ThemeWorkbenchGateState | null;
|
||||
workflowSteps?: ThemeWorkbenchWorkflowStep[];
|
||||
themeWorkbenchRunState?: "idle" | "auto_running" | "await_user_decision";
|
||||
isSending: boolean;
|
||||
pendingA2UIForm: boolean;
|
||||
a2uiSubmissionNotice?: A2UISubmissionNoticeData | null;
|
||||
}
|
||||
|
||||
export function useInputbarDisplayState({
|
||||
isThemeWorkbenchVariant,
|
||||
themeWorkbenchGate,
|
||||
workflowSteps,
|
||||
themeWorkbenchRunState,
|
||||
isSending,
|
||||
pendingA2UIForm,
|
||||
a2uiSubmissionNotice,
|
||||
}: UseInputbarDisplayStateParams) {
|
||||
const {
|
||||
themeWorkbenchQuickActions,
|
||||
themeWorkbenchQueueItems,
|
||||
renderThemeWorkbenchGeneratingPanel,
|
||||
shouldShowA2UISubmissionNotice,
|
||||
} = useThemeWorkbenchInputState({
|
||||
isThemeWorkbenchVariant,
|
||||
themeWorkbenchGate,
|
||||
workflowSteps,
|
||||
themeWorkbenchRunState,
|
||||
isSending,
|
||||
hasPendingA2UIForm: pendingA2UIForm,
|
||||
hasSubmissionNotice: Boolean(a2uiSubmissionNotice),
|
||||
});
|
||||
|
||||
const {
|
||||
visibleNotice: visibleA2UISubmissionNotice,
|
||||
isVisible: isA2UISubmissionNoticeVisible,
|
||||
} = useA2UISubmissionNotice({
|
||||
notice: a2uiSubmissionNotice,
|
||||
enabled: shouldShowA2UISubmissionNotice,
|
||||
});
|
||||
|
||||
return {
|
||||
themeWorkbenchQuickActions,
|
||||
themeWorkbenchQueueItems,
|
||||
renderThemeWorkbenchGeneratingPanel,
|
||||
visibleA2UISubmissionNotice,
|
||||
isA2UISubmissionNoticeVisible,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
import type { MessageImage } from "../../../types";
|
||||
|
||||
const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover";
|
||||
|
||||
interface UseInputbarSendParams {
|
||||
input: string;
|
||||
pendingImages: MessageImage[];
|
||||
webSearchEnabled: boolean;
|
||||
thinkingEnabled: boolean;
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
activeTools: Record<string, boolean>;
|
||||
activeSkill: Skill | null;
|
||||
activeTheme?: string;
|
||||
onSend: (
|
||||
images?: MessageImage[],
|
||||
webSearch?: boolean,
|
||||
thinking?: boolean,
|
||||
textOverride?: string,
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto",
|
||||
) => void;
|
||||
clearPendingImages: () => void;
|
||||
clearActiveSkill: () => void;
|
||||
}
|
||||
|
||||
export function useInputbarSend({
|
||||
input,
|
||||
pendingImages,
|
||||
webSearchEnabled,
|
||||
thinkingEnabled,
|
||||
executionStrategy,
|
||||
activeTools,
|
||||
activeSkill,
|
||||
activeTheme,
|
||||
onSend,
|
||||
clearPendingImages,
|
||||
clearActiveSkill,
|
||||
}: UseInputbarSendParams) {
|
||||
return useCallback(() => {
|
||||
if (!input.trim() && pendingImages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webSearch = webSearchEnabled;
|
||||
const thinking = thinkingEnabled;
|
||||
let strategy =
|
||||
executionStrategy ||
|
||||
(activeTools["execution_strategy"] ? "code_orchestrated" : "react");
|
||||
|
||||
if (webSearch && strategy !== "react") {
|
||||
strategy = "react";
|
||||
}
|
||||
|
||||
let textOverride: string | undefined;
|
||||
if (activeSkill) {
|
||||
textOverride = `/${activeSkill.key} ${input}`.trim();
|
||||
} else if (
|
||||
activeTheme === "social-media" &&
|
||||
input.trim() &&
|
||||
!input.trimStart().startsWith("/")
|
||||
) {
|
||||
textOverride = `/${SOCIAL_ARTICLE_SKILL_KEY} ${input}`.trim();
|
||||
}
|
||||
|
||||
onSend(
|
||||
pendingImages.length > 0 ? pendingImages : undefined,
|
||||
webSearch,
|
||||
thinking,
|
||||
textOverride,
|
||||
strategy,
|
||||
);
|
||||
clearPendingImages();
|
||||
clearActiveSkill();
|
||||
}, [
|
||||
activeSkill,
|
||||
activeTheme,
|
||||
activeTools,
|
||||
clearActiveSkill,
|
||||
clearPendingImages,
|
||||
executionStrategy,
|
||||
input,
|
||||
onSend,
|
||||
pendingImages,
|
||||
thinkingEnabled,
|
||||
webSearchEnabled,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface InputbarToolStates {
|
||||
webSearch: boolean;
|
||||
thinking: boolean;
|
||||
}
|
||||
|
||||
interface UseInputbarToolStateParams {
|
||||
toolStates?: Partial<InputbarToolStates>;
|
||||
onToolStatesChange?: (states: InputbarToolStates) => void;
|
||||
executionStrategy?: "react" | "code_orchestrated" | "auto";
|
||||
setExecutionStrategy?: (
|
||||
strategy: "react" | "code_orchestrated" | "auto",
|
||||
) => void;
|
||||
setInput: (value: string) => void;
|
||||
onClearMessages?: () => void;
|
||||
onToggleCanvas?: () => void;
|
||||
clearPendingImages: () => void;
|
||||
openFileDialog: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_INPUTBAR_TOOL_STATES: InputbarToolStates = {
|
||||
webSearch: false,
|
||||
thinking: false,
|
||||
};
|
||||
|
||||
export function useInputbarToolState({
|
||||
toolStates,
|
||||
onToolStatesChange,
|
||||
executionStrategy,
|
||||
setExecutionStrategy,
|
||||
setInput,
|
||||
onClearMessages,
|
||||
onToggleCanvas,
|
||||
clearPendingImages,
|
||||
openFileDialog,
|
||||
}: UseInputbarToolStateParams) {
|
||||
const [localActiveTools, setLocalActiveTools] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [localToolStates, setLocalToolStates] = useState<InputbarToolStates>(
|
||||
DEFAULT_INPUTBAR_TOOL_STATES,
|
||||
);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const webSearchEnabled =
|
||||
toolStates?.webSearch ?? localToolStates.webSearch;
|
||||
const thinkingEnabled = toolStates?.thinking ?? localToolStates.thinking;
|
||||
|
||||
const activeTools = useMemo<Record<string, boolean>>(
|
||||
() => ({
|
||||
...localActiveTools,
|
||||
web_search: webSearchEnabled,
|
||||
thinking: thinkingEnabled,
|
||||
}),
|
||||
[localActiveTools, thinkingEnabled, webSearchEnabled],
|
||||
);
|
||||
|
||||
const updateToolStates = useCallback(
|
||||
(next: InputbarToolStates) => {
|
||||
setLocalToolStates((prev) => ({
|
||||
webSearch: toolStates?.webSearch ?? next.webSearch ?? prev.webSearch,
|
||||
thinking: toolStates?.thinking ?? next.thinking ?? prev.thinking,
|
||||
}));
|
||||
onToolStatesChange?.(next);
|
||||
return next;
|
||||
},
|
||||
[onToolStatesChange, toolStates?.thinking, toolStates?.webSearch],
|
||||
);
|
||||
|
||||
const handleToolClick = useCallback(
|
||||
(tool: string) => {
|
||||
switch (tool) {
|
||||
case "thinking": {
|
||||
const nextThinking = !thinkingEnabled;
|
||||
updateToolStates({
|
||||
webSearch: webSearchEnabled,
|
||||
thinking: nextThinking,
|
||||
});
|
||||
toast.info(`深度思考${nextThinking ? "已开启" : "已关闭"}`);
|
||||
break;
|
||||
}
|
||||
case "web_search": {
|
||||
const nextWebSearch = !webSearchEnabled;
|
||||
updateToolStates({
|
||||
webSearch: nextWebSearch,
|
||||
thinking: thinkingEnabled,
|
||||
});
|
||||
toast.info(`联网搜索${nextWebSearch ? "已开启" : "已关闭"}`);
|
||||
break;
|
||||
}
|
||||
case "execution_strategy":
|
||||
if (setExecutionStrategy) {
|
||||
const strategyOrder: Array<
|
||||
"react" | "code_orchestrated" | "auto"
|
||||
> = ["react", "code_orchestrated", "auto"];
|
||||
const currentIndex = strategyOrder.indexOf(
|
||||
executionStrategy || "react",
|
||||
);
|
||||
const nextStrategy =
|
||||
strategyOrder[(currentIndex + 1) % strategyOrder.length];
|
||||
setExecutionStrategy(nextStrategy);
|
||||
toast.info(
|
||||
nextStrategy === "react"
|
||||
? "执行模式:ReAct"
|
||||
: nextStrategy === "code_orchestrated"
|
||||
? "执行模式:Plan"
|
||||
: "执行模式:Auto",
|
||||
);
|
||||
break;
|
||||
}
|
||||
setLocalActiveTools((prev) => {
|
||||
const enabled = !prev["execution_strategy"];
|
||||
toast.info(`Plan 模式${enabled ? "已开启" : "已关闭"}`);
|
||||
return { ...prev, execution_strategy: enabled };
|
||||
});
|
||||
break;
|
||||
case "clear":
|
||||
setInput("");
|
||||
clearPendingImages();
|
||||
toast.success("已清除输入");
|
||||
break;
|
||||
case "new_topic":
|
||||
onClearMessages?.();
|
||||
setInput("");
|
||||
clearPendingImages();
|
||||
break;
|
||||
case "attach":
|
||||
openFileDialog();
|
||||
break;
|
||||
case "quick_action":
|
||||
case "translate":
|
||||
toast.info("翻译功能开发中...");
|
||||
break;
|
||||
case "fullscreen":
|
||||
setIsFullscreen((prev) => !prev);
|
||||
toast.info(isFullscreen ? "已退出全屏" : "已进入全屏编辑");
|
||||
break;
|
||||
case "canvas":
|
||||
onToggleCanvas?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
clearPendingImages,
|
||||
executionStrategy,
|
||||
isFullscreen,
|
||||
onClearMessages,
|
||||
onToggleCanvas,
|
||||
openFileDialog,
|
||||
setExecutionStrategy,
|
||||
setInput,
|
||||
thinkingEnabled,
|
||||
updateToolStates,
|
||||
webSearchEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
activeTools,
|
||||
handleToolClick,
|
||||
isFullscreen,
|
||||
thinkingEnabled,
|
||||
webSearchEnabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMemo } from "react";
|
||||
import type { StepStatus } from "@/components/content-creator/types";
|
||||
|
||||
export interface ThemeWorkbenchGateState {
|
||||
key: string;
|
||||
title: string;
|
||||
status: "running" | "waiting" | "idle";
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ThemeWorkbenchWorkflowStep {
|
||||
id: string;
|
||||
title: string;
|
||||
status: StepStatus;
|
||||
}
|
||||
|
||||
export interface ThemeWorkbenchQuickAction {
|
||||
id: string;
|
||||
label: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
interface UseThemeWorkbenchInputStateParams {
|
||||
isThemeWorkbenchVariant: boolean;
|
||||
themeWorkbenchGate?: ThemeWorkbenchGateState | null;
|
||||
workflowSteps?: ThemeWorkbenchWorkflowStep[];
|
||||
themeWorkbenchRunState?: "idle" | "auto_running" | "await_user_decision";
|
||||
isSending: boolean;
|
||||
hasPendingA2UIForm: boolean;
|
||||
hasSubmissionNotice: boolean;
|
||||
}
|
||||
|
||||
function resolveThemeWorkbenchQuickActions(
|
||||
gateKey?: string,
|
||||
): ThemeWorkbenchQuickAction[] {
|
||||
switch (gateKey) {
|
||||
case "topic_select":
|
||||
return [
|
||||
{
|
||||
id: "topic-options",
|
||||
label: "生成 3 个选题",
|
||||
prompt: "请给我 3 个可执行选题方向,并说明目标读者与传播价值。",
|
||||
},
|
||||
{
|
||||
id: "topic-choose-b",
|
||||
label: "采纳 B 方向",
|
||||
prompt: "我采纳 B 方向,请继续推进主稿与配图编排。",
|
||||
},
|
||||
];
|
||||
case "write_mode":
|
||||
return [
|
||||
{
|
||||
id: "write-fast",
|
||||
label: "快速模式出稿",
|
||||
prompt: "请按快速模式生成可发布主稿,并标注可优化段落。",
|
||||
},
|
||||
{
|
||||
id: "write-coach",
|
||||
label: "教练模式引导",
|
||||
prompt: "请按教练模式逐步提问我,帮助补充真实案例后再成稿。",
|
||||
},
|
||||
];
|
||||
case "publish_confirm":
|
||||
return [
|
||||
{
|
||||
id: "publish-checklist",
|
||||
label: "发布前检查",
|
||||
prompt: "请给我发布前检查清单,包含标题、封面、平台合规与风险项。",
|
||||
},
|
||||
{
|
||||
id: "publish-now",
|
||||
label: "进入发布整理",
|
||||
prompt: "请整理最终发布稿,并输出配套标题、摘要和封面文案。",
|
||||
},
|
||||
];
|
||||
default:
|
||||
return [
|
||||
{
|
||||
id: "next-step",
|
||||
label: "继续编排",
|
||||
prompt: "请继续按照当前编排推进,并在关键闸门前向我确认。",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function useThemeWorkbenchInputState({
|
||||
isThemeWorkbenchVariant,
|
||||
themeWorkbenchGate,
|
||||
workflowSteps = [],
|
||||
themeWorkbenchRunState,
|
||||
isSending,
|
||||
hasPendingA2UIForm,
|
||||
hasSubmissionNotice,
|
||||
}: UseThemeWorkbenchInputStateParams) {
|
||||
const themeWorkbenchQuickActions = useMemo(
|
||||
() =>
|
||||
isThemeWorkbenchVariant
|
||||
? resolveThemeWorkbenchQuickActions(themeWorkbenchGate?.key)
|
||||
: [],
|
||||
[isThemeWorkbenchVariant, themeWorkbenchGate?.key],
|
||||
);
|
||||
|
||||
const themeWorkbenchQueueItems = useMemo(() => {
|
||||
if (!isThemeWorkbenchVariant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const visibleSteps = workflowSteps
|
||||
.filter((step) => step.status !== "completed" && step.status !== "skipped")
|
||||
.slice(0, 3);
|
||||
|
||||
if (visibleSteps.length > 0) {
|
||||
return visibleSteps;
|
||||
}
|
||||
|
||||
if (themeWorkbenchGate) {
|
||||
return [
|
||||
{
|
||||
id: `gate-${themeWorkbenchGate.key}`,
|
||||
title: themeWorkbenchGate.title,
|
||||
status:
|
||||
themeWorkbenchGate.status === "waiting"
|
||||
? ("pending" as StepStatus)
|
||||
: ("active" as StepStatus),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [isThemeWorkbenchVariant, themeWorkbenchGate, workflowSteps]);
|
||||
|
||||
const renderThemeWorkbenchGeneratingPanel = isThemeWorkbenchVariant
|
||||
? themeWorkbenchRunState
|
||||
? themeWorkbenchRunState === "auto_running"
|
||||
: isSending
|
||||
: false;
|
||||
|
||||
const shouldShowA2UISubmissionNotice = Boolean(
|
||||
!hasPendingA2UIForm &&
|
||||
hasSubmissionNotice &&
|
||||
(!isThemeWorkbenchVariant ||
|
||||
(!renderThemeWorkbenchGeneratingPanel &&
|
||||
themeWorkbenchQueueItems.length === 0 &&
|
||||
(themeWorkbenchGate?.status ?? "idle") === "idle")),
|
||||
);
|
||||
|
||||
return {
|
||||
themeWorkbenchQuickActions,
|
||||
themeWorkbenchQueueItems,
|
||||
renderThemeWorkbenchGeneratingPanel,
|
||||
shouldShowA2UISubmissionNotice,
|
||||
};
|
||||
}
|
||||
@@ -6,12 +6,10 @@ import { Inputbar } from "./index";
|
||||
import type { Character } from "@/lib/api/memory";
|
||||
import type { Skill } from "@/lib/api/skills";
|
||||
|
||||
const mockCharacterMention = vi.fn<
|
||||
(props: {
|
||||
characters?: Character[];
|
||||
skills?: Skill[];
|
||||
}) => React.ReactNode
|
||||
>();
|
||||
const mockCharacterMention =
|
||||
vi.fn<
|
||||
(props: { characters?: Character[]; skills?: Skill[] }) => React.ReactNode
|
||||
>();
|
||||
const mockInputbarCore = vi.fn(
|
||||
(props: {
|
||||
onToolClick?: (tool: string) => void;
|
||||
@@ -34,7 +32,11 @@ const mockInputbarCore = vi.fn(
|
||||
<span data-testid="web-search-state">
|
||||
{props.activeTools?.web_search ? "on" : "off"}
|
||||
</span>
|
||||
<button type="button" data-testid="send-btn" onClick={() => props.onSend?.()}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="send-btn"
|
||||
onClick={() => props.onSend?.()}
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
<div data-testid="right-extra">{props.rightExtra}</div>
|
||||
@@ -57,10 +59,7 @@ vi.mock("./components/InputbarCore", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./components/CharacterMention", () => ({
|
||||
CharacterMention: (props: {
|
||||
characters?: Character[];
|
||||
skills?: Skill[];
|
||||
}) => {
|
||||
CharacterMention: (props: { characters?: Character[]; skills?: Skill[] }) => {
|
||||
mockCharacterMention(props);
|
||||
return <div data-testid="character-mention-stub" />;
|
||||
},
|
||||
@@ -91,11 +90,15 @@ vi.mock("@/lib/dev-bridge", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Select: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
@@ -169,7 +172,9 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderInputbar(props?: Partial<React.ComponentProps<typeof Inputbar>>) {
|
||||
function renderInputbar(
|
||||
props?: Partial<React.ComponentProps<typeof Inputbar>>,
|
||||
) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
@@ -198,11 +203,15 @@ describe("Inputbar", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const mention = container.querySelector('[data-testid="character-mention-stub"]');
|
||||
const mention = container.querySelector(
|
||||
'[data-testid="character-mention-stub"]',
|
||||
);
|
||||
expect(mention).toBeTruthy();
|
||||
expect(mockCharacterMention.mock.calls.length).toBeGreaterThan(0);
|
||||
const latestCall =
|
||||
mockCharacterMention.mock.calls[mockCharacterMention.mock.calls.length - 1][0];
|
||||
mockCharacterMention.mock.calls[
|
||||
mockCharacterMention.mock.calls.length - 1
|
||||
][0];
|
||||
expect(latestCall.characters).toEqual([]);
|
||||
expect(latestCall.skills).toEqual([]);
|
||||
});
|
||||
@@ -331,7 +340,7 @@ describe("Inputbar", () => {
|
||||
expect(latestCall.toolMode).toBe("attach-only");
|
||||
expect(latestCall.showTranslate).toBe(false);
|
||||
expect(latestCall.placeholder).toContain("试着输入任何指令");
|
||||
expect(latestCall.rightExtra).toBeUndefined();
|
||||
expect(latestCall.rightExtra).toBeDefined();
|
||||
});
|
||||
|
||||
it("主题工作台在待启动状态下不应显示闸门条", async () => {
|
||||
@@ -376,13 +385,15 @@ describe("Inputbar", () => {
|
||||
expect(container.textContent).not.toContain("当前闸门");
|
||||
expect(container.textContent).not.toContain("请选择优先推进的选题方向。");
|
||||
|
||||
const quickActionButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("生成 3 个选题"),
|
||||
);
|
||||
const quickActionButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("生成 3 个选题"));
|
||||
expect(quickActionButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
quickActionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
quickActionButton?.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(setInput).toHaveBeenCalledWith(
|
||||
@@ -438,9 +449,9 @@ describe("Inputbar", () => {
|
||||
|
||||
expect(container.textContent).toContain("检索项目素材");
|
||||
|
||||
const collapseButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.getAttribute("aria-label") === "折叠待办列表",
|
||||
);
|
||||
const collapseButton = Array.from(
|
||||
container.querySelectorAll("button"),
|
||||
).find((button) => button.getAttribute("aria-label") === "折叠待办列表");
|
||||
expect(collapseButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
@@ -497,8 +508,9 @@ describe("Inputbar", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="inputbar-core"]')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('[data-testid="inputbar-core"]'),
|
||||
).toBeTruthy();
|
||||
expect(container.textContent).not.toContain("正在生成中");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,12 @@ import rehypeRaw from "rehype-raw";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import styled from "styled-components";
|
||||
import { Copy, Check, Loader2 } from "lucide-react";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { parseA2UIJson } from "@/components/content-creator/a2ui/parser";
|
||||
import { A2UIRenderer } from "@/components/content-creator/a2ui/components";
|
||||
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
|
||||
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
|
||||
import { ArtifactPlaceholder } from "./ArtifactPlaceholder";
|
||||
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
|
||||
|
||||
// Custom styles for markdown content to match Cherry Studio
|
||||
const MarkdownContainer = styled.div`
|
||||
@@ -223,41 +224,6 @@ const CopyButton = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
// A2UI 加载状态样式
|
||||
const A2UILoadingContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 0.05) 0%,
|
||||
hsl(var(--primary) / 0.1) 100%
|
||||
);
|
||||
border: 1px solid hsl(var(--primary) / 0.2);
|
||||
border-radius: 12px;
|
||||
margin: 12px 0;
|
||||
`;
|
||||
|
||||
const A2UILoadingSpinner = styled.div`
|
||||
animation: spin 1s linear infinite;
|
||||
color: hsl(var(--primary));
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const A2UILoadingText = styled.span`
|
||||
font-size: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
`;
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
/** A2UI 表单提交回调 */
|
||||
@@ -419,21 +385,19 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = memo(
|
||||
if (parsed) {
|
||||
// 解析成功,直接渲染 A2UI 组件(不包裹在 pre 中)
|
||||
return (
|
||||
<A2UIRenderer
|
||||
<A2UITaskCard
|
||||
response={parsed}
|
||||
onSubmit={onA2UISubmit}
|
||||
className="my-3"
|
||||
preset={CHAT_A2UI_TASK_CARD_PRESET}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// 解析失败(可能是流式输出中,JSON 还不完整)
|
||||
return (
|
||||
<A2UILoadingContainer>
|
||||
<A2UILoadingSpinner>
|
||||
<Loader2 size={20} />
|
||||
</A2UILoadingSpinner>
|
||||
<A2UILoadingText>表单加载中...</A2UILoadingText>
|
||||
</A2UILoadingContainer>
|
||||
<A2UITaskLoadingCard
|
||||
preset={CHAT_A2UI_TASK_CARD_PRESET}
|
||||
subtitle="正在解析结构化问题,请稍等。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { CheckCheck, LibraryBig, Palette, RefreshCcw, Settings2, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { StyleLibraryPickerDialog } from "@/components/style-library/StyleLibraryPickerDialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { StyleGuidePanel } from "@/components/projects/memory/StyleGuidePanel";
|
||||
import type { ThemeType } from "@/components/content-creator/types";
|
||||
import type { StyleGuide } from "@/lib/api/memory";
|
||||
import {
|
||||
DEFAULT_STYLE_PROFILE,
|
||||
buildRuntimeStyleOverridePrompt,
|
||||
describeRuntimeStyleSelection,
|
||||
getAvailableStylePresets,
|
||||
getStylePresetById,
|
||||
getStyleProfileFromGuide,
|
||||
type RuntimeStyleSelection,
|
||||
} from "@/lib/style-guide";
|
||||
|
||||
interface RuntimeStyleControlBarProps {
|
||||
projectId: string;
|
||||
activeTheme: ThemeType;
|
||||
projectStyleGuide?: StyleGuide | null;
|
||||
selection: RuntimeStyleSelection;
|
||||
onSelectionChange: (selection: RuntimeStyleSelection) => void;
|
||||
onRewrite: () => void;
|
||||
onAudit: () => void;
|
||||
actionsDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function RuntimeStyleControlBar({
|
||||
projectId,
|
||||
activeTheme,
|
||||
projectStyleGuide,
|
||||
selection,
|
||||
onSelectionChange,
|
||||
onRewrite,
|
||||
onAudit,
|
||||
actionsDisabled = false,
|
||||
}: RuntimeStyleControlBarProps) {
|
||||
const [styleGuideDialogOpen, setStyleGuideDialogOpen] = useState(false);
|
||||
const [styleLibraryDialogOpen, setStyleLibraryDialogOpen] = useState(false);
|
||||
const presets = useMemo(() => getAvailableStylePresets(activeTheme), [activeTheme]);
|
||||
const summary = useMemo(
|
||||
() =>
|
||||
describeRuntimeStyleSelection({
|
||||
projectStyleGuide,
|
||||
selection,
|
||||
}),
|
||||
[projectStyleGuide, selection],
|
||||
);
|
||||
const previewPrompt = useMemo(
|
||||
() =>
|
||||
buildRuntimeStyleOverridePrompt({
|
||||
projectStyleGuide,
|
||||
selection,
|
||||
activeTheme,
|
||||
}),
|
||||
[activeTheme, projectStyleGuide, selection],
|
||||
);
|
||||
const hasProjectDefaultStyle = Boolean(getStyleProfileFromGuide(projectStyleGuide));
|
||||
|
||||
const handlePresetChange = (value: string) => {
|
||||
const nextStrength =
|
||||
value === "project-default"
|
||||
? getStyleProfileFromGuide(projectStyleGuide)?.simulationStrength ||
|
||||
DEFAULT_STYLE_PROFILE.simulationStrength
|
||||
: getStylePresetById(value)?.profile.simulationStrength ||
|
||||
selection.strength;
|
||||
|
||||
onSelectionChange({
|
||||
...selection,
|
||||
presetId: value,
|
||||
strength: nextStrength,
|
||||
source: value === "project-default" ? "project-default" : "preset",
|
||||
sourceLabel: undefined,
|
||||
sourceProfile: null,
|
||||
});
|
||||
};
|
||||
|
||||
const clearLibrarySelection = () => {
|
||||
const fallbackStrength =
|
||||
getStyleProfileFromGuide(projectStyleGuide)?.simulationStrength ||
|
||||
DEFAULT_STYLE_PROFILE.simulationStrength;
|
||||
|
||||
onSelectionChange({
|
||||
...selection,
|
||||
presetId: "project-default",
|
||||
strength: fallbackStrength,
|
||||
source: "project-default",
|
||||
sourceLabel: undefined,
|
||||
sourceProfile: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mx-4 mt-3 border-dashed bg-muted/20">
|
||||
<CardContent className="space-y-3 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="mr-1 inline-flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Palette className="h-4 w-4" />
|
||||
任务风格
|
||||
</div>
|
||||
|
||||
<Select value={selection.presetId} onValueChange={handlePresetChange}>
|
||||
<SelectTrigger className="h-9 w-[210px] bg-background">
|
||||
<SelectValue placeholder="选择本次任务风格" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="project-default">使用项目默认风格</SelectItem>
|
||||
{presets.map((preset) => (
|
||||
<SelectItem key={preset.id} value={preset.id}>
|
||||
{preset.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
本次模拟说明
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96 space-y-3" align="start">
|
||||
<div>
|
||||
<div className="text-sm font-medium">临时风格备注</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
用来描述这次想模拟的额外风格,例如“更像知识型创作者,但保持克制,不要营销感”。
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={selection.customNotes}
|
||||
onChange={(event) =>
|
||||
onSelectionChange({
|
||||
...selection,
|
||||
customNotes: event.target.value,
|
||||
})
|
||||
}
|
||||
rows={6}
|
||||
placeholder="写下本次风格模拟要求..."
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => setStyleLibraryDialogOpen(true)}
|
||||
>
|
||||
<LibraryBig className="h-4 w-4" />
|
||||
从我的风格库选择
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => setStyleGuideDialogOpen(true)}
|
||||
>
|
||||
<Palette className="h-4 w-4" />
|
||||
编辑项目风格
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={onAudit} disabled={actionsDisabled}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
检查风格
|
||||
</Button>
|
||||
<Button size="sm" className="gap-2" onClick={onRewrite} disabled={actionsDisabled}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
按当前风格重写
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px] lg:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>{summary}</span>
|
||||
{selection.source === "library" && selection.sourceLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearLibrarySelection}
|
||||
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
来自我的风格:{selection.sourceLabel}
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{previewPrompt || "未设置临时风格覆盖,将沿用项目默认风格与当前创作上下文。"}
|
||||
</p>
|
||||
{!hasProjectDefaultStyle && (
|
||||
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
当前项目还没有默认风格,建议先点“编辑项目风格”配置基线风格。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>风格强度</span>
|
||||
<span>{selection.strength}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[selection.strength]}
|
||||
onValueChange={([value]) =>
|
||||
onSelectionChange({
|
||||
...selection,
|
||||
strength: value,
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<Dialog open={styleGuideDialogOpen} onOpenChange={setStyleGuideDialogOpen}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto p-0">
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle>项目默认风格</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-6">
|
||||
<StyleGuidePanel projectId={projectId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<StyleLibraryPickerDialog
|
||||
open={styleLibraryDialogOpen}
|
||||
onOpenChange={setStyleLibraryDialogOpen}
|
||||
onSelect={(entry) =>
|
||||
onSelectionChange({
|
||||
...selection,
|
||||
presetId: "project-default",
|
||||
strength: entry.profile.simulationStrength,
|
||||
source: "library",
|
||||
sourceLabel: entry.profile.name,
|
||||
sourceProfile: entry.profile,
|
||||
})
|
||||
}
|
||||
theme={activeTheme}
|
||||
onlyEnabled
|
||||
title="选择本次任务要使用的我的风格"
|
||||
description="从你上传或保存的风格中选择一条,作为当前任务的临时风格覆盖。"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -9,11 +9,12 @@ import React, { memo, useMemo, useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, Lightbulb, FileText } from "lucide-react";
|
||||
import { MarkdownRenderer } from "./MarkdownRenderer";
|
||||
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
|
||||
import { ToolCallList, ToolCallItem } from "./ToolCallDisplay";
|
||||
import { DecisionPanel } from "./DecisionPanel";
|
||||
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
|
||||
import { A2UIRenderer } from "@/components/content-creator/a2ui/components";
|
||||
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
|
||||
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
|
||||
import type { ToolCallState } from "@/lib/api/agent";
|
||||
import type { ContentPart, ActionRequired, ConfirmResponse } from "../types";
|
||||
|
||||
@@ -77,6 +78,12 @@ interface StreamingTextProps {
|
||||
charInterval?: number;
|
||||
/** A2UI 表单提交回调 */
|
||||
onA2UISubmit?: (formData: A2UIFormData) => void;
|
||||
/** A2UI 表单 ID(用于持久化) */
|
||||
a2uiFormId?: string;
|
||||
/** A2UI 初始表单数据(从数据库加载) */
|
||||
a2uiInitialFormData?: A2UIFormData;
|
||||
/** A2UI 表单数据变化回调(用于持久化) */
|
||||
onA2UIFormChange?: (formId: string, formData: A2UIFormData) => void;
|
||||
/** 是否折叠代码块 */
|
||||
collapseCodeBlocks?: boolean;
|
||||
/** 代码块点击回调 */
|
||||
@@ -96,6 +103,9 @@ const StreamingText: React.FC<StreamingTextProps> = memo(
|
||||
showCursor = true,
|
||||
charInterval = 12,
|
||||
onA2UISubmit,
|
||||
a2uiFormId,
|
||||
a2uiInitialFormData,
|
||||
onA2UIFormChange,
|
||||
collapseCodeBlocks,
|
||||
onCodeBlockClick,
|
||||
}) => {
|
||||
@@ -220,11 +230,14 @@ const StreamingText: React.FC<StreamingTextProps> = memo(
|
||||
// 直接渲染 A2UI 表单
|
||||
if (typeof part.content !== "string") {
|
||||
return (
|
||||
<A2UIRenderer
|
||||
<A2UITaskCard
|
||||
key={`a2ui-${index}`}
|
||||
response={part.content}
|
||||
onSubmit={onA2UISubmit}
|
||||
className="my-3"
|
||||
formId={a2uiFormId}
|
||||
initialFormData={a2uiInitialFormData}
|
||||
onFormChange={onA2UIFormChange}
|
||||
preset={CHAT_A2UI_TASK_CARD_PRESET}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -233,15 +246,11 @@ const StreamingText: React.FC<StreamingTextProps> = memo(
|
||||
case "pending_a2ui":
|
||||
// 显示加载状态
|
||||
return (
|
||||
<div
|
||||
<A2UITaskLoadingCard
|
||||
key={`pending-${index}`}
|
||||
className="flex items-center gap-2 px-3 py-4 bg-muted/50 rounded-lg animate-pulse"
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-muted-foreground/20" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
表单加载中...
|
||||
</span>
|
||||
</div>
|
||||
preset={CHAT_A2UI_TASK_CARD_PRESET}
|
||||
subtitle="正在解析结构化问题,请稍等。"
|
||||
/>
|
||||
);
|
||||
|
||||
case "text":
|
||||
@@ -549,6 +558,9 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
pIndex === partParsed.parts.length - 1
|
||||
}
|
||||
onA2UISubmit={onA2UISubmit}
|
||||
a2uiFormId={a2uiFormId}
|
||||
a2uiInitialFormData={a2uiInitialFormData}
|
||||
onA2UIFormChange={onA2UIFormChange}
|
||||
collapseCodeBlocks={collapseCodeBlocks}
|
||||
onCodeBlockClick={onCodeBlockClick}
|
||||
/>
|
||||
@@ -568,6 +580,9 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
isStreaming={isStreaming && isLastPart}
|
||||
showCursor={shouldShowCursor && isLastPart}
|
||||
onA2UISubmit={onA2UISubmit}
|
||||
a2uiFormId={a2uiFormId}
|
||||
a2uiInitialFormData={a2uiInitialFormData}
|
||||
onA2UIFormChange={onA2UIFormChange}
|
||||
collapseCodeBlocks={collapseCodeBlocks}
|
||||
onCodeBlockClick={onCodeBlockClick}
|
||||
/>
|
||||
@@ -630,14 +645,14 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
// 渲染 A2UI 表单 - content 是 A2UIResponse 类型
|
||||
if (typeof part.content !== "string") {
|
||||
return (
|
||||
<A2UIRenderer
|
||||
<A2UITaskCard
|
||||
key={`a2ui-${index}`}
|
||||
response={part.content}
|
||||
onSubmit={onA2UISubmit}
|
||||
formId={a2uiFormId}
|
||||
initialFormData={a2uiInitialFormData}
|
||||
onFormChange={onA2UIFormChange}
|
||||
className="my-3"
|
||||
preset={CHAT_A2UI_TASK_CARD_PRESET}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -701,6 +716,9 @@ export const StreamingRenderer: React.FC<StreamingRendererProps> = memo(
|
||||
shouldShowCursor && index === parsedContent.parts.length - 1
|
||||
}
|
||||
onA2UISubmit={onA2UISubmit}
|
||||
a2uiFormId={a2uiFormId}
|
||||
a2uiInitialFormData={a2uiInitialFormData}
|
||||
onA2UIFormChange={onA2UIFormChange}
|
||||
collapseCodeBlocks={collapseCodeBlocks}
|
||||
onCodeBlockClick={onCodeBlockClick}
|
||||
/>
|
||||
|
||||
@@ -1065,7 +1065,7 @@ const ActivityStepItem = styled.div`
|
||||
padding: 5px 6px;
|
||||
`;
|
||||
|
||||
const _RunLinkButton = styled.button`
|
||||
const RunLinkButton = styled.button`
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
@@ -1081,7 +1081,7 @@ const _RunLinkButton = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
const _RunDetailPanel = styled.div`
|
||||
const RunDetailPanel = styled.div`
|
||||
margin-top: 8px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
@@ -1089,14 +1089,14 @@ const _RunDetailPanel = styled.div`
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const _RunDetailTitle = styled.div`
|
||||
const RunDetailTitle = styled.div`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 6px;
|
||||
`;
|
||||
|
||||
const _RunDetailRow = styled.div`
|
||||
const RunDetailRow = styled.div`
|
||||
font-size: 11px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.45;
|
||||
@@ -1129,7 +1129,7 @@ const RunDetailArtifactPath = styled.code`
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const _RunDetailCode = styled.pre`
|
||||
const RunDetailCode = styled.pre`
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
@@ -1179,7 +1179,7 @@ function getBranchStatusText(status: TopicBranchStatus): string {
|
||||
return "备选";
|
||||
}
|
||||
|
||||
function _formatGateLabel(
|
||||
function formatGateLabel(
|
||||
gateKey?: SidebarActivityLog["gateKey"],
|
||||
): string | null {
|
||||
if (!gateKey || gateKey === "idle") {
|
||||
@@ -1197,7 +1197,7 @@ function _formatGateLabel(
|
||||
return null;
|
||||
}
|
||||
|
||||
function _formatRunIdShort(runId?: string): string | null {
|
||||
function formatRunIdShort(runId?: string): string | null {
|
||||
const trimmed = runId?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
@@ -1208,7 +1208,7 @@ function _formatRunIdShort(runId?: string): string | null {
|
||||
return `${trimmed.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
function _formatRunStatusLabel(status: AgentRun["status"]): string {
|
||||
function formatRunStatusLabel(status: AgentRun["status"]): string {
|
||||
if (status === "queued") return "排队中";
|
||||
if (status === "running") return "运行中";
|
||||
if (status === "success") return "成功";
|
||||
@@ -1605,7 +1605,7 @@ function ThemeWorkbenchSidebarComponent({
|
||||
onRequestCollapse,
|
||||
messages = [],
|
||||
}: ThemeWorkbenchSidebarProps) {
|
||||
const [showActivityLogs, _setShowActivityLogs] = useState(false);
|
||||
const [showActivityLogs, setShowActivityLogs] = useState(false);
|
||||
const [showCreationTasks, setShowCreationTasks] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<SidebarTab>("context");
|
||||
const [selectedSearchResultId, setSelectedSearchResultId] = useState<string | null>(null);
|
||||
@@ -1620,16 +1620,16 @@ function ThemeWorkbenchSidebarComponent({
|
||||
);
|
||||
const progressPercent =
|
||||
workflowSteps.length > 0 ? (completedSteps / workflowSteps.length) * 100 : 0;
|
||||
const _runMetadataText = useMemo(
|
||||
const runMetadataText = useMemo(
|
||||
() => formatRunMetadata(activeRunDetail?.metadata ?? null),
|
||||
[activeRunDetail?.metadata],
|
||||
);
|
||||
const _runMetadataSummary = useMemo(
|
||||
const runMetadataSummary = useMemo(
|
||||
() => parseRunMetadataSummary(activeRunDetail?.metadata ?? null),
|
||||
[activeRunDetail?.metadata],
|
||||
);
|
||||
const runDetailSessionId = activeRunDetail?.session_id?.trim() || null;
|
||||
const _handleRevealArtifactInFinder = useCallback(
|
||||
const handleRevealArtifactInFinder = useCallback(
|
||||
async (artifactPath: string, sessionId?: string | null) => {
|
||||
const resolvedSessionId = sessionId?.trim() || runDetailSessionId;
|
||||
if (!resolvedSessionId) {
|
||||
@@ -1645,7 +1645,7 @@ function ThemeWorkbenchSidebarComponent({
|
||||
},
|
||||
[runDetailSessionId],
|
||||
);
|
||||
const _handleOpenArtifactWithDefaultApp = useCallback(
|
||||
const handleOpenArtifactWithDefaultApp = useCallback(
|
||||
async (artifactPath: string, sessionId?: string | null) => {
|
||||
const resolvedSessionId = sessionId?.trim() || runDetailSessionId;
|
||||
if (!resolvedSessionId) {
|
||||
@@ -1981,6 +1981,97 @@ function ThemeWorkbenchSidebarComponent({
|
||||
});
|
||||
}, [creationTaskEvents]);
|
||||
|
||||
const renderActivityLogItem = useCallback(
|
||||
(group: ActivityLogGroup) => {
|
||||
const gateLabel = formatGateLabel(group.gateKey);
|
||||
const runLabel = formatRunIdShort(group.runId);
|
||||
const sourceLabel = group.source?.trim() || "-";
|
||||
const primaryLog =
|
||||
group.logs.find((log) => log.source === "skill") || group.logs[0];
|
||||
|
||||
return (
|
||||
<ActivityItem key={`activity-${group.key}`}>
|
||||
<ActivityGroupHeader>
|
||||
<span>●</span>
|
||||
<span>{primaryLog?.source === "skill" ? `技能:${primaryLog.name}` : primaryLog?.name || "活动日志"}</span>
|
||||
<span style={{ marginLeft: "auto" }}>{group.timeLabel}</span>
|
||||
</ActivityGroupHeader>
|
||||
{gateLabel || sourceLabel ? (
|
||||
<ActivityMeta>
|
||||
{gateLabel ? `闸门:${gateLabel}` : ""}
|
||||
{gateLabel && sourceLabel ? " · " : ""}
|
||||
{sourceLabel ? `来源:${sourceLabel}` : ""}
|
||||
</ActivityMeta>
|
||||
) : null}
|
||||
{group.artifactPaths.length > 0 ? (
|
||||
<ActivityMeta>
|
||||
修改:{group.artifactPaths.join("、")}
|
||||
</ActivityMeta>
|
||||
) : null}
|
||||
<ActivityStepList>
|
||||
{group.logs.map((log) => (
|
||||
<ActivityStepItem key={log.id}>
|
||||
<ActivityTitle>
|
||||
<span>•</span>
|
||||
<span>{log.name}</span>
|
||||
<span style={{ marginLeft: "auto" }}>{log.timeLabel}</span>
|
||||
</ActivityTitle>
|
||||
{log.inputSummary ? (
|
||||
<ActivityMeta>输入:{log.inputSummary}</ActivityMeta>
|
||||
) : null}
|
||||
{log.outputSummary ? (
|
||||
<ActivityMeta>输出:{log.outputSummary}</ActivityMeta>
|
||||
) : null}
|
||||
</ActivityStepItem>
|
||||
))}
|
||||
</ActivityStepList>
|
||||
<ActionRow>
|
||||
{group.runId && onViewRunDetail ? (
|
||||
<RunLinkButton
|
||||
type="button"
|
||||
onClick={() => onViewRunDetail(group.runId!)}
|
||||
>
|
||||
运行:{runLabel || group.runId}
|
||||
</RunLinkButton>
|
||||
) : null}
|
||||
{group.artifactPaths.map((artifactPath) => (
|
||||
<React.Fragment key={`${group.key}-${artifactPath}`}>
|
||||
<TinyButton
|
||||
type="button"
|
||||
aria-label={`定位活动产物路径-${artifactPath}`}
|
||||
onClick={() => {
|
||||
void handleRevealArtifactInFinder(artifactPath, group.sessionId || null);
|
||||
}}
|
||||
>
|
||||
定位产物
|
||||
</TinyButton>
|
||||
<TinyButton
|
||||
type="button"
|
||||
aria-label={`打开活动产物路径-${artifactPath}`}
|
||||
onClick={() => {
|
||||
void handleOpenArtifactWithDefaultApp(artifactPath, group.sessionId || null);
|
||||
}}
|
||||
>
|
||||
打开产物
|
||||
</TinyButton>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ActionRow>
|
||||
</ActivityItem>
|
||||
);
|
||||
},
|
||||
[handleOpenArtifactWithDefaultApp, handleRevealArtifactInFinder, onViewRunDetail],
|
||||
);
|
||||
|
||||
const activeRunStagesLabel = useMemo(() => {
|
||||
if (runMetadataSummary.stages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return runMetadataSummary.stages
|
||||
.map((stage) => _formatStageLabelByKey(stage))
|
||||
.join(" → ");
|
||||
}, [runMetadataSummary.stages]);
|
||||
|
||||
// ── 执行日志 entries(从 messages 解析)──
|
||||
interface ExecLogEntry {
|
||||
id: string;
|
||||
@@ -2632,6 +2723,121 @@ function ThemeWorkbenchSidebarComponent({
|
||||
</ActivityList>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionTitle>
|
||||
<span>活动日志</span>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<SectionBadge>{groupedActivityLogs.length}</SectionBadge>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="切换活动日志"
|
||||
onClick={() => setShowActivityLogs((previous) => !previous)}
|
||||
style={{
|
||||
border: 0,
|
||||
background: "transparent",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{showActivityLogs ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
</button>
|
||||
</span>
|
||||
</SectionTitle>
|
||||
{showActivityLogs ? (
|
||||
<>
|
||||
<ActivityList className="custom-scrollbar">
|
||||
{groupedActivityLogs.length === 0 ? (
|
||||
<ActivityMeta>暂无活动日志</ActivityMeta>
|
||||
) : (
|
||||
groupedActivityLogs.map((group) => renderActivityLogItem(group))
|
||||
)}
|
||||
</ActivityList>
|
||||
{activeRunDetailLoading ? (
|
||||
<ActivityMeta>运行详情加载中...</ActivityMeta>
|
||||
) : activeRunDetail ? (
|
||||
<RunDetailPanel>
|
||||
<RunDetailTitle>运行详情</RunDetailTitle>
|
||||
<RunDetailRow>ID:{activeRunDetail.id}</RunDetailRow>
|
||||
<RunDetailRow>状态:{formatRunStatusLabel(activeRunDetail.status)}</RunDetailRow>
|
||||
{runMetadataSummary.workflow ? (
|
||||
<RunDetailRow>工作流:{runMetadataSummary.workflow}</RunDetailRow>
|
||||
) : null}
|
||||
{runMetadataSummary.executionId ? (
|
||||
<RunDetailRow>执行ID:{runMetadataSummary.executionId}</RunDetailRow>
|
||||
) : null}
|
||||
{runMetadataSummary.versionId ? (
|
||||
<RunDetailRow>版本ID:{runMetadataSummary.versionId}</RunDetailRow>
|
||||
) : null}
|
||||
{activeRunStagesLabel ? (
|
||||
<RunDetailRow>阶段:{activeRunStagesLabel}</RunDetailRow>
|
||||
) : null}
|
||||
<RunDetailActions>
|
||||
<RunDetailActionButton
|
||||
type="button"
|
||||
aria-label="复制运行ID"
|
||||
onClick={() => {
|
||||
void writeClipboardText(activeRunDetail.id);
|
||||
}}
|
||||
>
|
||||
复制运行ID
|
||||
</RunDetailActionButton>
|
||||
<RunDetailActionButton
|
||||
type="button"
|
||||
aria-label="复制运行元数据"
|
||||
onClick={() => {
|
||||
void writeClipboardText(runMetadataText);
|
||||
}}
|
||||
>
|
||||
复制运行元数据
|
||||
</RunDetailActionButton>
|
||||
</RunDetailActions>
|
||||
{runMetadataSummary.artifactPaths.length > 0 ? (
|
||||
<RunDetailArtifacts>
|
||||
{runMetadataSummary.artifactPaths.map((artifactPath) => (
|
||||
<RunDetailArtifactRow key={`run-detail-${artifactPath}`}>
|
||||
<RunDetailArtifactPath>
|
||||
{artifactPath}
|
||||
</RunDetailArtifactPath>
|
||||
<RunDetailActionButton
|
||||
type="button"
|
||||
aria-label={`复制产物路径-${artifactPath}`}
|
||||
onClick={() => {
|
||||
void writeClipboardText(artifactPath);
|
||||
}}
|
||||
>
|
||||
复制路径
|
||||
</RunDetailActionButton>
|
||||
<RunDetailActionButton
|
||||
type="button"
|
||||
aria-label={`定位产物路径-${artifactPath}`}
|
||||
onClick={() => {
|
||||
void handleRevealArtifactInFinder(artifactPath);
|
||||
}}
|
||||
>
|
||||
定位
|
||||
</RunDetailActionButton>
|
||||
<RunDetailActionButton
|
||||
type="button"
|
||||
aria-label={`打开产物路径-${artifactPath}`}
|
||||
onClick={() => {
|
||||
void handleOpenArtifactWithDefaultApp(artifactPath);
|
||||
}}
|
||||
>
|
||||
打开
|
||||
</RunDetailActionButton>
|
||||
</RunDetailArtifactRow>
|
||||
))}
|
||||
</RunDetailArtifacts>
|
||||
) : null}
|
||||
<RunDetailCode>{runMetadataText}</RunDetailCode>
|
||||
</RunDetailPanel>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Section>
|
||||
</>
|
||||
) : null}
|
||||
{activeTab === "log" ? (
|
||||
|
||||
@@ -96,26 +96,29 @@ vi.mock("@/components/content-creator/hooks/useWorkflow", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/content-creator/core/LayoutTransition/LayoutTransition", () => ({
|
||||
LayoutTransition: ({
|
||||
mode,
|
||||
chatContent,
|
||||
canvasContent,
|
||||
}: {
|
||||
mode: string;
|
||||
chatContent: ReactNode;
|
||||
canvasContent: ReactNode;
|
||||
}) => (
|
||||
<div data-testid="layout-transition" data-mode={mode}>
|
||||
<div data-testid="layout-chat" hidden={mode === "canvas"}>
|
||||
{chatContent}
|
||||
vi.mock(
|
||||
"@/components/content-creator/core/LayoutTransition/LayoutTransition",
|
||||
() => ({
|
||||
LayoutTransition: ({
|
||||
mode,
|
||||
chatContent,
|
||||
canvasContent,
|
||||
}: {
|
||||
mode: string;
|
||||
chatContent: ReactNode;
|
||||
canvasContent: ReactNode;
|
||||
}) => (
|
||||
<div data-testid="layout-transition" data-mode={mode}>
|
||||
<div data-testid="layout-chat" hidden={mode === "canvas"}>
|
||||
{chatContent}
|
||||
</div>
|
||||
<div data-testid="layout-canvas" hidden={mode !== "canvas"}>
|
||||
{canvasContent}
|
||||
</div>
|
||||
</div>
|
||||
<div data-testid="layout-canvas" hidden={mode !== "canvas"}>
|
||||
{canvasContent}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("./components/ChatNavbar", () => ({
|
||||
ChatNavbar: ({
|
||||
@@ -217,7 +220,6 @@ vi.mock("./components/ThemeWorkbenchSidebar", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
vi.mock("./components/MessageList", () => ({
|
||||
MessageList: (props: Record<string, unknown>) => mockMessageList(props),
|
||||
}));
|
||||
@@ -282,9 +284,15 @@ vi.mock("@/components/content-creator/canvas/document", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/workflowMapping", () => ({
|
||||
getFileToStepMap: vi.fn(() => new Map()),
|
||||
}));
|
||||
vi.mock("./utils/workflowMapping", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("./utils/workflowMapping")>();
|
||||
return {
|
||||
...actual,
|
||||
getFileToStepMap: vi.fn(() => ({})),
|
||||
getSupportedFilenames: vi.fn(() => []),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/workspace/navigation", () => ({
|
||||
buildHomeAgentParams: vi.fn(() => ({})),
|
||||
@@ -381,9 +389,7 @@ function renderPage(
|
||||
}
|
||||
|
||||
function createMockThemeContextWorkspaceState(
|
||||
overrides: Partial<
|
||||
ReturnType<typeof mockUseThemeContextWorkspace>
|
||||
> = {},
|
||||
overrides: Partial<ReturnType<typeof mockUseThemeContextWorkspace>> = {},
|
||||
) {
|
||||
const merged = {
|
||||
enabled: false,
|
||||
@@ -410,9 +416,9 @@ function createMockThemeContextWorkspaceState(
|
||||
};
|
||||
|
||||
if (!("prepareActiveContextPrompt" in overrides)) {
|
||||
merged.prepareActiveContextPrompt = vi.fn().mockResolvedValue(
|
||||
merged.activeContextPrompt || "",
|
||||
);
|
||||
merged.prepareActiveContextPrompt = vi
|
||||
.fn()
|
||||
.mockResolvedValue(merged.activeContextPrompt || "");
|
||||
}
|
||||
|
||||
return merged;
|
||||
@@ -458,6 +464,15 @@ beforeEach(() => {
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
observedWorkspaceIds.length = 0;
|
||||
@@ -560,6 +575,7 @@ afterEach(() => {
|
||||
mounted.container.remove();
|
||||
}
|
||||
localStorage.clear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("AgentChatPage 话题切换项目恢复", () => {
|
||||
@@ -610,7 +626,9 @@ describe("AgentChatPage 话题切换项目恢复", () => {
|
||||
it("无可用项目时应自动创建默认项目并继续切换话题", async () => {
|
||||
mockGetProject.mockResolvedValue(null);
|
||||
mockGetDefaultProject.mockResolvedValue(null);
|
||||
mockGetOrCreateDefaultProject.mockResolvedValue(createProject("default-new"));
|
||||
mockGetOrCreateDefaultProject.mockResolvedValue(
|
||||
createProject("default-new"),
|
||||
);
|
||||
|
||||
const container = renderPage();
|
||||
await flushEffects();
|
||||
@@ -661,7 +679,6 @@ describe("AgentChatPage 话题切换项目恢复", () => {
|
||||
await flushEffects();
|
||||
expect(observedWorkspaceIds[observedWorkspaceIds.length - 1]).toBe("");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("AgentChatPage 侧栏显示控制", () => {
|
||||
@@ -703,11 +720,15 @@ describe("AgentChatPage 侧栏显示控制", () => {
|
||||
const container = renderPage();
|
||||
await flushEffects();
|
||||
|
||||
expect(container.querySelector('[data-testid="chat-sidebar"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="chat-sidebar"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
clickButton(container, "set-project");
|
||||
await flushEffects();
|
||||
expect(container.querySelector('[data-testid="chat-sidebar"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="chat-sidebar"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("showChatPanel=false 时应保持侧栏隐藏", async () => {
|
||||
@@ -898,8 +919,14 @@ describe("AgentChatPage 自动引导", () => {
|
||||
|
||||
const layout = container.querySelector('[data-testid="layout-transition"]');
|
||||
expect(layout?.getAttribute("data-mode")).toBe("canvas");
|
||||
expect(container.querySelector('[data-testid="canvas-loading-state"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="layout-chat"]')?.hasAttribute("hidden")).toBe(true);
|
||||
expect(
|
||||
container.querySelector('[data-testid="canvas-loading-state"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-testid="layout-chat"]')
|
||||
?.hasAttribute("hidden"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("主题工作台打开已有文稿时首帧应直接显示画布,避免旧对话闪现", async () => {
|
||||
@@ -957,13 +984,21 @@ describe("AgentChatPage 自动引导", () => {
|
||||
|
||||
const layout = container.querySelector('[data-testid="layout-transition"]');
|
||||
expect(layout?.getAttribute("data-mode")).toBe("canvas");
|
||||
expect(container.querySelector('[data-testid="layout-chat"]')?.hasAttribute("hidden")).toBe(true);
|
||||
expect(container.querySelector('[data-testid="canvas-loading-state"]')).not.toBeNull();
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-testid="layout-chat"]')
|
||||
?.hasAttribute("hidden"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
container.querySelector('[data-testid="canvas-loading-state"]'),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("历史对话");
|
||||
|
||||
await flushEffects(10);
|
||||
|
||||
expect(container.querySelector('[data-testid="canvas-factory"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="canvas-factory"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("主题工作台启用时应仅保留专用侧栏,不再渲染右侧旧操作面板", async () => {
|
||||
@@ -981,15 +1016,17 @@ describe("AgentChatPage 自动引导", () => {
|
||||
});
|
||||
await flushEffects(10);
|
||||
|
||||
expect(container.querySelector('[data-testid="theme-workbench-sidebar"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="theme-workbench-skills"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="theme-workbench-sidebar"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="theme-workbench-skills"]'),
|
||||
).toBeNull();
|
||||
expect(container.querySelector('[data-testid="chat-sidebar"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="empty-state"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="inputbar"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("主题工作台在初始意图稍后注入时应自动发送首条创作请求", async () => {
|
||||
mockIsContentCreationTheme.mockReturnValue(true);
|
||||
mockUseThemeContextWorkspace.mockReturnValue(
|
||||
@@ -1229,7 +1266,7 @@ describe("AgentChatPage 自动引导", () => {
|
||||
"social-posts/demo-post.md",
|
||||
);
|
||||
latestMessageListProps?.onWriteFile?.(
|
||||
"{\"pipeline\":[\"topic_select\",\"write_mode\",\"publish_confirm\"]}",
|
||||
'{"pipeline":["topic_select","write_mode","publish_confirm"]}',
|
||||
"social-posts/demo-post.publish-pack.json",
|
||||
);
|
||||
});
|
||||
@@ -1400,7 +1437,9 @@ describe("AgentChatPage 自动引导", () => {
|
||||
});
|
||||
await flushEffects(16);
|
||||
|
||||
const latestTopicBranchCall = mockUseTopicBranchBoard.mock.calls.at(-1)?.[0] as
|
||||
const latestTopicBranchCall = mockUseTopicBranchBoard.mock.calls.at(
|
||||
-1,
|
||||
)?.[0] as
|
||||
| { topics?: Array<{ id: string }>; currentTopicId?: string | null }
|
||||
| undefined;
|
||||
expect(latestTopicBranchCall?.currentTopicId).toBe(
|
||||
@@ -1408,7 +1447,9 @@ describe("AgentChatPage 自动引导", () => {
|
||||
);
|
||||
expect(latestTopicBranchCall?.topics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "artifact:social-posts/local-fallback.md" }),
|
||||
expect.objectContaining({
|
||||
id: "artifact:social-posts/local-fallback.md",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -1509,8 +1550,14 @@ describe("AgentChatPage 自动引导", () => {
|
||||
expect(workflowSteps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: "生成社媒主稿", status: "completed" }),
|
||||
expect.objectContaining({ title: "写入 social-posts/final.md", status: "completed" }),
|
||||
expect.objectContaining({ title: "生成封面图(1024x1024)", status: "active" }),
|
||||
expect.objectContaining({
|
||||
title: "写入 social-posts/final.md",
|
||||
status: "completed",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "生成封面图(1024x1024)",
|
||||
status: "active",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(workflowSteps.some((step) => step.title === "平台适配")).toBe(false);
|
||||
@@ -1604,7 +1651,10 @@ describe("AgentChatPage 自动引导", () => {
|
||||
expect(workflowSteps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: "生成社媒主稿", status: "completed" }),
|
||||
expect.objectContaining({ title: "生成封面图(1024x1024)", status: "error" }),
|
||||
expect.objectContaining({
|
||||
title: "生成封面图(1024x1024)",
|
||||
status: "error",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -1651,7 +1701,9 @@ describe("AgentChatPage 自动引导", () => {
|
||||
{
|
||||
id: "tool-browser-1",
|
||||
name: "browser_navigate",
|
||||
arguments: JSON.stringify({ url: "https://www.rokid.com/glasses" }),
|
||||
arguments: JSON.stringify({
|
||||
url: "https://www.rokid.com/glasses",
|
||||
}),
|
||||
status: "running",
|
||||
startTime: new Date("2026-03-06T11:00:02.500Z"),
|
||||
},
|
||||
@@ -1695,8 +1747,14 @@ describe("AgentChatPage 自动引导", () => {
|
||||
|
||||
expect(workflowSteps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: "检索 Rokid Glasses 最新功能", status: "completed" }),
|
||||
expect.objectContaining({ title: "打开 https://www.rokid.com/glasses", status: "active" }),
|
||||
expect.objectContaining({
|
||||
title: "检索 Rokid Glasses 最新功能",
|
||||
status: "completed",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "打开 https://www.rokid.com/glasses",
|
||||
status: "active",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -1751,7 +1809,9 @@ describe("AgentChatPage 自动引导", () => {
|
||||
{
|
||||
id: "tool-bash-1",
|
||||
name: "bash",
|
||||
arguments: JSON.stringify({ command: "ffmpeg -i input.mp4 output.mp4" }),
|
||||
arguments: JSON.stringify({
|
||||
command: "ffmpeg -i input.mp4 output.mp4",
|
||||
}),
|
||||
status: "running",
|
||||
startTime: new Date("2026-03-06T12:00:03.500Z"),
|
||||
},
|
||||
@@ -1795,8 +1855,14 @@ describe("AgentChatPage 自动引导", () => {
|
||||
|
||||
expect(workflowSteps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ title: "点击「发布按钮」", status: "completed" }),
|
||||
expect.objectContaining({ title: "分析页面区域:结果区域", status: "completed" }),
|
||||
expect.objectContaining({
|
||||
title: "点击「发布按钮」",
|
||||
status: "completed",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "分析页面区域:结果区域",
|
||||
status: "completed",
|
||||
}),
|
||||
expect.objectContaining({ title: "处理音视频素材", status: "active" }),
|
||||
]),
|
||||
);
|
||||
@@ -1841,7 +1907,9 @@ describe("AgentChatPage 自动引导", () => {
|
||||
workflowSteps?: Array<{ title: string; status: string }>;
|
||||
}
|
||||
| undefined;
|
||||
expect(latestInputbarProps?.themeWorkbenchGate?.key).toBe("publish_confirm");
|
||||
expect(latestInputbarProps?.themeWorkbenchGate?.key).toBe(
|
||||
"publish_confirm",
|
||||
);
|
||||
const workflowSteps = latestInputbarProps?.workflowSteps || [];
|
||||
expect(workflowSteps.length).toBeGreaterThan(0);
|
||||
expect(workflowSteps.at(-1)?.status).toBe("active");
|
||||
|
||||
@@ -22,10 +22,7 @@ import { PanelLeftOpen } from "lucide-react";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { safeListen } from "@/lib/dev-bridge";
|
||||
import {
|
||||
uploadImageToSession,
|
||||
importDocument,
|
||||
} from "@/lib/api/session-files";
|
||||
import { uploadImageToSession, importDocument } from "@/lib/api/session-files";
|
||||
import {
|
||||
useAgentChatUnified,
|
||||
useThemeContextWorkspace,
|
||||
@@ -35,6 +32,7 @@ import type { SidebarActivityLog } from "./hooks/useThemeContextWorkspace";
|
||||
import type { TopicBranchStatus } from "./hooks/useTopicBranchBoard";
|
||||
import { useSessionFiles } from "./hooks/useSessionFiles";
|
||||
import { useContentSync } from "./hooks/useContentSync";
|
||||
import { getDefaultGuidePromptByTheme } from "./utils/defaultGuidePrompt";
|
||||
import { ChatNavbar } from "./components/ChatNavbar";
|
||||
import { ChatSidebar } from "./components/ChatSidebar";
|
||||
import {
|
||||
@@ -43,6 +41,7 @@ import {
|
||||
} from "./components/ThemeWorkbenchSidebar";
|
||||
import { MessageList } from "./components/MessageList";
|
||||
import { Inputbar } from "./components/Inputbar";
|
||||
import { RuntimeStyleControlBar } from "./components/RuntimeStyleControlBar";
|
||||
import { EmptyState } from "./components/EmptyState";
|
||||
import type { CreationMode } from "./components/types";
|
||||
import { type TaskFile } from "./components/TaskFiles";
|
||||
@@ -148,6 +147,10 @@ import type {
|
||||
import type { A2UIFormData } from "@/components/content-creator/a2ui/types";
|
||||
import { getFileToStepMap } from "./utils/workflowMapping";
|
||||
import { normalizeProjectId } from "./utils/topicProjectResolution";
|
||||
import {
|
||||
extractStyleActionContent,
|
||||
resolveStyleActionFileName,
|
||||
} from "./utils/styleRuntime";
|
||||
import { resolveTopicSwitchProject } from "./utils/topicProjectSwitch";
|
||||
import {
|
||||
loadChatToolPreferences,
|
||||
@@ -160,6 +163,14 @@ import {
|
||||
} from "./utils/taskFileCanvasSync";
|
||||
import { parseSkillSlashCommand } from "./hooks/skillCommand";
|
||||
import { subscribeDocumentEditorFocus } from "@/lib/documentEditorFocusEvents";
|
||||
import {
|
||||
DEFAULT_STYLE_PROFILE,
|
||||
buildRuntimeStyleOverridePrompt,
|
||||
buildStyleAuditPrompt,
|
||||
buildStyleRewritePrompt,
|
||||
getStyleProfileFromGuide,
|
||||
type RuntimeStyleSelection,
|
||||
} from "@/lib/style-guide";
|
||||
import { useWorkbenchStore } from "@/stores/useWorkbenchStore";
|
||||
|
||||
const SUPPORTED_ENTRY_THEMES: ThemeType[] = [
|
||||
@@ -319,7 +330,7 @@ interface HandleSendObserver {
|
||||
|
||||
interface HandleSendOptions {
|
||||
skipThemeSkillPrefix?: boolean;
|
||||
purpose?: "content_review" | "text_stylize";
|
||||
purpose?: "content_review" | "text_stylize" | "style_rewrite" | "style_audit";
|
||||
observer?: HandleSendObserver;
|
||||
}
|
||||
|
||||
@@ -1330,7 +1341,7 @@ function buildThemeWorkbenchWorkflowSteps(
|
||||
const item = queueItems[0];
|
||||
const sourceRef = item.source_ref?.trim();
|
||||
const workflowSteps = sourceRef
|
||||
? (skillDetailMap[sourceRef]?.workflow_steps || [])
|
||||
? skillDetailMap[sourceRef]?.workflow_steps || []
|
||||
: [];
|
||||
if (workflowSteps.length > 0) {
|
||||
const latestAssistantContent =
|
||||
@@ -1689,6 +1700,37 @@ export function AgentChatPage({
|
||||
const [projectMemory, setProjectMemory] = useState<ProjectMemory | null>(
|
||||
null,
|
||||
);
|
||||
const [runtimeStyleSelection, setRuntimeStyleSelection] =
|
||||
useState<RuntimeStyleSelection>({
|
||||
presetId: "project-default",
|
||||
strength: DEFAULT_STYLE_PROFILE.simulationStrength,
|
||||
customNotes: "",
|
||||
source: "project-default",
|
||||
sourceLabel: undefined,
|
||||
sourceProfile: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setRuntimeStyleSelection((previous) => {
|
||||
if (
|
||||
previous.presetId !== "project-default" ||
|
||||
previous.customNotes.trim()
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const nextStrength =
|
||||
getStyleProfileFromGuide(projectMemory?.style_guide)
|
||||
?.simulationStrength || DEFAULT_STYLE_PROFILE.simulationStrength;
|
||||
|
||||
return previous.strength === nextStrength
|
||||
? previous
|
||||
: {
|
||||
...previous,
|
||||
strength: nextStrength,
|
||||
};
|
||||
});
|
||||
}, [projectMemory?.style_guide]);
|
||||
|
||||
// 主动 workspace 健康检查失败标记(区别于 workspacePathMissing 发送失败场景)
|
||||
const [workspaceHealthError, setWorkspaceHealthError] = useState(false);
|
||||
@@ -1702,9 +1744,7 @@ export function AgentChatPage({
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
|
||||
// Workbench Store(用于主题工作台右侧面板状态同步)
|
||||
const pendingSkillKey = useWorkbenchStore(
|
||||
(state) => state.pendingSkillKey,
|
||||
);
|
||||
const pendingSkillKey = useWorkbenchStore((state) => state.pendingSkillKey);
|
||||
const clearThemeSkillsRailState = useWorkbenchStore(
|
||||
(state) => state.clearThemeSkillsRailState,
|
||||
);
|
||||
@@ -1726,6 +1766,14 @@ export function AgentChatPage({
|
||||
|
||||
// 工作流状态(仅在内容创作模式下使用)
|
||||
const mappedTheme = activeTheme as ThemeType;
|
||||
|
||||
useEffect(() => {
|
||||
setRuntimeStyleSelection({
|
||||
presetId: "project-default",
|
||||
strength: DEFAULT_STYLE_PROFILE.simulationStrength,
|
||||
customNotes: "",
|
||||
});
|
||||
}, [mappedTheme, projectId]);
|
||||
const { steps, currentStepIndex, goToStep, completeStep } = useWorkflow(
|
||||
mappedTheme,
|
||||
creationMode,
|
||||
@@ -1855,7 +1903,9 @@ export function AgentChatPage({
|
||||
: rawBody;
|
||||
|
||||
if (rawBody && sanitizedBody !== rawBody) {
|
||||
setInitialContentLoadError("当前文稿未生成有效主稿,请重新生成或稍后重试");
|
||||
setInitialContentLoadError(
|
||||
"当前文稿未生成有效主稿,请重新生成或稍后重试",
|
||||
);
|
||||
} else {
|
||||
setInitialContentLoadError(null);
|
||||
}
|
||||
@@ -1886,7 +1936,9 @@ export function AgentChatPage({
|
||||
initialState = backendApplied.state;
|
||||
setDocumentVersionStatusMap(backendApplied.statusMap);
|
||||
} else {
|
||||
const persisted = readPersistedThemeWorkbenchDocument(content.metadata);
|
||||
const persisted = readPersistedThemeWorkbenchDocument(
|
||||
content.metadata,
|
||||
);
|
||||
if (persisted) {
|
||||
const restoredVersions = persisted.versions.map((version) =>
|
||||
version.id === persisted.currentVersionId
|
||||
@@ -2008,6 +2060,32 @@ export function AgentChatPage({
|
||||
});
|
||||
}, [project, projectId]);
|
||||
|
||||
const runtimeStylePrompt = useMemo(
|
||||
() =>
|
||||
buildRuntimeStyleOverridePrompt({
|
||||
projectStyleGuide: projectMemory?.style_guide,
|
||||
selection: runtimeStyleSelection,
|
||||
activeTheme: mappedTheme,
|
||||
}),
|
||||
[mappedTheme, projectMemory?.style_guide, runtimeStyleSelection],
|
||||
);
|
||||
|
||||
const runtimeStyleMessagePrompt = useMemo(() => {
|
||||
const projectDefaultStrength =
|
||||
getStyleProfileFromGuide(projectMemory?.style_guide)
|
||||
?.simulationStrength || DEFAULT_STYLE_PROFILE.simulationStrength;
|
||||
const hasPresetOverride =
|
||||
runtimeStyleSelection.presetId !== "project-default" ||
|
||||
runtimeStyleSelection.source === "library";
|
||||
const hasCustomNotes = runtimeStyleSelection.customNotes.trim().length > 0;
|
||||
const hasStrengthOverride =
|
||||
runtimeStyleSelection.strength !== projectDefaultStrength;
|
||||
|
||||
return hasPresetOverride || hasCustomNotes || hasStrengthOverride
|
||||
? runtimeStylePrompt
|
||||
: "";
|
||||
}, [projectMemory?.style_guide, runtimeStylePrompt, runtimeStyleSelection]);
|
||||
|
||||
// 生成系统提示词(包含项目 Memory)
|
||||
const systemPrompt = useMemo(() => {
|
||||
let prompt = "";
|
||||
@@ -2025,7 +2103,7 @@ export function AgentChatPage({
|
||||
}
|
||||
|
||||
return prompt || undefined;
|
||||
}, [isContentCreationMode, mappedTheme, creationMode, projectMemory]);
|
||||
}, [creationMode, isContentCreationMode, mappedTheme, projectMemory]);
|
||||
|
||||
// 使用 Agent Chat Hook(传递系统提示词)
|
||||
const {
|
||||
@@ -2317,7 +2395,6 @@ export function AgentChatPage({
|
||||
return null;
|
||||
}, [messages]);
|
||||
|
||||
|
||||
const a2uiSubmissionNotice = useMemo(() => {
|
||||
if (pendingA2UIForm) {
|
||||
return null;
|
||||
@@ -2750,7 +2827,6 @@ export function AgentChatPage({
|
||||
[contextWorkspace],
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!isThemeWorkbench || !selectedThemeWorkbenchRunId) {
|
||||
setThemeWorkbenchRunDetailLoading(false);
|
||||
@@ -2996,6 +3072,7 @@ export function AgentChatPage({
|
||||
const restoredFilesSessionId = useRef<string | null>(null);
|
||||
// 用于追踪是否已触发过 AI 引导
|
||||
const hasTriggeredGuide = useRef(false);
|
||||
const consumedInitialPromptRef = useRef<string | null>(null);
|
||||
|
||||
// 当 sessionMeta 加载完成时,恢复主题和创建模式
|
||||
useEffect(() => {
|
||||
@@ -3116,6 +3193,7 @@ export function AgentChatPage({
|
||||
restoredMetaSessionId.current = null;
|
||||
restoredFilesSessionId.current = null;
|
||||
hasTriggeredGuide.current = false;
|
||||
consumedInitialPromptRef.current = null;
|
||||
}, []);
|
||||
|
||||
const runTopicSwitch = useCallback(
|
||||
@@ -3321,7 +3399,8 @@ export function AgentChatPage({
|
||||
msg.role === "assistant" &&
|
||||
!msg.isThinking &&
|
||||
msg.content &&
|
||||
msg.purpose !== "content_review",
|
||||
msg.purpose !== "content_review" &&
|
||||
msg.purpose !== "style_audit",
|
||||
);
|
||||
|
||||
if (!lastAssistantMsg) return;
|
||||
@@ -3454,6 +3533,10 @@ export function AgentChatPage({
|
||||
text = `[角色上下文]\n${characterContext}\n\n[用户输入]\n${text}`;
|
||||
}
|
||||
|
||||
if (!sendOptions?.purpose && runtimeStyleMessagePrompt) {
|
||||
text = `[本次任务风格要求]\n${runtimeStyleMessagePrompt}\n\n[用户输入]\n${text}`;
|
||||
}
|
||||
|
||||
setInput("");
|
||||
setMentionedCharacters([]); // 清空引用的角色
|
||||
|
||||
@@ -3554,6 +3637,7 @@ export function AgentChatPage({
|
||||
projectId,
|
||||
providerModels,
|
||||
providerType,
|
||||
runtimeStyleMessagePrompt,
|
||||
sendMessage,
|
||||
sessionId,
|
||||
setModel,
|
||||
@@ -3674,12 +3758,7 @@ export function AgentChatPage({
|
||||
const command = `/${pendingSkillKey}`;
|
||||
console.log("[AgentChatPage] 执行技能命令:", command);
|
||||
handleSend([], false, false, command);
|
||||
}, [
|
||||
pendingSkillKey,
|
||||
isThemeWorkbench,
|
||||
consumePendingSkill,
|
||||
handleSend,
|
||||
]);
|
||||
}, [pendingSkillKey, isThemeWorkbench, consumePendingSkill, handleSend]);
|
||||
|
||||
const handleClearMessages = useCallback(() => {
|
||||
clearMessages();
|
||||
@@ -3768,63 +3847,60 @@ export function AgentChatPage({
|
||||
[setTopicStatus],
|
||||
);
|
||||
|
||||
const handleAddImage = useCallback(
|
||||
async () => {
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "图片",
|
||||
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
const handleAddImage = useCallback(async () => {
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "图片",
|
||||
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = selected;
|
||||
if (!filePath) {
|
||||
toast.error("未选择文件");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
toast.error("会话未就绪");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.info("正在上传图片...");
|
||||
|
||||
// 上传图片到会话
|
||||
const imageUrl = await uploadImageToSession(sessionId, filePath);
|
||||
|
||||
// 插入图片到文档
|
||||
setCanvasState((previous) => {
|
||||
if (!previous || previous.type !== "document") {
|
||||
toast.error("当前不在文档编辑模式");
|
||||
return previous;
|
||||
}
|
||||
|
||||
const fileName = filePath.split(/[\\/]/).pop() || "image";
|
||||
const imageMarkdown = `\n\n\n\n`;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
content: previous.content + imageMarkdown,
|
||||
};
|
||||
});
|
||||
|
||||
toast.success("图片已添加");
|
||||
} catch (error) {
|
||||
console.error("添加图片失败:", error);
|
||||
toast.error(error instanceof Error ? error.message : "添加图片失败");
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
},
|
||||
[sessionId, setCanvasState],
|
||||
);
|
||||
|
||||
const filePath = selected;
|
||||
if (!filePath) {
|
||||
toast.error("未选择文件");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
toast.error("会话未就绪");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.info("正在上传图片...");
|
||||
|
||||
// 上传图片到会话
|
||||
const imageUrl = await uploadImageToSession(sessionId, filePath);
|
||||
|
||||
// 插入图片到文档
|
||||
setCanvasState((previous) => {
|
||||
if (!previous || previous.type !== "document") {
|
||||
toast.error("当前不在文档编辑模式");
|
||||
return previous;
|
||||
}
|
||||
|
||||
const fileName = filePath.split(/[\\/]/).pop() || "image";
|
||||
const imageMarkdown = `\n\n\n\n`;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
content: previous.content + imageMarkdown,
|
||||
};
|
||||
});
|
||||
|
||||
toast.success("图片已添加");
|
||||
} catch (error) {
|
||||
console.error("添加图片失败:", error);
|
||||
toast.error(error instanceof Error ? error.message : "添加图片失败");
|
||||
}
|
||||
}, [sessionId, setCanvasState]);
|
||||
|
||||
const handleImportDocument = useCallback(async () => {
|
||||
try {
|
||||
@@ -3903,6 +3979,7 @@ export function AgentChatPage({
|
||||
restoredMetaSessionId.current = null;
|
||||
restoredFilesSessionId.current = null;
|
||||
hasTriggeredGuide.current = false;
|
||||
consumedInitialPromptRef.current = null;
|
||||
|
||||
if (!externalProjectId) {
|
||||
setInternalProjectId(null);
|
||||
@@ -4765,6 +4842,10 @@ export function AgentChatPage({
|
||||
// - 尚未触发过引导
|
||||
const canvasEmpty = isCanvasStateEmpty(canvasState);
|
||||
const pendingInitialPrompt = (initialUserPrompt || "").trim();
|
||||
const defaultGuidePrompt =
|
||||
contentId && canvasEmpty && !isThemeWorkbench
|
||||
? getDefaultGuidePromptByTheme(mappedTheme)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
contentId &&
|
||||
@@ -4772,10 +4853,13 @@ export function AgentChatPage({
|
||||
project &&
|
||||
systemPrompt &&
|
||||
!isSending &&
|
||||
canvasEmpty &&
|
||||
!hasTriggeredGuide.current
|
||||
canvasEmpty
|
||||
) {
|
||||
if (pendingInitialPrompt) {
|
||||
if (consumedInitialPromptRef.current === pendingInitialPrompt) {
|
||||
return;
|
||||
}
|
||||
consumedInitialPromptRef.current = pendingInitialPrompt;
|
||||
hasTriggeredGuide.current = true;
|
||||
console.log("[AgentChatPage] 自动发送首条创作意图消息");
|
||||
void (async () => {
|
||||
@@ -4790,19 +4874,41 @@ export function AgentChatPage({
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTriggeredGuide.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultGuidePrompt) {
|
||||
hasTriggeredGuide.current = true;
|
||||
setInput((previous) => previous.trim() || defaultGuidePrompt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThemeWorkbench) {
|
||||
hasTriggeredGuide.current = true;
|
||||
console.log("[AgentChatPage] 主题工作台:触发 AI 引导,创建后端工作流");
|
||||
// 同步创建后端工作流(不阻塞触发)
|
||||
void (async () => {
|
||||
try {
|
||||
const { contentWorkflowApi } = await import("@/lib/api/content-workflow");
|
||||
const themeForApi = mappedTheme as import("@/lib/api/content-workflow").ThemeType;
|
||||
const modeForApi = (creationMode as import("@/lib/api/content-workflow").CreationMode) ?? "guided";
|
||||
await contentWorkflowApi.create(contentId!, themeForApi, modeForApi);
|
||||
const { contentWorkflowApi } = await import(
|
||||
"@/lib/api/content-workflow"
|
||||
);
|
||||
const themeForApi =
|
||||
mappedTheme as import("@/lib/api/content-workflow").ThemeType;
|
||||
const modeForApi =
|
||||
(creationMode as import("@/lib/api/content-workflow").CreationMode) ??
|
||||
"guided";
|
||||
await contentWorkflowApi.create(
|
||||
contentId!,
|
||||
themeForApi,
|
||||
modeForApi,
|
||||
);
|
||||
console.log("[AgentChatPage] 后端工作流创建成功");
|
||||
} catch (e) {
|
||||
console.warn("[AgentChatPage] 后端工作流创建失败(不影响主流程):", e);
|
||||
console.warn(
|
||||
"[AgentChatPage] 后端工作流创建失败(不影响主流程):",
|
||||
e,
|
||||
);
|
||||
}
|
||||
})();
|
||||
triggerAIGuideRef.current();
|
||||
@@ -4824,6 +4930,7 @@ export function AgentChatPage({
|
||||
isSending,
|
||||
canvasState,
|
||||
initialUserPrompt,
|
||||
setInput,
|
||||
isThemeWorkbench,
|
||||
handleSend,
|
||||
chatToolPreferences,
|
||||
@@ -4833,6 +4940,7 @@ export function AgentChatPage({
|
||||
// 当 contentId 变化时重置引导状态
|
||||
useEffect(() => {
|
||||
hasTriggeredGuide.current = false;
|
||||
consumedInitialPromptRef.current = null;
|
||||
}, [contentId]);
|
||||
|
||||
// 当 contentId 变化且是主题工作台时,尝试从后端恢复工作流
|
||||
@@ -4841,7 +4949,9 @@ export function AgentChatPage({
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { contentWorkflowApi } = await import("@/lib/api/content-workflow");
|
||||
const { contentWorkflowApi } = await import(
|
||||
"@/lib/api/content-workflow"
|
||||
);
|
||||
const workflow = await contentWorkflowApi.getByContent(contentId);
|
||||
if (workflow) {
|
||||
const completedCount = workflow.steps.filter(
|
||||
@@ -4861,7 +4971,9 @@ export function AgentChatPage({
|
||||
// 监听封面图重新生成成功事件,将占位 URL 替换为真实图片 URL
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { placeholder, imageUrl } = (e as CustomEvent<CoverImageReplacedDetail>).detail;
|
||||
const { placeholder, imageUrl } = (
|
||||
e as CustomEvent<CoverImageReplacedDetail>
|
||||
).detail;
|
||||
if (!placeholder || !imageUrl) return;
|
||||
setCanvasState((prev) => {
|
||||
if (!prev || prev.type !== "document") return prev;
|
||||
@@ -4871,7 +4983,8 @@ export function AgentChatPage({
|
||||
});
|
||||
};
|
||||
window.addEventListener(COVER_IMAGE_REPLACED_EVENT, handler);
|
||||
return () => window.removeEventListener(COVER_IMAGE_REPLACED_EVENT, handler);
|
||||
return () =>
|
||||
window.removeEventListener(COVER_IMAGE_REPLACED_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
// 主题工作台始终使用聊天布局与浮层输入,不走旧 EmptyState 输入流程
|
||||
@@ -5095,6 +5208,115 @@ export function AgentChatPage({
|
||||
: undefined;
|
||||
}, [selectedFileId, visibleTaskFiles]);
|
||||
|
||||
const styleActionContent = useMemo(
|
||||
() =>
|
||||
extractStyleActionContent({
|
||||
activeTheme: mappedTheme,
|
||||
generalCanvasState,
|
||||
resolvedCanvasState,
|
||||
taskFiles: visibleTaskFiles,
|
||||
selectedFileId: visibleSelectedFileId,
|
||||
}),
|
||||
[
|
||||
generalCanvasState,
|
||||
mappedTheme,
|
||||
resolvedCanvasState,
|
||||
visibleSelectedFileId,
|
||||
visibleTaskFiles,
|
||||
],
|
||||
);
|
||||
|
||||
const styleActionFileName = useMemo(
|
||||
() =>
|
||||
resolveStyleActionFileName({
|
||||
activeTheme: mappedTheme,
|
||||
generalCanvasState,
|
||||
resolvedCanvasState,
|
||||
taskFiles: visibleTaskFiles,
|
||||
selectedFileId: visibleSelectedFileId,
|
||||
}),
|
||||
[
|
||||
generalCanvasState,
|
||||
mappedTheme,
|
||||
resolvedCanvasState,
|
||||
visibleSelectedFileId,
|
||||
visibleTaskFiles,
|
||||
],
|
||||
);
|
||||
|
||||
const styleActionsDisabled =
|
||||
!projectId || !runtimeStylePrompt || !styleActionContent.trim();
|
||||
|
||||
const handleRunStyleRewrite = useCallback(() => {
|
||||
if (!styleActionContent.trim()) {
|
||||
toast.error("当前画布还没有可重写的正文内容");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtimeStylePrompt) {
|
||||
toast.error("请先选择项目默认风格或任务风格");
|
||||
return;
|
||||
}
|
||||
|
||||
void handleSend(
|
||||
[],
|
||||
chatToolPreferences.webSearch,
|
||||
chatToolPreferences.thinking,
|
||||
buildStyleRewritePrompt({
|
||||
content: styleActionContent,
|
||||
stylePrompt: runtimeStylePrompt,
|
||||
fileName: styleActionFileName,
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
skipThemeSkillPrefix: true,
|
||||
purpose: "style_rewrite",
|
||||
},
|
||||
);
|
||||
}, [
|
||||
chatToolPreferences.thinking,
|
||||
chatToolPreferences.webSearch,
|
||||
handleSend,
|
||||
runtimeStylePrompt,
|
||||
styleActionContent,
|
||||
styleActionFileName,
|
||||
]);
|
||||
|
||||
const handleRunStyleAudit = useCallback(() => {
|
||||
if (!styleActionContent.trim()) {
|
||||
toast.error("当前画布还没有可检查的正文内容");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runtimeStylePrompt) {
|
||||
toast.error("请先选择项目默认风格或任务风格");
|
||||
return;
|
||||
}
|
||||
|
||||
void handleSend(
|
||||
[],
|
||||
chatToolPreferences.webSearch,
|
||||
chatToolPreferences.thinking,
|
||||
buildStyleAuditPrompt({
|
||||
content: styleActionContent,
|
||||
stylePrompt: runtimeStylePrompt,
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
skipThemeSkillPrefix: true,
|
||||
purpose: "style_audit",
|
||||
},
|
||||
);
|
||||
}, [
|
||||
chatToolPreferences.thinking,
|
||||
chatToolPreferences.webSearch,
|
||||
handleSend,
|
||||
runtimeStylePrompt,
|
||||
styleActionContent,
|
||||
]);
|
||||
|
||||
const inputbarNode = useMemo(
|
||||
() => (
|
||||
<Inputbar
|
||||
@@ -5190,6 +5412,19 @@ export function AgentChatPage({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isContentCreationMode && projectId ? (
|
||||
<RuntimeStyleControlBar
|
||||
projectId={projectId}
|
||||
activeTheme={mappedTheme}
|
||||
projectStyleGuide={projectMemory?.style_guide}
|
||||
selection={runtimeStyleSelection}
|
||||
onSelectionChange={setRuntimeStyleSelection}
|
||||
onRewrite={handleRunStyleRewrite}
|
||||
onAudit={handleRunStyleAudit}
|
||||
actionsDisabled={styleActionsDisabled}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showChatLayout ? (
|
||||
<ChatContent>
|
||||
{contextWorkspace.enabled ? (
|
||||
@@ -5349,7 +5584,9 @@ export function AgentChatPage({
|
||||
lockTheme,
|
||||
messages,
|
||||
model,
|
||||
projectId,
|
||||
projectMemory?.characters,
|
||||
projectMemory?.style_guide,
|
||||
providerType,
|
||||
setCreationMode,
|
||||
setExecutionStrategy,
|
||||
@@ -5360,6 +5597,11 @@ export function AgentChatPage({
|
||||
shouldCollapseCodeBlocks,
|
||||
selectedText,
|
||||
showChatLayout,
|
||||
handleRunStyleAudit,
|
||||
handleRunStyleRewrite,
|
||||
mappedTheme,
|
||||
runtimeStyleSelection,
|
||||
styleActionsDisabled,
|
||||
skills,
|
||||
steps,
|
||||
workspaceHealthError,
|
||||
@@ -5422,13 +5664,13 @@ export function AgentChatPage({
|
||||
}
|
||||
|
||||
const shouldShowCanvasLoadingState =
|
||||
((!canvasState &&
|
||||
(!canvasState &&
|
||||
(shouldBootstrapCanvasOnEntry ||
|
||||
isInitialContentLoading ||
|
||||
Boolean(initialContentLoadError))) ||
|
||||
(resolvedCanvasState?.type === "document" &&
|
||||
!resolvedCanvasState.content.trim() &&
|
||||
(isInitialContentLoading || Boolean(initialContentLoadError))));
|
||||
(resolvedCanvasState?.type === "document" &&
|
||||
!resolvedCanvasState.content.trim() &&
|
||||
(isInitialContentLoading || Boolean(initialContentLoadError)));
|
||||
|
||||
if (shouldShowCanvasLoadingState) {
|
||||
return (
|
||||
|
||||
@@ -103,7 +103,7 @@ export interface Message {
|
||||
/** 上下文准备轨迹(可选) */
|
||||
contextTrace?: ContextTraceStep[];
|
||||
/** 消息用途(用于跳过特定副作用) */
|
||||
purpose?: "content_review" | "text_stylize";
|
||||
purpose?: "content_review" | "text_stylize" | "style_rewrite" | "style_audit";
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractStyleActionContent,
|
||||
resolveStyleActionFileName,
|
||||
} from "./styleRuntime";
|
||||
|
||||
describe("styleRuntime", () => {
|
||||
it("有选中文件内容时应优先使用该文件作为风格重写目标", () => {
|
||||
const result = extractStyleActionContent({
|
||||
activeTheme: "document",
|
||||
generalCanvasState: {
|
||||
type: "document",
|
||||
filename: "general.md",
|
||||
content: "通用画布内容",
|
||||
selectedText: "",
|
||||
title: "",
|
||||
lastModified: Date.now(),
|
||||
},
|
||||
resolvedCanvasState: {
|
||||
type: "document",
|
||||
content: "画布正文",
|
||||
versions: [],
|
||||
currentVersionId: undefined,
|
||||
},
|
||||
taskFiles: [
|
||||
{
|
||||
id: "file-1",
|
||||
name: "final-article.md",
|
||||
type: "document",
|
||||
content: "任务文件正文",
|
||||
version: 1,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
selectedFileId: "file-1",
|
||||
} as any);
|
||||
|
||||
expect(result).toBe("任务文件正文");
|
||||
});
|
||||
|
||||
it("应优先返回当前选中文件名", () => {
|
||||
const result = resolveStyleActionFileName({
|
||||
activeTheme: "document",
|
||||
generalCanvasState: {
|
||||
type: "document",
|
||||
filename: "general.md",
|
||||
content: "",
|
||||
selectedText: "",
|
||||
title: "",
|
||||
lastModified: Date.now(),
|
||||
},
|
||||
resolvedCanvasState: {
|
||||
type: "document",
|
||||
content: "画布正文",
|
||||
versions: [],
|
||||
currentVersionId: undefined,
|
||||
},
|
||||
taskFiles: [
|
||||
{
|
||||
id: "file-1",
|
||||
name: "final-article.md",
|
||||
type: "document",
|
||||
content: "任务文件正文",
|
||||
version: 1,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
selectedFileId: "file-1",
|
||||
} as any);
|
||||
|
||||
expect(result).toBe("final-article.md");
|
||||
});
|
||||
|
||||
it("未选中文件时应回退到当前画布内容", () => {
|
||||
const result = extractStyleActionContent({
|
||||
activeTheme: "document",
|
||||
generalCanvasState: {
|
||||
type: "document",
|
||||
filename: "general.md",
|
||||
content: "通用画布内容",
|
||||
selectedText: "",
|
||||
title: "",
|
||||
lastModified: Date.now(),
|
||||
},
|
||||
resolvedCanvasState: {
|
||||
type: "document",
|
||||
content: "画布正文",
|
||||
versions: [],
|
||||
currentVersionId: undefined,
|
||||
},
|
||||
taskFiles: [],
|
||||
} as any);
|
||||
|
||||
expect(result).toBe("画布正文");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { ThemeType } from "@/components/content-creator/types";
|
||||
import type { CanvasStateUnion } from "@/components/content-creator/canvas/canvasUtils";
|
||||
import { scriptStateToText } from "@/components/content-creator/canvas/script";
|
||||
import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/types";
|
||||
import type { TaskFile } from "../components/TaskFiles";
|
||||
import { getSupportedFilenames } from "./workflowMapping";
|
||||
|
||||
interface StyleActionContext {
|
||||
activeTheme: ThemeType;
|
||||
generalCanvasState: GeneralCanvasState;
|
||||
resolvedCanvasState: CanvasStateUnion | null;
|
||||
taskFiles: TaskFile[];
|
||||
selectedFileId?: string;
|
||||
}
|
||||
|
||||
function getSelectedTaskFileContent(context: StyleActionContext): string {
|
||||
const selectedFile = context.taskFiles.find(
|
||||
(file) => file.id === context.selectedFileId,
|
||||
);
|
||||
return typeof selectedFile?.content === "string"
|
||||
? selectedFile.content.trim()
|
||||
: "";
|
||||
}
|
||||
|
||||
export function extractStyleActionContent(context: StyleActionContext): string {
|
||||
const { activeTheme, generalCanvasState, resolvedCanvasState } = context;
|
||||
const selectedFileContent = getSelectedTaskFileContent(context);
|
||||
|
||||
if (selectedFileContent) {
|
||||
return selectedFileContent;
|
||||
}
|
||||
|
||||
if (activeTheme === "general") {
|
||||
return generalCanvasState.content.trim();
|
||||
}
|
||||
|
||||
if (!resolvedCanvasState) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (resolvedCanvasState.type) {
|
||||
case "document":
|
||||
return resolvedCanvasState.content.trim();
|
||||
case "novel": {
|
||||
const currentChapter =
|
||||
resolvedCanvasState.chapters.find(
|
||||
(chapter) => chapter.id === resolvedCanvasState.currentChapterId,
|
||||
) || resolvedCanvasState.chapters[0];
|
||||
return currentChapter?.content.trim() || "";
|
||||
}
|
||||
case "script":
|
||||
return scriptStateToText(resolvedCanvasState).trim();
|
||||
case "music":
|
||||
return resolvedCanvasState.sections
|
||||
.map((section) => {
|
||||
const title = section.name || section.type;
|
||||
const content = section.lyricsLines.join("\n").trim();
|
||||
return content ? `[${title}]\n${content}` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
case "video":
|
||||
return resolvedCanvasState.prompt.trim();
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveStyleActionFileName(context: StyleActionContext): string {
|
||||
const selectedFile = context.taskFiles.find(
|
||||
(file) => file.id === context.selectedFileId,
|
||||
);
|
||||
if (selectedFile?.name) {
|
||||
return selectedFile.name;
|
||||
}
|
||||
|
||||
if (context.activeTheme === "general") {
|
||||
return context.generalCanvasState.filename || "article.md";
|
||||
}
|
||||
|
||||
const supportedFileNames = getSupportedFilenames(context.activeTheme);
|
||||
if (supportedFileNames.length > 0) {
|
||||
return supportedFileNames[supportedFileNames.length - 1] || "article.md";
|
||||
}
|
||||
|
||||
switch (context.resolvedCanvasState?.type) {
|
||||
case "document":
|
||||
return "article.md";
|
||||
case "novel":
|
||||
return "chapter-final.md";
|
||||
case "script":
|
||||
return "script-final.md";
|
||||
case "music":
|
||||
return "lyrics-final.txt";
|
||||
case "video":
|
||||
return "script-final.md";
|
||||
default:
|
||||
return "article.md";
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,11 @@ import {
|
||||
saveConfig,
|
||||
reloadCredentials,
|
||||
testApi,
|
||||
exportSupportBundle,
|
||||
revealInFinder,
|
||||
ServerStatus,
|
||||
ServerDiagnostics,
|
||||
SupportBundleExportResult,
|
||||
Config,
|
||||
TestResult,
|
||||
getDefaultProvider,
|
||||
@@ -47,6 +50,8 @@ interface FetchModelsResult {
|
||||
models: EnhancedModelMetadata[];
|
||||
source: "Api" | "LocalFallback";
|
||||
error: string | null;
|
||||
request_url?: string | null;
|
||||
diagnostic_hint?: string | null;
|
||||
}
|
||||
|
||||
interface TestState {
|
||||
@@ -154,9 +159,14 @@ interface AvailableProvider {
|
||||
|
||||
export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [diagnostics, setDiagnostics] = useState<ServerDiagnostics | null>(null);
|
||||
const [diagnostics, setDiagnostics] = useState<ServerDiagnostics | null>(
|
||||
null,
|
||||
);
|
||||
const [diagnosticsLoading, setDiagnosticsLoading] = useState(false);
|
||||
const [diagnosticsCopied, setDiagnosticsCopied] = useState(false);
|
||||
const [supportBundleLoading, setSupportBundleLoading] = useState(false);
|
||||
const [supportBundleResult, setSupportBundleResult] =
|
||||
useState<SupportBundleExportResult | null>(null);
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [_error, setError] = useState<string | null>(null);
|
||||
@@ -194,6 +204,13 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const canExportSupportBundle =
|
||||
typeof window !== "undefined" &&
|
||||
Boolean(
|
||||
(window as any).__TAURI__?.core?.invoke ||
|
||||
(window as any).__TAURI__?.invoke,
|
||||
);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const s = await getServerStatus();
|
||||
@@ -229,6 +246,33 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportSupportBundle = async () => {
|
||||
setSupportBundleLoading(true);
|
||||
try {
|
||||
const result = await exportSupportBundle();
|
||||
setSupportBundleResult(result);
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: `支持包已导出到:${result.bundle_path}`,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
setMessage({ type: "error", text: `导出支持包失败: ${errMsg}` });
|
||||
} finally {
|
||||
setSupportBundleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevealSupportBundle = async () => {
|
||||
if (!supportBundleResult?.bundle_path) return;
|
||||
try {
|
||||
await revealInFinder(supportBundleResult.bundle_path);
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
setMessage({ type: "error", text: `打开支持包目录失败: ${errMsg}` });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const c = await getConfig();
|
||||
@@ -1568,7 +1612,7 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
诊断接口(`/health?full=true` / `/cache` / `/stats`)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<button
|
||||
onClick={fetchDiagnostics}
|
||||
disabled={diagnosticsLoading}
|
||||
@@ -1583,11 +1627,36 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) {
|
||||
>
|
||||
{diagnosticsCopied ? "已复制" : "复制 JSON"}
|
||||
</button>
|
||||
{canExportSupportBundle ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleExportSupportBundle}
|
||||
disabled={supportBundleLoading}
|
||||
className="rounded border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{supportBundleLoading ? "导出中..." : "导出支持包"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRevealSupportBundle}
|
||||
disabled={!supportBundleResult?.bundle_path}
|
||||
className="rounded border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
打开目录
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<pre className="rounded bg-muted/40 p-2 text-xs overflow-auto max-h-48">
|
||||
{diagnosticsJson || "点击“拉取诊断”获取当前服务诊断 JSON。"}
|
||||
</pre>
|
||||
<div className="text-xs text-muted-foreground break-all">
|
||||
{canExportSupportBundle
|
||||
? supportBundleResult?.bundle_path
|
||||
? `最近一次支持包:${supportBundleResult.bundle_path}`
|
||||
: "支持包会默认导出到桌面;若桌面不可用,则回退到下载目录或系统临时目录。"
|
||||
: "支持包导出仅在桌面端可用。"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ const presets: Record<AppType, ProviderPreset[]> = {
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey",
|
||||
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
defaultBaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
},
|
||||
// 聚合服务
|
||||
{
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { A2UIRenderer } from "./index";
|
||||
import { TextRenderer } from "./display/Text";
|
||||
import { A2UI_RENDERER_TOKENS } from "../rendererTokens";
|
||||
import {
|
||||
cleanupMountedRoots,
|
||||
clickButtonByText,
|
||||
mountHarness,
|
||||
setupReactActEnvironment,
|
||||
type MountedRoot,
|
||||
} from "@/components/workspace/hooks/testUtils";
|
||||
|
||||
setupReactActEnvironment();
|
||||
|
||||
describe("A2UIRenderer", () => {
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMountedRoots(mountedRoots);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("应使用统一容器与提交按钮样式,并支持禁用提交", () => {
|
||||
const submitSpy = vi.fn();
|
||||
const { container } = mountHarness(
|
||||
A2UIRenderer,
|
||||
{
|
||||
response: {
|
||||
id: "demo",
|
||||
root: "root",
|
||||
thinking: "这是推理提示",
|
||||
data: {},
|
||||
components: [
|
||||
{
|
||||
id: "content",
|
||||
component: "Text",
|
||||
text: "请选择开始方式",
|
||||
variant: "body",
|
||||
},
|
||||
{
|
||||
id: "root",
|
||||
component: "Column",
|
||||
children: ["content"],
|
||||
gap: 12,
|
||||
align: "stretch",
|
||||
},
|
||||
],
|
||||
submitAction: {
|
||||
label: "开始处理",
|
||||
action: { name: "submit" },
|
||||
},
|
||||
},
|
||||
submitDisabled: true,
|
||||
onSubmit: submitSpy,
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
const root = container.querySelector(".a2ui-container") as HTMLDivElement | null;
|
||||
expect(root?.className).toContain("space-y-4");
|
||||
expect(container.textContent).toContain("这是推理提示");
|
||||
const submitButton = clickButtonByText(container, "开始处理");
|
||||
expect(submitButton?.className).toContain("rounded-xl");
|
||||
expect(submitButton?.disabled).toBe(true);
|
||||
expect(submitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("找不到根组件时应显示统一错误样式", () => {
|
||||
const { container } = mountHarness(
|
||||
A2UIRenderer,
|
||||
{
|
||||
response: {
|
||||
id: "missing-root",
|
||||
root: "unknown",
|
||||
data: {},
|
||||
components: [],
|
||||
},
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
const errorNode = container.querySelector("div");
|
||||
expect(errorNode?.className).toBe(A2UI_RENDERER_TOKENS.errorText);
|
||||
expect(container.textContent).toContain("错误:找不到根组件 unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TextRenderer", () => {
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMountedRoots(mountedRoots);
|
||||
});
|
||||
|
||||
it("应使用统一文本 variant token", () => {
|
||||
const { container } = mountHarness(
|
||||
TextRenderer,
|
||||
{
|
||||
component: {
|
||||
id: "caption",
|
||||
component: "Text",
|
||||
text: "辅助说明",
|
||||
variant: "caption",
|
||||
},
|
||||
data: {},
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
const textNode = container.querySelector("div");
|
||||
expect(textNode?.className).toBe(A2UI_RENDERER_TOKENS.textVariants.caption);
|
||||
expect(container.textContent).toContain("辅助说明");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { A2UIComponent, A2UIFormData, A2UIEvent } from "../types";
|
||||
import { resolveDynamicValue } from "../parser";
|
||||
|
||||
// 布局组件
|
||||
import { RowRenderer } from "./layout/Row";
|
||||
@@ -38,6 +39,21 @@ export function ComponentRenderer({
|
||||
onFormChange,
|
||||
onAction,
|
||||
}: ComponentRendererProps) {
|
||||
const isVisible =
|
||||
component.visible === undefined
|
||||
? true
|
||||
: Boolean(
|
||||
resolveDynamicValue(
|
||||
component.visible as boolean | { path: string } | undefined,
|
||||
data,
|
||||
false,
|
||||
),
|
||||
);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (component.component) {
|
||||
case "Row":
|
||||
return (
|
||||
|
||||
@@ -5,27 +5,22 @@
|
||||
|
||||
import type { TextComponent } from "../../types";
|
||||
import { resolveDynamicValue } from "../../parser";
|
||||
import { A2UI_RENDERER_TOKENS } from "../../rendererTokens";
|
||||
|
||||
interface TextRendererProps {
|
||||
component: TextComponent;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const variantClass: Record<string, string> = {
|
||||
h1: "text-2xl font-bold",
|
||||
h2: "text-xl font-semibold",
|
||||
h3: "text-lg font-semibold",
|
||||
h4: "text-base font-medium",
|
||||
h5: "text-sm font-medium",
|
||||
body: "text-sm",
|
||||
caption: "text-xs text-muted-foreground",
|
||||
};
|
||||
|
||||
export function TextRenderer({ component, data }: TextRendererProps) {
|
||||
const text = resolveDynamicValue(component.text, data, "");
|
||||
|
||||
return (
|
||||
<div className={variantClass[component.variant || "body"]}>
|
||||
<div
|
||||
className={
|
||||
A2UI_RENDERER_TOKENS.textVariants[component.variant || "body"]
|
||||
}
|
||||
>
|
||||
{String(text)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { act } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChoicePickerRenderer } from "./ChoicePicker";
|
||||
import { TextFieldRenderer } from "./TextField";
|
||||
import { CheckBoxRenderer } from "./CheckBox";
|
||||
import { SliderRenderer } from "./Slider";
|
||||
import {
|
||||
cleanupMountedRoots,
|
||||
clickButtonByText,
|
||||
fillTextInput,
|
||||
mountHarness,
|
||||
setupReactActEnvironment,
|
||||
type MountedRoot,
|
||||
} from "@/components/workspace/hooks/testUtils";
|
||||
import { A2UI_FORM_TOKENS } from "../../taskFormTokens";
|
||||
|
||||
setupReactActEnvironment();
|
||||
|
||||
describe("A2UI 表单控件", () => {
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMountedRoots(mountedRoots);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("ChoicePicker 应使用统一样式并回传选项", () => {
|
||||
const onFormChange = vi.fn();
|
||||
const { container } = mountHarness(
|
||||
ChoicePickerRenderer,
|
||||
{
|
||||
component: {
|
||||
id: "start_mode",
|
||||
component: "ChoicePicker",
|
||||
label: "开始方式",
|
||||
options: [
|
||||
{ value: "new_post", label: "新写一篇内容" },
|
||||
{ value: "continue_history", label: "继续已有内容" },
|
||||
],
|
||||
value: [],
|
||||
variant: "mutuallyExclusive",
|
||||
layout: "vertical",
|
||||
},
|
||||
data,
|
||||
formData: {},
|
||||
onFormChange,
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
const optionButton = clickButtonByText(container, "新写一篇内容");
|
||||
expect(optionButton?.className).toContain("rounded-[20px]");
|
||||
expect(optionButton?.className).toContain("border-slate-200");
|
||||
expect(onFormChange).toHaveBeenCalledWith("start_mode", ["new_post"]);
|
||||
});
|
||||
|
||||
it("TextField 应使用统一输入样式并同步文本", () => {
|
||||
const onFormChange = vi.fn();
|
||||
const { container } = mountHarness(
|
||||
TextFieldRenderer,
|
||||
{
|
||||
component: {
|
||||
id: "note",
|
||||
component: "TextField",
|
||||
label: "补充说明",
|
||||
value: "",
|
||||
placeholder: "请输入补充说明",
|
||||
},
|
||||
data,
|
||||
formData: {},
|
||||
onFormChange,
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
const input = container.querySelector("input") as HTMLInputElement | null;
|
||||
expect(input?.className).toBe(A2UI_FORM_TOKENS.textInput);
|
||||
fillTextInput(input, "继续扩写");
|
||||
expect(onFormChange).toHaveBeenCalledWith("note", "继续扩写");
|
||||
});
|
||||
|
||||
it("CheckBox 应使用统一样式并回传布尔值", () => {
|
||||
const onFormChange = vi.fn();
|
||||
const { container } = mountHarness(
|
||||
CheckBoxRenderer,
|
||||
{
|
||||
component: {
|
||||
id: "agreed",
|
||||
component: "CheckBox",
|
||||
label: "我已确认",
|
||||
value: false,
|
||||
},
|
||||
data,
|
||||
formData: {},
|
||||
onFormChange,
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
const checkbox = container.querySelector(
|
||||
"input[type='checkbox']",
|
||||
) as HTMLInputElement | null;
|
||||
expect(checkbox?.className).toBe(A2UI_FORM_TOKENS.checkboxInput);
|
||||
act(() => {
|
||||
checkbox?.click();
|
||||
});
|
||||
expect(onFormChange).toHaveBeenCalledWith("agreed", true);
|
||||
});
|
||||
|
||||
it("Slider 应使用统一样式并回传数值", () => {
|
||||
const onFormChange = vi.fn();
|
||||
const { container } = mountHarness(
|
||||
SliderRenderer,
|
||||
{
|
||||
component: {
|
||||
id: "score",
|
||||
component: "Slider",
|
||||
label: "评分",
|
||||
min: 0,
|
||||
max: 10,
|
||||
value: 3,
|
||||
step: 1,
|
||||
},
|
||||
data,
|
||||
formData: {},
|
||||
onFormChange,
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
const slider = container.querySelector(
|
||||
"input[type='range']",
|
||||
) as HTMLInputElement | null;
|
||||
expect(slider?.className).toBe(A2UI_FORM_TOKENS.sliderInput);
|
||||
act(() => {
|
||||
if (!slider) {
|
||||
return;
|
||||
}
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setter?.call(slider, "8");
|
||||
slider.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
slider.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onFormChange).toHaveBeenCalledWith("score", 8);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { CheckBoxComponent, A2UIFormData } from "../../types";
|
||||
import { resolveDynamicValue } from "../../parser";
|
||||
import { A2UI_FORM_TOKENS } from "../../taskFormTokens";
|
||||
|
||||
interface CheckBoxRendererProps {
|
||||
component: CheckBoxComponent;
|
||||
@@ -25,14 +26,14 @@ export function CheckBoxRenderer({
|
||||
Boolean(resolveDynamicValue(component.value, data, false));
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<label className={A2UI_FORM_TOKENS.checkboxRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onFormChange(component.id, e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
className={A2UI_FORM_TOKENS.checkboxInput}
|
||||
/>
|
||||
<span className="text-sm">{label}</span>
|
||||
<span className={A2UI_FORM_TOKENS.checkboxText}>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
import type { ChoicePickerComponent, A2UIFormData } from "../../types";
|
||||
import { resolveDynamicValue } from "../../parser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
A2UI_FORM_TOKENS,
|
||||
getA2UIChoiceIndicatorClasses,
|
||||
getA2UIChoiceOptionClasses,
|
||||
getA2UIChoiceTitleClasses,
|
||||
} from "../../taskFormTokens";
|
||||
|
||||
interface ChoicePickerRendererProps {
|
||||
component: ChoicePickerComponent;
|
||||
@@ -29,6 +35,8 @@ export function ChoicePickerRenderer({
|
||||
const isMultiple = component.variant === "multipleSelection";
|
||||
const isWrap =
|
||||
component.layout === "wrap" || component.layout === "horizontal";
|
||||
const isMutuallyExclusive =
|
||||
component.variant === "mutuallyExclusive" || !isMultiple;
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
if (isMultiple) {
|
||||
@@ -42,9 +50,14 @@ export function ChoicePickerRenderer({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && <div className="text-sm font-medium">{label}</div>}
|
||||
<div className={cn("flex gap-2", isWrap ? "flex-wrap" : "flex-col")}>
|
||||
<div className={A2UI_FORM_TOKENS.fieldStack}>
|
||||
{label && <div className={A2UI_FORM_TOKENS.fieldLabel}>{label}</div>}
|
||||
<div
|
||||
className={cn(
|
||||
A2UI_FORM_TOKENS.optionList,
|
||||
isWrap ? "flex-wrap" : "flex-col",
|
||||
)}
|
||||
>
|
||||
{component.options.map((option) => {
|
||||
const optionLabel = String(
|
||||
resolveDynamicValue(option.label, data, ""),
|
||||
@@ -56,22 +69,28 @@ export function ChoicePickerRenderer({
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm rounded-lg border transition-all text-left",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border hover:border-primary/50 hover:bg-accent",
|
||||
)}
|
||||
className={getA2UIChoiceOptionClasses(isWrap, isSelected)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.icon && <span>{option.icon}</span>}
|
||||
<span>{optionLabel}</span>
|
||||
</div>
|
||||
{option.description && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{option.description}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className={getA2UIChoiceTitleClasses(isSelected)}>
|
||||
{option.icon && <span>{option.icon}</span>}
|
||||
<span>{optionLabel}</span>
|
||||
</div>
|
||||
{option.description && (
|
||||
<div className={A2UI_FORM_TOKENS.optionDescription}>
|
||||
{option.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={getA2UIChoiceIndicatorClasses(
|
||||
isMutuallyExclusive,
|
||||
isSelected,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { SliderComponent, A2UIFormData } from "../../types";
|
||||
import { resolveDynamicValue } from "../../parser";
|
||||
import { A2UI_FORM_TOKENS } from "../../taskFormTokens";
|
||||
|
||||
interface SliderRendererProps {
|
||||
component: SliderComponent;
|
||||
@@ -27,11 +28,11 @@ export function SliderRenderer({
|
||||
(resolveDynamicValue(component.value, data, component.min) as number);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{label && <label className="text-sm font-medium">{label}</label>}
|
||||
<div className={A2UI_FORM_TOKENS.fieldStack}>
|
||||
<div className={A2UI_FORM_TOKENS.sliderRow}>
|
||||
{label && <label className={A2UI_FORM_TOKENS.fieldLabel}>{label}</label>}
|
||||
{component.showValue !== false && (
|
||||
<span className="text-sm text-muted-foreground">{value}</span>
|
||||
<span className={A2UI_FORM_TOKENS.sliderValue}>{value}</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
@@ -41,10 +42,10 @@ export function SliderRenderer({
|
||||
step={component.step || 1}
|
||||
value={value}
|
||||
onChange={(e) => onFormChange(component.id, Number(e.target.value))}
|
||||
className="w-full"
|
||||
className={A2UI_FORM_TOKENS.sliderInput}
|
||||
/>
|
||||
{component.marks && (
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<div className={A2UI_FORM_TOKENS.sliderMarks}>
|
||||
{component.marks.map((mark) => (
|
||||
<span key={mark.value}>{mark.label}</span>
|
||||
))}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { TextFieldComponent, A2UIFormData } from "../../types";
|
||||
import { resolveDynamicValue } from "../../parser";
|
||||
import { A2UI_FORM_TOKENS } from "../../taskFormTokens";
|
||||
|
||||
interface TextFieldRendererProps {
|
||||
component: TextFieldComponent;
|
||||
@@ -25,7 +26,6 @@ export function TextFieldRenderer({
|
||||
(formData[component.id] as string) ??
|
||||
String(resolveDynamicValue(component.value, data, ""));
|
||||
const isLongText = component.variant === "longText";
|
||||
const commitFrameRef = useRef<number | null>(null);
|
||||
const latestLocalValueRef = useRef(value);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
@@ -41,54 +41,29 @@ export function TextFieldRenderer({
|
||||
[component.id, onFormChange],
|
||||
);
|
||||
|
||||
const scheduleCommit = useCallback(
|
||||
(nextValue: string) => {
|
||||
if (commitFrameRef.current !== null) {
|
||||
cancelAnimationFrame(commitFrameRef.current);
|
||||
}
|
||||
commitFrameRef.current = requestAnimationFrame(() => {
|
||||
commitValue(nextValue);
|
||||
commitFrameRef.current = null;
|
||||
});
|
||||
},
|
||||
[commitValue],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(nextValue: string) => {
|
||||
latestLocalValueRef.current = nextValue;
|
||||
setLocalValue(nextValue);
|
||||
scheduleCommit(nextValue);
|
||||
commitValue(nextValue);
|
||||
},
|
||||
[scheduleCommit],
|
||||
[commitValue],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (commitFrameRef.current !== null) {
|
||||
cancelAnimationFrame(commitFrameRef.current);
|
||||
commitFrameRef.current = null;
|
||||
}
|
||||
commitValue(latestLocalValueRef.current);
|
||||
}, [commitValue]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (commitFrameRef.current !== null) {
|
||||
cancelAnimationFrame(commitFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="text-sm font-medium">{label}</label>}
|
||||
<div className={A2UI_FORM_TOKENS.fieldStack}>
|
||||
{label && <label className={A2UI_FORM_TOKENS.fieldLabel}>{label}</label>}
|
||||
{isLongText ? (
|
||||
<textarea
|
||||
value={localValue}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder={component.placeholder}
|
||||
className="w-full min-h-[80px] px-3 py-2 text-sm border rounded-md bg-background resize-y"
|
||||
className={A2UI_FORM_TOKENS.textarea}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -103,11 +78,11 @@ export function TextFieldRenderer({
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder={component.placeholder}
|
||||
className="w-full px-3 py-2 text-sm border rounded-md bg-background"
|
||||
className={A2UI_FORM_TOKENS.textInput}
|
||||
/>
|
||||
)}
|
||||
{component.helperText && (
|
||||
<p className="text-xs text-muted-foreground">{component.helperText}</p>
|
||||
<p className={A2UI_FORM_TOKENS.helperText}>{component.helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import type { A2UIResponse, A2UIEvent, A2UIFormData } from "../types";
|
||||
import { getComponentById, resolveDynamicValue } from "../parser";
|
||||
import { A2UI_RENDERER_TOKENS } from "../rendererTokens";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentRenderer } from "./ComponentRenderer";
|
||||
|
||||
@@ -18,6 +19,7 @@ interface A2UIRendererProps {
|
||||
response: A2UIResponse;
|
||||
onEvent?: (event: A2UIEvent) => void;
|
||||
onSubmit?: (formData: A2UIFormData) => void;
|
||||
onFormStateChange?: (formData: A2UIFormData) => void;
|
||||
className?: string;
|
||||
/** 表单 ID(用于持久化) */
|
||||
formId?: string;
|
||||
@@ -25,6 +27,8 @@ interface A2UIRendererProps {
|
||||
initialFormData?: A2UIFormData;
|
||||
/** 表单数据变化回调(用于持久化) */
|
||||
onFormChange?: (formId: string, formData: A2UIFormData) => void;
|
||||
submitDisabled?: boolean;
|
||||
submitButtonClassName?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -35,10 +39,13 @@ export function A2UIRenderer({
|
||||
response,
|
||||
onEvent,
|
||||
onSubmit,
|
||||
onFormStateChange,
|
||||
className,
|
||||
formId,
|
||||
initialFormData,
|
||||
onFormChange,
|
||||
submitDisabled = false,
|
||||
submitButtonClassName,
|
||||
}: A2UIRendererProps) {
|
||||
// 防抖定时器引用
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -72,10 +79,16 @@ export function A2UIRenderer({
|
||||
}
|
||||
}, [initialFormData]);
|
||||
|
||||
useEffect(() => {
|
||||
onFormStateChange?.(formData);
|
||||
}, [formData, onFormStateChange]);
|
||||
|
||||
const handleFormChange = useCallback(
|
||||
(id: string, value: unknown) => {
|
||||
let nextFormData: A2UIFormData | null = null;
|
||||
setFormData((prev) => {
|
||||
const newData = { ...prev, [id]: value };
|
||||
nextFormData = newData;
|
||||
|
||||
// 防抖保存到数据库
|
||||
if (formId && onFormChange) {
|
||||
@@ -89,9 +102,14 @@ export function A2UIRenderer({
|
||||
|
||||
return newData;
|
||||
});
|
||||
onEvent?.({ type: "change", componentId: id, value });
|
||||
onEvent?.({
|
||||
type: "change",
|
||||
componentId: id,
|
||||
value,
|
||||
formData: nextFormData || formData,
|
||||
});
|
||||
},
|
||||
[formId, onFormChange, onEvent],
|
||||
[formData, formId, onFormChange, onEvent],
|
||||
);
|
||||
|
||||
// 清理防抖定时器
|
||||
@@ -116,30 +134,38 @@ export function A2UIRenderer({
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (submitDisabled) {
|
||||
return;
|
||||
}
|
||||
onSubmit?.(formData);
|
||||
onEvent?.({
|
||||
type: "submit",
|
||||
componentId: "form",
|
||||
formData,
|
||||
});
|
||||
}, [formData, onEvent, onSubmit]);
|
||||
}, [formData, onEvent, onSubmit, submitDisabled]);
|
||||
|
||||
const rootComponent = useMemo(
|
||||
() => getComponentById(response.components, response.root),
|
||||
[response.components, response.root],
|
||||
);
|
||||
const renderData = useMemo(
|
||||
() => ({
|
||||
...(response.data || {}),
|
||||
formData,
|
||||
}),
|
||||
[formData, response.data],
|
||||
);
|
||||
|
||||
if (!rootComponent) {
|
||||
return (
|
||||
<div className="text-red-500">错误:找不到根组件 {response.root}</div>
|
||||
);
|
||||
return <div className={A2UI_RENDERER_TOKENS.errorText}>错误:找不到根组件 {response.root}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("a2ui-container", className)}>
|
||||
<div className={cn(A2UI_RENDERER_TOKENS.container, className)}>
|
||||
{/* 思考过程 */}
|
||||
{response.thinking && (
|
||||
<div className="mb-3 text-sm text-muted-foreground italic">
|
||||
<div className={A2UI_RENDERER_TOKENS.thinkingText}>
|
||||
{response.thinking}
|
||||
</div>
|
||||
)}
|
||||
@@ -148,7 +174,7 @@ export function A2UIRenderer({
|
||||
<ComponentRenderer
|
||||
component={rootComponent}
|
||||
components={response.components}
|
||||
data={response.data || {}}
|
||||
data={renderData}
|
||||
formData={formData}
|
||||
onFormChange={handleFormChange}
|
||||
onAction={handleAction}
|
||||
@@ -156,10 +182,14 @@ export function A2UIRenderer({
|
||||
|
||||
{/* 提交按钮 */}
|
||||
{response.submitAction && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<div className={A2UI_RENDERER_TOKENS.submitRow}>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
disabled={submitDisabled}
|
||||
className={cn(
|
||||
A2UI_RENDERER_TOKENS.submitButton,
|
||||
submitButtonClassName,
|
||||
)}
|
||||
>
|
||||
{response.submitAction.label}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RowRenderer } from "./Row";
|
||||
import { ColumnRenderer } from "./Column";
|
||||
import { CardRenderer } from "./Card";
|
||||
import { DividerRenderer } from "./Divider";
|
||||
import { A2UI_LAYOUT_TOKENS } from "../../layoutTokens";
|
||||
import {
|
||||
cleanupMountedRoots,
|
||||
mountHarness,
|
||||
setupReactActEnvironment,
|
||||
type MountedRoot,
|
||||
} from "@/components/workspace/hooks/testUtils";
|
||||
|
||||
setupReactActEnvironment();
|
||||
|
||||
describe("A2UI 布局组件", () => {
|
||||
const mountedRoots: MountedRoot[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
cleanupMountedRoots(mountedRoots);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const baseRendererProps = {
|
||||
data: {},
|
||||
formData: {},
|
||||
onFormChange: vi.fn(),
|
||||
onAction: vi.fn(),
|
||||
};
|
||||
|
||||
it("Row 应使用统一布局类并渲染子组件", () => {
|
||||
const { container } = mountHarness(
|
||||
RowRenderer,
|
||||
{
|
||||
component: {
|
||||
id: "row",
|
||||
component: "Row",
|
||||
children: ["text"],
|
||||
justify: "center",
|
||||
align: "start",
|
||||
gap: 8,
|
||||
},
|
||||
components: [
|
||||
{
|
||||
id: "text",
|
||||
component: "Text",
|
||||
text: "行布局内容",
|
||||
variant: "body",
|
||||
},
|
||||
],
|
||||
...baseRendererProps,
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
const row = container.querySelector("div");
|
||||
expect(row?.className).toContain(A2UI_LAYOUT_TOKENS.flexBase);
|
||||
expect(row?.className).toContain(A2UI_LAYOUT_TOKENS.rowDirection);
|
||||
expect(container.textContent).toContain("行布局内容");
|
||||
});
|
||||
|
||||
it("Column 应使用统一布局类并渲染子组件", () => {
|
||||
const { container } = mountHarness(
|
||||
ColumnRenderer,
|
||||
{
|
||||
component: {
|
||||
id: "column",
|
||||
component: "Column",
|
||||
children: ["text"],
|
||||
justify: "start",
|
||||
align: "stretch",
|
||||
gap: 12,
|
||||
},
|
||||
components: [
|
||||
{
|
||||
id: "text",
|
||||
component: "Text",
|
||||
text: "列布局内容",
|
||||
variant: "body",
|
||||
},
|
||||
],
|
||||
...baseRendererProps,
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
const column = container.querySelector("div");
|
||||
expect(column?.className).toContain(A2UI_LAYOUT_TOKENS.flexBase);
|
||||
expect(column?.className).toContain(A2UI_LAYOUT_TOKENS.columnDirection);
|
||||
expect(container.textContent).toContain("列布局内容");
|
||||
});
|
||||
|
||||
it("Card 应使用统一卡片样式包裹子组件", () => {
|
||||
const { container } = mountHarness(
|
||||
CardRenderer,
|
||||
{
|
||||
component: {
|
||||
id: "card",
|
||||
component: "Card",
|
||||
child: "text",
|
||||
},
|
||||
components: [
|
||||
{
|
||||
id: "text",
|
||||
component: "Text",
|
||||
text: "卡片内容",
|
||||
variant: "body",
|
||||
},
|
||||
],
|
||||
...baseRendererProps,
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
const card = container.querySelector("div");
|
||||
expect(card?.className).toBe(A2UI_LAYOUT_TOKENS.cardShell);
|
||||
expect(container.textContent).toContain("卡片内容");
|
||||
});
|
||||
|
||||
it("Divider 应使用统一分隔线样式", () => {
|
||||
const { container } = mountHarness(
|
||||
DividerRenderer,
|
||||
{
|
||||
component: {
|
||||
id: "divider",
|
||||
component: "Divider",
|
||||
axis: "vertical",
|
||||
},
|
||||
},
|
||||
mountedRoots,
|
||||
);
|
||||
|
||||
const divider = container.querySelector("div");
|
||||
expect(divider?.className).toContain(A2UI_LAYOUT_TOKENS.dividerBase);
|
||||
expect(divider?.className).toContain(A2UI_LAYOUT_TOKENS.dividerVertical);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
A2UIEvent,
|
||||
} from "../../types";
|
||||
import { getComponentById } from "../../parser";
|
||||
import { A2UI_LAYOUT_TOKENS } from "../../layoutTokens";
|
||||
import { ComponentRenderer } from "../ComponentRenderer";
|
||||
|
||||
interface CardRendererProps {
|
||||
@@ -33,7 +34,7 @@ export function CardRenderer({
|
||||
if (!child) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className={A2UI_LAYOUT_TOKENS.cardShell}>
|
||||
<ComponentRenderer
|
||||
component={child}
|
||||
components={components}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
A2UIEvent,
|
||||
} from "../../types";
|
||||
import { getComponentById } from "../../parser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getA2UILayoutClasses } from "../../layoutTokens";
|
||||
import { ComponentRenderer } from "../ComponentRenderer";
|
||||
|
||||
interface ColumnRendererProps {
|
||||
@@ -22,23 +22,6 @@ interface ColumnRendererProps {
|
||||
onAction: (event: A2UIEvent) => void;
|
||||
}
|
||||
|
||||
const justifyClass: Record<string, string> = {
|
||||
start: "justify-start",
|
||||
center: "justify-center",
|
||||
end: "justify-end",
|
||||
spaceBetween: "justify-between",
|
||||
spaceAround: "justify-around",
|
||||
spaceEvenly: "justify-evenly",
|
||||
stretch: "justify-stretch",
|
||||
};
|
||||
|
||||
const alignClass: Record<string, string> = {
|
||||
start: "items-start",
|
||||
center: "items-center",
|
||||
end: "items-end",
|
||||
stretch: "items-stretch",
|
||||
};
|
||||
|
||||
export function ColumnRenderer({
|
||||
component,
|
||||
components,
|
||||
@@ -51,11 +34,12 @@ export function ColumnRenderer({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
justifyClass[component.justify || "start"],
|
||||
alignClass[component.align || "stretch"],
|
||||
)}
|
||||
className={getA2UILayoutClasses({
|
||||
direction: "column",
|
||||
justify: component.justify,
|
||||
align: component.align,
|
||||
defaultAlign: "stretch",
|
||||
})}
|
||||
style={{ gap: component.gap || 12 }}
|
||||
>
|
||||
{childIds.map((childId: string) => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { DividerComponent } from "../../types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { A2UI_LAYOUT_TOKENS } from "../../layoutTokens";
|
||||
|
||||
interface DividerRendererProps {
|
||||
component: DividerComponent;
|
||||
@@ -15,8 +16,10 @@ export function DividerRenderer({ component }: DividerRendererProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border",
|
||||
isVertical ? "w-px h-full min-h-[20px]" : "h-px w-full",
|
||||
A2UI_LAYOUT_TOKENS.dividerBase,
|
||||
isVertical
|
||||
? A2UI_LAYOUT_TOKENS.dividerVertical
|
||||
: A2UI_LAYOUT_TOKENS.dividerHorizontal,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
A2UIEvent,
|
||||
} from "../../types";
|
||||
import { getComponentById } from "../../parser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getA2UILayoutClasses } from "../../layoutTokens";
|
||||
import { ComponentRenderer } from "../ComponentRenderer";
|
||||
|
||||
interface RowRendererProps {
|
||||
@@ -22,23 +22,6 @@ interface RowRendererProps {
|
||||
onAction: (event: A2UIEvent) => void;
|
||||
}
|
||||
|
||||
const justifyClass: Record<string, string> = {
|
||||
start: "justify-start",
|
||||
center: "justify-center",
|
||||
end: "justify-end",
|
||||
spaceBetween: "justify-between",
|
||||
spaceAround: "justify-around",
|
||||
spaceEvenly: "justify-evenly",
|
||||
stretch: "justify-stretch",
|
||||
};
|
||||
|
||||
const alignClass: Record<string, string> = {
|
||||
start: "items-start",
|
||||
center: "items-center",
|
||||
end: "items-end",
|
||||
stretch: "items-stretch",
|
||||
};
|
||||
|
||||
export function RowRenderer({
|
||||
component,
|
||||
components,
|
||||
@@ -51,11 +34,12 @@ export function RowRenderer({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row",
|
||||
justifyClass[component.justify || "start"],
|
||||
alignClass[component.align || "start"],
|
||||
)}
|
||||
className={getA2UILayoutClasses({
|
||||
direction: "row",
|
||||
justify: component.justify,
|
||||
align: component.align,
|
||||
defaultAlign: "start",
|
||||
})}
|
||||
style={{ gap: component.gap || 8 }}
|
||||
>
|
||||
{childIds.map((childId: string) => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const A2UI_LAYOUT_TOKENS = {
|
||||
flexBase: "flex",
|
||||
rowDirection: "flex-row",
|
||||
columnDirection: "flex-col",
|
||||
cardShell: "rounded-[20px] border border-slate-200 bg-white p-4 shadow-sm",
|
||||
dividerBase: "bg-border",
|
||||
dividerHorizontal: "h-px w-full",
|
||||
dividerVertical: "w-px h-full min-h-[20px]",
|
||||
} as const;
|
||||
|
||||
const JUSTIFY_CLASS_MAP: Record<string, string> = {
|
||||
start: "justify-start",
|
||||
center: "justify-center",
|
||||
end: "justify-end",
|
||||
spaceBetween: "justify-between",
|
||||
spaceAround: "justify-around",
|
||||
spaceEvenly: "justify-evenly",
|
||||
stretch: "justify-stretch",
|
||||
};
|
||||
|
||||
const ALIGN_CLASS_MAP: Record<string, string> = {
|
||||
start: "items-start",
|
||||
center: "items-center",
|
||||
end: "items-end",
|
||||
stretch: "items-stretch",
|
||||
};
|
||||
|
||||
export function getA2UILayoutClasses(options: {
|
||||
direction: "row" | "column";
|
||||
justify?: string;
|
||||
align?: string;
|
||||
defaultAlign?: "start" | "stretch";
|
||||
}) {
|
||||
return cn(
|
||||
A2UI_LAYOUT_TOKENS.flexBase,
|
||||
options.direction === "row"
|
||||
? A2UI_LAYOUT_TOKENS.rowDirection
|
||||
: A2UI_LAYOUT_TOKENS.columnDirection,
|
||||
JUSTIFY_CLASS_MAP[options.justify || "start"],
|
||||
ALIGN_CLASS_MAP[options.align || options.defaultAlign || "stretch"],
|
||||
);
|
||||
}
|
||||
|
||||
export default A2UI_LAYOUT_TOKENS;
|
||||
@@ -0,0 +1,19 @@
|
||||
export const A2UI_RENDERER_TOKENS = {
|
||||
container: "a2ui-container space-y-4",
|
||||
thinkingText: "text-sm text-muted-foreground italic",
|
||||
errorText: "text-red-500",
|
||||
submitRow: "flex justify-end",
|
||||
submitButton:
|
||||
"inline-flex h-11 items-center justify-center rounded-xl bg-primary px-5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 disabled:hover:bg-slate-200",
|
||||
textVariants: {
|
||||
h1: "text-2xl font-bold",
|
||||
h2: "text-xl font-semibold",
|
||||
h3: "text-lg font-semibold",
|
||||
h4: "text-base font-medium",
|
||||
h5: "text-sm font-medium",
|
||||
body: "text-sm",
|
||||
caption: "text-xs text-muted-foreground",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default A2UI_RENDERER_TOKENS;
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface A2UITaskCardPreset {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
statusLabel: string;
|
||||
footerText?: string;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
|
||||
title: "补充信息",
|
||||
subtitle: "请先完成这一步,我再继续后续处理。",
|
||||
statusLabel: "待完成 1 / 1",
|
||||
loadingText: "表单加载中...",
|
||||
};
|
||||
|
||||
export const CHAT_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
|
||||
...DEFAULT_A2UI_TASK_CARD_PRESET,
|
||||
subtitle: "请先完成这一步,我再继续当前对话。",
|
||||
};
|
||||
|
||||
export const CHAT_FLOATING_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
|
||||
...DEFAULT_A2UI_TASK_CARD_PRESET,
|
||||
subtitle: "请先完成这一步,我再继续。",
|
||||
};
|
||||
|
||||
export const REVIEW_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
|
||||
title: "结构化补充信息",
|
||||
subtitle: "评审结果已切换为结构化预览,仅展示字段与提示,不直接允许提交。",
|
||||
statusLabel: "评审预览",
|
||||
loadingText: "结构化评审结果加载中...",
|
||||
};
|
||||
|
||||
export const WORKSPACE_CREATE_CONFIRMATION_TASK_PRESET: A2UITaskCardPreset = {
|
||||
...DEFAULT_A2UI_TASK_CARD_PRESET,
|
||||
subtitle: "请选择一种开始方式,确认后我再继续执行后续创作。",
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
import { Loader2, Sparkles, type LucideIcon } from "lucide-react";
|
||||
import { A2UI_TASK_CARD_TOKENS } from "./taskCardTokens";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface A2UITaskCardShellProps {
|
||||
children: ReactNode;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
preview?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export interface A2UITaskCardHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
compact?: boolean;
|
||||
statusLabel?: string;
|
||||
statusIcon?: LucideIcon;
|
||||
headerActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface A2UITaskCardBodyProps {
|
||||
children: ReactNode;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function A2UITaskCardShell({
|
||||
children,
|
||||
compact = false,
|
||||
className,
|
||||
preview = false,
|
||||
testId,
|
||||
}: A2UITaskCardShellProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
A2UI_TASK_CARD_TOKENS.shell,
|
||||
compact
|
||||
? A2UI_TASK_CARD_TOKENS.shellCompactPadding
|
||||
: A2UI_TASK_CARD_TOKENS.shellDefaultPadding,
|
||||
preview &&
|
||||
"[&_.a2ui-container_button]:pointer-events-none [&_.a2ui-container_input]:pointer-events-none [&_.a2ui-container_textarea]:pointer-events-none [&_.a2ui-container_input]:bg-slate-100 [&_.a2ui-container_textarea]:bg-slate-100 [&_.a2ui-container_button]:opacity-70",
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function A2UITaskCardStatusBadge({
|
||||
label,
|
||||
icon: Icon = Sparkles,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className={A2UI_TASK_CARD_TOKENS.statusBadge}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function A2UITaskCardHeader({
|
||||
title,
|
||||
subtitle,
|
||||
compact = false,
|
||||
statusLabel,
|
||||
statusIcon,
|
||||
headerActions,
|
||||
}: A2UITaskCardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"font-semibold tracking-tight text-slate-900",
|
||||
compact ? "text-lg" : "text-xl",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-slate-500",
|
||||
compact ? "text-xs leading-5" : "text-sm leading-6",
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{headerActions ? (
|
||||
headerActions
|
||||
) : statusLabel ? (
|
||||
<A2UITaskCardStatusBadge label={statusLabel} icon={statusIcon} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function A2UITaskCardBody({
|
||||
children,
|
||||
compact = false,
|
||||
className,
|
||||
}: A2UITaskCardBodyProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
A2UI_TASK_CARD_TOKENS.contentPanel,
|
||||
compact
|
||||
? A2UI_TASK_CARD_TOKENS.contentPanelCompactPadding
|
||||
: A2UI_TASK_CARD_TOKENS.contentPanelDefaultPadding,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function A2UITaskCardLoadingBody({
|
||||
text,
|
||||
compact = false,
|
||||
}: {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
A2UI_TASK_CARD_TOKENS.loadingPanel,
|
||||
compact
|
||||
? A2UI_TASK_CARD_TOKENS.loadingPanelCompactPadding
|
||||
: A2UI_TASK_CARD_TOKENS.loadingPanelDefaultPadding,
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const A2UI_TASK_CARD_TOKENS = {
|
||||
shell:
|
||||
"overflow-hidden rounded-[24px] border border-slate-200/90 bg-background/95 shadow-[0_14px_40px_rgba(15,23,42,0.08)]",
|
||||
shellCompactPadding: "p-4",
|
||||
shellDefaultPadding: "my-3 p-5",
|
||||
statusBadge:
|
||||
"flex shrink-0 items-center gap-2 rounded-full border border-blue-100 bg-blue-50 px-3 py-1 text-xs font-medium text-blue-600",
|
||||
contentPanel: "mt-4 rounded-[20px] border border-slate-200 bg-slate-50/70",
|
||||
contentPanelCompactPadding: "p-4",
|
||||
contentPanelDefaultPadding: "p-5",
|
||||
loadingPanel:
|
||||
"mt-4 flex items-center gap-3 rounded-[20px] border border-slate-200 bg-slate-50/70 text-slate-500",
|
||||
loadingPanelCompactPadding: "px-4 py-3 text-xs",
|
||||
loadingPanelDefaultPadding: "px-5 py-4 text-sm",
|
||||
workspaceOverlay:
|
||||
"pointer-events-auto w-full max-w-[820px] rounded-[28px] border border-slate-200/90 bg-background/98 p-6 shadow-[0_22px_70px_rgba(15,23,42,0.16)]",
|
||||
workspaceSection:
|
||||
"mt-5 rounded-[24px] border border-slate-200 bg-slate-50/70 p-5",
|
||||
workspaceDock:
|
||||
"flex w-full max-w-[640px] items-center justify-between gap-4 rounded-2xl border border-slate-200/90 bg-background/96 px-5 py-3.5 text-left shadow-[0_8px_30px_rgba(15,23,42,0.12)] backdrop-blur transition hover:border-blue-200 hover:shadow-[0_12px_36px_rgba(15,23,42,0.14)]",
|
||||
} as const;
|
||||
|
||||
export default A2UI_TASK_CARD_TOKENS;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const A2UI_FORM_TOKENS = {
|
||||
fieldStack: "space-y-2",
|
||||
fieldLabel: "text-sm font-medium text-slate-900",
|
||||
helperText: "text-xs text-muted-foreground",
|
||||
optionList: "flex gap-3",
|
||||
optionBase:
|
||||
"group rounded-[20px] border px-5 py-4 text-left text-sm transition-all",
|
||||
optionSelected:
|
||||
"border-primary/70 bg-white text-slate-900 shadow-[0_8px_24px_rgba(37,99,235,0.10)] ring-2 ring-primary/10",
|
||||
optionIdle: "border-slate-200 bg-white hover:border-primary/30 hover:bg-slate-50",
|
||||
optionTitle: "flex items-center gap-2 font-medium",
|
||||
optionTitleSelected: "text-slate-900",
|
||||
optionTitleIdle: "text-slate-800",
|
||||
optionDescription: "mt-1.5 text-xs leading-5 text-muted-foreground",
|
||||
radioIndicatorBase:
|
||||
"mt-0.5 inline-flex h-6 w-6 shrink-0 rounded-full border transition-colors",
|
||||
radioIndicatorSelected:
|
||||
"border-primary bg-primary shadow-[inset_0_0_0_5px_white]",
|
||||
radioIndicatorIdle: "border-slate-300 bg-white group-hover:border-primary/60",
|
||||
checkboxIndicatorBase:
|
||||
"mt-0.5 inline-flex h-5 w-5 shrink-0 rounded-md border transition-colors",
|
||||
checkboxIndicatorSelected:
|
||||
"border-primary bg-primary shadow-[inset_0_0_0_4px_white]",
|
||||
checkboxIndicatorIdle:
|
||||
"border-slate-300 bg-white group-hover:border-primary/60",
|
||||
textInput:
|
||||
"h-11 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm shadow-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/10",
|
||||
textarea:
|
||||
"min-h-[96px] w-full resize-y rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm leading-6 shadow-sm outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/10",
|
||||
checkboxRow: "flex items-center gap-3 cursor-pointer",
|
||||
checkboxInput:
|
||||
"h-4 w-4 rounded border-slate-300 text-primary focus:ring-2 focus:ring-primary/10",
|
||||
checkboxText: "text-sm text-slate-800",
|
||||
sliderRow: "flex items-center justify-between",
|
||||
sliderValue: "text-sm text-muted-foreground",
|
||||
sliderInput: "w-full accent-primary",
|
||||
sliderMarks: "flex justify-between text-xs text-muted-foreground",
|
||||
} as const;
|
||||
|
||||
export function getA2UIChoiceOptionClasses(
|
||||
isWrap: boolean,
|
||||
isSelected: boolean,
|
||||
): string {
|
||||
return cn(
|
||||
A2UI_FORM_TOKENS.optionBase,
|
||||
isWrap ? "min-w-[180px] flex-1" : "w-full",
|
||||
isSelected
|
||||
? A2UI_FORM_TOKENS.optionSelected
|
||||
: A2UI_FORM_TOKENS.optionIdle,
|
||||
);
|
||||
}
|
||||
|
||||
export function getA2UIChoiceTitleClasses(isSelected: boolean): string {
|
||||
return cn(
|
||||
A2UI_FORM_TOKENS.optionTitle,
|
||||
isSelected
|
||||
? A2UI_FORM_TOKENS.optionTitleSelected
|
||||
: A2UI_FORM_TOKENS.optionTitleIdle,
|
||||
);
|
||||
}
|
||||
|
||||
export function getA2UIChoiceIndicatorClasses(
|
||||
isMutuallyExclusive: boolean,
|
||||
isSelected: boolean,
|
||||
): string {
|
||||
if (isMutuallyExclusive) {
|
||||
return cn(
|
||||
A2UI_FORM_TOKENS.radioIndicatorBase,
|
||||
isSelected
|
||||
? A2UI_FORM_TOKENS.radioIndicatorSelected
|
||||
: A2UI_FORM_TOKENS.radioIndicatorIdle,
|
||||
);
|
||||
}
|
||||
|
||||
return cn(
|
||||
A2UI_FORM_TOKENS.checkboxIndicatorBase,
|
||||
isSelected
|
||||
? A2UI_FORM_TOKENS.checkboxIndicatorSelected
|
||||
: A2UI_FORM_TOKENS.checkboxIndicatorIdle,
|
||||
);
|
||||
}
|
||||
|
||||
export default A2UI_FORM_TOKENS;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { act, type ComponentProps } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ContentReviewPanel } from "./ContentReviewPanel";
|
||||
|
||||
interface RenderResult {
|
||||
container: HTMLDivElement;
|
||||
root: Root;
|
||||
}
|
||||
|
||||
const mountedRoots: RenderResult[] = [];
|
||||
|
||||
const defaultExpert = {
|
||||
id: "expert-1",
|
||||
name: "评审专家",
|
||||
title: "结构审校",
|
||||
description: "负责检查评审输出结构是否合理",
|
||||
tags: ["结构", "审校"],
|
||||
avatarLabel: "审",
|
||||
avatarColor: "#2563eb",
|
||||
};
|
||||
|
||||
function renderPanel(
|
||||
overrides: Partial<ComponentProps<typeof ContentReviewPanel>> = {},
|
||||
): RenderResult {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ContentReviewPanel
|
||||
open={true}
|
||||
experts={[defaultExpert]}
|
||||
selectedExpertIds={["expert-1"]}
|
||||
onToggleExpert={() => {}}
|
||||
onClose={() => {}}
|
||||
onCreateExpert={() => {}}
|
||||
onStartReview={() => {}}
|
||||
reviewRunning={false}
|
||||
reviewResult=""
|
||||
reviewError=""
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const rendered = { container, root };
|
||||
mountedRoots.push(rendered);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mountedRoots.length > 0) {
|
||||
const mounted = mountedRoots.pop();
|
||||
if (!mounted) {
|
||||
break;
|
||||
}
|
||||
act(() => {
|
||||
mounted.root.unmount();
|
||||
});
|
||||
mounted.container.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("ContentReviewPanel", () => {
|
||||
it("应按结构化方式渲染 A2UI 评审结果,而不是直接显示原始代码块", () => {
|
||||
renderPanel({
|
||||
reviewResult: `\`\`\`a2ui
|
||||
{
|
||||
"type": "form",
|
||||
"title": "创作需求收集",
|
||||
"description": "请补充以下内容",
|
||||
"fields": [
|
||||
{
|
||||
"id": "topic",
|
||||
"type": "text",
|
||||
"label": "内容主题",
|
||||
"placeholder": "请输入主题"
|
||||
}
|
||||
],
|
||||
"submitLabel": "继续"
|
||||
}
|
||||
\`\`\``,
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("创作需求收集");
|
||||
expect(document.body.textContent).toContain("内容主题");
|
||||
expect(document.body.textContent).not.toContain("```a2ui");
|
||||
expect(document.body.textContent).toContain(
|
||||
"检测到结构化补充信息,右侧栏已按结构化内容展示",
|
||||
);
|
||||
expect(document.body.textContent).toContain("结构化补充信息");
|
||||
expect(document.body.textContent).toContain("评审预览");
|
||||
});
|
||||
|
||||
it("普通文本评审结果应保持原样显示", () => {
|
||||
renderPanel({
|
||||
reviewResult:
|
||||
"内容评审结果:整体结构清晰,但导语偏长,建议压缩到两句话内。",
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("内容评审结果:整体结构清晰");
|
||||
});
|
||||
|
||||
it("未完成的结构化评审结果应显示统一加载卡片", () => {
|
||||
renderPanel({
|
||||
reviewResult: "```a2ui\n{\n \"type\": \"form\"\n",
|
||||
reviewRunning: false,
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("结构化评审结果加载中...");
|
||||
expect(document.body.textContent).toContain("结构化补充信息");
|
||||
expect(document.body.textContent).toContain("评审预览");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,13 @@ import React, {
|
||||
import styled from "styled-components";
|
||||
import { ArrowLeft, Check, Plus, Upload, X } from "lucide-react";
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { parseAIResponse } from "@/components/content-creator/a2ui/parser";
|
||||
import type { A2UIResponse } from "@/components/content-creator/a2ui/types";
|
||||
import { REVIEW_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
|
||||
import {
|
||||
A2UITaskCard,
|
||||
A2UITaskLoadingCard,
|
||||
} from "@/components/agent/chat/components/A2UITaskCard";
|
||||
import type {
|
||||
ContentReviewExpert,
|
||||
CustomContentReviewExpertInput,
|
||||
@@ -289,6 +296,26 @@ const ReviewStateText = styled.div`
|
||||
word-break: break-word;
|
||||
`;
|
||||
|
||||
const ReviewStateContent = styled.div`
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
const ReviewStructuredHint = styled.div`
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
color: hsl(var(--primary));
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const ReviewA2UIPreview = styled.div`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const SidebarFooter = styled.div`
|
||||
padding: 14px 14px 18px;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
@@ -661,6 +688,12 @@ export const ContentReviewPanel: React.FC<ContentReviewPanelProps> = memo(
|
||||
() => new Set(selectedExpertIds),
|
||||
[selectedExpertIds],
|
||||
);
|
||||
const parsedReviewResult = useMemo(() => {
|
||||
if (!reviewResult.trim()) {
|
||||
return null;
|
||||
}
|
||||
return parseAIResponse(reviewResult, false);
|
||||
}, [reviewResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -678,6 +711,65 @@ export const ContentReviewPanel: React.FC<ContentReviewPanelProps> = memo(
|
||||
|
||||
const selectedCount = selectedExpertIds.length;
|
||||
|
||||
const renderReviewResult = useCallback(() => {
|
||||
if (
|
||||
!parsedReviewResult ||
|
||||
(!parsedReviewResult.hasA2UI && !parsedReviewResult.hasPending)
|
||||
) {
|
||||
return <ReviewStateText>{reviewResult}</ReviewStateText>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReviewStateContent>
|
||||
<ReviewStructuredHint>
|
||||
检测到结构化补充信息,右侧栏已按结构化内容展示,不再直接输出原始
|
||||
A2UI 代码块。
|
||||
</ReviewStructuredHint>
|
||||
{parsedReviewResult.parts.map((part, index) => {
|
||||
if (part.type === "a2ui" && typeof part.content !== "string") {
|
||||
const readonlyResponse: A2UIResponse = {
|
||||
...part.content,
|
||||
submitAction: undefined,
|
||||
};
|
||||
return (
|
||||
<ReviewA2UIPreview key={`review-a2ui-${index}`}>
|
||||
<A2UITaskCard
|
||||
response={readonlyResponse}
|
||||
compact={true}
|
||||
preview={true}
|
||||
preset={REVIEW_A2UI_TASK_CARD_PRESET}
|
||||
/>
|
||||
</ReviewA2UIPreview>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === "pending_a2ui") {
|
||||
return (
|
||||
<A2UITaskLoadingCard
|
||||
key={`review-pending-a2ui-${index}`}
|
||||
compact={true}
|
||||
preset={REVIEW_A2UI_TASK_CARD_PRESET}
|
||||
subtitle="评审结果正在解析结构化字段。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const textContent =
|
||||
typeof part.content === "string" ? part.content.trim() : "";
|
||||
if (!textContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReviewStateText key={`review-text-${index}`}>
|
||||
{textContent}
|
||||
</ReviewStateText>
|
||||
);
|
||||
})}
|
||||
</ReviewStateContent>
|
||||
);
|
||||
}, [parsedReviewResult, reviewResult]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarShell $open={open} aria-hidden={!open}>
|
||||
@@ -769,7 +861,7 @@ export const ContentReviewPanel: React.FC<ContentReviewPanelProps> = memo(
|
||||
{!reviewRunning && !reviewError && reviewResult ? (
|
||||
<ReviewStateCard>
|
||||
<ReviewStateTitle>评审结果</ReviewStateTitle>
|
||||
<ReviewStateText>{reviewResult}</ReviewStateText>
|
||||
{renderReviewResult()}
|
||||
</ReviewStateCard>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import React, {
|
||||
} from "react";
|
||||
import styled from "styled-components";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getStyleGuide, type StyleGuide } from "@/lib/api/memory";
|
||||
import type {
|
||||
AutoContinueSettings,
|
||||
ContentReviewExpert,
|
||||
@@ -59,6 +60,10 @@ import {
|
||||
} from "./utils/autoContinueSettings";
|
||||
import { logRenderPerf } from "@/lib/perfDebug";
|
||||
import { useWorkbenchStore } from "@/stores/useWorkbenchStore";
|
||||
import {
|
||||
buildTextStylizePrompt,
|
||||
resolveTextStylizeSourceLabel,
|
||||
} from "@/lib/style-guide";
|
||||
|
||||
interface WebImageSearchResponse {
|
||||
total: number;
|
||||
@@ -173,6 +178,8 @@ export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
|
||||
const [toastMessage, setToastMessage] = useState("");
|
||||
const [showToast, setShowToast] = useState(false);
|
||||
const [autoInsertLoading, setAutoInsertLoading] = useState(false);
|
||||
const [projectStyleGuide, setProjectStyleGuide] =
|
||||
useState<StyleGuide | null>(null);
|
||||
|
||||
// Undo/Redo 历史栈
|
||||
const undoStackRef = useRef<string[]>([]);
|
||||
@@ -193,6 +200,32 @@ export const DocumentCanvas: React.FC<DocumentCanvasProps> = memo(
|
||||
setCanRedo(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setProjectStyleGuide(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
getStyleGuide(projectId)
|
||||
.then((nextStyleGuide) => {
|
||||
if (!disposed) {
|
||||
setProjectStyleGuide(nextStyleGuide);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[DocumentCanvas] 加载项目风格失败:", error);
|
||||
if (!disposed) {
|
||||
setProjectStyleGuide(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (undoStackRef.current.length === 0) return;
|
||||
const previous = undoStackRef.current.pop()!;
|
||||
@@ -875,26 +908,32 @@ CONTENT`,
|
||||
}
|
||||
|
||||
try {
|
||||
showMessage("✨ 正在进行文本风格化...");
|
||||
const latestProjectStyleGuide = projectId
|
||||
? await getStyleGuide(projectId).catch((error) => {
|
||||
console.warn("[DocumentCanvas] 刷新项目风格失败:", error);
|
||||
return projectStyleGuide;
|
||||
})
|
||||
: null;
|
||||
|
||||
const prompt = `请对以下文本进行风格化优化,使其更加生动、有吸引力,同时保持原意不变。
|
||||
if (projectId) {
|
||||
setProjectStyleGuide(latestProjectStyleGuide);
|
||||
}
|
||||
|
||||
优化要求:
|
||||
1. 增强文字的表现力和感染力
|
||||
2. 使用更生动的词汇和修辞手法
|
||||
3. 优化句式结构,使其更流畅
|
||||
4. 保持原文的核心观点和信息
|
||||
5. 适当添加情感色彩,但不要过度夸张
|
||||
6. 输出纯文本,不要使用 Markdown 格式
|
||||
const styleSourceLabel = resolveTextStylizeSourceLabel({
|
||||
projectId,
|
||||
projectStyleGuide: latestProjectStyleGuide,
|
||||
});
|
||||
showMessage(
|
||||
styleSourceLabel === "项目默认风格"
|
||||
? "✨ 正在根据项目默认风格进行文本风格化..."
|
||||
: "✨ 正在进行文本风格化...",
|
||||
);
|
||||
|
||||
当前平台:${state.platform}
|
||||
|
||||
原始文本:
|
||||
<<<CONTENT
|
||||
${baseContent}
|
||||
CONTENT
|
||||
|
||||
请直接输出优化后的文本,不要添加任何说明或注释。`;
|
||||
const prompt = buildTextStylizePrompt({
|
||||
content: baseContent,
|
||||
platform: state.platform,
|
||||
projectStyleGuide: latestProjectStyleGuide,
|
||||
});
|
||||
|
||||
const result = await onTextStylizeRun({
|
||||
prompt,
|
||||
@@ -916,11 +955,22 @@ CONTENT
|
||||
}, [
|
||||
onTextStylizeRun,
|
||||
editingContent,
|
||||
projectId,
|
||||
projectStyleGuide,
|
||||
state.platform,
|
||||
resolvedThinkingEnabled,
|
||||
showMessage,
|
||||
]);
|
||||
|
||||
const textStylizeSourceLabel = useMemo(
|
||||
() =>
|
||||
resolveTextStylizeSourceLabel({
|
||||
projectId,
|
||||
projectStyleGuide,
|
||||
}),
|
||||
[projectId, projectStyleGuide],
|
||||
);
|
||||
|
||||
const handleCloseContentReview = useCallback(() => {
|
||||
setContentReviewOpen(false);
|
||||
if (contentReviewPlacement === "external-rail") {
|
||||
@@ -1061,6 +1111,7 @@ CONTENT
|
||||
onAddImage={onAddImage}
|
||||
onImportDocument={onImportDocument}
|
||||
onTextStylize={handleTextStylize}
|
||||
textStylizeSourceLabel={textStylizeSourceLabel}
|
||||
onContentReview={handleContentReview}
|
||||
contentReviewActive={contentReviewOpen}
|
||||
onUndo={handleUndo}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user