diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index bafe2fe39..b41d5bd7e 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -42,7 +42,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" - name: Setup pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a3226bcc..f6f78829e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,18 +3,18 @@ name: Release on: push: tags: - - 'v*' + - "v*" paths-ignore: - - 'docs/**' - - '*.md' - - '.github/workflows/deploy-docs.yml' - - '.github/workflows/update-homebrew.yml' + - "docs/**" + - "*.md" + - ".github/workflows/deploy-docs.yml" + - ".github/workflows/update-homebrew.yml" workflow_dispatch: inputs: version: - description: 'Version to release (e.g., v0.1.0)' + description: "Version to release (e.g., v0.1.0)" required: true - default: 'v0.1.0' + default: "v0.1.0" permissions: contents: write @@ -56,7 +56,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: "22" - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -174,7 +174,7 @@ jobs: tauriScript: npx tauri projectPath: src-tauri tagName: ${{ github.event.inputs.version || github.ref_name }} - releaseName: 'ProxyCast ${{ github.event.inputs.version || github.ref_name }}' + releaseName: "ProxyCast ${{ github.event.inputs.version || github.ref_name }}" releaseBody: ${{ env.RELEASE_BODY }} releaseDraft: false prerelease: false @@ -197,7 +197,7 @@ jobs: tauriScript: npx tauri projectPath: src-tauri tagName: ${{ github.event.inputs.version || github.ref_name }} - releaseName: 'ProxyCast ${{ github.event.inputs.version || github.ref_name }}' + releaseName: "ProxyCast ${{ github.event.inputs.version || github.ref_name }}" releaseBody: ${{ env.RELEASE_BODY }} releaseDraft: false prerelease: false @@ -230,7 +230,7 @@ jobs: tauriScript: npx tauri projectPath: src-tauri tagName: ${{ github.event.inputs.version || github.ref_name }} - releaseName: 'ProxyCast ${{ github.event.inputs.version || github.ref_name }}' + releaseName: "ProxyCast ${{ github.event.inputs.version || github.ref_name }}" releaseBody: ${{ env.RELEASE_BODY }} releaseDraft: false prerelease: false @@ -259,7 +259,7 @@ jobs: tauriScript: npx tauri projectPath: src-tauri tagName: ${{ github.event.inputs.version || github.ref_name }} - releaseName: 'ProxyCast ${{ github.event.inputs.version || github.ref_name }}' + releaseName: "ProxyCast ${{ github.event.inputs.version || github.ref_name }}" releaseBody: ${{ env.RELEASE_BODY }} releaseDraft: false prerelease: false @@ -282,7 +282,7 @@ jobs: tauriScript: npx tauri projectPath: src-tauri tagName: ${{ github.event.inputs.version || github.ref_name }} - releaseName: 'ProxyCast ${{ github.event.inputs.version || github.ref_name }}' + releaseName: "ProxyCast ${{ github.event.inputs.version || github.ref_name }}" releaseBody: ${{ env.RELEASE_BODY }} releaseDraft: false prerelease: false diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/AGENTS.md b/AGENTS.md index 405d3a4db..a5445898c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,15 @@ 2. **文件超过 20 行,分批输出** - 避免一次性输出过长内容 3. **先读后写** - 修改文件前必须先读取现有内容 +## 跨平台兼容约束 + +1. **默认双平台** - 所有新增功能、脚本、文档默认同时考虑 macOS 与 Windows;若只支持单平台,必须明确标注原因、影响范围和降级方案 +2. **避免硬编码平台细节** - 路径分隔符、可执行文件后缀、换行符、大小写敏感性、文件权限等差异必须通过跨平台 API 或统一封装处理,不要直接写死平台判断分支 +3. **优先平台无关入口** - 优先使用 `npm`、`cargo`、Tauri 命令和项目内封装,避免新增只适用于 Bash/zsh 的开发流程;如果文档示例包含 shell 特性,需补充 Windows 可执行方式 +4. **目录定位走系统 API** - 用户数据、日志、缓存、凭证等落盘位置必须通过应用目录 API 或统一工具函数解析,禁止在实现中写死 `~/Library/...`、`C:/Users/...` 之类路径 +5. **变更前做兼容性自检** - 涉及文件系统、进程启动、终端、快捷键、窗口、托盘、权限、路径解析的改动时,提交前必须检查 macOS/Windows 的行为差异;未验证的平台假设要明确说明 +6. **不确定先查文档** - 对 Tauri、Rust、Node.js 或系统 API 的平台行为拿不准时,先查官方文档;可以优先使用 Context7 MCP 获取最新资料 + ## AGENTS.md 维护原则 1. **根 AGENTS.md 只放仓库级规则** - 保留全局约束、高频命令、文档索引,避免塞入过长操作手册 @@ -132,14 +141,14 @@ Kiro 凭证采用完全独立的副本策略: ### 修改凭证管理 -- 凭证文件存储在 `~/Library/Application Support/proxycast/credentials/` +- 凭证文件存储在应用数据目录下的 `proxycast/credentials/`;`~/Library/Application Support/proxycast/credentials/` 仅作为 macOS 示例,Windows 请使用对应的应用数据目录 - 数据库存储凭证元数据和状态 - Token 缓存在数据库中,避免频繁读取文件 ### 调试技巧 - 日志输出使用 `tracing` 宏 -- API 请求调试文件保存在 `~/.proxycast/logs/` +- API 请求调试文件保存在应用日志目录;`~/.proxycast/logs/` 仅作为 macOS 示例,实现与排障时不要写死该路径 - 使用 `debug_kiro_credentials` 命令调试凭证加载 ## 文档维护 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..8bd1260be --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,13 @@ +# Claude Code 工作指令 + +本仓库的统一工程规范以 `AGENTS.md` 为准。 + +请在开始任何分析、编码、测试、文档更新前,先读取并严格遵循以下仓库级规则: + +@AGENTS.md + +## 补充说明 + +- 如本文件与 `AGENTS.md` 有重复或冲突,以 `AGENTS.md` 为准 +- 本文件保持精简,避免与 `AGENTS.md` 产生规则漂移 +- 需要模块级背景时,按 `AGENTS.md` 中的文档索引继续阅读 `docs/aiprompts/` diff --git a/docs/content/06.development/2.contributing.md b/docs/content/06.development/2.contributing.md index 625745e00..e7c8c178c 100644 --- a/docs/content/06.development/2.contributing.md +++ b/docs/content/06.development/2.contributing.md @@ -13,12 +13,12 @@ navigation: ### 前置要求 -| 工具 | 版本 | -|------|------| -| Node.js | >= 18 | -| pnpm | >= 8 | -| Rust | >= 1.70 | -| Tauri CLI | >= 2.0 | +| 工具 | 版本 | +| --------- | ------- | +| Node.js | >= 22 | +| pnpm | >= 8 | +| Rust | >= 1.70 | +| Tauri CLI | >= 2.0 | ### 安装依赖 diff --git a/docs/content/06.development/3.building.md b/docs/content/06.development/3.building.md index 67a3ae447..fa4992755 100644 --- a/docs/content/06.development/3.building.md +++ b/docs/content/06.development/3.building.md @@ -17,10 +17,12 @@ navigation: ```bash # 使用 nvm 安装 -nvm install 18 -nvm use 18 +nvm install 22 +nvm use 22 ``` +Windows 用户请使用 Node.js 官方安装器安装 22.x,并重新打开终端后通过 `node -v` 确认版本。 + 2. **安装 pnpm** ```bash @@ -40,15 +42,18 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh 4. **安装 Tauri 依赖** **macOS:** + ```bash xcode-select --install ``` **Windows:** + - 安装 Visual Studio Build Tools - 安装 WebView2(开发模式必需;对外分发时建议使用带离线 WebView2 的 NSIS 安装器) **Linux:** + ```bash sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libayatana-appindicator3-dev librsvg2-dev ``` @@ -64,6 +69,7 @@ pnpm tauri dev ``` 开发模式特性: + - 前端热重载 - Rust 代码变更自动重新编译 - 开发者工具可用 @@ -111,11 +117,11 @@ pnpm tauri build --debug ### 构建产物 -| 平台 | 产物位置 | -|------|----------| -| macOS | `src-tauri/target/release/bundle/dmg/` | +| 平台 | 产物位置 | +| ------- | --------------------------------------- | +| macOS | `src-tauri/target/release/bundle/dmg/` | | Windows | `src-tauri/target/release/bundle/nsis/` | -| Linux | `src-tauri/target/release/bundle/deb/` | +| Linux | `src-tauri/target/release/bundle/deb/` | ### 跨平台构建 @@ -141,7 +147,6 @@ pnpm tauri build --target x86_64-pc-windows-msvc --config src-tauri/tauri.window > 建议对外分发 `-setup.exe`,不要把默认 `.msi` 作为首选下载项。 - #### Linux 构建 ```bash @@ -195,12 +200,12 @@ git push origin v1.0.1 ### 构建矩阵 -| 平台 | 架构 | Runner | -|------|------|--------| -| macOS | arm64 | macos-latest | -| macOS | x64 | macos-13 | -| Windows | x64 | windows-2022 | -| Linux | x64 | ubuntu-latest | +| 平台 | 架构 | Runner | +| ------- | ----- | ------------- | +| macOS | arm64 | macos-latest | +| macOS | x64 | macos-13 | +| Windows | x64 | windows-2022 | +| Linux | x64 | ubuntu-latest | ## 调试 @@ -217,11 +222,11 @@ RUST_LOG=debug pnpm tauri dev ### 日志位置 -| 平台 | 路径 | -|------|------| -| macOS | `~/Library/Logs/ProxyCast/` | -| Windows | `%APPDATA%\ProxyCast\logs\` | -| Linux | `~/.local/share/proxycast/logs/` | +| 平台 | 路径 | +| ------- | -------------------------------- | +| macOS | `~/Library/Logs/ProxyCast/` | +| Windows | `%APPDATA%\ProxyCast\logs\` | +| Linux | `~/.local/share/proxycast/logs/` | ## 常见问题 diff --git a/docs/package.json b/docs/package.json index de4f564c6..8440ea7a2 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,5 +1,8 @@ { "name": "proxycast-docs", + "engines": { + "node": ">=22.0.0" + }, "scripts": { "dev": "nuxt dev --extends docus", "build": "nuxt build --extends docus", diff --git a/docs/test/e2e-tests.md b/docs/test/e2e-tests.md index 4293095a3..33d9c51ff 100644 --- a/docs/test/e2e-tests.md +++ b/docs/test/e2e-tests.md @@ -5,6 +5,7 @@ ## 概述 E2E 测试模拟真实用户操作,验证从前端到后端的完整流程。ProxyCast 使用 Tauri 框架,E2E 测试需要覆盖: + - 桌面应用启动和初始化 - 用户界面交互 - API 代理完整流程 @@ -28,13 +29,13 @@ npm run test:e2e ```javascript // playwright.config.ts -import { defineConfig } from '@playwright/test'; +import { defineConfig } from "@playwright/test"; export default defineConfig({ - testDir: './tests/e2e', + testDir: "./tests/e2e", timeout: 30000, use: { - baseURL: 'tauri://localhost', + baseURL: "tauri://localhost", }, }); ``` @@ -44,23 +45,23 @@ export default defineConfig({ ### 1. 应用启动流程 ```typescript -import { test, expect } from '@playwright/test'; +import { test, expect } from "@playwright/test"; -test.describe('应用启动', () => { - test('应用正常启动并显示主界面', async ({ page }) => { +test.describe("应用启动", () => { + test("应用正常启动并显示主界面", async ({ page }) => { // 等待应用加载 await page.waitForSelector('[data-testid="main-layout"]'); - + // 验证核心组件存在 await expect(page.locator('[data-testid="sidebar"]')).toBeVisible(); await expect(page.locator('[data-testid="content-area"]')).toBeVisible(); }); - test('首次启动显示欢迎引导', async ({ page }) => { + test("首次启动显示欢迎引导", async ({ page }) => { // 清除本地存储模拟首次启动 await page.evaluate(() => localStorage.clear()); await page.reload(); - + await expect(page.locator('[data-testid="welcome-modal"]')).toBeVisible(); }); }); @@ -69,35 +70,37 @@ test.describe('应用启动', () => { ### 2. 凭证管理流程 ```typescript -test.describe('凭证管理', () => { - test('添加 Kiro 凭证', async ({ page }) => { +test.describe("凭证管理", () => { + test("添加 Kiro 凭证", async ({ page }) => { // 打开凭证管理 await page.click('[data-testid="credentials-tab"]'); await page.click('[data-testid="add-credential-btn"]'); - + // 选择 Provider await page.click('[data-testid="provider-kiro"]'); - + // 上传凭证文件 const fileInput = page.locator('input[type="file"]'); - await fileInput.setInputFiles('./tests/fixtures/test-credential.json'); - + await fileInput.setInputFiles("./tests/fixtures/test-credential.json"); + // 验证凭证添加成功 await expect(page.locator('[data-testid="credential-item"]')).toBeVisible(); - await expect(page.locator('text=test@example.com')).toBeVisible(); + await expect(page.locator("text=test@example.com")).toBeVisible(); }); - test('删除凭证', async ({ page }) => { + test("删除凭证", async ({ page }) => { // 假设已有凭证 await page.click('[data-testid="credentials-tab"]'); - + // 删除凭证 await page.click('[data-testid="credential-menu"]'); await page.click('[data-testid="delete-credential"]'); await page.click('[data-testid="confirm-delete"]'); - + // 验证凭证已删除 - await expect(page.locator('[data-testid="credential-item"]')).not.toBeVisible(); + await expect( + page.locator('[data-testid="credential-item"]'), + ).not.toBeVisible(); }); }); ``` @@ -105,33 +108,38 @@ test.describe('凭证管理', () => { ### 3. API 代理流程 ```typescript -test.describe('API 代理', () => { - test('启动代理服务器', async ({ page }) => { +test.describe("API 代理", () => { + test("启动代理服务器", async ({ page }) => { await page.click('[data-testid="server-tab"]'); await page.click('[data-testid="start-server-btn"]'); - + // 等待服务器启动 - await expect(page.locator('text=服务器运行中')).toBeVisible(); - await expect(page.locator('[data-testid="server-port"]')).toContainText('8080'); + await expect(page.locator("text=服务器运行中")).toBeVisible(); + await expect(page.locator('[data-testid="server-port"]')).toContainText( + "8080", + ); }); - test('代理请求成功', async ({ page, request }) => { + test("代理请求成功", async ({ page, request }) => { // 启动服务器 await page.click('[data-testid="start-server-btn"]'); - await page.waitForSelector('text=服务器运行中'); - + await page.waitForSelector("text=服务器运行中"); + // 发送测试请求 - const response = await request.post('http://localhost:8080/v1/chat/completions', { - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer test-key', + const response = await request.post( + "http://localhost:8080/v1/chat/completions", + { + headers: { + "Content-Type": "application/json", + Authorization: "Bearer test-key", + }, + data: { + model: "gpt-4", + messages: [{ role: "user", content: "Hello" }], + }, }, - data: { - model: 'gpt-4', - messages: [{ role: 'user', content: 'Hello' }], - }, - }); - + ); + expect(response.ok()).toBeTruthy(); }); }); @@ -140,28 +148,30 @@ test.describe('API 代理', () => { ### 4. Agent 对话流程 ```typescript -test.describe('Agent 对话', () => { - test('发送消息并接收响应', async ({ page }) => { +test.describe("Agent 对话", () => { + test("发送消息并接收响应", async ({ page }) => { await page.click('[data-testid="agent-tab"]'); - + // 输入消息 - await page.fill('[data-testid="message-input"]', '你好,请介绍一下自己'); + await page.fill('[data-testid="message-input"]', "你好,请介绍一下自己"); await page.click('[data-testid="send-btn"]'); - + // 等待响应 - await expect(page.locator('[data-testid="assistant-message"]')).toBeVisible({ - timeout: 30000, - }); + await expect(page.locator('[data-testid="assistant-message"]')).toBeVisible( + { + timeout: 30000, + }, + ); }); - test('流式响应正确显示', async ({ page }) => { + test("流式响应正确显示", async ({ page }) => { await page.click('[data-testid="agent-tab"]'); - await page.fill('[data-testid="message-input"]', '写一首短诗'); + await page.fill('[data-testid="message-input"]', "写一首短诗"); await page.click('[data-testid="send-btn"]'); - + // 验证流式显示(内容逐渐增加) const messageEl = page.locator('[data-testid="assistant-message"]'); - + let prevLength = 0; for (let i = 0; i < 5; i++) { await page.waitForTimeout(500); @@ -193,20 +203,22 @@ tests/ ```typescript // tests/mocks/api-server.ts -import { setupServer } from 'msw/node'; -import { rest } from 'msw'; +import { setupServer } from "msw/node"; +import { rest } from "msw"; export const mockServer = setupServer( - rest.post('*/v1/chat/completions', (req, res, ctx) => { + rest.post("*/v1/chat/completions", (req, res, ctx) => { return res( ctx.json({ - id: 'test-id', - choices: [{ - message: { role: 'assistant', content: 'Mock response' }, - }], - }) + id: "test-id", + choices: [ + { + message: { role: "assistant", content: "Mock response" }, + }, + ], + }), ); - }) + }), ); ``` @@ -239,21 +251,21 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v4 - + - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '20' - + node-version: "22" + - name: Setup Rust uses: dtolnay/rust-toolchain@stable - + - name: Install dependencies run: npm ci - + - name: Build app run: npm run build - + - name: Run E2E tests run: npm run test:e2e ``` diff --git a/package.json b/package.json index 512efcd4d..67d66f795 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.83.0", "type": "module", + "engines": { + "node": ">=22.0.0" + }, "repository": { "type": "git", "url": "https://github.com/aiclientproxy/proxycast.git" diff --git a/scripts/chrome-bridge-e2e.mjs b/scripts/chrome-bridge-e2e.mjs index 381105d6c..37e311f6a 100644 --- a/scripts/chrome-bridge-e2e.mjs +++ b/scripts/chrome-bridge-e2e.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node -import { randomUUID } from 'node:crypto'; -import process from 'node:process'; +import { randomUUID } from "node:crypto"; +import process from "node:process"; const DEFAULTS = { - server: 'ws://127.0.0.1:8787', - key: '', - profile: 'default', + server: "ws://127.0.0.1:8787", + key: "", + profile: "default", timeoutMs: 15000, }; @@ -14,27 +14,27 @@ function parseArgs(argv) { const args = { ...DEFAULTS }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; - if (arg === '--server' && argv[i + 1]) { + if (arg === "--server" && argv[i + 1]) { args.server = argv[i + 1]; i += 1; continue; } - if (arg === '--key' && argv[i + 1]) { + if (arg === "--key" && argv[i + 1]) { args.key = argv[i + 1]; i += 1; continue; } - if (arg === '--profile' && argv[i + 1]) { + if (arg === "--profile" && argv[i + 1]) { args.profile = argv[i + 1]; i += 1; continue; } - if (arg === '--timeout-ms' && argv[i + 1]) { + if (arg === "--timeout-ms" && argv[i + 1]) { args.timeoutMs = Number(argv[i + 1]); i += 1; continue; } - if (arg === '--help' || arg === '-h') { + if (arg === "--help" || arg === "-h") { printHelp(); process.exit(0); } @@ -62,23 +62,26 @@ Proxycast Chrome Bridge E2E 联调脚本 } function assertGlobalWebSocket() { - if (typeof WebSocket !== 'undefined') { + if (typeof WebSocket !== "undefined") { return; } throw new Error( - '当前 Node 运行时不支持全局 WebSocket,请使用 Node 20+ 或安装支持 WebSocket 的运行环境。', + "当前 Node 运行时不支持全局 WebSocket,请使用 Node 22+ 或安装支持 WebSocket 的运行环境。", ); } function normalizeServer(server) { - return String(server || '').trim().replace(/\/$/, ''); + return String(server || "") + .trim() + .replace(/\/$/, ""); } function toText(data) { - if (typeof data === 'string') return data; - if (Buffer.isBuffer(data)) return data.toString('utf8'); - if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8'); - if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer).toString('utf8'); + if (typeof data === "string") return data; + if (Buffer.isBuffer(data)) return data.toString("utf8"); + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (ArrayBuffer.isView(data)) + return Buffer.from(data.buffer).toString("utf8"); return String(data); } @@ -108,7 +111,11 @@ function createClient(url, label, timeoutMs) { ws.onerror = (event) => { clearTimeout(timer); - reject(new Error(`[${label}] WebSocket 连接失败: ${event?.message || 'unknown error'}`)); + reject( + new Error( + `[${label}] WebSocket 连接失败: ${event?.message || "unknown error"}`, + ), + ); }; ws.onmessage = (event) => { @@ -117,7 +124,7 @@ function createClient(url, label, timeoutMs) { try { payload = JSON.parse(text); } catch (_) { - payload = { type: 'raw_text', data: text }; + payload = { type: "raw_text", data: text }; } state.messages.push(payload); @@ -184,154 +191,157 @@ async function main() { if (!args.key) { printHelp(); - throw new Error('缺少必填参数: --key'); + throw new Error("缺少必填参数: --key"); } if (!Number.isFinite(args.timeoutMs) || args.timeoutMs < 1000) { - throw new Error('--timeout-ms 必须是 >= 1000 的数字'); + throw new Error("--timeout-ms 必须是 >= 1000 的数字"); } const server = normalizeServer(args.server); const key = encodeURIComponent(args.key); - const profile = encodeURIComponent(args.profile || 'default'); + const profile = encodeURIComponent(args.profile || "default"); const observerUrl = `${server}/proxycast-chrome-observer/Proxycast_Key=${key}?profileKey=${profile}`; const controlUrl = `${server}/proxycast-chrome-control/Proxycast_Key=${key}`; - console.log('[E2E] observer:', observerUrl); - console.log('[E2E] control :', controlUrl); + console.log("[E2E] observer:", observerUrl); + console.log("[E2E] control :", controlUrl); let observer; let control; try { - observer = await createClient(observerUrl, 'observer', args.timeoutMs); - control = await createClient(controlUrl, 'control', args.timeoutMs); + observer = await createClient(observerUrl, "observer", args.timeoutMs); + control = await createClient(controlUrl, "control", args.timeoutMs); await waitForMessage( observer, - (msg) => msg.type === 'connection_ack', + (msg) => msg.type === "connection_ack", args.timeoutMs, - 'observer connection_ack', + "observer connection_ack", ); await waitForMessage( control, - (msg) => msg.type === 'connection_ack', + (msg) => msg.type === "connection_ack", args.timeoutMs, - 'control connection_ack', + "control connection_ack", ); - console.log('[E2E] 连接握手通过'); + console.log("[E2E] 连接握手通过"); - send(observer, { type: 'heartbeat', timestamp: Date.now() }); - send(control, { type: 'heartbeat', timestamp: Date.now() }); + send(observer, { type: "heartbeat", timestamp: Date.now() }); + send(control, { type: "heartbeat", timestamp: Date.now() }); await waitForMessage( observer, - (msg) => msg.type === 'heartbeat_ack', + (msg) => msg.type === "heartbeat_ack", args.timeoutMs, - 'observer heartbeat_ack', + "observer heartbeat_ack", ); await waitForMessage( control, - (msg) => msg.type === 'heartbeat_ack', + (msg) => msg.type === "heartbeat_ack", args.timeoutMs, - 'control heartbeat_ack', + "control heartbeat_ack", ); - console.log('[E2E] 心跳通道通过'); + console.log("[E2E] 心跳通道通过"); const requestId1 = `e2e-${randomUUID()}`; send(control, { - type: 'command', + type: "command", data: { requestId: requestId1, - command: 'get_page_info', + command: "get_page_info", wait_for_page_info: true, }, }); const cmdFromServer1 = await waitForMessage( observer, - (msg) => msg.type === 'command' && msg.data?.requestId === requestId1, + (msg) => msg.type === "command" && msg.data?.requestId === requestId1, args.timeoutMs, - 'observer 收到 get_page_info 命令', + "observer 收到 get_page_info 命令", ); - console.log('[E2E] observer 收到命令:', cmdFromServer1.data?.command); + console.log("[E2E] observer 收到命令:", cmdFromServer1.data?.command); send(observer, { - type: 'command_result', + type: "command_result", data: { requestId: requestId1, - status: 'success', - message: 'get_page_info executed by e2e observer', + status: "success", + message: "get_page_info executed by e2e observer", }, }); send(observer, { - type: 'pageInfoUpdate', + type: "pageInfoUpdate", data: { - markdown: '# E2E Page\nURL: https://example.com/e2e\n\n## 内容\nbridge e2e test', + markdown: + "# E2E Page\nURL: https://example.com/e2e\n\n## 内容\nbridge e2e test", }, }); await waitForMessage( control, (msg) => - msg.type === 'command_result' && + msg.type === "command_result" && msg.data?.requestId === requestId1 && - msg.data?.status === 'success', + msg.data?.status === "success", args.timeoutMs, - 'control 收到 command_result(success)', + "control 收到 command_result(success)", ); await waitForMessage( control, (msg) => - msg.type === 'page_info_update' && + msg.type === "page_info_update" && msg.data?.requestId === requestId1 && - typeof msg.data?.markdown === 'string' && - msg.data.markdown.includes('E2E Page'), + typeof msg.data?.markdown === "string" && + msg.data.markdown.includes("E2E Page"), args.timeoutMs, - 'control 收到 page_info_update', + "control 收到 page_info_update", ); - console.log('[E2E] wait_for_page_info 命令链路通过'); + console.log("[E2E] wait_for_page_info 命令链路通过"); const requestId2 = `e2e-${randomUUID()}`; send(control, { - type: 'command', + type: "command", data: { requestId: requestId2, - command: 'scroll', - text: 'down:300', + command: "scroll", + text: "down:300", wait_for_page_info: false, }, }); const cmdFromServer2 = await waitForMessage( observer, - (msg) => msg.type === 'command' && msg.data?.requestId === requestId2, + (msg) => msg.type === "command" && msg.data?.requestId === requestId2, args.timeoutMs, - 'observer 收到 scroll 命令', + "observer 收到 scroll 命令", ); - if (cmdFromServer2.data?.command !== 'scroll') { - throw new Error(`期望 scroll,实际为 ${cmdFromServer2.data?.command || 'unknown'}`); + if (cmdFromServer2.data?.command !== "scroll") { + throw new Error( + `期望 scroll,实际为 ${cmdFromServer2.data?.command || "unknown"}`, + ); } send(observer, { - type: 'command_result', + type: "command_result", data: { requestId: requestId2, - status: 'success', - message: 'scroll executed by e2e observer', + status: "success", + message: "scroll executed by e2e observer", }, }); await waitForMessage( control, (msg) => - msg.type === 'command_result' && + msg.type === "command_result" && msg.data?.requestId === requestId2 && - msg.data?.status === 'success', + msg.data?.status === "success", args.timeoutMs, - 'control 收到 scroll command_result', + "control 收到 scroll command_result", ); - console.log('[E2E] 非 wait_for_page_info 命令链路通过'); + console.log("[E2E] 非 wait_for_page_info 命令链路通过"); - console.log('\n[E2E] ✅ Chrome Bridge 联调通过'); + console.log("\n[E2E] ✅ Chrome Bridge 联调通过"); } finally { await closeClient(control); await closeClient(observer); @@ -339,7 +349,7 @@ async function main() { } main().catch((error) => { - console.error('\n[E2E] ❌ Chrome Bridge 联调失败'); + console.error("\n[E2E] ❌ Chrome Bridge 联调失败"); console.error(error?.stack || error?.message || String(error)); process.exit(1); }); diff --git a/scripts/playwright-login/package.json b/scripts/playwright-login/package.json index fcaa90e2c..7254251bd 100644 --- a/scripts/playwright-login/package.json +++ b/scripts/playwright-login/package.json @@ -16,7 +16,7 @@ "fast-check": "^3.15.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "author": "ProxyCast", "license": "MIT" diff --git a/src-tauri/crates/services/src/model_registry_service.rs b/src-tauri/crates/services/src/model_registry_service.rs index ad65d8854..81f7dcf58 100644 --- a/src-tauri/crates/services/src/model_registry_service.rs +++ b/src-tauri/crates/services/src/model_registry_service.rs @@ -925,12 +925,14 @@ impl ModelRegistryService { error: None, request_url: Some(api_url), diagnostic_hint: None, + error_kind: None, + should_prompt_error: false, }) } Err(api_error) => { tracing::warn!( "[ModelRegistry] API 获取失败: {}, 回退到本地文件", - api_error + api_error.message ); // 回退到本地资源模型(多级匹配) @@ -947,17 +949,24 @@ impl ModelRegistryService { Ok(FetchModelsResult { models: vec![], source: ModelFetchSource::LocalFallback, - error: Some(format!("API 获取失败: {api_error}, 本地也无数据")), + error: Some(format!("API 获取失败: {}, 本地也无数据", api_error.message)), request_url: Some(api_url), diagnostic_hint, + error_kind: Some(api_error.kind.clone()), + should_prompt_error: Self::should_prompt_model_fetch_error(&api_error.kind), }) } else { Ok(FetchModelsResult { models: local_models, source: ModelFetchSource::LocalFallback, - error: Some(format!("API 获取失败: {api_error}, 已使用本地数据")), + error: Some(format!( + "API 获取失败: {}, 已使用本地数据", + api_error.message + )), request_url: Some(api_url), diagnostic_hint, + error_kind: Some(api_error.kind.clone()), + should_prompt_error: Self::should_prompt_model_fetch_error(&api_error.kind), }) } } @@ -1355,16 +1364,30 @@ impl ModelRegistryService { None } + fn should_prompt_model_fetch_error(kind: &ModelFetchErrorKind) -> bool { + matches!( + kind, + ModelFetchErrorKind::NotFound + | ModelFetchErrorKind::Unauthorized + | ModelFetchErrorKind::Forbidden + ) + } + /// 调用 /v1/models API async fn call_models_api( &self, url: &str, api_key: &str, - ) -> Result, String> { + ) -> Result, ModelsApiError> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() - .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; + .map_err(|e| { + ModelsApiError::new( + ModelFetchErrorKind::Other, + format!("创建 HTTP 客户端失败: {e}"), + ) + })?; let response = client .get(url) @@ -1372,7 +1395,9 @@ impl ModelRegistryService { .header("Content-Type", "application/json") .send() .await - .map_err(|e| format!("请求失败: {e}"))?; + .map_err(|e| { + ModelsApiError::new(ModelFetchErrorKind::Network, format!("请求失败: {e}")) + })?; if !response.status().is_success() { let status = response.status(); @@ -1381,21 +1406,40 @@ impl ModelRegistryService { .await .unwrap_or_else(|_| "无法读取响应体".to_string()); if status == reqwest::StatusCode::NOT_FOUND { - return Err(format!( - "API 返回错误 {status}: {body}(请求地址: {url})。这通常表示 Base URL 路径不兼容,请检查 Provider Base URL 是否已经包含版本路径,或是否应直接使用 /models 端点。" + return Err(ModelsApiError::new( + ModelFetchErrorKind::NotFound, + format!( + "API 返回错误 {status}: {body}(请求地址: {url})。这通常表示 Base URL 路径不兼容,请检查 Provider Base URL 是否已经包含版本路径,或是否应直接使用 /models 端点。" + ), )); } - return Err(format!("API 返回错误 {status}: {body}(请求地址: {url})")); + let kind = if status == reqwest::StatusCode::UNAUTHORIZED { + ModelFetchErrorKind::Unauthorized + } else if status == reqwest::StatusCode::FORBIDDEN { + ModelFetchErrorKind::Forbidden + } else { + ModelFetchErrorKind::Other + }; + return Err(ModelsApiError::new( + kind, + format!("API 返回错误 {status}: {body}(请求地址: {url})"), + )); } - let body = response - .text() - .await - .map_err(|e| format!("读取响应失败: {e}"))?; + let body = response.text().await.map_err(|e| { + ModelsApiError::new( + ModelFetchErrorKind::InvalidResponse, + format!("读取响应失败: {e}"), + ) + })?; // 解析 OpenAI 格式的响应 - let api_response: ApiModelsResponse = - serde_json::from_str(&body).map_err(|e| format!("解析响应失败: {e}"))?; + let api_response: ApiModelsResponse = serde_json::from_str(&body).map_err(|e| { + ModelsApiError::new( + ModelFetchErrorKind::InvalidResponse, + format!("解析响应失败: {e}"), + ) + })?; Ok(api_response.data) } @@ -1452,6 +1496,18 @@ impl ModelRegistryService { // API 响应类型 // ============================================================================ +#[derive(Debug, Clone)] +struct ModelsApiError { + kind: ModelFetchErrorKind, + message: String, +} + +impl ModelsApiError { + fn new(kind: ModelFetchErrorKind, message: String) -> Self { + Self { kind, message } + } +} + /// OpenAI /v1/models API 响应格式 #[derive(Debug, Deserialize)] struct ApiModelsResponse { @@ -1477,6 +1533,18 @@ pub enum ModelFetchSource { LocalFallback, } +/// 模型获取错误类型 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ModelFetchErrorKind { + NotFound, + Unauthorized, + Forbidden, + Network, + InvalidResponse, + Other, +} + /// 从 API 获取模型的结果 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FetchModelsResult { @@ -1490,6 +1558,10 @@ pub struct FetchModelsResult { pub request_url: Option, /// 面向用户的诊断建议(如果有) pub diagnostic_hint: Option, + /// 错误类型(如果有) + pub error_kind: Option, + /// 是否应将该错误作为配置问题强提示 + pub should_prompt_error: bool, } #[cfg(test)] diff --git a/src-tauri/crates/services/src/screenshot_capture_service.rs b/src-tauri/crates/services/src/screenshot_capture_service.rs index b6d3b7aa3..b0f054ba5 100644 --- a/src-tauri/crates/services/src/screenshot_capture_service.rs +++ b/src-tauri/crates/services/src/screenshot_capture_service.rs @@ -2,9 +2,12 @@ //! //! 提供跨平台的屏幕截图功能,支持交互式区域选择 -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tracing::{debug, error, info}; +#[cfg(target_os = "macos")] +use std::path::Path; + #[cfg(target_os = "macos")] use tracing::warn; diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 6fe4b226d..77a0d28b7 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -97,10 +97,14 @@ pub fn run() { let logs_clone = logs.clone(); let db_clone = db.clone(); let pool_service_clone = provider_pool_service_state.0.clone(); - let api_key_provider_service_clone = api_key_provider_service_state.0.clone(); - let connect_state_clone = connect_state.0.clone(); - let model_registry_clone = model_registry_state.clone(); - let skill_service_clone = skill_service_state.0.clone(); + #[cfg(debug_assertions)] + let _api_key_provider_service_clone = api_key_provider_service_state.0.clone(); + #[cfg(debug_assertions)] + let _connect_state_clone = connect_state.0.clone(); + #[cfg(debug_assertions)] + let _model_registry_clone = model_registry_state.clone(); + #[cfg(debug_assertions)] + let _skill_service_clone = skill_service_state.0.clone(); let token_cache_clone = token_cache_service_state.0.clone(); let shared_stats_clone = shared_stats.clone(); let shared_tokens_clone = shared_tokens.clone(); @@ -983,6 +987,8 @@ pub fn run() { 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_get_command_preview, + commands::openclaw_cmd::openclaw_get_progress_logs, commands::openclaw_cmd::openclaw_install, commands::openclaw_cmd::openclaw_uninstall, commands::openclaw_cmd::openclaw_start_gateway, diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index 71b9e69a9..71c13994d 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -736,7 +736,7 @@ impl WorkspaceSandboxedBashTool { format!("'{}'", value.replace('\'', "'\"'\"'")) } - fn build_shell_command(&self, command: &str, context: &ToolContext) -> (String, Vec) { + fn build_shell_command(&self, command: &str, _context: &ToolContext) -> (String, Vec) { #[cfg(target_os = "windows")] { return ( diff --git a/src-tauri/src/commands/openclaw_cmd.rs b/src-tauri/src/commands/openclaw_cmd.rs index ffd302286..9bda64c1e 100644 --- a/src-tauri/src/commands/openclaw_cmd.rs +++ b/src-tauri/src/commands/openclaw_cmd.rs @@ -2,8 +2,8 @@ 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, + ChannelInfo, CommandPreview, GatewayStatusInfo, HealthInfo, InstallProgressEvent, + NodeCheckResult, OpenClawServiceState, SyncModelEntry, }; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, State}; @@ -57,12 +57,24 @@ pub async fn openclaw_get_git_download_url( Ok(service.get_git_download_url()) } +#[tauri::command] +pub async fn openclaw_get_command_preview( + app: AppHandle, + service: State<'_, OpenClawServiceState>, + operation: String, + port: Option, +) -> Result { + let mut service = service.0.lock().await; + service.get_command_preview(&app, &operation, port).await +} + #[tauri::command] pub async fn openclaw_install( app: AppHandle, service: State<'_, OpenClawServiceState>, ) -> Result { - let service = service.0.lock().await; + let mut service = service.0.lock().await; + service.clear_progress_logs(); service.install(&app).await } @@ -72,6 +84,7 @@ pub async fn openclaw_uninstall( service: State<'_, OpenClawServiceState>, ) -> Result { let mut service = service.0.lock().await; + service.clear_progress_logs(); service.uninstall(&app).await } @@ -82,6 +95,7 @@ pub async fn openclaw_start_gateway( service: State<'_, OpenClawServiceState>, ) -> Result { let mut service = service.0.lock().await; + service.clear_progress_logs(); service.start_gateway(Some(&app), port).await } @@ -91,6 +105,7 @@ pub async fn openclaw_stop_gateway( service: State<'_, OpenClawServiceState>, ) -> Result { let mut service = service.0.lock().await; + service.clear_progress_logs(); service.stop_gateway(Some(&app)).await } @@ -100,9 +115,18 @@ pub async fn openclaw_restart_gateway( service: State<'_, OpenClawServiceState>, ) -> Result { let mut service = service.0.lock().await; + service.clear_progress_logs(); service.restart_gateway(&app).await } +#[tauri::command] +pub async fn openclaw_get_progress_logs( + service: State<'_, OpenClawServiceState>, +) -> Result, String> { + let service = service.0.lock().await; + Ok(service.get_progress_logs()) +} + #[tauri::command] pub async fn openclaw_get_status( service: State<'_, OpenClawServiceState>, diff --git a/src-tauri/src/commands/windows_startup_cmd.rs b/src-tauri/src/commands/windows_startup_cmd.rs index c336a94b1..74b867471 100644 --- a/src-tauri/src/commands/windows_startup_cmd.rs +++ b/src-tauri/src/commands/windows_startup_cmd.rs @@ -1,5 +1,5 @@ use serde::Serialize; -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; #[cfg(target_os = "windows")] use std::path::PathBuf; @@ -126,14 +126,14 @@ pub fn collect_windows_startup_diagnostics(app: &AppHandle) -> WindowsStartupDia let mut errors = Vec::new(); let mut warnings = Vec::new(); - let app_data_dir = app.path().app_data_dir().ok(); + let app_data_dir: Option = 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 resource_dir: Option = 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 = diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 9ac71e4f2..b7869f5f6 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -25,6 +25,7 @@ use rusqlite::{params_from_iter, types::Value}; use serde::de::DeserializeOwned; use serde_json::Value as JsonValue; use std::path::PathBuf; +use tauri::Manager; fn load_model_registry_provider_ids_from_db( state: &DevBridgeState, @@ -1237,6 +1238,214 @@ pub async fn handle_command( })) } + // ========== OpenClaw ========== + "openclaw_check_installed" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let service = service.0.lock().await; + Ok(serde_json::to_value(service.check_installed().await?)?) + } + + "openclaw_check_node_version" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let service = service.0.lock().await; + Ok(serde_json::to_value(service.check_node_version().await?)?) + } + + "openclaw_check_git_available" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let service = service.0.lock().await; + Ok(serde_json::to_value(service.check_git_available().await?)?) + } + + "openclaw_get_node_download_url" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let service = service.0.lock().await; + Ok(serde_json::json!(service.get_node_download_url())) + } + + "openclaw_get_git_download_url" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let service = service.0.lock().await; + Ok(serde_json::json!(service.get_git_download_url())) + } + + "openclaw_get_command_preview" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let args = args.unwrap_or_default(); + let operation = get_string_arg(&args, "operation", "operation")?; + let port = args.get("port").and_then(|value| value.as_u64()).map(|value| value as u16); + let service = app_handle.state::(); + let mut service = service.0.lock().await; + Ok(serde_json::to_value(service.get_command_preview(app_handle, &operation, port).await?)?) + } + + "openclaw_install" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let mut service = service.0.lock().await; + service.clear_progress_logs(); + Ok(serde_json::to_value(service.install(app_handle).await?)?) + } + + "openclaw_uninstall" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let mut service = service.0.lock().await; + service.clear_progress_logs(); + Ok(serde_json::to_value(service.uninstall(app_handle).await?)?) + } + + "openclaw_start_gateway" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let port = args + .as_ref() + .and_then(|value| value.get("port")) + .and_then(|value| value.as_u64()) + .map(|value| value as u16); + let service = app_handle.state::(); + let mut service = service.0.lock().await; + service.clear_progress_logs(); + Ok(serde_json::to_value(service.start_gateway(Some(app_handle), port).await?)?) + } + + "openclaw_stop_gateway" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let mut service = service.0.lock().await; + service.clear_progress_logs(); + Ok(serde_json::to_value(service.stop_gateway(Some(app_handle)).await?)?) + } + + "openclaw_restart_gateway" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let mut service = service.0.lock().await; + service.clear_progress_logs(); + Ok(serde_json::to_value(service.restart_gateway(app_handle).await?)?) + } + + "openclaw_get_status" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let mut service = service.0.lock().await; + Ok(serde_json::to_value(service.get_status().await?)?) + } + + "openclaw_check_health" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let mut service = service.0.lock().await; + Ok(serde_json::to_value(service.check_health().await?)?) + } + + "openclaw_get_dashboard_url" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let mut service = service.0.lock().await; + Ok(serde_json::json!(service.get_dashboard_url())) + } + + "openclaw_get_channels" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let mut service = service.0.lock().await; + Ok(serde_json::to_value(service.get_channels().await?)?) + } + + "openclaw_get_progress_logs" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let service = app_handle.state::(); + let service = service.0.lock().await; + Ok(serde_json::to_value(service.get_progress_logs())?) + } + + "openclaw_sync_provider_config" => { + let app_handle = state + .app_handle + .as_ref() + .ok_or_else(|| "Dev Bridge 未持有 AppHandle".to_string())?; + let request: crate::commands::openclaw_cmd::OpenClawSyncConfigRequest = + parse_nested_arg(&args.unwrap_or_default(), "request")?; + let db = get_db(state)?; + let provider = state + .api_key_provider_service + .get_provider(db, &request.provider_id)? + .ok_or_else(|| "未找到指定 Provider。".to_string())?; + + if !provider.provider.enabled { + return Ok(serde_json::json!({ + "success": false, + "message": "该 Provider 已被禁用。" + })); + } + + let api_key = state + .api_key_provider_service + .get_next_api_key(db, &request.provider_id)? + .unwrap_or_default(); + let service = app_handle.state::(); + let mut service = service.0.lock().await; + Ok(serde_json::to_value(service.sync_provider_config( + &provider.provider, + &api_key, + &request.primary_model_id, + &request.models, + )?)?) + } + // ========== Agent 会话管理 ========== "agent_create_session" => { let args = args.unwrap_or_default(); diff --git a/src-tauri/src/services/openclaw_service.rs b/src-tauri/src/services/openclaw_service.rs index 595bab889..4bee646d7 100644 --- a/src-tauri/src/services/openclaw_service.rs +++ b/src-tauri/src/services/openclaw_service.rs @@ -5,13 +5,13 @@ use rand::{distributions::Alphanumeric, Rng}; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; -use std::collections::HashSet; +use std::collections::{HashSet, VecDeque}; 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::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, BufReader}; use tokio::net::TcpStream; use tokio::process::{Child, Command}; use tokio::sync::Mutex; @@ -24,6 +24,7 @@ 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); +const OPENCLAW_PROGRESS_LOG_LIMIT: usize = 400; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -54,6 +55,13 @@ pub struct ActionResult { pub message: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandPreview { + pub title: String, + pub command: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GatewayStatusInfo { @@ -110,6 +118,7 @@ pub struct OpenClawService { gateway_port: u16, gateway_auth_token: String, gateway_started_at: Option, + progress_logs: VecDeque, } impl Default for OpenClawService { @@ -120,6 +129,7 @@ impl Default for OpenClawService { gateway_port: DEFAULT_GATEWAY_PORT, gateway_auth_token: String::new(), gateway_started_at: None, + progress_logs: VecDeque::new(), } } } @@ -133,6 +143,38 @@ impl Default for OpenClawServiceState { } impl OpenClawService { + pub fn clear_progress_logs(&mut self) { + self.progress_logs.clear(); + } + + pub fn get_progress_logs(&self) -> Vec { + self.progress_logs.iter().cloned().collect() + } + + fn push_progress_log(&mut self, message: String, level: String) { + if self.progress_logs.len() >= OPENCLAW_PROGRESS_LOG_LIMIT { + self.progress_logs.pop_front(); + } + self.progress_logs + .push_back(InstallProgressEvent { message, level }); + } + + pub async fn get_command_preview( + &mut self, + app: &AppHandle, + operation: &str, + port: Option, + ) -> Result { + match operation { + "install" => self.build_install_command_preview(app).await, + "uninstall" => self.build_uninstall_command_preview().await, + "restart" => self.build_restart_command_preview(port).await, + "start" => self.build_start_command_preview(port).await, + "stop" => self.build_stop_command_preview(port).await, + _ => Err(format!("不支持的 OpenClaw 操作预览: {operation}")), + } + } + pub async fn check_installed(&self) -> Result { let path = find_command_in_shell("openclaw").await?; Ok(BinaryInstallStatus { @@ -215,29 +257,8 @@ impl OpenClawService { } 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 (_, npm_path, npm_prefix, cleanup_command, install_command) = + self.resolve_install_commands(app).await?; let command = format!("{cleanup_command}\n{install_command}"); emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info"); @@ -255,19 +276,7 @@ impl OpenClawService { 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) - ); + let (npm_path, npm_prefix, command) = self.resolve_uninstall_command().await?; emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info"); if let Some(prefix) = npm_prefix { @@ -902,6 +911,148 @@ impl OpenClawService { std::fs::write(proxycast_config_path, content).map_err(|e| format!("写入配置失败: {e}"))?; Ok(()) } + + async fn resolve_install_commands( + &self, + app: &AppHandle, + ) -> Result<(String, String, Option, String, String), String> { + 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).await { + 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).await { + format!("{prefix_env}{npm_cmd} install -g {package} --registry={NPM_MIRROR_CN}") + } else { + format!("{prefix_env}{npm_cmd} install -g {package}") + }; + Ok(( + package.to_string(), + npm_path, + npm_prefix, + cleanup_command, + install_command, + )) + } + + async fn resolve_uninstall_command(&self) -> Result<(String, Option, String), String> { + 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) + ); + Ok((npm_path, npm_prefix, command)) + } + + async fn build_install_command_preview( + &self, + app: &AppHandle, + ) -> Result { + let (package, npm_path, npm_prefix, cleanup_command, install_command) = + self.resolve_install_commands(app).await?; + let prefix_note = npm_prefix + .map(|prefix| format!("npm: {npm_path}\nprefix: {prefix}\n")) + .unwrap_or_else(|| format!("npm: {npm_path}\n")); + Ok(CommandPreview { + title: format!("安装 {package}"), + command: format!("{prefix_note}{cleanup_command}\n{install_command}"), + }) + } + + async fn build_uninstall_command_preview(&self) -> Result { + let (npm_path, npm_prefix, command) = self.resolve_uninstall_command().await?; + let prefix_note = npm_prefix + .map(|prefix| format!("npm: {npm_path}\nprefix: {prefix}\n")) + .unwrap_or_else(|| format!("npm: {npm_path}\n")); + Ok(CommandPreview { + title: "卸载 OpenClaw".to_string(), + command: format!("{prefix_note}{command}"), + }) + } + + async fn build_start_command_preview( + &mut self, + port: Option, + ) -> Result { + if let Some(next_port) = port { + self.gateway_port = next_port.max(1); + } + self.restore_auth_token_from_config(); + let binary = find_command_in_shell("openclaw") + .await? + .ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?; + let config_path = openclaw_proxycast_config_path(); + Ok(CommandPreview { + title: "启动 Gateway".to_string(), + command: format!( + "{}OPENCLAW_CONFIG_PATH={} {} gateway --port {}", + if cfg!(target_os = "windows") { + "set " + } else { + "" + }, + shell_escape(config_path.to_string_lossy().as_ref()), + shell_escape(&binary), + self.gateway_port + ), + }) + } + + async fn build_stop_command_preview( + &mut self, + port: Option, + ) -> Result { + if let Some(next_port) = port { + self.gateway_port = next_port.max(1); + } + self.restore_auth_token_from_config(); + let binary = find_command_in_shell("openclaw") + .await? + .ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?; + let config_path = openclaw_proxycast_config_path(); + Ok(CommandPreview { + title: "停止 Gateway".to_string(), + command: format!( + "OPENCLAW_CONFIG_PATH={} {} gateway stop --url {} --token {}", + shell_escape(config_path.to_string_lossy().as_ref()), + shell_escape(&binary), + self.gateway_ws_url(), + shell_escape(&self.gateway_auth_token) + ), + }) + } + + async fn build_restart_command_preview( + &mut self, + port: Option, + ) -> Result { + let stop = self.build_stop_command_preview(port).await?; + let start = self.build_start_command_preview(port).await?; + Ok(CommandPreview { + title: "重启 Gateway".to_string(), + command: format!("{}\n{}", stop.command, start.command), + }) + } } pub fn openclaw_install_event_name() -> &'static str { @@ -1091,12 +1242,12 @@ fn generate_auth_token() -> String { .collect() } -fn should_use_china_package(app: &AppHandle) -> bool { +async 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 language = { let state = app_state.read().await; state.config.language.clone() - }); + }; if language.starts_with("zh") { return true; @@ -1273,31 +1424,14 @@ async fn run_shell_command_with_progress( 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"); - } - } + stream_reader_to_progress(app, stdout, "info").await; }) }); 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); - } - } + stream_reader_to_progress(app, stderr, "error").await; }) }); @@ -1337,6 +1471,15 @@ fn spawn_shell_command(command_line: &str) -> Result { let mut cmd = Command::new("cmd"); cmd.arg("/C").arg(command_line); cmd + } else if cfg!(target_os = "macos") { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + let mut cmd = Command::new("script"); + cmd.arg("-q") + .arg("/dev/null") + .arg(shell) + .arg("-lc") + .arg(command_line); + cmd } else { let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); let mut cmd = Command::new(shell); @@ -1344,11 +1487,109 @@ fn spawn_shell_command(command_line: &str) -> Result { cmd }; - command.stdout(Stdio::piped()).stderr(Stdio::piped()); + command + .env("NO_COLOR", "1") + .env("CLICOLOR", "0") + .env("FORCE_COLOR", "0") + .env("npm_config_color", "false") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); command.spawn().map_err(|e| format!("启动命令失败: {e}")) } +async fn stream_reader_to_progress(app: AppHandle, mut reader: R, default_level: &'static str) +where + R: AsyncRead + Unpin, +{ + let mut buffer = [0_u8; 2048]; + let mut pending = String::new(); + + loop { + match reader.read(&mut buffer).await { + Ok(0) => break, + Ok(size) => { + pending.push_str(&String::from_utf8_lossy(&buffer[..size])); + flush_progress_chunks(&app, &mut pending, default_level); + } + Err(error) => { + emit_install_progress(&app, &format!("读取命令输出失败: {error}"), "warn"); + break; + } + } + } + + let tail = pending.trim(); + if !tail.is_empty() { + emit_install_progress(&app, tail, classify_progress_level(tail, default_level)); + } +} + +fn flush_progress_chunks(app: &AppHandle, pending: &mut String, default_level: &'static str) { + loop { + let next_break = pending.find(['\n', '\r']); + let Some(index) = next_break else { + break; + }; + + let mut line = pending[..index].trim().to_string(); + let mut consume_len = index + 1; + while pending + .get(consume_len..consume_len + 1) + .is_some_and(|ch| ch == "\n" || ch == "\r") + { + consume_len += 1; + } + + pending.drain(..consume_len); + + if line.is_empty() { + continue; + } + + line = sanitize_progress_line(&line); + if line.is_empty() { + continue; + } + + emit_install_progress(app, &line, classify_progress_level(&line, default_level)); + } + + if pending.len() > 4096 { + let line = sanitize_progress_line(pending.trim()); + if !line.is_empty() { + emit_install_progress(app, &line, classify_progress_level(&line, default_level)); + } + pending.clear(); + } +} + +fn sanitize_progress_line(value: &str) -> String { + value + .replace('\u{1b}', "") + .replace("[?25h", "") + .replace("[?25l", "") + .trim() + .to_string() +} + +fn classify_progress_level(message: &str, default_level: &'static str) -> &'static str { + let lower = message.to_ascii_lowercase(); + if lower.contains("error") || lower.contains("fatal") { + "error" + } else if lower.contains("warn") || lower.contains("warning") { + "warn" + } else { + default_level + } +} + fn emit_install_progress(app: &AppHandle, message: &str, level: &str) { + if let Some(service_state) = app.try_state::() { + if let Ok(mut service) = service_state.0.try_lock() { + service.push_progress_log(message.to_string(), level.to_string()); + } + } + let payload = InstallProgressEvent { message: message.to_string(), level: level.to_string(), diff --git a/src/App.tsx b/src/App.tsx index 3dada9f06..b4e8cc22e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -536,6 +536,9 @@ function AppContent() { onNavigate={handleNavigate} projectId={(pageParams as AgentPageParams).projectId} contentId={(pageParams as AgentPageParams).contentId} + initialUserPrompt={ + (pageParams as AgentPageParams).initialUserPrompt + } theme={(pageParams as AgentPageParams).theme} lockTheme={(pageParams as AgentPageParams).lockTheme} fromResources={(pageParams as AgentPageParams).fromResources} diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index f6ab2570b..b9c5847ad 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -4937,6 +4937,42 @@ export function AgentChatPage({ onInitialUserPromptConsumed, ]); + // 通用聊天场景:若带有 initialUserPrompt,则自动新建并发送首条消息 + useEffect(() => { + const pendingInitialPrompt = (initialUserPrompt || "").trim(); + if ( + !pendingInitialPrompt || + contentId || + messages.length > 0 || + isSending + ) { + return; + } + + if (consumedInitialPromptRef.current === pendingInitialPrompt) { + return; + } + + consumedInitialPromptRef.current = pendingInitialPrompt; + void (async () => { + await handleSend( + [], + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + pendingInitialPrompt, + ); + onInitialUserPromptConsumed?.(); + })(); + }, [ + chatToolPreferences, + contentId, + handleSend, + initialUserPrompt, + isSending, + messages.length, + onInitialUserPromptConsumed, + ]); + // 当 contentId 变化时重置引导状态 useEffect(() => { hasTriggeredGuide.current = false; diff --git a/src/components/api-server/ApiServerPage.tsx b/src/components/api-server/ApiServerPage.tsx index 5d2a343f7..eca0ea8dd 100644 --- a/src/components/api-server/ApiServerPage.tsx +++ b/src/components/api-server/ApiServerPage.tsx @@ -52,6 +52,15 @@ interface FetchModelsResult { error: string | null; request_url?: string | null; diagnostic_hint?: string | null; + error_kind?: + | "not_found" + | "unauthorized" + | "forbidden" + | "network" + | "invalid_response" + | "other" + | null; + should_prompt_error?: boolean; } interface TestState { @@ -465,6 +474,23 @@ export function ApiServerPage({ hideHeader = false }: ApiServerPageProps) { "fetch_provider_models_auto", { providerId: provider }, ); + if (result?.error && result.should_prompt_error) { + const diagnostics = [ + result.error, + result.request_url ? `请求地址:${result.request_url}` : null, + result.diagnostic_hint ?? null, + ] + .filter((line): line is string => + Boolean(line && line.trim()), + ) + .join(" | "); + setMessage({ + type: "error", + text: + diagnostics || + "模型列表接口配置异常,请检查 Provider Base URL", + }); + } if (result && result.models && result.models.length > 0) { registryModels = result.models; } diff --git a/src/components/content-creator/core/LayoutTransition/LayoutTransition.test.tsx b/src/components/content-creator/core/LayoutTransition/LayoutTransition.test.tsx new file mode 100644 index 000000000..abe30a2ac --- /dev/null +++ b/src/components/content-creator/core/LayoutTransition/LayoutTransition.test.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import type { LayoutMode } from "../../types"; +import { LayoutTransition } from "./LayoutTransition"; +import { + cleanupMountedRoots, + mountHarness, + setupReactActEnvironment, + type MountedRoot, +} from "../../../workspace/hooks/testUtils"; + +setupReactActEnvironment(); + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function LayoutHarness({ mode }: { mode: LayoutMode }) { + return ( +
+ chat
} + canvasContent={
canvas
} + /> + + ); +} + +describe("LayoutTransition", () => { + const mountedRoots: MountedRoot[] = []; + + afterEach(() => { + cleanupMountedRoots(mountedRoots); + }); + + it("chat-canvas 模式应为画布与对话保留分栏间距", () => { + const { container } = mountHarness( + LayoutHarness, + { mode: "chat-canvas" }, + mountedRoots, + ); + + const root = container.querySelector( + '[data-testid="layout-transition-root"]', + ); + expect(root).not.toBeNull(); + + const styles = Array.from(document.head.querySelectorAll("style")) + .map((node) => node.textContent || "") + .join("\n"); + + const hasGapRule = Array.from(root?.classList || []).some((className) => + new RegExp(`\\.${escapeRegExp(className)}\\{[^}]*gap:12px;`).test(styles), + ); + + expect(hasGapRule).toBe(true); + }); +}); diff --git a/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx b/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx index 553fcc8e0..ffb5c7651 100644 --- a/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx +++ b/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx @@ -14,6 +14,7 @@ const Container = styled.div` width: 100%; height: 100%; overflow: hidden; + gap: 12px; `; const ChatPanel = styled.div<{ @@ -53,6 +54,7 @@ const CanvasPanel = styled.div<{ position: relative; height: 100%; flex: 1; + min-width: 0; overflow: hidden; transition: transform ${({ $duration }) => $duration}ms ease-out, @@ -90,7 +92,7 @@ export const LayoutTransition: React.FC = memo( const canvasStyles = getTransitionStyles("canvas"); return ( - + { return { useImageGen: () => { - const [selectedImageId, setSelectedImageId] = React.useState( - images[0].id, - ); + const [selectedImageId, setSelectedImageId] = React.useState< + string | null + >(images[0].id); const selectedImage = images.find((image) => image.id === selectedImageId) ?? images[0]; @@ -184,11 +184,17 @@ import ImageGenPage from "./ImageGenPage"; const mountedRoots: MountedRoot[] = []; -function renderPage(onNavigate?: (page: Page, params?: PageParams) => void): HTMLDivElement { - return renderIntoDom(, mountedRoots).container; +function renderPage( + onNavigate?: (page: Page, params?: PageParams) => void, +): HTMLDivElement { + return renderIntoDom(, mountedRoots) + .container; } -function findButtonByText(container: HTMLElement, text: string): HTMLButtonElement { +function findButtonByText( + container: HTMLElement, + text: string, +): HTMLButtonElement { const target = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes(text), ); @@ -213,6 +219,10 @@ function getPromptChip(container: HTMLElement): HTMLButtonElement { return chip as HTMLButtonElement; } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + beforeEach(() => { setReactActEnvironment(); @@ -271,7 +281,9 @@ describe("ImageGenPage", () => { expect(secondHistoryItem).not.toBeNull(); act(() => { - secondHistoryItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + secondHistoryItem?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); }); const chipAfter = getPromptChip(container); @@ -291,7 +303,36 @@ describe("ImageGenPage", () => { }); expect(mockBackfillImagesToResource).toHaveBeenCalledTimes(1); - expect(mockBackfillImagesToResource).toHaveBeenCalledWith("project-default"); + expect(mockBackfillImagesToResource).toHaveBeenCalledWith( + "project-default", + ); expect(mockToast.success).toHaveBeenCalled(); }); + + it("AI 生图布局应锁定父级高度避免小屏裁切输入区", () => { + const mounted = renderIntoDom( +
+ +
, + mountedRoots, + ); + + const layout = mounted.container.querySelector( + '[data-testid="ai-image-gen-layout"]', + ); + expect(layout).not.toBeNull(); + + const styles = Array.from(document.head.querySelectorAll("style")) + .map((node) => node.textContent || "") + .join("\n"); + + const hasExpectedRule = Array.from(layout?.classList || []).some( + (className) => + new RegExp( + `\\.${escapeRegExp(className)}\\{[^}]*height:100%;[^}]*overflow:hidden;`, + ).test(styles), + ); + + expect(hasExpectedRule).toBe(true); + }); }); diff --git a/src/components/image-gen/tabs/AiImageGenTab.tsx b/src/components/image-gen/tabs/AiImageGenTab.tsx index c3dc48947..064de325e 100644 --- a/src/components/image-gen/tabs/AiImageGenTab.tsx +++ b/src/components/image-gen/tabs/AiImageGenTab.tsx @@ -223,9 +223,11 @@ function getStatusText(status: GeneratedImage["status"]): string { // ==================== Styled Components ==================== const Container = styled.div` + height: 100%; flex: 1; min-height: 0; display: flex; + overflow: hidden; background: hsl(var(--background)); color: hsl(var(--foreground)); `; @@ -454,6 +456,7 @@ const Workspace = styled.main` const Canvas = styled.div` flex: 1; + min-height: 0; border: 1px solid hsl(var(--border)); border-radius: 10px; background: hsl(var(--background)); @@ -1061,7 +1064,7 @@ export function AiImageGenTab({ projectId, onNavigate }: AiImageGenTabProps) { }; return ( - + {availableProviders.length > 1 && (
diff --git a/src/components/openclaw/OpenClawPage.tsx b/src/components/openclaw/OpenClawPage.tsx index 1b3259f8d..4ad335b9a 100644 --- a/src/components/openclaw/OpenClawPage.tsx +++ b/src/components/openclaw/OpenClawPage.tsx @@ -23,6 +23,7 @@ import { type OpenClawNodeCheckResult, type OpenClawSyncModelEntry, } from "@/lib/api/openclaw"; +import { getOrCreateDefaultProject } from "@/lib/api/project"; import { OpenClawConfigurePage } from "./OpenClawConfigurePage"; import { OpenClawDashboardPage } from "./OpenClawDashboardPage"; @@ -133,6 +134,76 @@ function toSyncModels( })); } +function openClawOperationLabel(kind: OpenClawOperationKind | null): string { + switch (kind) { + case "install": + return "安装"; + case "uninstall": + return "卸载"; + case "restart": + return "重启"; + default: + return "处理"; + } +} + +function buildOpenClawRepairPrompt( + kind: OpenClawOperationKind | null, + message: string | null, + logs: OpenClawInstallProgressEvent[], + systemInfo: { + os: string; + userAgent: string; + installPath: string; + nodeStatus: string; + gitStatus: string; + gatewayStatus: string; + gatewayPort: number; + healthStatus: string; + dashboardUrl: string; + }, +): string { + const operationLabel = openClawOperationLabel(kind); + const visibleLogs = logs.slice(-40); + const summarizedError = + visibleLogs + .slice() + .reverse() + .find((log) => log.level === "error" || log.level === "warn")?.message || + message || + "安装/运行过程中出现异常"; + const logText = + visibleLogs.length > 0 + ? visibleLogs + .map((log) => `[${log.level.toUpperCase()}] ${log.message}`) + .join("\n") + : "暂无日志输出"; + + return [ + `我正在${operationLabel} openclaw,但在过程中遇到了这个问题:${summarizedError}。`, + "", + "请帮我:", + "1. 判断最可能的根因", + "2. 给出最小可执行的修复步骤", + "3. 如果需要修改环境变量、Node/npm、PATH、全局包冲突,请明确指出", + "4. 如果可以在当前 ProxyCast / Tauri 项目中修复,也请给出具体修改建议", + "", + "当前系统信息:", + `- 操作系统: ${systemInfo.os}`, + `- User Agent: ${systemInfo.userAgent}`, + `- OpenClaw 安装路径: ${systemInfo.installPath}`, + `- Node.js 状态: ${systemInfo.nodeStatus}`, + `- Git 状态: ${systemInfo.gitStatus}`, + `- Gateway 状态: ${systemInfo.gatewayStatus}`, + `- Gateway 端口: ${systemInfo.gatewayPort}`, + `- 健康检查: ${systemInfo.healthStatus}`, + `- Dashboard 地址: ${systemInfo.dashboardUrl}`, + "", + "以下是完整日志:", + logText, + ].join("\n"); +} + function renderBlockedPage( title: string, description: string, @@ -393,6 +464,35 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { }; }, []); + useEffect(() => { + if (!operationState.running) { + return; + } + + let cancelled = false; + + const syncProgressLogs = async () => { + try { + const logs = await openclawApi.getProgressLogs(); + if (!cancelled && logs.length > 0) { + setInstallLogs(logs); + } + } catch { + // 忽略轮询失败,保留事件流或已有日志 + } + }; + + void syncProgressLogs(); + const timer = window.setInterval(() => { + void syncProgressLogs(); + }, 400); + + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [operationState.running]); + const refreshGatewayRuntime = useCallback(async () => { const status = await openclawApi.getStatus(); setGatewayStatus(status.status); @@ -598,17 +698,27 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { ); const handleInstall = useCallback(async () => { + const preview = await openclawApi + .getCommandPreview("install") + .catch(() => null); await runProgressOperation( "install", () => openclawApi.install(), "runtime", "install", - [ - { - level: "info", - message: "已发送安装请求,正在等待后端返回安装命令...", - }, - ], + preview + ? [ + { level: "info", message: preview.title }, + ...preview.command + .split("\n") + .map((line) => ({ level: "info" as const, message: line })), + ] + : [ + { + level: "info", + message: "已发送安装请求,正在等待后端返回安装命令...", + }, + ], ); }, [runProgressOperation]); @@ -618,18 +728,28 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { } await closeDashboardWindowSilently(); + const preview = await openclawApi + .getCommandPreview("uninstall") + .catch(() => null); await runProgressOperation( "uninstall", () => openclawApi.uninstall(), "install", installed ? "configure" : "install", - [ - { - level: "info", - message: "已发送卸载请求,正在等待后端返回卸载命令...", - }, - ], + preview + ? [ + { level: "info", message: preview.title }, + ...preview.command + .split("\n") + .map((line) => ({ level: "info" as const, message: line })), + ] + : [ + { + level: "info", + message: "已发送卸载请求,正在等待后端返回卸载命令...", + }, + ], () => { clearLastSynced(); setSelectedModelId(""); @@ -645,20 +765,30 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { const handleRestart = useCallback(async () => { await closeDashboardWindowSilently(); + const preview = await openclawApi + .getCommandPreview("restart", gatewayPort) + .catch(() => null); await runProgressOperation( "restart", () => openclawApi.restartGateway(), "runtime", "runtime", - [ - { - level: "info", - message: "已发送重启请求,正在停止并重新拉起 Gateway...", - }, - ], + preview + ? [ + { level: "info", message: preview.title }, + ...preview.command + .split("\n") + .map((line) => ({ level: "info" as const, message: line })), + ] + : [ + { + level: "info", + message: "已发送重启请求,正在停止并重新拉起 Gateway...", + }, + ], ); - }, [closeDashboardWindowSilently, runProgressOperation]); + }, [closeDashboardWindowSilently, gatewayPort, runProgressOperation]); const handleSync = useCallback(async () => { await syncProviderConfig(); @@ -779,6 +909,168 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { navigateSubpage(operationState.returnSubpage); }, [navigateSubpage, operationState.returnSubpage]); + const openClawRepairPrompt = useMemo( + () => + buildOpenClawRepairPrompt( + operationState.kind, + operationState.message, + installLogs, + { + os: + typeof navigator !== "undefined" + ? `${navigator.platform || "unknown"} / ${navigator.language || "unknown"}` + : "unknown", + userAgent: + typeof navigator !== "undefined" + ? navigator.userAgent || "unknown" + : "unknown", + installPath: installedStatus?.path || "未检测到安装路径", + nodeStatus: formatNodeStatus(nodeStatus), + gitStatus: formatBinaryStatus(gitStatus, "可用", "未检测到 Git"), + gatewayStatus, + gatewayPort, + healthStatus: healthInfo + ? `${healthInfo.status}${healthInfo.version ? ` · ${healthInfo.version}` : ""}` + : "尚未执行健康检查", + dashboardUrl: dashboardUrl || "尚未生成 Dashboard 地址", + }, + ), + [ + dashboardUrl, + gatewayPort, + gatewayStatus, + gitStatus, + healthInfo, + installLogs, + installedStatus?.path, + nodeStatus, + operationState.kind, + operationState.message, + ], + ); + + const openClawRawLogsText = useMemo( + () => + installLogs.length > 0 + ? installLogs + .map((log) => `[${log.level.toUpperCase()}] ${log.message}`) + .join("\n") + : "", + [installLogs], + ); + + const openClawDiagnosticBundleJson = useMemo( + () => + JSON.stringify( + { + generatedAt: new Date().toISOString(), + source: "openclaw-progress", + operation: operationState.kind, + running: operationState.running, + message: operationState.message, + system: { + os: + typeof navigator !== "undefined" + ? `${navigator.platform || "unknown"} / ${navigator.language || "unknown"}` + : "unknown", + userAgent: + typeof navigator !== "undefined" + ? navigator.userAgent || "unknown" + : "unknown", + installPath: installedStatus?.path || "未检测到安装路径", + nodeStatus: formatNodeStatus(nodeStatus), + gitStatus: formatBinaryStatus(gitStatus, "可用", "未检测到 Git"), + gatewayStatus, + gatewayPort, + healthStatus: healthInfo + ? `${healthInfo.status}${healthInfo.version ? ` · ${healthInfo.version}` : ""}` + : "尚未执行健康检查", + dashboardUrl: dashboardUrl || "尚未生成 Dashboard 地址", + }, + logs: installLogs, + }, + null, + 2, + ), + [ + dashboardUrl, + gatewayPort, + gatewayStatus, + gitStatus, + healthInfo, + installLogs, + installedStatus?.path, + nodeStatus, + operationState.kind, + operationState.message, + operationState.running, + ], + ); + + const handleCopyOpenClawRepairPrompt = useCallback(async () => { + try { + await navigator.clipboard.writeText(openClawRepairPrompt); + toast.success("OpenClaw 修复提示词已复制。"); + } catch { + toast.error("复制修复提示词失败。"); + } + }, [openClawRepairPrompt]); + + const handleCopyOpenClawLogs = useCallback(async () => { + if (!openClawRawLogsText.trim()) { + toast.error("当前没有可复制的日志。"); + return; + } + + try { + await navigator.clipboard.writeText(openClawRawLogsText); + toast.success("OpenClaw 纯日志已复制。"); + } catch { + toast.error("复制纯日志失败。"); + } + }, [openClawRawLogsText]); + + const handleCopyOpenClawDiagnosticBundle = useCallback(async () => { + if (!openClawRawLogsText.trim()) { + toast.error("当前没有可复制的诊断内容。"); + return; + } + + try { + await navigator.clipboard.writeText(openClawDiagnosticBundleJson); + toast.success("OpenClaw JSON 诊断包已复制。"); + } catch { + toast.error("复制 JSON 诊断包失败。"); + } + }, [openClawDiagnosticBundleJson, openClawRawLogsText]); + + const handleAskAgentFixOpenClaw = useCallback(async () => { + const prompt = openClawRepairPrompt.trim(); + if (!prompt) { + toast.error("当前没有可用于诊断的日志内容。"); + return; + } + + const project = await getOrCreateDefaultProject().catch((error) => { + toast.error( + error instanceof Error ? error.message : "创建默认项目失败。", + ); + return null; + }); + + if (!project) { + return; + } + + onNavigate?.("agent", { + projectId: project.id, + initialUserPrompt: prompt, + newChatAt: Date.now(), + theme: "general", + lockTheme: false, + }); + }, [onNavigate, openClawRepairPrompt]); + if (!statusResolved && !operationState.running) { return (
@@ -838,7 +1130,12 @@ export function OpenClawPage({ pageParams, onNavigate }: OpenClawPageProps) { } message={operationState.message} logs={installLogs} + repairPrompt={openClawRepairPrompt} onClose={handleCloseProgress} + onCopyLogs={() => void handleCopyOpenClawLogs()} + onCopyDiagnosticBundle={() => void handleCopyOpenClawDiagnosticBundle()} + onCopyRepairPrompt={() => void handleCopyOpenClawRepairPrompt()} + onAskAgentFix={handleAskAgentFixOpenClaw} /> ); } diff --git a/src/components/openclaw/OpenClawProgressPage.tsx b/src/components/openclaw/OpenClawProgressPage.tsx index eb7d631de..78b701f3e 100644 --- a/src/components/openclaw/OpenClawProgressPage.tsx +++ b/src/components/openclaw/OpenClawProgressPage.tsx @@ -1,4 +1,4 @@ -import { Loader2 } from "lucide-react"; +import { Bot, Copy, Loader2 } from "lucide-react"; import type { OpenClawInstallProgressEvent } from "@/lib/api/openclaw"; import type { OpenClawOperationKind } from "./types"; import { OpenClawMark } from "./OpenClawMark"; @@ -8,7 +8,12 @@ interface OpenClawProgressPageProps { running: boolean; message: string | null; logs: OpenClawInstallProgressEvent[]; + repairPrompt: string; onClose: () => void; + onCopyLogs: () => void; + onCopyDiagnosticBundle: () => void; + onCopyRepairPrompt: () => void; + onAskAgentFix: () => void; } const titleMap: Record = { @@ -28,7 +33,12 @@ export function OpenClawProgressPage({ running, message, logs, + repairPrompt, onClose, + onCopyLogs, + onCopyDiagnosticBundle, + onCopyRepairPrompt, + onAskAgentFix, }: OpenClawProgressPageProps) { return (
@@ -51,15 +61,53 @@ export function OpenClawProgressPage({ {running ? "命令执行中,请稍候。" : message || "操作已结束。"}

- +
+ + + + + +
diff --git a/src/components/provider-pool/api-key/ProviderModelList.tsx b/src/components/provider-pool/api-key/ProviderModelList.tsx index 5b2a0083b..da9315dac 100644 --- a/src/components/provider-pool/api-key/ProviderModelList.tsx +++ b/src/components/provider-pool/api-key/ProviderModelList.tsx @@ -60,6 +60,15 @@ interface FetchModelsResult { error: string | null; request_url?: string | null; diagnostic_hint?: string | null; + error_kind?: + | "not_found" + | "unauthorized" + | "forbidden" + | "network" + | "invalid_response" + | "other" + | null; + should_prompt_error?: boolean; } function buildApiDiagnosticLines(result: { @@ -252,6 +261,7 @@ export const ProviderModelList: React.FC = ({ const [apiDiagnosticHint, setApiDiagnosticHint] = useState( null, ); + const [apiShouldPromptError, setApiShouldPromptError] = useState(false); // 从 API 获取模型列表(自动获取 API Key) const handleRefreshFromApi = useCallback(async () => { @@ -259,6 +269,7 @@ export const ProviderModelList: React.FC = ({ setApiError(null); setApiRequestUrl(null); setApiDiagnosticHint(null); + setApiShouldPromptError(false); try { const result = await invoke( @@ -273,6 +284,7 @@ export const ProviderModelList: React.FC = ({ setApiSource(result.source); setApiRequestUrl(result.request_url ?? null); setApiDiagnosticHint(result.diagnostic_hint ?? null); + setApiShouldPromptError(Boolean(result.should_prompt_error)); if (result.error) { setApiError(result.error); } @@ -383,6 +395,11 @@ export const ProviderModelList: React.FC = ({
{apiError && (
+ {apiShouldPromptError ? ( +
+ 检测到 Provider 配置错误,请优先修正 Base URL 或鉴权配置 +
+ ) : null} {apiDiagnosticLines.map((line) => (
{line} @@ -470,6 +487,11 @@ export const ProviderModelList: React.FC = ({ {/* API 错误提示 */} {apiError && (
+ {apiShouldPromptError ? ( +
+ 检测到 Provider 配置错误,请优先修正 Base URL 或鉴权配置 +
+ ) : null} {apiDiagnosticLines.map((line) => (
{line} diff --git a/src/hooks/useProviderModels.ts b/src/hooks/useProviderModels.ts index f7554255f..fcd230d1e 100644 --- a/src/hooks/useProviderModels.ts +++ b/src/hooks/useProviderModels.ts @@ -44,6 +44,15 @@ interface FetchModelsResult { error: string | null; request_url?: string | null; diagnostic_hint?: string | null; + error_kind?: + | "not_found" + | "unauthorized" + | "forbidden" + | "network" + | "invalid_response" + | "other" + | null; + should_prompt_error?: boolean; } // ============================================================================ diff --git a/src/lib/api/openclaw.ts b/src/lib/api/openclaw.ts index 60887c320..3507e8c41 100644 --- a/src/lib/api/openclaw.ts +++ b/src/lib/api/openclaw.ts @@ -27,6 +27,11 @@ export interface OpenClawActionResult { message: string; } +export interface OpenClawCommandPreview { + title: string; + command: string; +} + export interface OpenClawGatewayStatusInfo { status: OpenClawGatewayStatus; port: number; @@ -89,6 +94,13 @@ export async function openclawInstall(): Promise { return safeInvoke("openclaw_install"); } +export async function openclawGetCommandPreview( + operation: "install" | "uninstall" | "start" | "stop" | "restart", + port?: number, +): Promise { + return safeInvoke("openclaw_get_command_preview", { operation, port }); +} + export async function openclawUninstall(): Promise { return safeInvoke("openclaw_uninstall"); } @@ -138,6 +150,12 @@ export async function listenOpenClawInstallProgress( ); } +export async function openclawGetProgressLogs(): Promise< + OpenClawInstallProgressEvent[] +> { + return safeInvoke("openclaw_get_progress_logs"); +} + export const openclawApi = { checkInstalled: openclawCheckInstalled, checkNodeVersion: openclawCheckNodeVersion, @@ -145,6 +163,7 @@ export const openclawApi = { getNodeDownloadUrl: openclawGetNodeDownloadUrl, getGitDownloadUrl: openclawGetGitDownloadUrl, install: openclawInstall, + getCommandPreview: openclawGetCommandPreview, uninstall: openclawUninstall, startGateway: openclawStartGateway, stopGateway: openclawStopGateway, @@ -153,6 +172,7 @@ export const openclawApi = { checkHealth: openclawCheckHealth, getDashboardUrl: openclawGetDashboardUrl, getChannels: openclawGetChannels, + getProgressLogs: openclawGetProgressLogs, syncProviderConfig: openclawSyncProviderConfig, listenInstallProgress: listenOpenClawInstallProgress, }; diff --git a/src/types/page.ts b/src/types/page.ts index b0f2fa7e7..3236d9bcb 100644 --- a/src/types/page.ts +++ b/src/types/page.ts @@ -135,6 +135,8 @@ export interface OpenClawPageParams { export interface AgentPageParams { projectId?: string; contentId?: string; + /** 进入 Agent 时自动发送的首条用户消息 */ + initialUserPrompt?: string; /** 首屏主题(用于左侧导航直达创作主题) */ theme?: string; /** 是否锁定主题(锁定后不在首屏显示主题切换) */