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:
coso
2026-03-10 07:57:13 +08:00
parent bb6ae1259d
commit 90bd63908f
193 changed files with 25343 additions and 5875 deletions
+3 -3
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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. 日志目录压缩包
+9 -6
View File
@@ -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
View File
@@ -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"
},
+123
View File
@@ -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);
});
+194
View File
@@ -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"
+16 -16
View File
@@ -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 -2
View File
@@ -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
+11
View File
@@ -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) {
+43 -5
View File
@@ -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, "");
}
}
+7 -19
View File
@@ -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));
}
}
+1 -1
View File
@@ -114,7 +114,7 @@ impl McpClientManager {
/// # Arguments
///
/// * `emitter` - 事件发射器,用于发送事件到前端。
/// 如果为 None,则不会发送事件。
/// 如果为 None,则不会发送事件。
///
/// # Returns
///
-2
View File
@@ -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 命令");
+219 -37
View File
@@ -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)
+2 -2
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
+49 -1
View File
@@ -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));
}
}
+2
View File
@@ -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;
+174
View File
@@ -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()
}
+22
View File
@@ -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 &current_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
}
+5
View File
@@ -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,
+31 -15
View File
@@ -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(),
))),
+1
View File
@@ -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 -1
View File
@@ -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",
+11
View File
@@ -0,0 +1,11 @@
{
"bundle": {
"targets": ["nsis"],
"windows": {
"webviewInstallMode": {
"type": "offlineInstaller",
"silent": true
}
}
}
}
+140 -5
View File
@@ -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()}
+32 -12
View File
@@ -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}
/>
}
/>
</>
);
};
@@ -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>
);
}
@@ -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" ? (
+123 -55
View File
@@ -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");
+332 -90
View File
@@ -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![${fileName}](${imageUrl})\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![${fileName}](${imageUrl})\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 (
+1 -1
View File
@@ -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";
}
}
+71 -2
View File
@@ -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>
+1 -1
View File
@@ -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