diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4151018bb..8a3226bcc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index 9125c3ee7..0f08e1ca7 100644 --- a/README.md +++ b/README.md @@ -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 常见提示,不代表安装包必然损坏 + --- ## 🧭 适合谁 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0a4e616e6..6ab730675 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index a1d1a6714..d0bbc29af 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -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 会话是否可复用 diff --git a/docs/content/05.troubleshooting/1.common-issues.md b/docs/content/05.troubleshooting/1.common-issues.md index 7ab4fdc7e..f7cfa805d 100644 --- a/docs/content/05.troubleshooting/1.common-issues.md +++ b/docs/content/05.troubleshooting/1.common-issues.md @@ -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)。 + ## 仍然无法解决 请整理以下信息后反馈: diff --git a/docs/content/05.troubleshooting/4.windows-startup-issues.md b/docs/content/05.troubleshooting/4.windows-startup-issues.md new file mode 100644 index 000000000..0ac14a942 --- /dev/null +++ b/docs/content/05.troubleshooting/4.windows-startup-issues.md @@ -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. 日志目录压缩包 diff --git a/docs/content/06.development/3.building.md b/docs/content/06.development/3.building.md index d669a4866..67a3ae447 100644 --- a/docs/content/06.development/3.building.md +++ b/docs/content/06.development/3.building.md @@ -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 # 设置签名证书 diff --git a/docs/content/06.development/4.operations.md b/docs/content/06.development/4.operations.md index 6403cf3cb..01df37d30 100644 --- a/docs/content/06.development/4.operations.md +++ b/docs/content/06.development/4.operations.md @@ -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` diff --git a/package.json b/package.json index e869f8b14..512efcd4d 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/check-dev-bridge-health.mjs b/scripts/check-dev-bridge-health.mjs new file mode 100644 index 000000000..b26184461 --- /dev/null +++ b/scripts/check-dev-bridge-health.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +import process from "node:process"; + +const DEFAULTS = { + url: "http://127.0.0.1:3030/health", + timeoutMs: 60000, + intervalMs: 1000, +}; + +function printHelp() { + console.log(` +ProxyCast DevBridge 健康检查 + +用法: + node scripts/check-dev-bridge-health.mjs [选项] + +选项: + --url 健康检查地址,默认 http://127.0.0.1:3030/health + --timeout-ms 超时时间,默认 60000 + --interval-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); +}); diff --git a/scripts/windows-collect-support-bundle.ps1 b/scripts/windows-collect-support-bundle.ps1 new file mode 100644 index 000000000..488049021 --- /dev/null +++ b/scripts/windows-collect-support-bundle.ps1 @@ -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" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1a0262bc5..81ace37bc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4791fa551..5f255a914 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 diff --git a/src-tauri/build.rs b/src-tauri/build.rs index e9d7ed3be..8853df992 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -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) { diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index 143b42800..571b69bbe 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -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) -> Vec { } fn collect_tool_result_text(value: &serde_json::Value, target: &mut Vec) { + collect_tool_result_text_with_depth(value, target, 0); +} + +fn collect_tool_result_text_with_depth( + value: &serde_json::Value, + target: &mut Vec, + 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, seen_sources: &mut std::collections::HashSet, ) { + 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, + seen_sources: &mut std::collections::HashSet, + 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(" Result TauriMessage { // 调试日志 tracing::debug!( - "[SessionStore] 转换消息: role={}, content={:?}", + "[SessionStore] 转换消息: role={}, content_items={}", result.role, - result.content + result.content.len() ); result diff --git a/src-tauri/crates/core/src/database/dao/agent.rs b/src-tauri/crates/core/src/database/dao/agent.rs index 75798542e..b953ee4b6 100644 --- a/src-tauri/crates/core/src/database/dao/agent.rs +++ b/src-tauri/crates/core/src/database/dao/agent.rs @@ -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) -> Vec { deduped } -fn collect_text_candidates(value: &serde_json::Value, target: &mut Vec) { +fn collect_text_candidates_with_depth( + value: &serde_json::Value, + target: &mut Vec, + 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) } 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) } fn extract_tool_response_text(value: &serde_json::Value) -> Option { + extract_tool_response_text_with_depth(value, 0) +} + +fn extract_tool_response_text_with_depth( + value: &serde_json::Value, + depth: usize, +) -> Option { + 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 { .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 { } fn parse_content_parts_from_json(value: &serde_json::Value) -> Vec { + parse_content_parts_from_json_with_depth(value, 0) +} + +fn parse_content_parts_from_json_with_depth( + value: &serde_json::Value, + depth: usize, +) -> Vec { + 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(), ""); + } } diff --git a/src-tauri/crates/core/src/session_files/storage.rs b/src-tauri/crates/core/src/session_files/storage.rs index 03475ec17..043d870b8 100644 --- a/src-tauri/crates/core/src/session_files/storage.rs +++ b/src-tauri/crates/core/src/session_files/storage.rs @@ -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)); } } diff --git a/src-tauri/crates/mcp/src/manager.rs b/src-tauri/crates/mcp/src/manager.rs index bf1c88aff..bc6dcb05b 100644 --- a/src-tauri/crates/mcp/src/manager.rs +++ b/src-tauri/crates/mcp/src/manager.rs @@ -114,7 +114,7 @@ impl McpClientManager { /// # Arguments /// /// * `emitter` - 事件发射器,用于发送事件到前端。 - /// 如果为 None,则不会发送事件。 + /// 如果为 None,则不会发送事件。 /// /// # Returns /// diff --git a/src-tauri/crates/server-utils/src/lib.rs b/src-tauri/crates/server-utils/src/lib.rs index ff281dc1e..c7367631d 100644 --- a/src-tauri/crates/server-utils/src/lib.rs +++ b/src-tauri/crates/server-utils/src/lib.rs @@ -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 } diff --git a/src-tauri/crates/services/src/content_creator/types.rs b/src-tauri/crates/services/src/content_creator/types.rs index e3bd9b2bd..6ae3375df 100644 --- a/src-tauri/crates/services/src/content_creator/types.rs +++ b/src-tauri/crates/services/src/content_creator/types.rs @@ -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")] diff --git a/src-tauri/crates/services/src/model_registry_service.rs b/src-tauri/crates/services/src/model_registry_service.rs index 7e2d5a79e..ad65d8854 100644 --- a/src-tauri/crates/services/src/model_registry_service.rs +++ b/src-tauri/crates/services/src/model_registry_service.rs @@ -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 { + 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, + /// 实际请求 URL(如果有) + pub request_url: Option, + /// 面向用户的诊断建议(如果有) + pub diagnostic_hint: Option, } #[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 { diff --git a/src-tauri/crates/services/src/screenshot_capture_service.rs b/src-tauri/crates/services/src/screenshot_capture_service.rs index 4f746a64f..b6d3b7aa3 100644 --- a/src-tauri/crates/services/src/screenshot_capture_service.rs +++ b/src-tauri/crates/services/src/screenshot_capture_service.rs @@ -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 { /// 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 命令"); diff --git a/src-tauri/crates/services/src/skill_service.rs b/src-tauri/crates/services/src/skill_service.rs index 3e3632af4..4cca04403 100644 --- a/src-tauri/crates/services/src/skill_service.rs +++ b/src-tauri/crates/services/src/skill_service.rs @@ -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), + Error(String), +} + +#[derive(Debug, Clone)] +struct RepoCacheEntry { + value: RepoCacheValue, + fetched_at: Instant, +} + +impl RepoCacheEntry { + fn success(skills: Vec) -> 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>, + inflight_fetches: Mutex>>, } 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> { + 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>> { + 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, - ) -> Result> { + async fn fetch_skills_from_repo_uncached(&self, repo: &SkillRepo) -> Result> { + 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> { 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 { + 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()] + ); + } +} diff --git a/src-tauri/crates/services/src/update_check_service.rs b/src-tauri/crates/services/src/update_check_service.rs index 6146c7261..36e6dea0c 100644 --- a/src-tauri/crates/services/src/update_check_service.rs +++ b/src-tauri/crates/services/src/update_check_service.rs @@ -273,7 +273,6 @@ impl Default for UpdateCheckService { // .show() // .map_err(|e| format!("发送通知失败: {}", e)) // } - /// 更新检查服务状态包装器(用于 Tauri 状态管理) pub struct UpdateCheckServiceState(pub Arc>); diff --git a/src-tauri/crates/terminal/src/connections/ssh_connection.rs b/src-tauri/crates/terminal/src/connections/ssh_connection.rs index f5ef75453..8f9c017f5 100644 --- a/src-tauri/crates/terminal/src/connections/ssh_connection.rs +++ b/src-tauri/crates/terminal/src/connections/ssh_connection.rs @@ -2080,7 +2080,7 @@ pub trait SSHAuthCallback: Send + Sync { /// 请求密钥密码 /// /// 当私钥需要密码时调用。 - fn request_passphrase(&self, key_path: &PathBuf) -> Option; + fn request_passphrase(&self, key_path: &Path) -> Option; /// 请求密码 /// diff --git a/src-tauri/crates/terminal/src/connections/wsl_connection.rs b/src-tauri/crates/terminal/src/connections/wsl_connection.rs index 0c6c0cf67..33ff5a6f7 100644 --- a/src-tauri/crates/terminal/src/connections/wsl_connection.rs +++ b/src-tauri/crates/terminal/src/connections/wsl_connection.rs @@ -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 { diff --git a/src-tauri/crates/terminal/src/integration/shell_integration.rs b/src-tauri/crates/terminal/src/integration/shell_integration.rs index 8e7f10bf7..7e2441da7 100644 --- a/src-tauri/crates/terminal/src/integration/shell_integration.rs +++ b/src-tauri/crates/terminal/src/integration/shell_integration.rs @@ -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 { diff --git a/src-tauri/crates/terminal/src/persistence/block_file.rs b/src-tauri/crates/terminal/src/persistence/block_file.rs index 3ba19b9a1..e0ac24242 100644 --- a/src-tauri/crates/terminal/src/persistence/block_file.rs +++ b/src-tauri/crates/terminal/src/persistence/block_file.rs @@ -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 { + pub fn new(block_id: &str, base_dir: &Path, max_size: usize) -> Result { 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) diff --git a/src-tauri/crates/terminal/src/pty_session.rs b/src-tauri/crates/terminal/src/pty_session.rs index 7c4d6b7c5..fa8651c41 100644 --- a/src-tauri/crates/terminal/src/pty_session.rs +++ b/src-tauri/crates/terminal/src/pty_session.rs @@ -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) } diff --git a/src-tauri/src/app/commands/logs.rs b/src-tauri/src/app/commands/logs.rs index 51e29b386..0109d51ee 100644 --- a/src-tauri/src/app/commands/logs.rs +++ b/src-tauri/src/app/commands/logs.rs @@ -5,8 +5,16 @@ use crate::app::types::LogState; use crate::logger; use chrono::Utc; +use flate2::read::GzDecoder; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use std::fs; +use std::io::{Read, Seek, Write}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; +use tauri::{AppHandle, Manager}; +use zip::write::FileOptions; +use zip::{CompressionMethod, ZipWriter}; /// 前端异常上报参数 #[derive(Debug, Clone, Serialize, Deserialize)] @@ -26,6 +34,30 @@ pub struct FrontendCrashReport { pub context: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogArtifactEntry { + pub file_name: String, + pub path: String, + pub size_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_at: Option, + pub compressed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogStorageDiagnostics { + #[serde(skip_serializing_if = "Option::is_none")] + pub log_directory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_log_path: Option, + pub current_log_exists: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_log_size_bytes: Option, + pub in_memory_log_count: usize, + pub related_log_files: Vec, + pub raw_response_files: Vec, +} + /// 获取日志 #[tauri::command] pub async fn get_logs(logs: tauri::State<'_, LogState>) -> Result, String> { @@ -39,6 +71,15 @@ pub async fn clear_logs(logs: tauri::State<'_, LogState>) -> Result<(), String> Ok(()) } +/// 清除诊断相关历史日志与原始响应文件 +#[tauri::command] +pub async fn clear_diagnostic_log_history(logs: tauri::State<'_, LogState>) -> Result<(), String> { + let log_file_path = { logs.read().await.get_log_file_path() }; + logs.write().await.clear(); + clear_diagnostic_log_artifacts_from_path(log_file_path)?; + Ok(()) +} + fn parse_persisted_log_line(line: &str) -> Option { let trimmed = line.trim(); if trimmed.is_empty() { @@ -62,6 +103,247 @@ fn parse_persisted_log_line(line: &str) -> Option { }) } +fn to_rfc3339(system_time: std::time::SystemTime) -> String { + chrono::DateTime::::from(system_time).to_rfc3339() +} + +fn parse_rotated_log_timestamp(current_log_path: &Path, candidate: &Path) -> Option { + let current_name = current_log_path.file_name()?.to_str()?; + let candidate_name = candidate.file_name()?.to_str()?; + + if candidate_name == current_name { + return Some(i64::MAX); + } + + let prefix = format!("{current_name}."); + let suffix = candidate_name.strip_prefix(&prefix)?; + let suffix = suffix.strip_suffix(".gz").unwrap_or(suffix); + let parsed = chrono::NaiveDateTime::parse_from_str(suffix, "%Y%m%d-%H%M%S").ok()?; + parsed.and_utc().timestamp_nanos_opt() +} + +fn path_sort_key(current_log_path: &Path, path: &Path) -> (i64, String) { + let logical_ts = parse_rotated_log_timestamp(current_log_path, path).unwrap_or_else(|| { + fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| chrono::DateTime::::from(modified).timestamp_nanos_opt()) + .unwrap_or_else(|| { + chrono::DateTime::::from(UNIX_EPOCH) + .timestamp_nanos_opt() + .unwrap_or(0) + }) + }); + + (logical_ts, path.to_string_lossy().to_string()) +} + +fn build_log_artifact_entry(path: &Path) -> Option { + let metadata = fs::metadata(path).ok()?; + let modified_at = metadata.modified().ok().map(to_rfc3339); + let file_name = path.file_name()?.to_string_lossy().to_string(); + + Some(LogArtifactEntry { + file_name, + path: path.to_string_lossy().to_string(), + size_bytes: metadata.len(), + modified_at, + compressed: path.extension().and_then(|ext| ext.to_str()) == Some("gz"), + }) +} + +fn collect_related_log_paths(current_log_path: &Path) -> Vec { + let Some(log_dir) = current_log_path.parent() else { + return Vec::new(); + }; + let Some(file_name) = current_log_path.file_name().and_then(|name| name.to_str()) else { + return Vec::new(); + }; + let prefix = format!("{file_name}."); + + let mut candidates = Vec::new(); + + if current_log_path.exists() { + candidates.push(current_log_path.to_path_buf()); + } + + let Ok(entries) = fs::read_dir(log_dir) else { + return candidates; + }; + + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if name == file_name || name.starts_with(&prefix) { + if !candidates.iter().any(|candidate| candidate == &path) { + candidates.push(path); + } + } + } + + candidates.sort_by_key(|path| path_sort_key(current_log_path, path)); + candidates +} + +fn collect_raw_response_paths(current_log_path: &Path) -> Vec { + let Some(log_dir) = current_log_path.parent() else { + return Vec::new(); + }; + + let Ok(entries) = fs::read_dir(log_dir) else { + return Vec::new(); + }; + + let mut candidates: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| name.starts_with("raw_response_") && name.ends_with(".txt")) + }) + .collect(); + + candidates.sort_by_key(|path| path_sort_key(current_log_path, path)); + candidates.reverse(); + candidates +} + +pub fn clear_diagnostic_log_artifacts_from_path( + current_log_path: Option, +) -> Result<(), String> { + let Some(current_log_path) = current_log_path else { + return Ok(()); + }; + + let current_log_path = PathBuf::from(current_log_path); + + for path in collect_related_log_paths(¤t_log_path) { + if path == current_log_path || !path.exists() { + continue; + } + fs::remove_file(&path).map_err(|error| { + format!( + "删除历史日志文件失败({}): {}", + path.to_string_lossy(), + error + ) + })?; + } + + for path in collect_raw_response_paths(¤t_log_path) { + if !path.exists() { + continue; + } + fs::remove_file(&path).map_err(|error| { + format!( + "删除原始响应文件失败({}): {}", + path.to_string_lossy(), + error + ) + })?; + } + + Ok(()) +} + +fn read_log_file_content(path: &Path) -> Option { + if path.extension().and_then(|ext| ext.to_str()) == Some("gz") { + let file = fs::File::open(path).ok()?; + let mut decoder = GzDecoder::new(file); + let mut content = String::new(); + decoder.read_to_string(&mut content).ok()?; + return Some(content); + } + + fs::read_to_string(path).ok() +} + +pub fn get_log_storage_diagnostics_from_path( + log_file_path: Option, + in_memory_log_count: usize, +) -> LogStorageDiagnostics { + let current_log_path = log_file_path.map(PathBuf::from); + let current_log_exists = current_log_path.as_ref().is_some_and(|path| path.exists()); + let current_log_size_bytes = current_log_path + .as_ref() + .and_then(|path| fs::metadata(path).ok()) + .map(|metadata| metadata.len()); + let log_directory = current_log_path + .as_ref() + .and_then(|path| path.parent()) + .map(|path| path.to_string_lossy().to_string()); + let related_log_files = current_log_path + .as_ref() + .map(|path| collect_related_log_paths(path)) + .unwrap_or_default() + .into_iter() + .rev() + .take(12) + .filter_map(|path| build_log_artifact_entry(&path)) + .collect(); + let raw_response_files = current_log_path + .as_ref() + .map(|path| collect_raw_response_paths(path)) + .unwrap_or_default() + .into_iter() + .take(12) + .filter_map(|path| build_log_artifact_entry(&path)) + .collect(); + + LogStorageDiagnostics { + log_directory, + current_log_path: current_log_path.map(|path| path.to_string_lossy().to_string()), + current_log_exists, + current_log_size_bytes, + in_memory_log_count, + related_log_files, + raw_response_files, + } +} + +pub fn read_persisted_logs_tail_from_path( + log_file_path: Option, + lines: usize, +) -> Result, String> { + let safe_limit = lines.clamp(1, 1000); + + let Some(path) = log_file_path else { + return Ok(Vec::new()); + }; + + let related_paths = collect_related_log_paths(Path::new(&path)); + if related_paths.is_empty() { + return Ok(Vec::new()); + } + + let mut parsed: Vec = Vec::new(); + + for log_path in related_paths.into_iter().rev() { + let remaining = safe_limit.saturating_sub(parsed.len()); + if remaining == 0 { + break; + } + + let Some(content) = read_log_file_content(&log_path) else { + continue; + }; + + parsed.extend( + content + .lines() + .rev() + .take(remaining) + .filter_map(parse_persisted_log_line), + ); + } + + parsed.reverse(); + Ok(parsed) +} + /// 获取持久化日志文件尾部(用于崩溃后恢复诊断) #[tauri::command] pub async fn get_persisted_logs_tail( @@ -71,25 +353,18 @@ pub async fn get_persisted_logs_tail( let safe_limit = lines.unwrap_or(200).clamp(20, 1000); let log_file_path = logs.read().await.get_log_file_path(); - let Some(path) = log_file_path else { - return Ok(Vec::new()); - }; + read_persisted_logs_tail_from_path(log_file_path, safe_limit) +} - let content = std::fs::read_to_string(&path).map_err(|e| format!("读取持久化日志失败: {e}"))?; - - if content.trim().is_empty() { - return Ok(Vec::new()); - } - - let mut parsed: Vec = content - .lines() - .rev() - .take(safe_limit) - .filter_map(parse_persisted_log_line) - .collect(); - - parsed.reverse(); - Ok(parsed) +#[tauri::command] +pub async fn get_log_storage_diagnostics( + logs: tauri::State<'_, LogState>, +) -> Result { + let logs = logs.read().await; + Ok(get_log_storage_diagnostics_from_path( + logs.get_log_file_path(), + logs.get_logs().len(), + )) } /// 写入前端异常到本地日志并同步到崩溃上报后端 @@ -162,3 +437,763 @@ pub async fn report_frontend_crash( Ok(()) } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SupportBundleExportResult { + pub bundle_path: String, + pub output_directory: String, + pub generated_at: String, + pub platform: String, + pub included_sections: Vec, + pub omitted_sections: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct SupportBundlePathMetadata { + path: String, + exists: bool, + #[serde(skip_serializing_if = "Option::is_none")] + kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + last_write_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + size_bytes: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct SupportBundleTreeEntry { + relative_path: String, + is_directory: bool, + #[serde(skip_serializing_if = "Option::is_none")] + size_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + modified_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct SupportBundleManifest { + generated_at: String, + app_version: String, + platform: String, + arch: String, + username: String, + #[serde(skip_serializing_if = "Option::is_none")] + app_data_dir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + config_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + legacy_proxycast_dir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + database_path: Option, + path_checks: Vec, + log_storage_diagnostics: LogStorageDiagnostics, + persisted_log_tail_lines: usize, + #[serde(skip_serializing_if = "Option::is_none")] + windows_startup_diagnostics: + Option, + included_sections: Vec, + omitted_sections: Vec, +} + +#[derive(Debug, Clone)] +struct SupportBundleContext { + generated_at: String, + app_data_dir: Option, + config_path: Option, + legacy_proxycast_dir: Option, + database_path: Option, + log_storage_diagnostics: LogStorageDiagnostics, + persisted_log_tail: Vec, + windows_startup_diagnostics: + Option, +} + +#[tauri::command] +pub async fn export_support_bundle( + logs: tauri::State<'_, LogState>, + app: AppHandle, +) -> Result { + let (log_file_path, in_memory_log_count) = { + let logs = logs.read().await; + (logs.get_log_file_path(), logs.get_logs().len()) + }; + + let log_storage_diagnostics = + get_log_storage_diagnostics_from_path(log_file_path.clone(), in_memory_log_count); + let persisted_log_tail = read_persisted_logs_tail_from_path(log_file_path, 200)?; + + let app_data_dir = app + .path() + .app_data_dir() + .ok() + .or_else(guess_proxycast_app_data_dir); + let config_path = guess_proxycast_config_path(); + let legacy_proxycast_dir = dirs::home_dir().map(|home| home.join(".proxycast")); + let database_path = crate::database::get_db_path().ok().or_else(|| { + legacy_proxycast_dir + .as_ref() + .map(|dir| dir.join("proxycast.db")) + }); + let output_directory = default_support_bundle_output_dir(); + + let result = export_support_bundle_to( + &output_directory, + SupportBundleContext { + generated_at: Utc::now().to_rfc3339(), + app_data_dir, + config_path, + legacy_proxycast_dir, + database_path, + log_storage_diagnostics, + persisted_log_tail, + windows_startup_diagnostics: Some( + crate::commands::windows_startup_cmd::collect_windows_startup_diagnostics(&app), + ), + }, + )?; + + logs.write().await.add( + "info", + &format!( + "[SupportBundle] 已导出支持包: {}", + logger::sanitize_log_message(&result.bundle_path) + ), + ); + + Ok(result) +} + +fn guess_proxycast_app_data_dir() -> Option { + dirs::data_dir().map(|dir| dir.join("proxycast")) +} + +fn guess_proxycast_config_path() -> Option { + dirs::config_dir().map(|dir| dir.join("proxycast").join("config.yaml")) +} + +fn default_support_bundle_output_dir() -> PathBuf { + dirs::desktop_dir() + .or_else(dirs::download_dir) + .unwrap_or_else(std::env::temp_dir) +} + +fn collect_support_path_metadata(path: Option<&Path>) -> SupportBundlePathMetadata { + let Some(path) = path else { + return SupportBundlePathMetadata { + path: String::new(), + exists: false, + kind: None, + last_write_time: None, + size_bytes: None, + }; + }; + + let path_string = path.to_string_lossy().to_string(); + let Ok(metadata) = fs::metadata(path) else { + return SupportBundlePathMetadata { + path: path_string, + exists: false, + kind: None, + last_write_time: None, + size_bytes: None, + }; + }; + + let kind = if metadata.is_dir() { + Some("directory".to_string()) + } else if metadata.is_file() { + Some("file".to_string()) + } else { + Some("other".to_string()) + }; + + let size_bytes = if metadata.is_dir() { + None + } else { + Some(metadata.len()) + }; + + SupportBundlePathMetadata { + path: path_string, + exists: true, + kind, + last_write_time: metadata.modified().ok().map(to_rfc3339), + size_bytes, + } +} + +fn should_exclude_support_listing(relative_path: &Path) -> bool { + relative_path + .components() + .next() + .and_then(|component| component.as_os_str().to_str()) + .is_some_and(|name| matches!(name, "credentials" | "auth")) +} + +fn collect_directory_tree_entries(root: &Path) -> Vec { + fn walk(base: &Path, current: &Path, entries: &mut Vec) { + let Ok(children) = fs::read_dir(current) else { + return; + }; + + for child in children.flatten() { + let path = child.path(); + let Ok(relative_path) = path.strip_prefix(base) else { + continue; + }; + if should_exclude_support_listing(relative_path) { + continue; + } + + let Ok(metadata) = child.metadata() else { + continue; + }; + let is_directory = metadata.is_dir(); + entries.push(SupportBundleTreeEntry { + relative_path: relative_path.to_string_lossy().to_string(), + is_directory, + size_bytes: if is_directory { + None + } else { + Some(metadata.len()) + }, + modified_at: metadata.modified().ok().map(to_rfc3339), + }); + + if is_directory { + walk(base, &path, entries); + } + } + } + + if !root.exists() { + return Vec::new(); + } + + let mut entries = Vec::new(); + walk(root, root, &mut entries); + entries +} + +fn write_support_json(path: &Path, value: &T) -> Result<(), String> { + let content = serde_json::to_vec_pretty(value) + .map_err(|error| format!("序列化支持包 JSON 失败 {}: {error}", path.display()))?; + fs::write(path, content) + .map_err(|error| format!("写入支持包文件失败 {}: {error}", path.display())) +} + +fn copy_directory_recursive(source: &Path, destination: &Path) -> Result<(), String> { + if !source.exists() { + return Ok(()); + } + + fs::create_dir_all(destination) + .map_err(|error| format!("创建支持包目录失败 {}: {error}", destination.display()))?; + + for entry in fs::read_dir(source) + .map_err(|error| format!("读取目录失败 {}: {error}", source.display()))? + { + let entry = + entry.map_err(|error| format!("读取目录项失败 {}: {error}", source.display()))?; + let path = entry.path(); + let target = destination.join(entry.file_name()); + + if entry + .file_type() + .map_err(|error| format!("读取文件类型失败 {}: {error}", path.display()))? + .is_dir() + { + copy_directory_recursive(&path, &target)?; + } else { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!("创建支持包父目录失败 {}: {error}", parent.display()) + })?; + } + fs::copy(&path, &target).map_err(|error| { + format!( + "复制支持包文件失败 {} -> {}: {error}", + path.display(), + target.display() + ) + })?; + } + } + + Ok(()) +} + +fn write_support_bundle_readme(path: &Path, omitted_sections: &[String]) -> Result<(), String> { + let omitted = omitted_sections + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n"); + + let content = format!( + "ProxyCast 支持包\n\n已包含:\n- meta/manifest.json\n- meta/log-storage-diagnostics.json\n- meta/persisted-log-tail.json\n- meta/appdata-listing.json(如目录存在)\n- meta/legacy-listing.json(如目录存在)\n- logs/(如目录存在)\n- request_logs/(如目录存在)\n\n默认未包含:\n{omitted}\n" + ); + + fs::write(path, content) + .map_err(|error| format!("写入支持包 README 失败 {}: {error}", path.display())) +} + +fn normalize_archive_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn add_directory_to_zip( + writer: &mut ZipWriter, + root: &Path, + current: &Path, +) -> Result<(), String> { + let file_options = FileOptions::default().compression_method(CompressionMethod::Deflated); + let dir_options = FileOptions::default().compression_method(CompressionMethod::Stored); + + for entry in fs::read_dir(current) + .map_err(|error| format!("读取支持包目录失败 {}: {error}", current.display()))? + { + let entry = entry + .map_err(|error| format!("读取支持包目录项失败 {}: {error}", current.display()))?; + let path = entry.path(); + let relative = path + .strip_prefix(root) + .map_err(|error| format!("计算支持包相对路径失败 {}: {error}", path.display()))?; + let archive_path = normalize_archive_path(relative); + + if entry + .file_type() + .map_err(|error| format!("读取支持包文件类型失败 {}: {error}", path.display()))? + .is_dir() + { + writer + .add_directory(format!("{archive_path}/"), dir_options) + .map_err(|error| format!("写入 zip 目录失败 {archive_path}: {error}"))?; + add_directory_to_zip(writer, root, &path)?; + continue; + } + + writer + .start_file(archive_path.clone(), file_options) + .map_err(|error| format!("写入 zip 文件失败 {archive_path}: {error}"))?; + let mut file = fs::File::open(&path) + .map_err(|error| format!("打开支持包文件失败 {}: {error}", path.display()))?; + std::io::copy(&mut file, writer) + .map_err(|error| format!("压缩支持包文件失败 {}: {error}", path.display()))?; + } + + Ok(()) +} + +fn create_zip_from_directory(source_dir: &Path, zip_path: &Path) -> Result<(), String> { + let file = fs::File::create(zip_path) + .map_err(|error| format!("创建支持包 zip 失败 {}: {error}", zip_path.display()))?; + let mut writer = ZipWriter::new(file); + add_directory_to_zip(&mut writer, source_dir, source_dir)?; + writer + .finish() + .map_err(|error| format!("完成支持包压缩失败 {}: {error}", zip_path.display()))?; + Ok(()) +} + +fn export_support_bundle_to( + output_directory: &Path, + context: SupportBundleContext, +) -> Result { + fs::create_dir_all(output_directory).map_err(|error| { + format!( + "创建支持包输出目录失败 {}: {error}", + output_directory.display() + ) + })?; + + let timestamp = chrono::DateTime::parse_from_rfc3339(&context.generated_at) + .map(|value| value.format("%Y%m%d-%H%M%S").to_string()) + .unwrap_or_else(|_| Utc::now().format("%Y%m%d-%H%M%S").to_string()); + let bundle_name = format!("ProxyCast-Support-{timestamp}"); + let temp_dir = tempfile::tempdir().map_err(|error| format!("创建临时目录失败: {error}"))?; + let bundle_dir = temp_dir.path().join(&bundle_name); + let meta_dir = bundle_dir.join("meta"); + let logs_dir = bundle_dir.join("logs"); + let request_logs_dir = bundle_dir.join("request_logs"); + fs::create_dir_all(&meta_dir) + .map_err(|error| format!("创建支持包元数据目录失败 {}: {error}", meta_dir.display()))?; + + let legacy_request_logs_dir = context + .legacy_proxycast_dir + .as_ref() + .map(|dir| dir.join("request_logs")); + let effective_logs_dir = context + .log_storage_diagnostics + .log_directory + .as_ref() + .map(PathBuf::from) + .or_else(|| { + context + .legacy_proxycast_dir + .as_ref() + .map(|dir| dir.join("logs")) + }); + + if let Some(log_dir) = effective_logs_dir.as_deref() { + if log_dir.exists() { + copy_directory_recursive(log_dir, &logs_dir)?; + } + } + + if let Some(request_dir) = legacy_request_logs_dir.as_deref() { + if request_dir.exists() { + copy_directory_recursive(request_dir, &request_logs_dir)?; + } + } + + let included_sections = vec![ + "meta/manifest.json".to_string(), + "meta/log-storage-diagnostics.json".to_string(), + "meta/persisted-log-tail.json".to_string(), + "logs/".to_string(), + "request_logs/".to_string(), + ]; + let omitted_sections = vec![ + "config 内容".to_string(), + "数据库内容".to_string(), + "credentials 目录正文".to_string(), + "auth 目录正文".to_string(), + ]; + + let manifest = SupportBundleManifest { + generated_at: context.generated_at.clone(), + app_version: env!("CARGO_PKG_VERSION").to_string(), + platform: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + username: whoami::username(), + app_data_dir: context + .app_data_dir + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + config_path: context + .config_path + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + legacy_proxycast_dir: context + .legacy_proxycast_dir + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + database_path: context + .database_path + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + path_checks: vec![ + collect_support_path_metadata(context.app_data_dir.as_deref()), + collect_support_path_metadata(context.config_path.as_deref()), + collect_support_path_metadata(context.legacy_proxycast_dir.as_deref()), + collect_support_path_metadata(context.database_path.as_deref()), + collect_support_path_metadata(effective_logs_dir.as_deref()), + collect_support_path_metadata(legacy_request_logs_dir.as_deref()), + ], + log_storage_diagnostics: context.log_storage_diagnostics.clone(), + persisted_log_tail_lines: context.persisted_log_tail.len(), + windows_startup_diagnostics: context.windows_startup_diagnostics.clone(), + included_sections: included_sections.clone(), + omitted_sections: omitted_sections.clone(), + }; + + write_support_json(&meta_dir.join("manifest.json"), &manifest)?; + write_support_json( + &meta_dir.join("log-storage-diagnostics.json"), + &context.log_storage_diagnostics, + )?; + write_support_json( + &meta_dir.join("persisted-log-tail.json"), + &context.persisted_log_tail, + )?; + + if let Some(app_data_dir) = context.app_data_dir.as_deref() { + let entries = collect_directory_tree_entries(app_data_dir); + write_support_json(&meta_dir.join("appdata-listing.json"), &entries)?; + } + if let Some(legacy_dir) = context.legacy_proxycast_dir.as_deref() { + let entries = collect_directory_tree_entries(legacy_dir); + write_support_json(&meta_dir.join("legacy-listing.json"), &entries)?; + } + + write_support_bundle_readme(&bundle_dir.join("README.txt"), &omitted_sections)?; + + let bundle_path = output_directory.join(format!("{bundle_name}.zip")); + create_zip_from_directory(&bundle_dir, &bundle_path)?; + + Ok(SupportBundleExportResult { + bundle_path: bundle_path.to_string_lossy().to_string(), + output_directory: output_directory.to_string_lossy().to_string(), + generated_at: context.generated_at, + platform: std::env::consts::OS.to_string(), + included_sections, + omitted_sections, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + clear_diagnostic_log_artifacts_from_path, export_support_bundle_to, + get_log_storage_diagnostics_from_path, read_persisted_logs_tail_from_path, + SupportBundleContext, + }; + use flate2::write::GzEncoder; + use flate2::Compression; + use std::fs; + use std::io::{Read, Write}; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn unique_log_path() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("系统时间异常") + .as_nanos(); + std::env::temp_dir().join(format!("proxycast-log-tail-test-{nanos}.log")) + } + + #[test] + fn read_persisted_logs_tail_from_path_should_parse_latest_lines() { + let path = unique_log_path(); + fs::write( + &path, + concat!( + "2026-03-09 09:00:00.000 [INFO] first line\n", + "2026-03-09 09:00:01.000 [WARN] second line\n", + "2026-03-09 09:00:02.000 [ERROR] third line\n" + ), + ) + .expect("写入测试日志失败"); + + let entries = + read_persisted_logs_tail_from_path(Some(path.to_string_lossy().to_string()), 2) + .expect("读取持久化日志失败"); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].level, "warn"); + assert_eq!(entries[0].message, "second line"); + assert_eq!(entries[1].level, "error"); + assert_eq!(entries[1].message, "third line"); + + let _ = fs::remove_file(path); + } + + #[test] + fn read_persisted_logs_tail_from_path_should_merge_rotated_and_gzip_logs() { + let current = unique_log_path(); + let rotated = current.with_file_name(format!( + "{}.20260309-085900", + current + .file_name() + .and_then(|name| name.to_str()) + .expect("文件名缺失") + )); + let gz_path = current.with_file_name(format!( + "{}.20260309-085800.gz", + current + .file_name() + .and_then(|name| name.to_str()) + .expect("文件名缺失") + )); + + fs::write(¤t, "2026-03-09 09:00:02.000 [ERROR] current line\n") + .expect("写入当前日志失败"); + fs::write(&rotated, "2026-03-09 09:00:01.000 [WARN] rotated line\n") + .expect("写入轮转日志失败"); + + let gz_file = fs::File::create(&gz_path).expect("创建 gzip 日志失败"); + let mut encoder = GzEncoder::new(gz_file, Compression::default()); + encoder + .write_all(b"2026-03-09 09:00:00.000 [INFO] gz line\n") + .expect("写入 gzip 内容失败"); + encoder.finish().expect("完成 gzip 写入失败"); + + let entries = + read_persisted_logs_tail_from_path(Some(current.to_string_lossy().to_string()), 3) + .expect("读取跨文件日志失败"); + + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].message, "gz line"); + assert_eq!(entries[1].message, "rotated line"); + assert_eq!(entries[2].message, "current line"); + + let _ = fs::remove_file(current); + let _ = fs::remove_file(rotated); + let _ = fs::remove_file(gz_path); + } + + #[test] + fn get_log_storage_diagnostics_should_list_related_and_raw_response_files() { + let current = unique_log_path(); + let rotated = current.with_file_name(format!( + "{}.20260309-085900", + current + .file_name() + .and_then(|name| name.to_str()) + .expect("文件名缺失") + )); + let raw = current.with_file_name("raw_response_demo.txt"); + + fs::write(¤t, "current\n").expect("写入当前日志失败"); + fs::write(&rotated, "rotated\n").expect("写入轮转日志失败"); + fs::write(&raw, "raw body\n").expect("写入原始响应文件失败"); + + let diagnostics = + get_log_storage_diagnostics_from_path(Some(current.to_string_lossy().to_string()), 7); + + assert!(diagnostics.current_log_exists); + assert_eq!(diagnostics.in_memory_log_count, 7); + assert!(diagnostics.related_log_files.len() >= 2); + assert_eq!(diagnostics.raw_response_files.len(), 1); + assert_eq!( + diagnostics.raw_response_files[0].file_name, + "raw_response_demo.txt" + ); + + let _ = fs::remove_file(current); + let _ = fs::remove_file(rotated); + let _ = fs::remove_file(raw); + } + + #[test] + fn clear_diagnostic_log_artifacts_from_path_should_remove_history_files() { + let current = unique_log_path(); + let rotated = current.with_file_name(format!( + "{}.20260309-085900", + current + .file_name() + .and_then(|name| name.to_str()) + .expect("文件名缺失") + )); + let gz_path = current.with_file_name(format!( + "{}.20260309-085800.gz", + current + .file_name() + .and_then(|name| name.to_str()) + .expect("文件名缺失") + )); + let raw = current.with_file_name("raw_response_demo.txt"); + + fs::write(¤t, "current\n").expect("写入当前日志失败"); + fs::write(&rotated, "rotated\n").expect("写入轮转日志失败"); + fs::write(&raw, "raw body\n").expect("写入原始响应文件失败"); + + let gz_file = fs::File::create(&gz_path).expect("创建 gzip 日志失败"); + let mut encoder = GzEncoder::new(gz_file, Compression::default()); + encoder + .write_all(b"gzip body\n") + .expect("写入 gzip 内容失败"); + encoder.finish().expect("完成 gzip 写入失败"); + + clear_diagnostic_log_artifacts_from_path(Some(current.to_string_lossy().to_string())) + .expect("清理诊断日志历史失败"); + + assert!(current.exists()); + assert!(!rotated.exists()); + assert!(!gz_path.exists()); + assert!(!raw.exists()); + + let _ = fs::remove_file(current); + } + + #[test] + fn export_support_bundle_to_should_create_zip_with_manifest_and_logs() { + let temp_dir = tempfile::tempdir().expect("创建临时目录失败"); + let output_dir = temp_dir.path().join("output"); + let app_data_dir = temp_dir.path().join("appdata").join("proxycast"); + let legacy_dir = temp_dir.path().join("home").join(".proxycast"); + let logs_dir = legacy_dir.join("logs"); + let request_logs_dir = legacy_dir.join("request_logs"); + let config_path = app_data_dir.join("config.yaml"); + let database_path = legacy_dir.join("proxycast.db"); + let current_log_path = logs_dir.join("proxycast.log"); + let raw_response_path = logs_dir.join("raw_response_demo.txt"); + let request_log_path = request_logs_dir.join("requests.log"); + + fs::create_dir_all(&app_data_dir).expect("创建 appdata 目录失败"); + fs::create_dir_all(&logs_dir).expect("创建 logs 目录失败"); + fs::create_dir_all(&request_logs_dir).expect("创建 request_logs 目录失败"); + fs::write(&config_path, "api_key: hidden").expect("写入配置失败"); + fs::write(&database_path, b"sqlite").expect("写入数据库文件失败"); + fs::write( + ¤t_log_path, + concat!( + "2026-03-09 09:00:00.000 [INFO] first line +", + "2026-03-09 09:00:01.000 [ERROR] second line +" + ), + ) + .expect("写入日志失败"); + fs::write(&raw_response_path, "raw body").expect("写入 raw response 失败"); + fs::write(&request_log_path, "request body").expect("写入 request log 失败"); + + let diagnostics = get_log_storage_diagnostics_from_path( + Some(current_log_path.to_string_lossy().to_string()), + 2, + ); + let tail = read_persisted_logs_tail_from_path( + Some(current_log_path.to_string_lossy().to_string()), + 20, + ) + .expect("读取日志尾部失败"); + + let result = export_support_bundle_to( + &output_dir, + SupportBundleContext { + generated_at: "2026-03-09T10:00:00Z".to_string(), + app_data_dir: Some(app_data_dir), + config_path: Some(config_path), + legacy_proxycast_dir: Some(legacy_dir), + database_path: Some(database_path), + log_storage_diagnostics: diagnostics, + persisted_log_tail: tail, + windows_startup_diagnostics: None, + }, + ) + .expect("导出支持包失败"); + + assert!(Path::new(&result.bundle_path).exists()); + + let file = fs::File::open(&result.bundle_path).expect("打开 zip 失败"); + let mut archive = zip::ZipArchive::new(file).expect("读取 zip 失败"); + let mut names = Vec::new(); + for index in 0..archive.len() { + let entry = archive.by_index(index).expect("读取 zip 条目失败"); + names.push(entry.name().to_string()); + } + + assert!(names + .iter() + .any(|name| name.ends_with("meta/manifest.json"))); + assert!(names + .iter() + .any(|name| name.ends_with("meta/persisted-log-tail.json"))); + assert!(names + .iter() + .any(|name| name.ends_with("logs/proxycast.log"))); + assert!(names + .iter() + .any(|name| name.ends_with("logs/raw_response_demo.txt"))); + assert!(names + .iter() + .any(|name| name.ends_with("request_logs/requests.log"))); + + let manifest_name = names + .iter() + .find(|name| name.ends_with("meta/manifest.json")) + .expect("manifest 条目缺失") + .to_string(); + let mut manifest = archive + .by_name(&manifest_name) + .expect("打开 manifest 条目失败"); + let mut manifest_content = String::new(); + manifest + .read_to_string(&mut manifest_content) + .expect("读取 manifest 内容失败"); + assert!(manifest_content.contains("request_logs/")); + assert!(manifest_content.contains("credentials 目录正文")); + } +} diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 2cdba4698..6fe4b226d 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -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::() { @@ -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)); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 00aa45991..c35ff853b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/openclaw_cmd.rs b/src-tauri/src/commands/openclaw_cmd.rs new file mode 100644 index 000000000..ffd302286 --- /dev/null +++ b/src-tauri/src/commands/openclaw_cmd.rs @@ -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, +} + +#[tauri::command] +pub async fn openclaw_check_installed( + service: State<'_, OpenClawServiceState>, +) -> Result { + let service = service.0.lock().await; + service.check_installed().await +} + +#[tauri::command] +pub async fn openclaw_check_node_version( + service: State<'_, OpenClawServiceState>, +) -> Result { + let service = service.0.lock().await; + service.check_node_version().await +} + +#[tauri::command] +pub async fn openclaw_check_git_available( + service: State<'_, OpenClawServiceState>, +) -> Result { + 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 { + 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 { + 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 { + let service = service.0.lock().await; + service.install(&app).await +} + +#[tauri::command] +pub async fn openclaw_uninstall( + app: AppHandle, + service: State<'_, OpenClawServiceState>, +) -> Result { + let mut service = service.0.lock().await; + service.uninstall(&app).await +} + +#[tauri::command] +pub async fn openclaw_start_gateway( + app: AppHandle, + port: Option, + service: State<'_, OpenClawServiceState>, +) -> Result { + 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 { + 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 { + let mut service = service.0.lock().await; + service.restart_gateway(&app).await +} + +#[tauri::command] +pub async fn openclaw_get_status( + service: State<'_, OpenClawServiceState>, +) -> Result { + let mut service = service.0.lock().await; + service.get_status().await +} + +#[tauri::command] +pub async fn openclaw_check_health( + service: State<'_, OpenClawServiceState>, +) -> Result { + let mut service = service.0.lock().await; + service.check_health().await +} + +#[tauri::command] +pub async fn openclaw_get_dashboard_url( + service: State<'_, OpenClawServiceState>, +) -> Result { + let mut service = service.0.lock().await; + Ok(service.get_dashboard_url()) +} + +#[tauri::command] +pub async fn openclaw_get_channels( + service: State<'_, OpenClawServiceState>, +) -> Result, 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 { + 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() +} diff --git a/src-tauri/src/commands/webview_cmd.rs b/src-tauri/src/commands/webview_cmd.rs index 40f893978..35af24e9d 100644 --- a/src-tauri/src/commands/webview_cmd.rs +++ b/src-tauri/src/commands/webview_cmd.rs @@ -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, 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::>() + }; + + 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 { 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 { diff --git a/src-tauri/src/commands/windows_startup_cmd.rs b/src-tauri/src/commands/windows_startup_cmd.rs new file mode 100644 index 000000000..c336a94b1 --- /dev/null +++ b/src-tauri/src/commands/windows_startup_cmd.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WindowsStartupDiagnostics { + pub platform: String, + pub app_data_dir: Option, + pub legacy_proxycast_dir: Option, + pub db_path: Option, + pub webview2_version: Option, + pub current_exe: Option, + pub current_dir: Option, + pub resource_dir: Option, + pub home_dir: Option, + pub shell_env: Option, + pub comspec_env: Option, + pub resolved_terminal_shell: Option, + pub installation_kind_guess: Option, + pub checks: Vec, + pub has_blocking_issues: bool, + pub has_warnings: bool, + pub summary_message: Option, +} + +#[tauri::command] +pub async fn get_windows_startup_diagnostics( + app: AppHandle, +) -> Result { + Ok(collect_windows_startup_diagnostics(&app)) +} + +#[cfg(target_os = "windows")] +pub fn maybe_show_windows_startup_notice(app: &AppHandle) { + let diagnostics = collect_windows_startup_diagnostics(app); + + for check in &diagnostics.checks { + match check.status.as_str() { + "error" => tracing::error!( + "[WindowsStartup] {}: {} {}", + check.key, + check.message, + check.detail.as_deref().unwrap_or("") + ), + "warning" => tracing::warn!( + "[WindowsStartup] {}: {} {}", + check.key, + check.message, + check.detail.as_deref().unwrap_or("") + ), + _ => tracing::info!( + "[WindowsStartup] {}: {} {}", + check.key, + check.message, + check.detail.as_deref().unwrap_or("") + ), + } + } + + if !diagnostics.has_blocking_issues { + return; + } + + let message = diagnostics.summary_message.clone().unwrap_or_else(|| { + "检测到 Windows 启动环境存在阻塞问题,请查看日志并优先使用 setup.exe 安装包重新安装。" + .to_string() + }); + + app.dialog() + .message(message) + .title("ProxyCast Windows 启动自检") + .kind(MessageDialogKind::Error) + .buttons(MessageDialogButtons::OkCustom("我知道了".to_string())) + .show(|_| {}); +} + +pub fn collect_windows_startup_diagnostics(app: &AppHandle) -> WindowsStartupDiagnostics { + #[cfg(not(target_os = "windows"))] + { + let _ = app; + return WindowsStartupDiagnostics { + platform: std::env::consts::OS.to_string(), + app_data_dir: None, + legacy_proxycast_dir: None, + db_path: None, + webview2_version: None, + current_exe: None, + current_dir: None, + resource_dir: None, + home_dir: None, + shell_env: None, + comspec_env: None, + resolved_terminal_shell: None, + installation_kind_guess: None, + checks: vec![], + has_blocking_issues: false, + has_warnings: false, + summary_message: None, + }; + } + + #[cfg(target_os = "windows")] + { + let mut checks = Vec::new(); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + let app_data_dir = app.path().app_data_dir().ok(); + let home_dir = dirs::home_dir(); + let legacy_proxycast_dir = home_dir.clone().map(|home| home.join(".proxycast")); + let db_path = crate::database::get_db_path().ok(); + let webview2_version = detect_webview2_runtime_version(); + let current_exe = std::env::current_exe().ok(); + let current_dir = std::env::current_dir().ok(); + let resource_dir = app.path().resource_dir().ok(); + let shell_env = get_env_path_value("SHELL"); + let comspec_env = get_env_path_value("COMSPEC"); + let resolved_terminal_shell = + resolve_terminal_shell(shell_env.as_deref(), comspec_env.as_deref()); + let installation_kind_guess = current_exe + .as_ref() + .map(|path| guess_installation_kind(path).to_string()); + + match &app_data_dir { + Some(path) => match ensure_dir_writable(path) { + Ok(()) => checks.push(ok_check( + "app_data_dir", + format!("应用数据目录可写: {}", path.display()), + )), + Err(error) => { + warnings.push(format!("应用数据目录不可写: {}", path.display())); + checks.push(warn_check( + "app_data_dir", + format!("应用数据目录不可写: {}", path.display()), + Some(error), + )); + } + }, + None => { + warnings.push("无法解析应用数据目录".to_string()); + checks.push(warn_check( + "app_data_dir", + "无法解析应用数据目录".to_string(), + None, + )); + } + } + + match &legacy_proxycast_dir { + Some(path) => match ensure_dir_writable(path) { + Ok(()) => checks.push(ok_check( + "legacy_proxycast_dir", + format!("用户目录数据根可写: {}", path.display()), + )), + Err(error) => { + errors.push(format!("用户目录数据根不可写: {}", path.display())); + checks.push(error_check( + "legacy_proxycast_dir", + format!("用户目录数据根不可写: {}", path.display()), + Some(error), + )); + } + }, + None => { + errors.push("无法解析用户 Home 目录".to_string()); + checks.push(error_check( + "legacy_proxycast_dir", + "无法解析用户 Home 目录".to_string(), + None, + )); + } + } + + match &db_path { + Some(path) => match check_database_file(path) { + Ok(()) => checks.push(ok_check( + "database", + format!("数据库可访问: {}", path.display()), + )), + Err(error) => { + errors.push(format!("数据库不可访问: {}", path.display())); + checks.push(error_check( + "database", + format!("数据库不可访问: {}", path.display()), + Some(error), + )); + } + }, + None => { + errors.push("无法解析数据库路径".to_string()); + checks.push(error_check( + "database", + "无法解析数据库路径".to_string(), + None, + )); + } + } + + match &webview2_version { + Some(version) => checks.push(ok_check( + "webview2", + format!("检测到 WebView2 Runtime: {version}"), + )), + None => { + warnings.push("未检测到 WebView2 Runtime 注册表项".to_string()); + checks.push(warn_check( + "webview2", + "未检测到 WebView2 Runtime 注册表项".to_string(), + Some( + "如果用户通过便携版启动失败,请优先改用 setup.exe 安装包重新安装。" + .to_string(), + ), + )); + } + } + + match detect_shell_availability() { + Some(shell) => checks.push(ok_check("shell", format!("检测到可用 Shell: {shell}"))), + None => { + warnings.push("未检测到 PowerShell 或 cmd.exe".to_string()); + checks.push(warn_check( + "shell", + "未检测到 PowerShell 或 cmd.exe".to_string(), + Some("Agent、终端与部分系统命令可能无法使用。".to_string()), + )); + } + } + + match ¤t_exe { + Some(path) if path.exists() => checks.push(ok_check( + "current_exe", + format!("当前可执行文件: {}", path.display()), + )), + Some(path) => { + warnings.push(format!("当前可执行文件不存在: {}", path.display())); + checks.push(warn_check( + "current_exe", + format!("当前可执行文件不存在: {}", path.display()), + None, + )); + } + None => { + warnings.push("无法解析当前可执行文件路径".to_string()); + checks.push(warn_check( + "current_exe", + "无法解析当前可执行文件路径".to_string(), + None, + )); + } + } + + match &resource_dir { + Some(path) if path.exists() => checks.push(ok_check( + "resource_dir", + format!("资源目录已解析: {}", path.display()), + )), + Some(path) => { + warnings.push(format!("资源目录不存在: {}", path.display())); + checks.push(warn_check( + "resource_dir", + format!("资源目录不存在: {}", path.display()), + Some("安装包资源缺失时,模型索引与内置资源初始化可能失败。".to_string()), + )); + } + None => { + warnings.push("无法解析资源目录".to_string()); + checks.push(warn_check( + "resource_dir", + "无法解析资源目录".to_string(), + Some("便携运行或安装不完整时较常见。".to_string()), + )); + } + } + + if let Some(shell_value) = &shell_env { + if shell_value.trim_start().starts_with('/') { + warnings.push(format!("检测到 Unix 风格 SHELL 环境变量: {shell_value}")); + checks.push(warn_check( + "shell_env", + format!("检测到 Unix 风格 SHELL 环境变量: {shell_value}"), + Some( + "旧版本 Windows 终端实现可能错误使用该值并触发 /bin/bash 启动失败。" + .to_string(), + ), + )); + } else { + checks.push(ok_check( + "shell_env", + format!("SHELL 环境变量: {shell_value}"), + )); + } + } + + if let Some(comspec_value) = &comspec_env { + let path = PathBuf::from(comspec_value); + if path.exists() { + checks.push(ok_check( + "comspec_env", + format!("COMSPEC 环境变量: {comspec_value}"), + )); + } else { + warnings.push(format!("COMSPEC 指向的路径不存在: {comspec_value}")); + checks.push(warn_check( + "comspec_env", + format!("COMSPEC 指向的路径不存在: {comspec_value}"), + Some("终端默认 shell 可能回退到 cmd.exe。".to_string()), + )); + } + } + + match &resolved_terminal_shell { + Some(shell) => checks.push(ok_check( + "resolved_terminal_shell", + format!("终端默认 Shell 解析结果: {shell}"), + )), + None => { + warnings.push("无法解析终端默认 Shell".to_string()); + checks.push(warn_check( + "resolved_terminal_shell", + "无法解析终端默认 Shell".to_string(), + Some( + "如终端/Agent 创建失败,请重点检查 SHELL 与 COMSPEC 环境变量。".to_string(), + ), + )); + } + } + + let summary_message = if !errors.is_empty() { + Some(format!( + "检测到 {} 个阻塞问题:{}。建议先检查目录权限,并优先使用带 WebView2 的 Windows setup.exe 安装包。", + errors.len(), + errors.join(";") + )) + } else if !warnings.is_empty() { + Some(format!( + "检测到 {} 个 Windows 环境提示:{}。如用户反馈启动失败,请优先收集日志并确认使用 setup.exe 安装包。", + warnings.len(), + warnings.join(";") + )) + } else { + None + }; + + WindowsStartupDiagnostics { + platform: "windows".to_string(), + app_data_dir: app_data_dir.map(path_to_string), + legacy_proxycast_dir: legacy_proxycast_dir.map(path_to_string), + db_path: db_path.map(path_to_string), + webview2_version, + current_exe: current_exe.map(path_to_string), + current_dir: current_dir.map(path_to_string), + resource_dir: resource_dir.map(path_to_string), + home_dir: home_dir.map(path_to_string), + shell_env, + comspec_env, + resolved_terminal_shell, + installation_kind_guess, + checks, + has_blocking_issues: !errors.is_empty(), + has_warnings: !warnings.is_empty(), + summary_message, + } + } +} + +#[cfg(target_os = "windows")] +fn ok_check(key: &str, message: String) -> WindowsStartupCheck { + WindowsStartupCheck { + key: key.to_string(), + status: "ok".to_string(), + message, + detail: None, + } +} + +#[cfg(target_os = "windows")] +fn warn_check(key: &str, message: String, detail: Option) -> 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) -> 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 { + 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 = 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 { + 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 { + 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 { + 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 +} diff --git a/src-tauri/src/dev_bridge.rs b/src-tauri/src/dev_bridge.rs index 4005abfd6..473a158be 100644 --- a/src-tauri/src/dev_bridge.rs +++ b/src-tauri/src/dev_bridge.rs @@ -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, pub server: app::AppState, pub logs: app::LogState, pub db: Option, @@ -95,6 +98,7 @@ impl DevBridgeServer { /// /// 服务器会在后台持续运行,直到应用退出。 pub async fn start( + app_handle: AppHandle, server: app::AppState, logs: app::LogState, db: Option, @@ -108,6 +112,7 @@ impl DevBridgeServer { ) -> Result<(), Box> { let config = config.unwrap_or_default(); let bridge_state = DevBridgeState { + app_handle: Some(app_handle), server, logs, db, diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 2295306c2..9ac71e4f2 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -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(), ))), diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 27bdb4878..7d9f807c6 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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; diff --git a/src-tauri/src/services/openclaw_service.rs b/src-tauri/src/services/openclaw_service.rs new file mode 100644 index 000000000..595bab889 --- /dev/null +++ b/src-tauri/src/services/openclaw_service.rs @@ -0,0 +1,1509 @@ +use crate::app::AppState; +use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType}; +use dirs::{data_dir, home_dir}; +use rand::{distributions::Alphanumeric, Rng}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::OnceLock; +use std::time::SystemTime; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::net::TcpStream; +use tokio::process::{Child, Command}; +use tokio::sync::Mutex; +use tokio::time::{sleep, timeout, Duration}; + +const DEFAULT_GATEWAY_PORT: u16 = 18790; +const OPENCLAW_INSTALL_EVENT: &str = "openclaw:install-progress"; +const OPENCLAW_CONFIG_ENV: &str = "OPENCLAW_CONFIG_PATH"; +const OPENCLAW_CN_PACKAGE: &str = "@qingchencloud/openclaw-zh@latest"; +const OPENCLAW_DEFAULT_PACKAGE: &str = "openclaw@latest"; +const NPM_MIRROR_CN: &str = "https://registry.npmmirror.com"; +const NODE_MIN_VERSION: (u64, u64, u64) = (22, 0, 0); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BinaryInstallStatus { + pub installed: bool, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BinaryAvailabilityStatus { + pub available: bool, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeCheckResult { + pub status: String, + pub version: Option, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionResult { + pub success: bool, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayStatusInfo { + pub status: GatewayStatus, + pub port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum GatewayStatus { + Stopped, + Starting, + Running, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HealthInfo { + pub status: String, + pub gateway_port: u16, + pub uptime: Option, + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChannelInfo { + pub id: String, + pub name: String, + pub channel_type: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstallProgressEvent { + pub message: String, + pub level: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncModelEntry { + pub id: String, + pub name: String, + pub context_window: Option, +} + +#[derive(Debug)] +pub struct OpenClawService { + gateway_process: Option, + gateway_status: GatewayStatus, + gateway_port: u16, + gateway_auth_token: String, + gateway_started_at: Option, +} + +impl Default for OpenClawService { + fn default() -> Self { + Self { + gateway_process: None, + gateway_status: GatewayStatus::Stopped, + gateway_port: DEFAULT_GATEWAY_PORT, + gateway_auth_token: String::new(), + gateway_started_at: None, + } + } +} + +pub struct OpenClawServiceState(pub std::sync::Arc>); + +impl Default for OpenClawServiceState { + fn default() -> Self { + Self(std::sync::Arc::new(Mutex::new(OpenClawService::default()))) + } +} + +impl OpenClawService { + pub async fn check_installed(&self) -> Result { + let path = find_command_in_shell("openclaw").await?; + Ok(BinaryInstallStatus { + installed: path.is_some(), + path, + }) + } + + pub async fn check_git_available(&self) -> Result { + let path = find_command_in_shell("git").await?; + Ok(BinaryAvailabilityStatus { + available: path.is_some(), + path, + }) + } + + pub async fn check_node_version(&self) -> Result { + let Some(path) = find_command_in_shell("node").await? else { + return Ok(NodeCheckResult { + status: "not_found".to_string(), + version: None, + path: None, + }); + }; + + let output = Command::new(&path) + .arg("--version") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("检查 Node.js 版本失败: {e}"))?; + + let version_text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let Some(version) = parse_semver(&version_text) else { + return Ok(NodeCheckResult { + status: "version_low".to_string(), + version: Some(version_text), + path: Some(path), + }); + }; + + if version >= NODE_MIN_VERSION { + Ok(NodeCheckResult { + status: "ok".to_string(), + version: Some(format_semver(version)), + path: Some(path), + }) + } else { + Ok(NodeCheckResult { + status: "version_low".to_string(), + version: Some(format_semver(version)), + path: Some(path), + }) + } + } + + pub fn get_node_download_url(&self) -> String { + if cfg!(target_os = "windows") { + "https://nodejs.org/en/download".to_string() + } else if cfg!(target_os = "macos") { + "https://nodejs.org/en/download".to_string() + } else if cfg!(target_os = "linux") { + "https://nodejs.org/en/download".to_string() + } else { + "https://nodejs.org/en/download".to_string() + } + } + + pub fn get_git_download_url(&self) -> String { + if cfg!(target_os = "windows") { + "https://git-scm.com/download/win".to_string() + } else if cfg!(target_os = "macos") { + "https://git-scm.com/download/mac".to_string() + } else if cfg!(target_os = "linux") { + "https://git-scm.com/download/linux".to_string() + } else { + "https://git-scm.com/downloads".to_string() + } + } + + pub async fn install(&self, app: &AppHandle) -> Result { + let npm_path = find_command_in_shell("npm") + .await? + .ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?; + let npm_prefix = detect_npm_global_prefix(&npm_path).await; + let package = if should_use_china_package(app) { + OPENCLAW_CN_PACKAGE + } else { + OPENCLAW_DEFAULT_PACKAGE + }; + + let prefix_env = npm_prefix + .as_deref() + .map(shell_env_assignment) + .unwrap_or_default(); + let npm_cmd = shell_escape(&npm_path); + let cleanup_command = format!( + "{prefix_env}{npm_cmd} uninstall -g openclaw @qingchencloud/openclaw-zh || true" + ); + let install_command = if should_use_china_package(app) { + format!("{prefix_env}{npm_cmd} install -g {package} --registry={NPM_MIRROR_CN}") + } else { + format!("{prefix_env}{npm_cmd} install -g {package}") + }; + let command = format!("{cleanup_command}\n{install_command}"); + + emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info"); + if let Some(prefix) = npm_prefix { + emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info"); + } + emit_install_progress(app, "安装前先清理已有 OpenClaw 全局包。", "info"); + + emit_install_progress(app, &format!("执行安装命令: {install_command}"), "info"); + run_shell_command_with_progress(app, &command).await + } + + pub async fn uninstall(&mut self, app: &AppHandle) -> Result { + if self.gateway_status == GatewayStatus::Running || self.gateway_process.is_some() { + let _ = self.stop_gateway(None).await; + } + + let npm_path = find_command_in_shell("npm") + .await? + .ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?; + let npm_prefix = detect_npm_global_prefix(&npm_path).await; + let prefix_env = npm_prefix + .as_deref() + .map(shell_env_assignment) + .unwrap_or_default(); + let command = format!( + "{}{} uninstall -g openclaw @qingchencloud/openclaw-zh", + prefix_env, + shell_escape(&npm_path) + ); + + emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info"); + if let Some(prefix) = npm_prefix { + emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info"); + } + emit_install_progress(app, &format!("执行卸载命令: {command}"), "info"); + run_shell_command_with_progress(app, &command).await + } + + pub async fn start_gateway( + &mut self, + app: Option<&AppHandle>, + port: Option, + ) -> Result { + if let Some(next_port) = port { + self.gateway_port = next_port.max(1); + } + + if let Some(app) = app { + emit_install_progress( + app, + &format!("准备启动 Gateway,目标端口 {}。", self.gateway_port), + "info", + ); + } + + self.ensure_runtime_config(None, None)?; + self.refresh_process_state().await?; + + if self.gateway_status == GatewayStatus::Running { + if let Some(app) = app { + emit_install_progress( + app, + &format!("检测到 Gateway 已在端口 {} 运行。", self.gateway_port), + "info", + ); + } + return Ok(ActionResult { + success: true, + message: format!("Gateway 已在端口 {} 运行", self.gateway_port), + }); + } + + let Some(binary) = find_command_in_shell("openclaw").await? else { + self.gateway_status = GatewayStatus::Error; + if let Some(app) = app { + emit_install_progress(app, "未检测到 OpenClaw 可执行文件,请先安装。", "error"); + } + return Ok(ActionResult { + success: false, + message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(), + }); + }; + + self.gateway_status = GatewayStatus::Starting; + + let config_path = openclaw_proxycast_config_path(); + if let Some(app) = app { + emit_install_progress( + app, + &format!("使用配置文件启动 Gateway: {}", config_path.display()), + "info", + ); + } + let mut command = Command::new(&binary); + command + .arg("gateway") + .arg("--port") + .arg(self.gateway_port.to_string()) + .env(OPENCLAW_CONFIG_ENV, &config_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command + .spawn() + .map_err(|e| format!("启动 Gateway 失败: {e}"))?; + + if let Some(stdout) = child.stdout.take() { + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::info!(target: "openclaw", "Gateway stdout: {}", line); + } + }); + } + + if let Some(stderr) = child.stderr.take() { + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::warn!(target: "openclaw", "Gateway stderr: {}", line); + } + }); + } + + self.gateway_process = Some(child); + self.gateway_started_at = Some(SystemTime::now()); + + if let Some(app) = app { + emit_install_progress(app, "Gateway 进程已拉起,等待服务就绪。", "info"); + } + + let start_at = tokio::time::Instant::now(); + while start_at.elapsed() < Duration::from_secs(30) { + sleep(Duration::from_millis(300)).await; + self.refresh_process_state().await?; + if self.gateway_status == GatewayStatus::Running { + if let Some(app) = app { + emit_install_progress( + app, + &format!("Gateway 启动成功,监听端口 {}。", self.gateway_port), + "info", + ); + } + return Ok(ActionResult { + success: true, + message: format!("Gateway 已启动,端口 {}", self.gateway_port), + }); + } + + if self.check_port_open().await { + self.gateway_status = GatewayStatus::Running; + if let Some(app) = app { + emit_install_progress( + app, + &format!("Gateway 探测成功,监听端口 {}。", self.gateway_port), + "info", + ); + } + return Ok(ActionResult { + success: true, + message: format!("Gateway 已启动,端口 {}", self.gateway_port), + }); + } + } + + self.gateway_status = GatewayStatus::Error; + if let Some(app) = app { + emit_install_progress(app, "Gateway 启动超时,请检查配置或端口占用。", "error"); + } + Ok(ActionResult { + success: false, + message: "Gateway 启动超时,请检查配置或端口占用。".to_string(), + }) + } + + pub async fn stop_gateway(&mut self, app: Option<&AppHandle>) -> Result { + if let Some(app) = app { + emit_install_progress(app, "准备停止 Gateway。", "info"); + } + + if let Some(mut child) = self.gateway_process.take() { + if let Some(app) = app { + emit_install_progress(app, "正在终止当前托管的 Gateway 子进程。", "info"); + } + let _ = child.kill().await; + let _ = timeout(Duration::from_secs(3), child.wait()).await; + } else { + let binary = find_command_in_shell("openclaw").await?; + if let Some(openclaw_path) = binary.as_deref() { + let mut cmd = Command::new(openclaw_path); + cmd.arg("gateway") + .arg("stop") + .arg("--url") + .arg(self.gateway_ws_url()) + .arg("--token") + .arg(&self.gateway_auth_token) + .env(OPENCLAW_CONFIG_ENV, openclaw_proxycast_config_path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + match timeout(Duration::from_secs(5), cmd.status()).await { + Ok(Ok(status)) if status.success() => { + if let Some(app) = app { + emit_install_progress(app, "已发送 Gateway 停止命令。", "info"); + } + } + Ok(Ok(status)) => { + if let Some(app) = app { + emit_install_progress( + app, + &format!("Gateway 停止命令返回异常状态: {:?}", status.code()), + "warn", + ); + } + } + Ok(Err(error)) => { + if let Some(app) = app { + emit_install_progress( + app, + &format!("执行 Gateway 停止命令失败: {error}"), + "warn", + ); + } + } + Err(_) => { + if let Some(app) = app { + emit_install_progress( + app, + "Gateway 停止命令超时,继续本地状态收敛。", + "warn", + ); + } + } + } + } + } + + self.gateway_status = GatewayStatus::Stopped; + self.gateway_started_at = None; + + if let Some(app) = app { + emit_install_progress(app, "Gateway 已停止。", "info"); + } + + Ok(ActionResult { + success: true, + message: "Gateway 已停止。".to_string(), + }) + } + + pub async fn restart_gateway(&mut self, app: &AppHandle) -> Result { + emit_install_progress(app, "开始重启 Gateway。", "info"); + let _ = self.stop_gateway(Some(app)).await; + emit_install_progress(app, "Gateway 停止阶段结束,开始重新启动。", "info"); + self.start_gateway(Some(app), Some(self.gateway_port)).await + } + + pub async fn get_status(&mut self) -> Result { + self.refresh_process_state().await?; + Ok(GatewayStatusInfo { + status: self.gateway_status.clone(), + port: self.gateway_port, + }) + } + + pub async fn check_health(&mut self) -> Result { + self.refresh_process_state().await?; + + self.restore_auth_token_from_config(); + + let health_snapshot = self.fetch_authenticated_gateway_health_json().await; + let healthy = self.gateway_status == GatewayStatus::Running + && self.check_port_open().await + && health_snapshot + .as_ref() + .and_then(|value| value.get("ok").and_then(Value::as_bool)) + .unwrap_or(false); + let version = self.read_openclaw_version().await.ok().flatten(); + let uptime = self.gateway_started_at.and_then(|start| { + SystemTime::now() + .duration_since(start) + .ok() + .map(|elapsed| elapsed.as_secs()) + }); + + Ok(HealthInfo { + status: if healthy { "healthy" } else { "unhealthy" }.to_string(), + gateway_port: self.gateway_port, + uptime, + version, + }) + } + + pub fn get_dashboard_url(&mut self) -> String { + self.restore_auth_token_from_config(); + let mut url = format!("http://127.0.0.1:{}", self.gateway_port); + if !self.gateway_auth_token.is_empty() { + url.push_str(&format!( + "/#token={}", + urlencoding::encode(&self.gateway_auth_token) + )); + } + url + } + + pub async fn get_channels(&mut self) -> Result, String> { + self.refresh_process_state().await?; + if self.gateway_status != GatewayStatus::Running { + return Ok(Vec::new()); + } + + self.restore_auth_token_from_config(); + + let Some(body) = self.fetch_authenticated_gateway_health_json().await else { + return Ok(Vec::new()); + }; + + let channels_map = body + .get("channels") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let labels = body + .get("channelLabels") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let ordered_ids = body + .get("channelOrder") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + let mut ordered = Vec::new(); + for channel_id in ordered_ids.iter().filter_map(Value::as_str) { + if let Some(entry) = channels_map.get(channel_id) { + ordered.push(build_channel_info( + channel_id, + entry, + labels.get(channel_id), + )); + } + } + + if ordered.is_empty() { + ordered = channels_map + .iter() + .map(|(channel_id, entry)| { + build_channel_info(channel_id, entry, labels.get(channel_id)) + }) + .collect(); + } + + Ok(ordered) + } + + pub fn sync_provider_config( + &mut self, + provider: &ApiKeyProvider, + api_key: &str, + primary_model_id: &str, + models: &[SyncModelEntry], + ) -> Result { + if api_key.trim().is_empty() && provider.provider_type != ApiProviderType::Ollama { + return Ok(ActionResult { + success: false, + message: "该 Provider 没有可用的 API Key。".to_string(), + }); + } + + let api_type = determine_api_type(provider.provider_type)?; + let base_url = format_provider_base_url(provider)?; + let provider_key = format!("proxycast-{}", provider.id); + + let normalized_models = if models.is_empty() { + vec![SyncModelEntry { + id: primary_model_id.to_string(), + name: primary_model_id.to_string(), + context_window: None, + }] + } else { + let mut items = models.to_vec(); + if !items.iter().any(|item| item.id == primary_model_id) { + items.insert( + 0, + SyncModelEntry { + id: primary_model_id.to_string(), + name: primary_model_id.to_string(), + context_window: None, + }, + ); + } + items + }; + + self.ensure_runtime_config( + Some(( + &provider_key, + json!({ + "baseUrl": base_url, + "apiKey": api_key, + "api": api_type, + "models": normalized_models.iter().map(|model| { + json!({ + "id": model.id, + "name": model.name, + "contextWindow": model.context_window, + }) + }).collect::>() + }), + )), + Some(format!("{provider_key}/{primary_model_id}")), + )?; + + Ok(ActionResult { + success: true, + message: format!("已同步 Provider“{}”到 OpenClaw。", provider.name), + }) + } + + async fn refresh_process_state(&mut self) -> Result<(), String> { + let mut process_exited = false; + + if let Some(child) = self.gateway_process.as_mut() { + match child.try_wait() { + Ok(Some(status)) => { + tracing::info!(target: "openclaw", "Gateway 进程已退出: {}", status); + process_exited = true; + } + Ok(None) => {} + Err(error) => { + tracing::warn!(target: "openclaw", "检查 Gateway 进程状态失败: {}", error); + process_exited = true; + } + } + } + + if process_exited { + self.gateway_process = None; + self.gateway_started_at = None; + } + + let binary = find_command_in_shell("openclaw").await?; + let running = + self.check_port_open().await || self.check_gateway_status(binary.as_deref()).await?; + + self.gateway_status = if running { + GatewayStatus::Running + } else if self.gateway_status == GatewayStatus::Starting { + GatewayStatus::Error + } else { + GatewayStatus::Stopped + }; + + if !running { + self.gateway_process = None; + self.gateway_started_at = None; + } + + Ok(()) + } + + async fn check_port_open(&self) -> bool { + timeout( + Duration::from_secs(2), + TcpStream::connect(("127.0.0.1", self.gateway_port)), + ) + .await + .map(|result| result.is_ok()) + .unwrap_or(false) + } + + async fn check_gateway_status(&self, binary: Option<&str>) -> Result { + let Some(openclaw_path) = binary else { + return Ok(false); + }; + + let output = Command::new(openclaw_path) + .arg("gateway") + .arg("status") + .arg("--url") + .arg(self.gateway_ws_url()) + .arg("--token") + .arg(&self.gateway_auth_token) + .env(OPENCLAW_CONFIG_ENV, openclaw_proxycast_config_path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await; + + match output { + Ok(result) => { + let stdout = String::from_utf8_lossy(&result.stdout).to_lowercase(); + let stderr = String::from_utf8_lossy(&result.stderr).to_lowercase(); + Ok(result.status.success() + && (stdout.contains("listening") + || stdout.contains("running") + || stderr.contains("listening"))) + } + Err(_) => Ok(false), + } + } + + async fn read_openclaw_version(&self) -> Result, String> { + let Some(binary) = find_command_in_shell("openclaw").await? else { + return Ok(None); + }; + + let output = Command::new(binary) + .arg("--version") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| format!("读取 OpenClaw 版本失败: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + Ok(None) + } else { + Ok(Some(stdout)) + } + } + + fn gateway_ws_url(&self) -> String { + format!("ws://127.0.0.1:{}", self.gateway_port) + } + + fn restore_auth_token_from_config(&mut self) { + if !self.gateway_auth_token.is_empty() { + return; + } + + match read_base_openclaw_config() + .ok() + .and_then(|config| extract_gateway_auth_token(&config)) + { + Some(token) => { + self.gateway_auth_token = token; + } + None => { + tracing::warn!( + target: "openclaw", + "未能从 OpenClaw 配置恢复 gateway token,Dashboard 访问可能鉴权失败" + ); + } + } + } + + async fn fetch_authenticated_gateway_health_json(&self) -> Option { + if self.gateway_auth_token.is_empty() { + return None; + } + + let Some(openclaw_path) = find_command_in_shell("openclaw").await.ok().flatten() else { + return None; + }; + + let output = Command::new(openclaw_path) + .arg("gateway") + .arg("health") + .arg("--url") + .arg(self.gateway_ws_url()) + .arg("--token") + .arg(&self.gateway_auth_token) + .arg("--json") + .env(OPENCLAW_CONFIG_ENV, openclaw_proxycast_config_path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await; + + match output { + Ok(output) if output.status.success() => { + serde_json::from_slice::(&output.stdout) + .map_err(|error| { + tracing::warn!( + target: "openclaw", + "解析 Gateway 官方健康检查结果失败: {}", + error + ); + error + }) + .ok() + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + target: "openclaw", + "Gateway 官方健康检查失败: {}", + stderr.trim() + ); + None + } + Err(error) => { + tracing::warn!(target: "openclaw", "执行 Gateway 官方健康检查失败: {}", error); + None + } + } + } + + fn ensure_runtime_config( + &mut self, + provider_entry: Option<(&str, Value)>, + primary_model: Option, + ) -> Result<(), String> { + let config_dir = openclaw_config_dir(); + std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建配置目录失败: {e}"))?; + + let proxycast_config_path = openclaw_proxycast_config_path(); + let mut config = read_base_openclaw_config()?; + + if self.gateway_auth_token.is_empty() { + self.gateway_auth_token = generate_auth_token(); + } + + ensure_path_object(&mut config, &["gateway"]); + set_json_path( + &mut config, + &["gateway", "mode"], + Value::String("local".to_string()), + ); + set_json_path( + &mut config, + &["gateway", "port"], + Value::Number(self.gateway_port.into()), + ); + set_json_path( + &mut config, + &["gateway", "auth", "token"], + Value::String(self.gateway_auth_token.clone()), + ); + set_json_path( + &mut config, + &["gateway", "remote", "token"], + Value::String(self.gateway_auth_token.clone()), + ); + + if let Some((provider_key, provider_value)) = provider_entry { + set_json_path( + &mut config, + &["models", "mode"], + Value::String("merge".to_string()), + ); + set_json_path( + &mut config, + &["models", "providers", provider_key], + provider_value, + ); + } + + if let Some(primary) = primary_model { + set_json_path( + &mut config, + &["agents", "defaults", "model", "primary"], + Value::String(primary), + ); + } + + let content = + serde_json::to_string_pretty(&config).map_err(|e| format!("序列化配置失败: {e}"))?; + std::fs::write(proxycast_config_path, content).map_err(|e| format!("写入配置失败: {e}"))?; + Ok(()) + } +} + +pub fn openclaw_install_event_name() -> &'static str { + OPENCLAW_INSTALL_EVENT +} + +fn openclaw_config_dir() -> PathBuf { + home_dir() + .or_else(data_dir) + .unwrap_or_else(|| PathBuf::from(".")) + .join(".openclaw") +} + +fn openclaw_original_config_path() -> PathBuf { + openclaw_config_dir().join("openclaw.json") +} + +fn openclaw_proxycast_config_path() -> PathBuf { + openclaw_config_dir().join("openclaw.proxycast.json") +} + +fn read_base_openclaw_config() -> Result { + let proxycast_path = openclaw_proxycast_config_path(); + if proxycast_path.exists() { + return read_json_file(&proxycast_path); + } + + let original_path = openclaw_original_config_path(); + if original_path.exists() { + return read_json_file(&original_path); + } + + Ok(json!({})) +} + +fn read_json_file(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("读取配置文件失败({}): {e}", path.display()))?; + serde_json::from_str(&content).map_err(|e| format!("解析配置文件失败({}): {e}", path.display())) +} + +fn ensure_path_object<'a>(root: &'a mut Value, path: &[&str]) -> &'a mut Map { + let mut current = root; + for segment in path { + let object = ensure_value_object(current); + current = object + .entry((*segment).to_string()) + .or_insert_with(|| Value::Object(Map::new())); + } + ensure_value_object(current) +} + +fn set_json_path(root: &mut Value, path: &[&str], value: Value) { + if path.is_empty() { + *root = value; + return; + } + + let parent = ensure_path_object(root, &path[..path.len() - 1]); + parent.insert(path[path.len() - 1].to_string(), value); +} + +fn ensure_value_object(value: &mut Value) -> &mut Map { + if !value.is_object() { + *value = Value::Object(Map::new()); + } + value.as_object_mut().expect("value should be object") +} + +fn build_channel_info(channel_id: &str, entry: &Value, label: Option<&Value>) -> ChannelInfo { + ChannelInfo { + id: channel_id.to_string(), + name: entry + .get("name") + .and_then(Value::as_str) + .or_else(|| label.and_then(Value::as_str)) + .unwrap_or("未命名通道") + .to_string(), + channel_type: entry + .get("type") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + status: entry + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(), + } +} + +fn extract_gateway_auth_token(config: &Value) -> Option { + config + .get("gateway") + .and_then(|gateway| { + gateway + .get("auth") + .and_then(|auth| auth.get("token")) + .or_else(|| gateway.get("remote").and_then(|remote| remote.get("token"))) + }) + .and_then(Value::as_str) + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(ToString::to_string) +} + +fn determine_api_type(provider_type: ApiProviderType) -> Result<&'static str, String> { + match provider_type { + ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => { + Ok("anthropic-messages") + } + ApiProviderType::OpenaiResponse => Ok("openai-responses"), + ApiProviderType::Openai + | ApiProviderType::Codex + | ApiProviderType::Gemini + | ApiProviderType::Ollama + | ApiProviderType::Fal + | ApiProviderType::NewApi + | ApiProviderType::Gateway => Ok("openai-completions"), + ApiProviderType::AzureOpenai | ApiProviderType::Vertexai | ApiProviderType::AwsBedrock => { + Err("当前暂不支持将该 Provider 同步到 OpenClaw。".to_string()) + } + } +} + +fn format_provider_base_url(provider: &ApiKeyProvider) -> Result { + let api_host = trim_trailing_slash(&provider.api_host); + + match provider.provider_type { + ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => Ok(api_host), + ApiProviderType::Gemini => { + if api_host.contains("generativelanguage.googleapis.com") { + if api_host.ends_with("/v1beta/openai") { + Ok(api_host) + } else { + Ok(format!("{api_host}/v1beta/openai")) + } + } else if has_api_version(&api_host) { + Ok(api_host) + } else { + Ok(format!("{api_host}/v1")) + } + } + ApiProviderType::Gateway => { + if api_host.ends_with("/v1/ai") { + Ok(api_host.trim_end_matches("/ai").to_string()) + } else if has_api_version(&api_host) { + Ok(api_host) + } else { + Ok(format!("{api_host}/v1")) + } + } + ApiProviderType::Openai + | ApiProviderType::OpenaiResponse + | ApiProviderType::Codex + | ApiProviderType::Ollama + | ApiProviderType::Fal + | ApiProviderType::NewApi => { + if has_api_version(&api_host) { + Ok(api_host) + } else { + Ok(format!("{api_host}/v1")) + } + } + ApiProviderType::AzureOpenai | ApiProviderType::Vertexai | ApiProviderType::AwsBedrock => { + Err("当前暂不支持将该 Provider 同步到 OpenClaw。".to_string()) + } + } +} + +fn trim_trailing_slash(value: &str) -> String { + value.trim().trim_end_matches('/').to_string() +} + +fn has_api_version(url: &str) -> bool { + static VERSION_RE: OnceLock = OnceLock::new(); + VERSION_RE + .get_or_init(|| Regex::new(r"/v\d+(?:[./]|$)").expect("regex should compile")) + .is_match(url) +} + +fn generate_auth_token() -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(48) + .map(char::from) + .collect() +} + +fn should_use_china_package(app: &AppHandle) -> bool { + if let Some(app_state) = app.try_state::() { + let language = tauri::async_runtime::block_on(async { + let state = app_state.read().await; + state.config.language.clone() + }); + + if language.starts_with("zh") { + return true; + } + } + + let locale = std::env::var("LC_ALL") + .ok() + .or_else(|| std::env::var("LANG").ok()) + .unwrap_or_default() + .to_lowercase(); + let timezone = std::env::var("TZ").unwrap_or_default().to_lowercase(); + locale.contains("zh_cn") || locale.contains("zh-hans") || timezone.contains("shanghai") +} + +async fn detect_npm_global_prefix(npm_path: &str) -> Option { + let output = Command::new(npm_path) + .arg("config") + .arg("get") + .arg("prefix") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await + .ok()?; + + if !output.status.success() { + return None; + } + + let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if prefix.is_empty() || prefix.eq_ignore_ascii_case("undefined") { + None + } else { + Some(prefix) + } +} + +fn shell_escape(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn shell_env_assignment(value: &str) -> String { + format!("NPM_CONFIG_PREFIX={} ", shell_escape(value)) +} + +async fn find_command_in_shell(command_name: &str) -> Result, String> { + if cfg!(target_os = "windows") { + let output = Command::new("cmd") + .arg("/C") + .arg("where") + .arg(command_name) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await + .map_err(|e| format!("查找命令失败: {e}"))?; + + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(str::to_string); + if result.is_some() { + return Ok(result); + } + } + + return Ok(find_command_in_known_locations(command_name) + .map(|path| path.to_string_lossy().to_string())); + } + + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + let output = Command::new(shell) + .arg("-lc") + .arg(format!("command -v {command_name}")) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await + .map_err(|e| format!("查找命令失败: {e}"))?; + + if output.status.success() { + let result = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(str::to_string); + if result.is_some() { + return Ok(result); + } + } + + Ok( + find_command_in_known_locations(command_name) + .map(|path| path.to_string_lossy().to_string()), + ) +} + +fn find_command_in_known_locations(command_name: &str) -> Option { + let mut search_dirs = Vec::new(); + let mut seen = HashSet::new(); + + let mut push_dir = |dir: PathBuf| { + if dir.as_os_str().is_empty() || !dir.exists() { + return; + } + if seen.insert(dir.clone()) { + search_dirs.push(dir); + } + }; + + if let Some(path_var) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path_var) { + push_dir(dir); + } + } + + if let Some(home) = home_dir() { + push_dir(home.join(".npm-global/bin")); + push_dir(home.join(".local/bin")); + push_dir(home.join(".bun/bin")); + push_dir(home.join("Library/PhpWebStudy/env/node/bin")); + + let nvm_versions = home.join(".nvm/versions/node"); + if let Ok(entries) = std::fs::read_dir(nvm_versions) { + for entry in entries.flatten() { + push_dir(entry.path().join("bin")); + } + } + } + + if cfg!(target_os = "macos") { + push_dir(PathBuf::from("/opt/homebrew/bin")); + push_dir(PathBuf::from("/usr/local/bin")); + push_dir(PathBuf::from("/usr/bin")); + push_dir(PathBuf::from("/bin")); + } + + find_command_in_paths(command_name, &search_dirs) +} + +fn find_command_in_paths(command_name: &str, search_dirs: &[PathBuf]) -> Option { + #[cfg(target_os = "windows")] + let candidates = [ + format!("{command_name}.exe"), + format!("{command_name}.cmd"), + format!("{command_name}.bat"), + command_name.to_string(), + ]; + + #[cfg(not(target_os = "windows"))] + let candidates = [command_name.to_string()]; + + for dir in search_dirs { + for candidate in &candidates { + let path = dir.join(candidate); + if path.is_file() { + return Some(path); + } + } + } + + None +} + +async fn run_shell_command_with_progress( + app: &AppHandle, + command_line: &str, +) -> Result { + let mut child = spawn_shell_command(command_line)?; + + let stdout_task = child.stdout.take().map(|stdout| { + let app = app.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let trimmed = line.trim(); + if !trimmed.is_empty() { + emit_install_progress(&app, trimmed, "info"); + } + } + }) + }); + + let stderr_task = child.stderr.take().map(|stderr| { + let app = app.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let trimmed = line.trim(); + if !trimmed.is_empty() { + let level = if trimmed.to_ascii_lowercase().contains("warn") { + "warn" + } else { + "error" + }; + emit_install_progress(&app, trimmed, level); + } + } + }) + }); + + let status = child + .wait() + .await + .map_err(|e| format!("执行命令失败: {e}"))?; + + if let Some(task) = stdout_task { + let _ = task.await; + } + if let Some(task) = stderr_task { + let _ = task.await; + } + + if status.success() { + emit_install_progress(app, "命令执行成功。", "info"); + Ok(ActionResult { + success: true, + message: "操作成功完成。".to_string(), + }) + } else { + emit_install_progress( + app, + &format!("命令执行失败,退出码: {:?}", status.code()), + "error", + ); + Ok(ActionResult { + success: false, + message: format!("命令执行失败,退出码: {:?}", status.code()), + }) + } +} + +fn spawn_shell_command(command_line: &str) -> Result { + let mut command = if cfg!(target_os = "windows") { + let mut cmd = Command::new("cmd"); + cmd.arg("/C").arg(command_line); + cmd + } else { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + let mut cmd = Command::new(shell); + cmd.arg("-lc").arg(command_line); + cmd + }; + + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + command.spawn().map_err(|e| format!("启动命令失败: {e}")) +} + +fn emit_install_progress(app: &AppHandle, message: &str, level: &str) { + let payload = InstallProgressEvent { + message: message.to_string(), + level: level.to_string(), + }; + let _ = app.emit(OPENCLAW_INSTALL_EVENT, payload); +} + +fn parse_semver(value: &str) -> Option<(u64, u64, u64)> { + let sanitized = value.trim().trim_start_matches('v'); + let core = sanitized.split(['-', '+']).next()?; + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next().unwrap_or("0").parse().ok()?; + let patch = parts.next().unwrap_or("0").parse().ok()?; + Some((major, minor, patch)) +} + +fn format_semver(version: (u64, u64, u64)) -> String { + format!("{}.{}.{}", version.0, version.1, version.2) +} + +#[cfg(test)] +mod tests { + use super::{ + determine_api_type, extract_gateway_auth_token, format_provider_base_url, has_api_version, + trim_trailing_slash, + }; + use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup}; + use chrono::Utc; + use serde_json::json; + + fn build_provider(provider_type: ApiProviderType, api_host: &str) -> ApiKeyProvider { + ApiKeyProvider { + id: "provider-1".to_string(), + name: "Provider 1".to_string(), + provider_type, + api_host: api_host.to_string(), + is_system: false, + group: ProviderGroup::Custom, + enabled: true, + sort_order: 0, + api_version: None, + project: None, + location: None, + region: None, + custom_models: Vec::new(), + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + #[test] + fn trims_trailing_slash() { + assert_eq!( + trim_trailing_slash("https://api.openai.com/"), + "https://api.openai.com" + ); + } + + #[test] + fn detects_version_segment() { + assert!(has_api_version("https://api.openai.com/v1")); + assert!(!has_api_version("https://api.openai.com")); + } + + #[test] + fn maps_api_type_correctly() { + assert_eq!( + determine_api_type(ApiProviderType::Openai).unwrap(), + "openai-completions" + ); + assert_eq!( + determine_api_type(ApiProviderType::OpenaiResponse).unwrap(), + "openai-responses" + ); + assert_eq!( + determine_api_type(ApiProviderType::Anthropic).unwrap(), + "anthropic-messages" + ); + } + + #[test] + fn formats_openai_url() { + let provider = build_provider(ApiProviderType::Openai, "https://api.openai.com"); + assert_eq!( + format_provider_base_url(&provider).unwrap(), + "https://api.openai.com/v1" + ); + } + + #[test] + fn keeps_existing_version_url() { + let provider = build_provider(ApiProviderType::Openai, "https://example.com/v2"); + assert_eq!( + format_provider_base_url(&provider).unwrap(), + "https://example.com/v2" + ); + } + + #[test] + fn formats_gemini_url() { + let provider = build_provider( + ApiProviderType::Gemini, + "https://generativelanguage.googleapis.com", + ); + assert_eq!( + format_provider_base_url(&provider).unwrap(), + "https://generativelanguage.googleapis.com/v1beta/openai" + ); + } + + #[test] + fn formats_gateway_url() { + let provider = build_provider( + ApiProviderType::Gateway, + "https://gateway.example.com/v1/ai", + ); + assert_eq!( + format_provider_base_url(&provider).unwrap(), + "https://gateway.example.com/v1" + ); + } + + #[test] + fn rejects_unsupported_provider_types() { + let provider = build_provider(ApiProviderType::AzureOpenai, "https://example.com"); + assert!(format_provider_base_url(&provider).is_err()); + } + + #[test] + fn extracts_gateway_auth_token_from_config() { + let config = json!({ + "gateway": { + "auth": { + "token": "proxycast-token" + } + } + }); + + assert_eq!( + extract_gateway_auth_token(&config).as_deref(), + Some("proxycast-token") + ); + } + + #[test] + fn ignores_empty_gateway_auth_token() { + let config = json!({ + "gateway": { + "auth": { + "token": " " + } + } + }); + + assert_eq!(extract_gateway_auth_token(&config), None); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 695b41d21..6a1724fbb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 000000000..1d4f394f1 --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,11 @@ +{ + "bundle": { + "targets": ["nsis"], + "windows": { + "webviewInstallMode": { + "type": "offlineInstaller", + "silent": true + } + } + } +} diff --git a/src/App.tsx b/src/App.tsx index c4d2f313d..3dada9f06 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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("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( + "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() { +
+ +
+
- + +
+ +
+
{shouldShowAppSidebar && ( - + )} {renderAllPages()} diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 45f0c811e..bc86fd47e 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -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); diff --git a/src/components/agent/chat/components/A2UITaskCard.test.tsx b/src/components/agent/chat/components/A2UITaskCard.test.tsx new file mode 100644 index 000000000..98ca944e5 --- /dev/null +++ b/src/components/agent/chat/components/A2UITaskCard.test.tsx @@ -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("表单加载中..."); + }); +}); diff --git a/src/components/agent/chat/components/A2UITaskCard.tsx b/src/components/agent/chat/components/A2UITaskCard.tsx new file mode 100644 index 000000000..d380ad7c3 --- /dev/null +++ b/src/components/agent/chat/components/A2UITaskCard.tsx @@ -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 ( + + + + + + + + {footerText ? ( +
{footerText}
+ ) : null} +
+ ); +} + +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 ( + + + + + + ); +} + +export default A2UITaskCard; diff --git a/src/components/agent/chat/components/Inputbar/components/A2UIFloatingForm.tsx b/src/components/agent/chat/components/Inputbar/components/A2UIFloatingForm.tsx index 542ae19d8..eb6724499 100644 --- a/src/components/agent/chat/components/Inputbar/components/A2UIFloatingForm.tsx +++ b/src/components/agent/chat/components/Inputbar/components/A2UIFloatingForm.tsx @@ -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 ( - + ); } diff --git a/src/components/agent/chat/components/Inputbar/components/HintRoutePopup.tsx b/src/components/agent/chat/components/Inputbar/components/HintRoutePopup.tsx new file mode 100644 index 000000000..6435a50a3 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/HintRoutePopup.tsx @@ -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 ( + + {routes.map((route, index) => ( + onSelect(route.hint)} + > + + + {route.provider} / {route.model} + + + ))} + + ); +} diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx new file mode 100644 index 000000000..54a39c7f0 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -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; + input: string; + onSelectCharacter?: (character: Character) => void; + onSelectSkill: (skill: Skill) => void; + onNavigateToSettings?: () => void; + onSend: () => void; + onToolClick: (tool: string) => void; + activeTools: Record; + 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 ( + + ); + } + + return ( + <> + + + + } + rightExtra={ + + } + /> + + ); +}; diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx new file mode 100644 index 000000000..918d5e2fe --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/InputbarExecutionStrategySelect.tsx @@ -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 ( + + ); +}; diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx new file mode 100644 index 000000000..e71063573 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/InputbarModelExtra.tsx @@ -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 = ({ + isFullscreen = false, + isThemeWorkbenchVariant = false, + providerType, + setProviderType, + model, + setModel, + activeTheme, + onManageProviders, +}) => { + if (isFullscreen || isThemeWorkbenchVariant || !providerType || !model) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx new file mode 100644 index 000000000..5919082d2 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx @@ -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; + onFileSelect: (event: ChangeEvent) => void; +} + +export const InputbarOverlayShell: React.FC = ({ + showHintPopup, + hintRoutes, + hintIndex, + onHintSelect, + taskFiles, + selectedFileId, + taskFilesExpanded = false, + onToggleTaskFiles, + onTaskFileClick, + submissionNotice, + isSubmissionNoticeVisible, + pendingA2UIForm, + onA2UISubmit, + fileInputRef, + onFileSelect, +}) => ( + <> + {showHintPopup ? ( + + ) : null} + + {submissionNotice ? ( + + ) : null} + {pendingA2UIForm && onA2UISubmit ? ( + + ) : null} + + +); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarSurface.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarSurface.tsx new file mode 100644 index 000000000..8cbb96f2b --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/InputbarSurface.tsx @@ -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 = ({ + isFullscreen, + onDragOver, + onDrop, + onKeyDown, + children, +}) => ( +
+ {children} +
+); diff --git a/src/components/agent/chat/components/Inputbar/components/TaskFilesPanel.tsx b/src/components/agent/chat/components/Inputbar/components/TaskFilesPanel.tsx new file mode 100644 index 000000000..c8913da1e --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/TaskFilesPanel.tsx @@ -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 ( + + + { + if (nextExpanded !== expanded) { + onToggle?.(); + } + }} + /> + 0} + $expanded={expanded} + onClick={onToggle} + data-task-files-trigger + > + + 任务文件 + ({files.length}) + + + + + + + ); +} diff --git a/src/components/agent/chat/components/Inputbar/components/ThemeWorkbenchStatusPanel.tsx b/src/components/agent/chat/components/Inputbar/components/ThemeWorkbenchStatusPanel.tsx new file mode 100644 index 000000000..28dba7312 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/ThemeWorkbenchStatusPanel.tsx @@ -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 ( + + + + setQueueCollapsed((prev) => !prev)} + aria-label={queueCollapsed ? "展开待办列表" : "折叠待办列表"} + > + 当前待办 + + + + + + {!queueCollapsed ? ( + + {queueItems.length === 0 ? ( + + + + + 正在编排任务节点... + 进行中 + + ) : ( + queueItems.map((item) => { + const statusKind = + item.status === "active" + ? "active" + : item.status === "error" + ? "error" + : "pending"; + return ( + + + {statusKind === "active" ? ( + + ) : statusKind === "error" ? ( + + ) : ( + + )} + + {item.title} + + {statusKind === "active" + ? "进行中" + : statusKind === "error" + ? "异常" + : "待处理"} + + + ); + }) + )} + + ) : null} + + + + + + 正在生成中 • • • + 切换项目或关闭网页将中断任务 + onStop?.()} + aria-label="停止生成" + > + + + + + ); + } + + if (!gate || gate.status === "idle") { + return null; + } + + return ( + + + + + + {gate.title} + + {gate.status === "waiting" + ? "等待决策" + : gate.status === "running" + ? "自动执行中" + : "待启动"} + + + {quickActions.length > 0 ? ( + + {quickActions.map((action) => ( + onQuickAction(action.prompt)} + > + {action.label} + + ))} + + ) : null} + + ); +} diff --git a/src/components/agent/chat/components/Inputbar/hooks/useA2UISubmissionNotice.ts b/src/components/agent/chat/components/Inputbar/hooks/useA2UISubmissionNotice.ts new file mode 100644 index 000000000..0c0edba89 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/hooks/useA2UISubmissionNotice.ts @@ -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(null); + const [isVisible, setIsVisible] = useState(false); + const hideTimerRef = useRef | 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, + }; +} diff --git a/src/components/agent/chat/components/Inputbar/hooks/useHintRoutes.ts b/src/components/agent/chat/components/Inputbar/hooks/useHintRoutes.ts new file mode 100644 index 000000000..de475607c --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/hooks/useHintRoutes.ts @@ -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; +} + +export function useHintRoutes({ + setInput, + textareaRef, +}: UseHintRoutesParams) { + const [showHintPopup, setShowHintPopup] = useState(false); + const [hintRoutes, setHintRoutes] = useState([]); + const [hintIndex, setHintIndex] = useState(0); + + useEffect(() => { + safeInvoke("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, + }; +} diff --git a/src/components/agent/chat/components/Inputbar/hooks/useImageAttachments.ts b/src/components/agent/chat/components/Inputbar/hooks/useImageAttachments.ts new file mode 100644 index 000000000..0b2453274 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/hooks/useImageAttachments.ts @@ -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 { + 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([]); + const fileInputRef = useRef(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) => { + 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, + }; +} diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarAdapter.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarAdapter.ts new file mode 100644 index 000000000..e2b17d654 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarAdapter.ts @@ -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, + ], + ); +} diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts new file mode 100644 index 000000000..ed2d3f6f7 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts @@ -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; + 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(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, + }; +} diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarDisplayState.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarDisplayState.ts new file mode 100644 index 000000000..0f515962b --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarDisplayState.ts @@ -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, + }; +} diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts new file mode 100644 index 000000000..a23466832 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts @@ -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; + 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, + ]); +} diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarToolState.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarToolState.ts new file mode 100644 index 000000000..de35c5d34 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarToolState.ts @@ -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; + 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 + >({}); + const [localToolStates, setLocalToolStates] = useState( + DEFAULT_INPUTBAR_TOOL_STATES, + ); + const [isFullscreen, setIsFullscreen] = useState(false); + + const webSearchEnabled = + toolStates?.webSearch ?? localToolStates.webSearch; + const thinkingEnabled = toolStates?.thinking ?? localToolStates.thinking; + + const activeTools = useMemo>( + () => ({ + ...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, + }; +} diff --git a/src/components/agent/chat/components/Inputbar/hooks/useThemeWorkbenchInputState.ts b/src/components/agent/chat/components/Inputbar/hooks/useThemeWorkbenchInputState.ts new file mode 100644 index 000000000..cec801478 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/hooks/useThemeWorkbenchInputState.ts @@ -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, + }; +} diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index d5b98669d..ee8bc60b8 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -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( {props.activeTools?.web_search ? "on" : "off"} -
{props.rightExtra}
@@ -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
; }, @@ -91,11 +90,15 @@ vi.mock("@/lib/dev-bridge", () => ({ })); vi.mock("@/components/ui/select", () => ({ - Select: ({ children }: { children: React.ReactNode }) =>
{children}
, + Select: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), SelectContent: ({ children }: { children: React.ReactNode }) => (
{children}
), - SelectItem: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), SelectTrigger: ({ children }: { children: React.ReactNode }) => ( ), @@ -169,7 +172,9 @@ afterEach(() => { vi.clearAllMocks(); }); -function renderInputbar(props?: Partial>) { +function renderInputbar( + props?: Partial>, +) { 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("正在生成中"); }); - }); diff --git a/src/components/agent/chat/components/Inputbar/index.tsx b/src/components/agent/chat/components/Inputbar/index.tsx index 12c8fea8c..948c5245f 100644 --- a/src/components/agent/chat/components/Inputbar/index.tsx +++ b/src/components/agent/chat/components/Inputbar/index.tsx @@ -1,509 +1,16 @@ -import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; -import { InputbarCore } from "./components/InputbarCore"; -import { CharacterMention } from "./components/CharacterMention"; -import { toast } from "sonner"; -import styled from "styled-components"; +import React from "react"; import type { MessageImage } from "../../types"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; -import { TaskFileList, type TaskFile } from "../TaskFiles"; -import { - A2UISubmissionNotice, - type A2UISubmissionNoticeData, -} from "./components/A2UISubmissionNotice"; -import { A2UIFloatingForm } from "./components/A2UIFloatingForm"; +import type { TaskFile } from "../TaskFiles"; +import { InputbarComposerSection } from "./components/InputbarComposerSection"; +import { InputbarOverlayShell } from "./components/InputbarOverlayShell"; +import { InputbarSurface } from "./components/InputbarSurface"; +import type { A2UISubmissionNoticeData } from "./components/A2UISubmissionNotice"; import type { A2UIResponse, A2UIFormData } from "@/components/content-creator/a2ui/types"; -import { - FolderOpen, - ChevronUp, - ChevronDown, - Code2, - Loader2, - Clock3, - AlertCircle, - Sparkles, -} from "lucide-react"; -import { useActiveSkill } from "./hooks/useActiveSkill"; -import { SkillBadge } from "./components/SkillBadge"; -import { ChatModelSelector } from "../ChatModelSelector"; -import { safeInvoke } from "@/lib/dev-bridge"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from "@/components/ui/select"; -import { createAgentInputAdapter } from "@/components/input-kit"; -import type { StepStatus } from "@/components/content-creator/types"; - -// 任务文件触发器区域(在输入框上方,与输入框对齐) -const TaskFilesArea = styled.div` - display: flex; - justify-content: flex-end; - padding: 0 8px 8px 8px; - width: 100%; - max-width: none; - margin: 0; -`; - -// 按钮和面板的包装容器 -const TaskFilesWrapper = styled.div` - position: relative; -`; - -// 任务文件按钮 -const TaskFilesButton = 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; -`; - -// Hint 路由弹出框 -const HintPopup = 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 HintItem = 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 HintLabel = styled.span` - font-weight: 500; -`; - -const HintModel = styled.span` - font-size: 11px; - color: hsl(var(--muted-foreground)); -`; - -const NOOP_SET_PROVIDER_TYPE = (_type: string) => {}; -const NOOP_SET_MODEL = (_model: string) => {}; -const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover"; - -const ThemeWorkbenchGateStrip = 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 ThemeWorkbenchGateMeta = styled.div` - min-width: 0; - display: inline-flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; -`; - -const ThemeWorkbenchGateIcon = 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 ThemeWorkbenchGateTitle = styled.span` - font-size: 12px; - color: hsl(var(--foreground) / 0.86); - font-weight: 600; - line-height: 1.4; -`; - -const ThemeWorkbenchGateStatus = 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 ThemeWorkbenchQuickActions = styled.div` - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-left: auto; -`; - -const ThemeWorkbenchQuickButton = 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 ThemeWorkbenchGeneratingWrap = styled.div` - margin: 0 10px 10px; - display: flex; - flex-direction: column; - gap: 10px; -`; - -const ThemeWorkbenchTaskCard = 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 ThemeWorkbenchTaskHead = 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 ThemeWorkbenchTaskHeadButton = styled.button` - display: inline-flex; - align-items: center; - gap: 6px; - border: none; - background: transparent; - color: inherit; - padding: 0; - cursor: pointer; -`; - -const ThemeWorkbenchTaskHeadChevron = styled.span<{ $collapsed: boolean }>` - display: inline-flex; - transition: transform 0.2s ease; - transform: ${({ $collapsed }) => - $collapsed ? "rotate(-90deg)" : "rotate(0deg)"}; -`; - -const ThemeWorkbenchTaskList = styled.div` - display: flex; - flex-direction: column; - gap: 8px; -`; - -const ThemeWorkbenchTaskRow = styled.div` - display: flex; - align-items: center; - gap: 10px; - min-height: 34px; - min-width: 0; -`; - -const ThemeWorkbenchTaskIcon = 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 ThemeWorkbenchTaskText = styled.span` - flex: 1; - font-size: 14px; - color: hsl(var(--foreground)); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`; - -const ThemeWorkbenchTaskStatus = 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 ThemeWorkbenchRunningBar = 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 ThemeWorkbenchRunningIcon = styled.span` - color: hsl(var(--primary)); - display: inline-flex; - flex-shrink: 0; -`; - -const ThemeWorkbenchRunningSub = styled.span` - flex: 1; - min-width: 0; - font-size: 12px; - color: hsl(var(--muted-foreground)); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`; - -const ThemeWorkbenchRunningMain = styled.span` - color: hsl(var(--primary)); - font-weight: 600; - margin-right: 2px; - font-size: 14px; -`; - -const ThemeWorkbenchStopButton = 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 ThemeWorkbenchStopGlyph = 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; - } -`; - -interface HintRouteItem { - hint: string; - provider: string; - model: string; -} - -interface ThemeWorkbenchQuickAction { - id: string; - label: string; - prompt: string; -} - -export interface InputbarToolStates { - webSearch: boolean; - thinking: boolean; -} - -export interface ThemeWorkbenchGateState { - key: string; - title: string; - status: "running" | "waiting" | "idle"; - description: string; -} - -interface ThemeWorkbenchWorkflowStep { - id: string; - title: string; - status: StepStatus; -} - -const DEFAULT_INPUTBAR_TOOL_STATES: InputbarToolStates = { - webSearch: false, - thinking: false, -}; - -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-adapt", - label: "双平台适配", - prompt: "请将主稿适配为公众号和小红书两个版本,并输出差异点。", - }, - ]; - default: - return [ - { - id: "next-step", - label: "继续编排", - prompt: "请继续按照当前编排推进,并在关键闸门前向我确认。", - }, - ]; - } -} +import type { ThemeWorkbenchGateState, ThemeWorkbenchWorkflowStep } from "./hooks/useThemeWorkbenchInputState"; +import { type InputbarToolStates } from "./hooks/useInputbarToolState"; +import { useInputbarController } from "./hooks/useInputbarController"; interface InputbarProps { input: string; @@ -603,796 +110,113 @@ export const Inputbar: React.FC = ({ onA2UISubmit, a2uiSubmissionNotice, }) => { - const [localActiveTools, setLocalActiveTools] = useState< - Record - >({}); - const [localToolStates, setLocalToolStates] = useState( - DEFAULT_INPUTBAR_TOOL_STATES, - ); - const [pendingImages, setPendingImages] = useState([]); - const [isFullscreen, setIsFullscreen] = useState(false); - const [themeWorkbenchQueueCollapsed, setThemeWorkbenchQueueCollapsed] = - useState(false); - const [visibleA2UISubmissionNotice, setVisibleA2UISubmissionNotice] = - useState(null); - const [isA2UISubmissionNoticeVisible, setIsA2UISubmissionNoticeVisible] = - useState(false); - const { activeSkill, setActiveSkill, clearActiveSkill } = useActiveSkill(); - const fileInputRef = useRef(null); - const textareaRef = useRef(null); - const a2uiSubmissionNoticeTimerRef = useRef | null>(null); - - // Hint 路由 - const [showHintPopup, setShowHintPopup] = useState(false); - const [hintRoutes, setHintRoutes] = useState([]); - const [hintIndex, setHintIndex] = useState(0); - - const webSearchEnabled = - toolStates?.webSearch ?? localToolStates.webSearch; - const thinkingEnabled = toolStates?.thinking ?? localToolStates.thinking; - const isThemeWorkbenchVariant = variant === "theme_workbench"; - 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 activeTools = useMemo>( - () => ({ - ...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], - ); - - useEffect(() => { - safeInvoke("get_hint_routes") - .then((routes) => { - if (routes?.length > 0) { - setHintRoutes(routes); - } - }) - .catch(() => {}); - }, []); - - // 监听输入变化,触发 hint 弹出 - 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], - ); - - const handleHintKeyDown = useCallback( - (e: React.KeyboardEvent) => { - 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], - ); - - 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(""); - setPendingImages([]); - toast.success("已清除输入"); - break; - case "new_topic": - onClearMessages?.(); - setInput(""); - setPendingImages([]); - break; - case "attach": - fileInputRef.current?.click(); - break; - case "quick_action": - case "translate": - toast.info("翻译功能开发中..."); - break; - case "fullscreen": - setIsFullscreen((prev) => !prev); - toast.info(isFullscreen ? "已退出全屏" : "已进入全屏编辑"); - break; - case "canvas": - onToggleCanvas?.(); - break; - default: - break; - } - }, - [ - executionStrategy, - thinkingEnabled, - onClearMessages, - onToggleCanvas, - setExecutionStrategy, - setInput, - updateToolStates, - webSearchEnabled, - isFullscreen, - ], - ); - - const handleFileSelect = useCallback( - (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files || files.length === 0) return; - - Array.from(files).forEach((file) => { - if (file.type.startsWith("image/")) { - const reader = new FileReader(); - reader.onload = (event) => { - const base64 = event.target?.result as string; - const base64Data = base64.split(",")[1]; - setPendingImages((prev) => [ - ...prev, - { - data: base64Data, - mediaType: file.type, - }, - ]); - toast.success(`已添加图片: ${file.name}`); - }; - reader.readAsDataURL(file); - } else { - toast.info(`暂不支持该文件类型: ${file.type}`); - } - }); - - e.target.value = ""; - }, - [], - ); - - const handlePaste = useCallback((e: React.ClipboardEvent) => { - const items = e.clipboardData?.items; - if (!items) return; - - for (const item of items) { - if (item.type.startsWith("image/")) { - e.preventDefault(); - const file = item.getAsFile(); - if (file) { - const reader = new FileReader(); - reader.onload = (event) => { - const base64 = event.target?.result as string; - const base64Data = base64.split(",")[1]; - setPendingImages((prev) => [ - ...prev, - { - data: base64Data, - mediaType: item.type, - }, - ]); - toast.success("已粘贴图片"); - }; - reader.readAsDataURL(file); - } - break; - } - } - }, []); - - // 文件拖拽处理 - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - }, []); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const files = e.dataTransfer.files; - if (!files || files.length === 0) return; - - Array.from(files).forEach((file) => { - if (file.type.startsWith("image/")) { - const reader = new FileReader(); - reader.onload = (event) => { - const base64 = event.target?.result as string; - const base64Data = base64.split(",")[1]; - setPendingImages((prev) => [ - ...prev, - { - data: base64Data, - mediaType: file.type, - }, - ]); - toast.success(`已添加图片: ${file.name}`); - }; - reader.readAsDataURL(file); - } else { - toast.info(`暂不支持该文件类型: ${file.type}`); - } - }); - }, []); - - const handleRemoveImage = useCallback((index: number) => { - setPendingImages((prev) => prev.filter((_, i) => i !== index)); - }, []); - - const handleSend = 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"; - } - - // 如果有 activeSkill,拼接 /skill.key 前缀 - 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, - ); - setPendingImages([]); - clearActiveSkill(); - }, [ - activeSkill, - activeTools, - clearActiveSkill, - executionStrategy, - input, - activeTheme, - onSend, + const { + textareaRef, + isThemeWorkbenchVariant, pendingImages, - thinkingEnabled, - webSearchEnabled, - ]); - - const handleToggleTaskFiles = useCallback(() => { - onToggleTaskFiles?.(); - }, [onToggleTaskFiles]); - - const resolvedExecutionStrategy = executionStrategy || "react"; - const executionStrategyLabel = - resolvedExecutionStrategy === "auto" - ? "Auto" - : resolvedExecutionStrategy === "code_orchestrated" - ? "Plan" - : "ReAct"; - - const inputAdapter = useMemo( - () => - createAgentInputAdapter({ - text: input, - setText: handleSetInput, - 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, - handleSetInput, - input, - isLoading, - model, - onStop, - pendingImages, - providerType, - setExecutionStrategy, - setModel, - setProviderType, - ], - ); - - const shouldRenderModelSelector = Boolean( - !isThemeWorkbenchVariant && - providerType && - setProviderType && - model && - setModel, - ); - const topExtra = activeSkill ? ( - - ) : undefined; - - const themeWorkbenchGateStrip = - isThemeWorkbenchVariant && - themeWorkbenchGate && - themeWorkbenchGate.status !== "idle" ? ( - - - - - - {themeWorkbenchGate.title} - - {themeWorkbenchGate.status === "waiting" - ? "等待决策" - : themeWorkbenchGate.status === "running" - ? "自动执行中" - : "待启动"} - - - {themeWorkbenchQuickActions.length > 0 ? ( - - {themeWorkbenchQuickActions.map((action) => ( - { - inputAdapter.actions.setText(action.prompt); - }} - > - {action.label} - - ))} - - ) : null} - - ) : null; - - const renderThemeWorkbenchGeneratingPanel = isThemeWorkbenchVariant - ? themeWorkbenchRunState - ? themeWorkbenchRunState === "auto_running" - : inputAdapter.state.isSending - : false; - - const shouldShowA2UISubmissionNotice = Boolean( - !pendingA2UIForm && - a2uiSubmissionNotice && - (!isThemeWorkbenchVariant || - (!renderThemeWorkbenchGeneratingPanel && - themeWorkbenchQueueItems.length === 0 && - (themeWorkbenchGate?.status ?? "idle") === "idle")), - ); - - useEffect(() => { - return () => { - if (a2uiSubmissionNoticeTimerRef.current) { - clearTimeout(a2uiSubmissionNoticeTimerRef.current); - } - }; - }, []); - - useEffect(() => { - if (a2uiSubmissionNoticeTimerRef.current) { - clearTimeout(a2uiSubmissionNoticeTimerRef.current); - a2uiSubmissionNoticeTimerRef.current = null; - } - - if (shouldShowA2UISubmissionNotice && a2uiSubmissionNotice) { - setVisibleA2UISubmissionNotice(a2uiSubmissionNotice); - const frameId = window.requestAnimationFrame(() => { - setIsA2UISubmissionNoticeVisible(true); - }); - return () => { - window.cancelAnimationFrame(frameId); - }; - } - - setIsA2UISubmissionNoticeVisible(false); - a2uiSubmissionNoticeTimerRef.current = setTimeout(() => { - setVisibleA2UISubmissionNotice(null); - a2uiSubmissionNoticeTimerRef.current = null; - }, 180); - - return () => { - if (a2uiSubmissionNoticeTimerRef.current) { - clearTimeout(a2uiSubmissionNoticeTimerRef.current); - a2uiSubmissionNoticeTimerRef.current = null; - } - }; - }, [a2uiSubmissionNotice, shouldShowA2UISubmissionNotice]); + fileInputRef, + handleFileSelect, + handlePaste, + handleDragOver, + handleDrop, + handleRemoveImage, + showHintPopup, + hintRoutes, + hintIndex, + handleHintSelect, + handleHintKeyDown, + activeTools, + handleToolClick, + isFullscreen, + handleSend, + inputAdapter, + topExtra, + themeWorkbenchQuickActions, + themeWorkbenchQueueItems, + renderThemeWorkbenchGeneratingPanel, + visibleA2UISubmissionNotice, + isA2UISubmissionNoticeVisible, + setActiveSkill, + } = useInputbarController({ + input, + setInput, + onSend, + onStop, + isLoading, + disabled, + onClearMessages, + onToggleCanvas, + providerType, + setProviderType, + model, + setModel, + executionStrategy, + setExecutionStrategy, + toolStates, + onToolStatesChange, + activeTheme, + variant, + themeWorkbenchGate, + workflowSteps, + themeWorkbenchRunState, + pendingA2UIForm, + a2uiSubmissionNotice, + }); return ( -
- {/* Hint 路由弹出框 */} - {showHintPopup && hintRoutes.length > 0 && ( - - {hintRoutes.map((route, i) => ( - handleHintSelect(route.hint)} - > - [{route.hint}] - {route.provider} / {route.model} - - ))} - - )} - {/* 任务文件区域 - 在输入框上方 */} - {taskFiles.length > 0 && ( - - {/* 按钮和面板的包装容器 */} - - {/* 任务文件面板 */} - { - if (expanded !== taskFilesExpanded) { - onToggleTaskFiles?.(); - } - }} - /> - {/* 任务文件按钮 */} - 0} - $expanded={taskFilesExpanded} - onClick={handleToggleTaskFiles} - data-task-files-trigger - > - - 任务文件 - ({taskFiles.length}) - - - - - - - )} - {/* A2UI 已提交提示 / 待填写表单 */} - {visibleA2UISubmissionNotice ? ( - - ) : null} - {pendingA2UIForm && onA2UISubmit ? ( - - ) : null} - - {renderThemeWorkbenchGeneratingPanel ? ( - - - - setThemeWorkbenchQueueCollapsed((prev) => !prev)} - aria-label={ - themeWorkbenchQueueCollapsed - ? "展开待办列表" - : "折叠待办列表" - } - > - 当前待办 - - - - - - {!themeWorkbenchQueueCollapsed ? ( - - {themeWorkbenchQueueItems.length === 0 ? ( - - - - - 正在编排任务节点... - - 进行中 - - - ) : ( - themeWorkbenchQueueItems.map((item) => { - const statusKind = - item.status === "active" - ? "active" - : item.status === "error" - ? "error" - : "pending"; - return ( - - - {statusKind === "active" ? ( - - ) : statusKind === "error" ? ( - - ) : ( - - )} - - {item.title} - - {statusKind === "active" - ? "进行中" - : statusKind === "error" - ? "异常" - : "待处理"} - - - ); - }) - )} - - ) : null} - - - - - - 正在生成中 • • • - 切换项目或关闭网页将中断任务 - inputAdapter.actions.stop?.()} - aria-label="停止生成" - > - - - - - ) : ( - <> - {themeWorkbenchGateStrip} - - - {shouldRenderModelSelector && inputAdapter.model ? ( - - ) : null} -
- ) : undefined - } - rightExtra={ - !isFullscreen && !isThemeWorkbenchVariant && setExecutionStrategy ? ( - - ) : undefined - } - /> - - )} -
+ + ); }; diff --git a/src/components/agent/chat/components/MarkdownRenderer.tsx b/src/components/agent/chat/components/MarkdownRenderer.tsx index ffb6adfc8..a0210ad41 100644 --- a/src/components/agent/chat/components/MarkdownRenderer.tsx +++ b/src/components/agent/chat/components/MarkdownRenderer.tsx @@ -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 = memo( if (parsed) { // 解析成功,直接渲染 A2UI 组件(不包裹在 pre 中) return ( - ); } else { // 解析失败(可能是流式输出中,JSON 还不完整) return ( - - - - - 表单加载中... - + ); } } diff --git a/src/components/agent/chat/components/RuntimeStyleControlBar.tsx b/src/components/agent/chat/components/RuntimeStyleControlBar.tsx new file mode 100644 index 000000000..8f8e1418e --- /dev/null +++ b/src/components/agent/chat/components/RuntimeStyleControlBar.tsx @@ -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 ( + + +
+
+ + 任务风格 +
+ + + + + + + + +
+
临时风格备注
+

+ 用来描述这次想模拟的额外风格,例如“更像知识型创作者,但保持克制,不要营销感”。 +

+
+