diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0edca8a0..fcd62e68e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -182,7 +182,22 @@ jobs: --notes-file "$NOTES_FILE" || gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null - name: Install frontend dependencies - run: pnpm install --frozen-lockfile --no-optional + run: pnpm install --frozen-lockfile + + - name: Ensure Tauri CLI native binding + if: startsWith(matrix.platform, 'macos') + shell: bash + run: | + set -euo pipefail + if node -e "require('@tauri-apps/cli')"; then + echo "Tauri CLI native binding is ready" + exit 0 + fi + + echo "::warning::Tauri CLI native binding missing after initial install; retrying clean install." + rm -rf node_modules + pnpm install --frozen-lockfile --force + node -e "require('@tauri-apps/cli')" - name: Normalize updater signing key if: steps.updater_mode.outputs.enabled == 'true' diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 609eb0724..7404fa782 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,20 +2,18 @@ ### ✨ 主要更新 -- **Harness 导出链路补齐四类制品**:处理工作台与统一运行时接通 `handoff bundle`、`evidence pack`、`replay case`、`analysis handoff` 导出,支持 pending request 重放、外部诊断交接和问题复盘闭环 -- **Replay Eval / Nightly 骨架进入 current 主线**:仓库新增 `docs/test/harness-evals.*`、固定 replay fixture、`harness-eval-runner`、`harness-eval-trend-report`、`harness-replay-promote` 与 nightly workflow,把 replay 样本、grader 合同和趋势摘要收口到统一入口 -- **Inputbar A2UI 浮层与工作台预览继续收口**:输入栏补齐 legacy 问卷折叠、结构化表单浮层、通用画布预览和 workbench stacked/split 行为,避免 A2UI 表单与画布入口互相抢占 -- **Service Skill 到 Automation 的落地链路更完整**:Home Shell、Workspace 与自动化设置页现在可以直接从服务技能创建本地 automation job,保留技能与任务关联,并回填 workspace/content 上下文 -- **Browser Runtime 站点采集继续收口**:站点采集工作台补齐推荐适配器、资料自动选择、目录状态展示和结果回写当前内容/项目的主链,优先复用已连接 Chrome 的真实登录态 -- **Release 流程补齐当前平台约束**:macOS x64 release runner 切换到当前 Intel 标签,并在依赖安装阶段跳过 `canvas` 这类可选原生包,降低最近 release 在 macOS/Windows 上的噪音失败与长时间等待 -- **Agent Chat 提交流程与处理面板继续瘦身**:slash skill、selected team、session/runtime steady-state 与 Harness 状态面板的交互拆分重组,关键回归测试同步补齐 +- **Chrome Relay / Browser Connector 进入 current 主线**:新增浏览器连接器命令与后端服务,设置页补齐 Chrome Relay 安装、连接状态和目录展示,扩展弹窗与清单同步更新 +- **Agent Chat 工作台与时间线继续收口**:`ArtifactWorkbenchShell`、`AgentThreadTimeline`、`CanvasWorkbenchLayout`、`MessageList` 等核心区域完成进一步瘦身,移除了旧的 `ProjectSelector`、`TaskFiles`、`TimelineInlineItem` 等遗留表面 +- **A2UI 与工作台运行时稳定性修复**:修正 legacy 问卷场景下 `useWorkspaceA2UIRuntime` 的自循环更新,补齐 Action Request/A2UI 预览和画布布局回归,减少自动引导与写文件链路的测试噪音 +- **测试执行与发布流程显著稳定化**:Vitest 默认切到智能分批 + 单 fork 模式,收敛 mock/info 日志风暴;macOS release workflow 现在会在构建前显式探测 Tauri CLI native binding,缺失时自动清理 `node_modules` 并重装,规避 `@tauri-apps/cli-darwin-*` 丢失导致的发布失败 +- **命令边界与文档同步更新**:浏览器连接器命令、GUI smoke 路径与质量流程文档已与当前实现保持一致,避免前端调用、Rust 注册、mock 与文档再度漂移 ### ⚠️ 兼容性说明 +- 本次仍然使用同一个版本号 `v0.97.0`,但 release/tag 会重指向 `main` 上的最新提交,用于覆盖之前的同版发布 - 正式发布仍由 `v*` tag 触发 `.github/workflows/release.yml`;`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文 -- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向的是干净的 `v0.22.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖 -- 站点适配器与 Browser Runtime 冒烟现在默认依赖已就绪的 `DevBridge`、浏览器资料和服务端同步目录;发布到目标环境前请确认对应控制面与 Browser Bridge 状态可用 -- Harness 新增 handoff/evidence/replay/analysis 导出后,会在工作区 `.lime/harness/sessions//...` 下沉淀更多制品;如有路径清理策略,请同步评估磁盘与归档规则 +- GUI 冒烟依赖本机可启动 headless Tauri、`DevBridge`、默认 workspace 和系统 Chrome;目标环境如果缺少对应条件,Browser Runtime/站点适配器相关能力会被降级 +- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.22.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖 ### 🔗 依赖同步 @@ -25,19 +23,23 @@ ### 🧪 测试 -- 发布前执行:`npm run verify:app-version` - 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml` -- 发布前执行:`npm run build` -- 发布前执行:`npm run verify:local` +- 发布前执行:`cargo test --manifest-path src-tauri/Cargo.toml` - 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml` -- 验证结果:`verify:local` 已覆盖 `npm run lint`、`npm run typecheck`、`npm test`、`npm run test:contracts`、`cargo test --manifest-path src-tauri/Cargo.toml` 与 `npm run verify:gui-smoke` +- 发布前执行:`npm run lint` +- 发布前执行:`npm run typecheck` +- 发布前执行:`npm test` +- 发布前执行:`npm run test:contracts` +- 发布前执行:`npm run verify:app-version` +- 发布前执行:`npm run verify:gui-smoke` +- 验证结果:上述命令已在当前工作区全部通过,`verify:gui-smoke` 已验证 `DevBridge`、默认 workspace、Browser Runtime 与 site adapter catalog 主路径 - 备注:`cargo clippy` 当前通过,但仍保留 3 条既有 warning,未在本次同版发布中额外扩范围消除 ### 📝 文档 -- 发布说明已切换到 `v0.97.0`,供 GitHub Release 工作流直接读取 -- Harness eval / 工程质量 / 命令边界与 GUI 冒烟相关文档已在当前工作区同步演进 +- 发布说明已切换到当前这次 `v0.97.0` 同版重发内容,供 GitHub Release 直接读取 +- 工程质量、命令边界与 Playwright / GUI 冒烟文档已同步更新到最新实现 --- -**完整变更**: v0.96.0...v0.97.0 +**完整变更**: `v0.97.0` 同版重发,对齐 `main` 最新提交 diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 506a1d2f4..8d17e95a4 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -24,6 +24,18 @@ - 新旧命令并存时,迁移边界清晰,不会继续扩散 - 契约检查脚本能稳定扫描并阻止回流 +浏览器连接器设置页同样遵循这条路径。当前主入口为 `src/lib/webview-api.ts` 中的浏览器连接器网关,统一承接: + +- `get_browser_connector_settings_cmd` +- `set_browser_connector_install_root_cmd` +- `set_browser_connector_enabled_cmd` +- `set_system_connector_enabled_cmd` +- `get_browser_connector_install_status_cmd` +- `install_browser_connector_extension_cmd` +- `open_browser_extensions_page_cmd` + +这些命令属于当前设置主路径,不应再在页面组件里散落裸 `invoke`。 + ## 命令契约的五个事实源 命令边界不是单文件事实,至少要同时看下面五处: diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index a1bb4213b..38b82bf7c 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -142,6 +142,15 @@ npm run test:contracts 7. 如工作台模式开启自动保存,再确认执行成功后保存态文案与打开入口正常 8. 打开控制台并确认浏览器资料 / 环境预设读取没有落回 web mock,尤其不应出现 `[Mock] invoke: list_browser_profiles_cmd` 或 `[Mock] invoke: list_browser_environment_presets_cmd` +### 连接器页验证 + +1. 进入 `设置 -> 连接器` +2. 确认首页能看到“我的浏览器”“macOS 连接器/系统连接器”“高级控制”三块主区域 +3. 点击“展开高级控制”,确认 `总览 / Profile / 桥接 / 后端 / 调试` 页签可切换 +4. 如当前环境允许目录选择,点击“选择目录并安装”或“同步更新扩展”,确认安装目录最终落到固定子目录 `Lime Browser Connector` +5. 点击“复制配置”,确认剪贴板内容包含 `serverUrl / bridgeKey / profileKey` +6. 如当前环境接通真实后端,再确认“打开 Chrome 扩展页”可成功唤起浏览器扩展管理页 + ### 话题模型恢复验证 1. 进入同一工作区中的两个话题 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 9d7d2c26d..81d7fc6a4 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -168,6 +168,7 @@ npm run bridge:health -- --timeout-ms 120000 - 修改 `execution_runtime.recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` 恢复语义,或前端 `harness.theme / harness.session_mode / harness.gate_key / harness.run_title / harness.content_id` steady-state 去重逻辑 - 修改 `site_*` 站点适配器命令族,例如 `site_recommend_adapters`、`site_run_adapter` - 修改浏览器资料 / 环境预设命令族,或调整它们在 `mockPriorityCommands` 里的优先级 +- 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器和扩展安装状态 - 修改 `src/lib/dev-bridge/` - 修改 `src/lib/tauri-mock/` - 修改 `src-tauri/src/app/runner.rs` @@ -216,6 +217,7 @@ npm run bridge:health -- --timeout-ms 120000 - 切换到新的 Theme Workbench gate 或运行标题、但 runtime 尚未同步时,前端仍会保留显式 `gate_key / run_title` - 如果这次改动影响浏览器工作台里的站点采集链路,例如推荐区、资料自动选择、`report_hint` 展示、`lime_site_recommend`,或“优先写回当前 `content_id` 而不是新建资源文档”的主线收敛,除了契约检查,还应补对应 `*.test.tsx` 回归并执行 `verify:gui-smoke`。 - 如果这次改动影响浏览器资料 / 环境预设的真实来源,还应补一次浏览器模式实测,确认控制台不再出现 `[Mock] invoke: list_browser_profiles_cmd` 或 `[Mock] invoke: list_browser_environment_presets_cmd`。 +- 如果这次改动影响设置页“连接器”主路径或 Chrome 扩展导出链路,除了 `test:contracts`,还应补对应设置页回归,并在 GUI smoke 或 Playwright 续测里确认连接器页能打开、目录可选、扩展状态可读。 - 如果这次改动影响 `agent_runtime_export_handoff_bundle`、`agent_runtime_export_evidence_pack`、`agent_runtime_export_analysis_handoff`、`agent_runtime_export_review_decision_template`、`agent_runtime_save_review_decision` 或 `agent_runtime_export_replay_case` 这条 Harness 导出 / 审核主链,除了契约检查,还应至少补: - `src/lib/api/agent.test.ts` 一类的网关回归,确认仍走统一 `agent_runtime_*` 主命令 - `HarnessStatusPanel.test.tsx` 一类的 UI 回归,确认导出入口、保存弹窗、状态与制品展示正常 diff --git a/extensions/lime-chrome/README.md b/extensions/lime-chrome/README.md index d62bc3cf7..fcaf87309 100644 --- a/extensions/lime-chrome/README.md +++ b/extensions/lime-chrome/README.md @@ -1,24 +1,32 @@ -# Lime Chrome Bridge 扩展 +# Lime Chrome Connector 扩展 -用于把 Chrome 页面能力接入 Lime 的浏览器桥接通道,供各业务 AI Agent 通过统一 `browser_execute_action` / MCP 浏览器工具调用。 +用于把 Chrome 页面能力接入 Lime 的浏览器连接器通道,供各业务 AI Agent 通过统一 `browser_execute_action` / MCP 浏览器工具调用。 ## 功能 - Observer 通道自动连接:`/lime-chrome-observer/Lime_Key=...` - 页面信息上报:标题、URL、Markdown - 远程指令执行:`open_url` / `click` / `type` / `scroll` / `switch_tab` / `list_tabs` / `go_back` 等 +- 弹窗入口:直接打开 Lime 的“连接器”页,并保留高级手动配置 +- 自动配置文件:导出后自动写入 `auto_config.json` - 弹窗配置:`serverUrl`、`bridgeKey`、`profileKey`、监控开关、手动抓取 ## 安装 -1. 打开 Chrome `chrome://extensions` -2. 打开右上角「开发者模式」 -3. 点击「加载已解压的扩展程序」 -4. 选择目录:`extensions/lime-chrome` +1. 在 Lime 设置页进入“连接器” +2. 点击“选择目录并安装”或“同步更新扩展” +3. 在文件选择器里选择一个用户自定义根目录 +4. Lime 会把扩展导出到固定子目录:`<你选择的目录>/Lime Browser Connector` +5. 打开 Chrome `chrome://extensions` +6. 打开右上角「开发者模式」 +7. 点击「加载已解压的扩展程序」 +8. 选择目录:`<你选择的目录>/Lime Browser Connector` ## 配置 -点击扩展图标打开弹窗,配置: +点击扩展图标后,优先使用“打开 Lime 连接器页”回到桌面端统一管理。 + +只有在自动配置失效或手动排查链路时,才需要展开“高级配置”并填写: - `Server URL`:Lime 服务地址,例如 `ws://127.0.0.1:8999` - `Bridge Key`:Lime 服务 API Key(与后端 `Lime_Key` 一致) @@ -26,6 +34,8 @@ 点击「保存并重连」后,扩展会建立 observer WebSocket 连接。 +如果你已经通过 Lime 导出了扩展,目录里会同时带上 `auto_config.json`。扩展加载后会自动读取该文件并应用默认配置。 + ## 验证 1. 在 Lime 设置中查看 `get_chrome_bridge_status`,`observer_count` 应大于 0 diff --git a/extensions/lime-chrome/manifest.json b/extensions/lime-chrome/manifest.json index ae7b54579..270edbd07 100644 --- a/extensions/lime-chrome/manifest.json +++ b/extensions/lime-chrome/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Lime Browser Bridge", "description": "Connect Chrome tabs with Lime browser runtime for AI agents.", - "version": "0.1.0", + "version": "0.2.0", "permissions": ["storage", "tabs", "activeTab", "scripting", "clipboardRead"], "host_permissions": ["http://*/*", "https://*/*"], "background": { diff --git a/extensions/lime-chrome/popup.html b/extensions/lime-chrome/popup.html index ef8588a07..887d60e94 100644 --- a/extensions/lime-chrome/popup.html +++ b/extensions/lime-chrome/popup.html @@ -3,242 +3,412 @@ - Lime Browser Bridge + Lime Browser Connector -
-

Lime Browser Bridge

-

连接 Chrome 与 Lime 服务

-
- -
- Observer 连接: - 未连接 -
- -
- 页面监控: - 关闭 -
- -
-
🚀 一键配置
-
从剪贴板粘贴配置(在 Lime 页面点击"复制配置")
-
- - +
+
+ BROWSER CONNECTOR +

Lime 浏览器连接器

+

扩展安装、连接器开关与高级配置统一在 Lime 的“连接器”页里管理。

-
-
- - -
+
+
+
+
+
当前状态
+

先看连接状态;未连通时优先打开 Lime 连接器页完成启用或重新同步。

+
+
-
- - -
+
+
+
Observer 连接
+
+ 未连接 +
+
+
+
页面监控
+
+ 关闭 +
+
+
-
- - -
+
+ 首次安装时,请先在 Lime 的“连接器”页里选择目录导出扩展,然后到 Chrome 扩展页加载已解压目录。 +
-
- Observer URL: 未配置 -
+
+ + +
+
-
- - -
+
+
无页面信息
+
当前还没有收到最近页面快照。
+
-
- - -
+
+ +
+
高级配置
+
保留手动配置、手动重连、页面抓取和监控切换。只在自动配置失效或排查链路时使用。
+
+ ⌄ +
-
-
无页面信息
-
+
+
+
快速处理
+

如果你刚从 Lime 复制了配置,可以直接粘贴到这里完成覆盖。

+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ Observer URL: 未配置 +
+ +
+ + +
+ +
+ + +
+
+
+
diff --git a/extensions/lime-chrome/popup.js b/extensions/lime-chrome/popup.js index e478d6078..2a819e4fc 100644 --- a/extensions/lime-chrome/popup.js +++ b/extensions/lime-chrome/popup.js @@ -7,7 +7,10 @@ const DEFAULT_SETTINGS = { const bridgeStatusEl = document.getElementById("bridgeStatus"); const monitorStatusEl = document.getElementById("monitorStatus"); +const statusHintEl = document.getElementById("statusHint"); const endpointPreviewEl = document.getElementById("endpointPreview"); +const openConnectorPageBtnEl = document.getElementById("openConnectorPageBtn"); +const refreshStatusBtnEl = document.getElementById("refreshStatusBtn"); const serverUrlEl = document.getElementById("serverUrl"); const bridgeKeyEl = document.getElementById("bridgeKey"); @@ -41,11 +44,20 @@ function applyStatus(status) { const monitoring = Boolean(status?.monitoringEnabled); setBadge(bridgeStatusEl, connected, "已连接", "未连接"); setBadge(monitorStatusEl, monitoring, "开启", "关闭"); + toggleConnBtnEl.textContent = connected ? "断开连接" : "立即连接"; + toggleMonitorBtnEl.textContent = monitoring ? "关闭页面监控" : "开启页面监控"; + + statusHintEl.textContent = connected + ? "浏览器扩展已经接入当前 Lime 运行时。后续如需重新安装或切换目录,请回到 Lime 的“连接器”页处理。" + : "如果当前仍未连接,请先打开 Lime 的“连接器”页启用浏览器连接器,或重新同步扩展到你选择的目录。"; const latestPageInfo = status?.latestPageInfo; if (latestPageInfo?.title || latestPageInfo?.url) { pageTitleEl.textContent = latestPageInfo.title || "无标题"; pageUrlEl.textContent = latestPageInfo.url || ""; + } else { + pageTitleEl.textContent = "无页面信息"; + pageUrlEl.textContent = "当前还没有收到最近页面快照。"; } const settings = status?.settings; @@ -96,13 +108,17 @@ async function loadInitialState() { ); try { - const status = await sendMessage({ type: "GET_STATUS" }); - applyStatus(status || {}); + await refreshStatus(); } catch (error) { console.warn("[LimeBridgePopup] 获取状态失败", error?.message || String(error)); } } +async function refreshStatus() { + const status = await sendMessage({ type: "GET_STATUS" }); + applyStatus(status || {}); +} + async function saveAndReconnect() { const payload = { serverUrl: serverUrlEl.value.trim(), @@ -123,6 +139,7 @@ async function saveAndReconnect() { try { await sendMessage({ type: "UPDATE_SETTINGS", data: payload }); + await refreshStatus(); saveBtnEl.textContent = "已保存"; setTimeout(() => { saveBtnEl.textContent = originalText; @@ -141,6 +158,7 @@ async function saveAndReconnect() { async function toggleConnection() { try { await sendMessage({ type: "TOGGLE_CONNECTION" }); + await refreshStatus(); } catch (error) { console.warn("[LimeBridgePopup] 切换连接失败", error?.message || String(error)); } @@ -149,6 +167,7 @@ async function toggleConnection() { async function toggleMonitoring() { try { await sendMessage({ type: "TOGGLE_MONITORING" }); + await refreshStatus(); } catch (error) { console.warn("[LimeBridgePopup] 切换监控失败", error?.message || String(error)); } @@ -161,6 +180,7 @@ async function capturePageNow() { try { await sendMessage({ type: "REQUEST_PAGE_CAPTURE" }); + await refreshStatus(); } catch (error) { console.warn("[LimeBridgePopup] 请求抓取失败", error?.message || String(error)); } finally { @@ -190,6 +210,9 @@ async function pasteConfigFromClipboard() { if (config.profileKey) { profileKeyEl.value = config.profileKey; } + if (typeof config.monitoringEnabled === "boolean") { + setBadge(monitorStatusEl, config.monitoringEnabled, "开启", "关闭"); + } endpointPreviewEl.textContent = buildObserverEndpoint( serverUrlEl.value, @@ -223,10 +246,55 @@ function clearConfig() { ); } +async function pollStatusUntilConnected() { + openConnectorPageBtnEl.disabled = true; + refreshStatusBtnEl.disabled = true; + + let attempts = 0; + const maxAttempts = 15; + const timer = window.setInterval(async () => { + attempts += 1; + try { + const status = await sendMessage({ type: "GET_STATUS" }); + applyStatus(status || {}); + if (status?.isConnected || attempts >= maxAttempts) { + window.clearInterval(timer); + openConnectorPageBtnEl.disabled = false; + refreshStatusBtnEl.disabled = false; + } + } catch (_) { + if (attempts >= maxAttempts) { + window.clearInterval(timer); + openConnectorPageBtnEl.disabled = false; + refreshStatusBtnEl.disabled = false; + } + } + }, 1000); +} + +function openConnectorSettings() { + try { + window.open("lime://connectors/browser?enable=true"); + } catch (error) { + console.warn( + "[LimeBridgePopup] 打开 Lime 连接器页失败", + error?.message || String(error), + ); + } + + void pollStatusUntilConnected(); +} + saveBtnEl.addEventListener("click", saveAndReconnect); toggleConnBtnEl.addEventListener("click", toggleConnection); toggleMonitorBtnEl.addEventListener("click", toggleMonitoring); captureBtnEl.addEventListener("click", capturePageNow); +openConnectorPageBtnEl.addEventListener("click", openConnectorSettings); +refreshStatusBtnEl.addEventListener("click", () => { + refreshStatus().catch((error) => { + console.warn("[LimeBridgePopup] 刷新状态失败", error?.message || String(error)); + }); +}); document.getElementById("pasteConfigBtn").addEventListener("click", pasteConfigFromClipboard); document.getElementById("clearConfigBtn").addEventListener("click", clearConfig); diff --git a/package.json b/package.json index d3ebe2ced..1c1234c1e 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "typecheck": "tsc --noEmit", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "prepare": "husky", - "test": "vitest --run", - "test:watch": "vitest", + "test": "node scripts/run-vitest-smart.mjs", + "test:watch": "node --max-old-space-size=8192 ./node_modules/vitest/vitest.mjs", "test:frontend": "npm run lint && npm run typecheck && npm test", "test:bridge": "npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts", "test:contracts": "node scripts/check-command-contracts.mjs && node scripts/check-harness-contracts.mjs", diff --git a/scripts/run-vitest-smart.mjs b/scripts/run-vitest-smart.mjs new file mode 100644 index 000000000..2ae23662d --- /dev/null +++ b/scripts/run-vitest-smart.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import process from "node:process"; + +const vitestEntrypoint = fileURLToPath( + new URL("../node_modules/vitest/vitest.mjs", import.meta.url), +); +const cliArgs = process.argv.slice(2); +const defaultBatchSize = Number.parseInt( + process.env.LIME_VITEST_BATCH_SIZE || "16", + 10, +); +const batchSize = + Number.isFinite(defaultBatchSize) && defaultBatchSize > 0 + ? defaultBatchSize + : 16; +const serialTestFiles = new Set([ + "scripts/lib/harness-eval-history-record.test.ts", + "src/components/agent/chat/index.test.tsx", + "src/components/agent/chat/hooks/useAsterAgentChat.test.tsx", + "src/components/workspace/WorkbenchPage.test.tsx", + "src/components/plugins/PluginManager.test.tsx", + "src/components/agent/chat/components/HarnessStatusPanel.test.tsx", + "src/components/agent/chat/components/ThemeWorkbenchSidebar.test.tsx", + "src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx", + "src/components/settings-v2/system/automation/index.test.tsx", +]); + +function runVitest(args, label) { + if (label) { + console.log(`[vitest-smart] ${label}`); + } + + const result = spawnSync( + process.execPath, + [ + "--max-old-space-size=8192", + vitestEntrypoint, + "--run", + "--silent=passed-only", + "--disableConsoleIntercept", + "--poolOptions.forks.singleFork", + ...args, + ], + { + stdio: "inherit", + env: process.env, + }, + ); + + if (result.error) { + throw result.error; + } + + if (typeof result.status === "number" && result.status !== 0) { + process.exit(result.status); + } +} + +function collectTestFiles() { + const result = spawnSync( + process.execPath, + [vitestEntrypoint, "list", "--filesOnly", "--json"], + { + stdio: ["inherit", "pipe", "inherit"], + env: process.env, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }, + ); + + if (result.error) { + throw result.error; + } + + if (typeof result.status === "number" && result.status !== 0) { + process.exit(result.status); + } + + const parsed = JSON.parse(result.stdout || "[]"); + return parsed + .map((entry) => (typeof entry === "string" ? entry : entry?.file)) + .filter((entry) => typeof entry === "string" && entry.length > 0); +} + +function chunkFiles(files, size) { + const chunks = []; + for (let index = 0; index < files.length; index += size) { + chunks.push(files.slice(index, index + size)); + } + return chunks; +} + +function buildBatches(files) { + const repoRoot = process.cwd(); + const serialBatches = []; + const regularFiles = []; + + for (const file of files) { + const relativePath = path.relative(repoRoot, file).replaceAll("\\", "/"); + if (serialTestFiles.has(relativePath)) { + serialBatches.push([file]); + continue; + } + regularFiles.push(file); + } + + return [...serialBatches, ...chunkFiles(regularFiles, batchSize)]; +} + +function main() { + if (cliArgs.length > 0) { + runVitest(cliArgs); + return; + } + + const files = collectTestFiles(); + const batches = buildBatches(files); + + for (let index = 0; index < batches.length; index += 1) { + runVitest( + [ + "--maxWorkers", + "1", + "--minWorkers", + "1", + "--no-file-parallelism", + ...batches[index], + ], + `运行批次 ${index + 1}/${batches.length}`, + ); + } +} + +main(); diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 58d869aee..20776654a 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1562,6 +1562,14 @@ pub fn run() { commands::browser_environment_cmd::save_browser_environment_preset_cmd, commands::browser_environment_cmd::archive_browser_environment_preset_cmd, commands::browser_environment_cmd::restore_browser_environment_preset_cmd, + // Browser connector commands + commands::browser_connector_cmd::get_browser_connector_settings_cmd, + commands::browser_connector_cmd::set_browser_connector_install_root_cmd, + commands::browser_connector_cmd::set_browser_connector_enabled_cmd, + commands::browser_connector_cmd::set_system_connector_enabled_cmd, + commands::browser_connector_cmd::get_browser_connector_install_status_cmd, + commands::browser_connector_cmd::install_browser_connector_extension_cmd, + commands::browser_connector_cmd::open_browser_extensions_page_cmd, // Browser profile commands commands::browser_profile_cmd::list_browser_profiles_cmd, commands::browser_profile_cmd::save_browser_profile_cmd, diff --git a/src-tauri/src/commands/browser_connector_cmd.rs b/src-tauri/src/commands/browser_connector_cmd.rs new file mode 100644 index 000000000..b2101648c --- /dev/null +++ b/src-tauri/src/commands/browser_connector_cmd.rs @@ -0,0 +1,180 @@ +//! 浏览器连接器命令 + +use crate::app::AppState; +use crate::services::browser_connector_service::{ + get_browser_connector_install_status, get_browser_connector_settings, + install_browser_connector_extension, sync_browser_connector_auto_config_if_installed, + update_browser_connector_enabled, update_browser_connector_install_root, + update_system_connector_enabled, BrowserConnectorAutoConfig, BrowserConnectorInstallResult, + BrowserConnectorInstallStatus, BrowserConnectorSettingsSnapshot, +}; +use serde::Deserialize; +use tauri::{AppHandle, State}; + +#[derive(Debug, Deserialize)] +pub struct SetBrowserConnectorInstallRootRequest { + pub install_root_dir: String, +} + +#[derive(Debug, Deserialize)] +pub struct InstallBrowserConnectorExtensionRequest { + #[serde(default)] + pub install_root_dir: Option, + #[serde(default)] + pub profile_key: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SetSystemConnectorEnabledRequest { + pub id: String, + pub enabled: bool, +} + +fn normalize_bridge_host(host: &str) -> String { + match host.trim() { + "" | "0.0.0.0" | "::" | "[::]" => "127.0.0.1".to_string(), + value => value.to_string(), + } +} + +async fn build_auto_config( + app_state: &AppState, + profile_key: Option<&str>, + monitoring_enabled: bool, +) -> Result { + let state_guard = app_state.read().await; + let status = state_guard.status(); + let host = normalize_bridge_host(&status.host); + let bridge_key = state_guard.config.server.api_key.clone(); + let profile_key = profile_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default") + .to_string(); + + Ok(BrowserConnectorAutoConfig { + server_url: format!("ws://{host}:{}", status.port), + bridge_key, + profile_key, + monitoring_enabled, + }) +} + +#[tauri::command] +pub fn get_browser_connector_settings_cmd() -> Result { + get_browser_connector_settings() +} + +#[tauri::command] +pub fn set_browser_connector_install_root_cmd( + request: SetBrowserConnectorInstallRootRequest, +) -> Result { + update_browser_connector_install_root(&request.install_root_dir) +} + +#[tauri::command] +pub async fn set_browser_connector_enabled_cmd( + app_state: State<'_, AppState>, + enabled: bool, +) -> Result { + let snapshot = update_browser_connector_enabled(enabled)?; + let auto_config = build_auto_config(app_state.inner(), None, enabled).await?; + let _ = sync_browser_connector_auto_config_if_installed(&auto_config)?; + Ok(snapshot) +} + +#[tauri::command] +pub fn set_system_connector_enabled_cmd( + request: SetSystemConnectorEnabledRequest, +) -> Result { + update_system_connector_enabled(&request.id, request.enabled) +} + +#[tauri::command] +pub fn get_browser_connector_install_status_cmd( + app: AppHandle, +) -> Result { + get_browser_connector_install_status(&app) +} + +#[tauri::command] +pub async fn install_browser_connector_extension_cmd( + app: AppHandle, + app_state: State<'_, AppState>, + request: InstallBrowserConnectorExtensionRequest, +) -> Result { + let settings = if let Some(install_root_dir) = request.install_root_dir.as_deref() { + update_browser_connector_install_root(install_root_dir)? + } else { + get_browser_connector_settings()? + }; + + let install_root_dir = settings + .install_root_dir + .ok_or_else(|| "尚未选择浏览器连接器安装目录".to_string())?; + let auto_config = build_auto_config( + app_state.inner(), + request.profile_key.as_deref(), + settings.enabled, + ) + .await?; + + install_browser_connector_extension(&app, std::path::Path::new(&install_root_dir), &auto_config) +} + +#[tauri::command] +pub async fn open_browser_extensions_page_cmd() -> Result { + #[cfg(target_os = "macos")] + { + let mut command = std::process::Command::new("open"); + command.args(["-a", "Google Chrome", "chrome://extensions"]); + if let Err(primary_error) = command.spawn() { + std::process::Command::new("open") + .arg("chrome://extensions") + .spawn() + .map_err(|fallback_error| { + format!("打开 Chrome 扩展页面失败: {primary_error}; fallback: {fallback_error}") + })?; + } + } + + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/C", "start", "", "chrome://extensions"]) + .spawn() + .map_err(|error| format!("打开 Chrome 扩展页面失败: {error}"))?; + } + + #[cfg(target_os = "linux")] + { + let attempts = [ + ("google-chrome", vec!["chrome://extensions"]), + ("chromium", vec!["chrome://extensions"]), + ("xdg-open", vec!["chrome://extensions"]), + ]; + + let mut last_error = None; + let mut opened = false; + for (binary, args) in attempts { + match std::process::Command::new(binary).args(args).spawn() { + Ok(_) => { + opened = true; + break; + } + Err(error) => { + last_error = Some(format!("{binary}: {error}")); + } + } + } + + if !opened { + return Err(format!( + "打开 Chrome 扩展页面失败: {}", + last_error.unwrap_or_else(|| "没有可用的浏览器命令".to_string()) + )); + } + } + + Ok(true) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 59d64eff5..4e9121b1f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod asr_cmd; pub mod aster_agent_cmd; pub mod auto_fix_cmd; pub mod automation_cmd; +pub mod browser_connector_cmd; pub mod browser_environment_cmd; pub mod browser_profile_cmd; pub mod browser_runtime_cmd; diff --git a/src-tauri/src/services/browser_connector_service.rs b/src-tauri/src/services/browser_connector_service.rs new file mode 100644 index 000000000..8dc85a48e --- /dev/null +++ b/src-tauri/src/services/browser_connector_service.rs @@ -0,0 +1,523 @@ +//! 浏览器连接器服务 +//! +//! 负责浏览器连接器安装目录、导出同步和本地设置持久化。 + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use tauri::{AppHandle, Manager}; + +const SETTINGS_SUBDIR: &str = "connectors"; +const SETTINGS_FILE_NAME: &str = "browser-connector-settings.json"; +const EXTENSION_INSTALL_DIR_NAME: &str = "Lime Browser Connector"; + +const SYSTEM_CONNECTOR_DEFINITIONS: [(&str, &str, &str); 5] = [ + ( + "reminders", + "提醒事项", + "读取和管理你的提醒事项和任务列表。", + ), + ("calendar", "日历", "读取和管理你的日历事件。"), + ("notes", "备忘录", "读取和创建你的备忘录。"), + ("mail", "邮件", "读取邮件和创建草稿。"), + ("contacts", "通讯录", "搜索、读取和创建联系人。"), +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserConnectorAutoConfig { + #[serde(rename = "serverUrl")] + pub server_url: String, + #[serde(rename = "bridgeKey")] + pub bridge_key: String, + #[serde(rename = "profileKey")] + pub profile_key: String, + #[serde(rename = "monitoringEnabled")] + pub monitoring_enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserConnectorInstallStatus { + pub status: String, + pub install_root_dir: Option, + pub install_dir: Option, + pub bundled_name: String, + pub bundled_version: String, + pub installed_name: Option, + pub installed_version: Option, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserConnectorInstallResult { + pub install_root_dir: String, + pub install_dir: String, + pub bundled_name: String, + pub bundled_version: String, + pub installed_version: String, + pub auto_config_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemConnectorSnapshot { + pub id: String, + pub label: String, + pub description: String, + pub enabled: bool, + pub available: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserConnectorSettingsSnapshot { + pub enabled: bool, + pub install_root_dir: Option, + pub install_dir: Option, + pub system_connectors: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BrowserConnectorSettingsRecord { + enabled: bool, + install_root_dir: Option, + system_connectors: HashMap, + updated_at: String, +} + +#[derive(Debug, Clone)] +struct ManifestInfo { + name: String, + version: String, +} + +impl Default for BrowserConnectorSettingsRecord { + fn default() -> Self { + Self { + enabled: true, + install_root_dir: None, + system_connectors: default_system_connector_states(), + updated_at: Utc::now().to_rfc3339(), + } + } +} + +fn default_system_connector_states() -> HashMap { + SYSTEM_CONNECTOR_DEFINITIONS + .iter() + .map(|(id, _, _)| ((*id).to_string(), false)) + .collect() +} + +fn browser_connector_settings_path() -> Result { + Ok(lime_core::app_paths::preferred_data_dir() + .map_err(|error| format!("获取应用数据目录失败: {error}"))? + .join(SETTINGS_SUBDIR) + .join(SETTINGS_FILE_NAME)) +} + +fn ensure_parent_dir(path: &Path) -> Result<(), String> { + let Some(parent) = path.parent() else { + return Err("目标路径缺少父目录".to_string()); + }; + fs::create_dir_all(parent).map_err(|error| format!("创建目录失败: {error}")) +} + +fn load_settings_record() -> Result { + let path = browser_connector_settings_path()?; + if !path.exists() { + return Ok(BrowserConnectorSettingsRecord::default()); + } + + let content = + fs::read_to_string(&path).map_err(|error| format!("读取连接器设置失败: {error}"))?; + let mut record: BrowserConnectorSettingsRecord = + serde_json::from_str(&content).map_err(|error| format!("解析连接器设置失败: {error}"))?; + + for (id, enabled) in default_system_connector_states() { + record.system_connectors.entry(id).or_insert(enabled); + } + + Ok(record) +} + +fn save_settings_record(record: &BrowserConnectorSettingsRecord) -> Result<(), String> { + let path = browser_connector_settings_path()?; + ensure_parent_dir(&path)?; + let content = serde_json::to_string_pretty(record) + .map_err(|error| format!("序列化连接器设置失败: {error}"))?; + fs::write(&path, content).map_err(|error| format!("写入连接器设置失败: {error}")) +} + +fn path_to_string(path: &Path) -> String { + path.to_string_lossy().to_string() +} + +fn normalize_install_root_dir(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("安装目录不能为空".to_string()); + } + + let candidate = PathBuf::from(trimmed); + let normalized = if candidate.is_absolute() { + candidate + } else { + std::env::current_dir() + .map_err(|error| format!("获取当前目录失败: {error}"))? + .join(candidate) + }; + + Ok(normalized) +} + +pub fn resolve_browser_connector_install_dir(root_dir: &Path) -> PathBuf { + root_dir.join(EXTENSION_INSTALL_DIR_NAME) +} + +fn read_manifest_info(extension_dir: &Path) -> Result { + let manifest_path = extension_dir.join("manifest.json"); + let content = fs::read_to_string(&manifest_path) + .map_err(|error| format!("读取 manifest.json 失败 {:?}: {error}", manifest_path))?; + let manifest: Value = serde_json::from_str(&content) + .map_err(|error| format!("解析 manifest.json 失败: {error}"))?; + + let name = manifest + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "manifest.json 缺少 name".to_string())? + .to_string(); + let version = manifest + .get("version") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "manifest.json 缺少 version".to_string())? + .to_string(); + + Ok(ManifestInfo { name, version }) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { + fs::create_dir_all(dst).map_err(|error| format!("创建目标目录失败: {error}"))?; + + for entry in fs::read_dir(src).map_err(|error| format!("读取源目录失败: {error}"))? { + let entry = entry.map_err(|error| format!("读取目录项失败: {error}"))?; + let entry_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if entry_path.is_dir() { + copy_dir_recursive(&entry_path, &dst_path)?; + } else { + fs::copy(&entry_path, &dst_path) + .map_err(|error| format!("复制文件失败 {:?}: {error}", entry_path.file_name()))?; + } + } + + Ok(()) +} + +pub fn resolve_browser_connector_extension_source(app: &AppHandle) -> Result { + let path = if cfg!(debug_assertions) { + let current_dir = + std::env::current_dir().map_err(|error| format!("获取当前目录失败: {error}"))?; + let project_root = if current_dir.ends_with("src-tauri") { + current_dir + .parent() + .ok_or_else(|| "无法获取项目根目录".to_string())? + .to_path_buf() + } else { + current_dir + }; + project_root.join("extensions").join("lime-chrome") + } else { + app.path() + .resource_dir() + .map_err(|error| format!("获取资源目录失败: {error}"))? + .join("extensions") + .join("lime-chrome") + }; + + if !path.exists() { + return Err(format!("未找到内置浏览器连接器目录: {:?}", path)); + } + + Ok(path) +} + +fn build_install_status_from_paths( + bundled_extension_dir: &Path, + install_root_dir: Option<&Path>, +) -> Result { + let bundled_manifest = read_manifest_info(bundled_extension_dir)?; + + let Some(root_dir) = install_root_dir else { + return Ok(BrowserConnectorInstallStatus { + status: "not_installed".to_string(), + install_root_dir: None, + install_dir: None, + bundled_name: bundled_manifest.name, + bundled_version: bundled_manifest.version, + installed_name: None, + installed_version: None, + message: Some("尚未选择浏览器连接器安装目录".to_string()), + }); + }; + + let install_dir = resolve_browser_connector_install_dir(root_dir); + if !install_dir.exists() { + return Ok(BrowserConnectorInstallStatus { + status: "not_installed".to_string(), + install_root_dir: Some(path_to_string(root_dir)), + install_dir: Some(path_to_string(&install_dir)), + bundled_name: bundled_manifest.name, + bundled_version: bundled_manifest.version, + installed_name: None, + installed_version: None, + message: Some("已记录安装目录,但尚未导出浏览器连接器".to_string()), + }); + } + + match read_manifest_info(&install_dir) { + Ok(installed_manifest) => { + let status = if installed_manifest.version == bundled_manifest.version { + "installed" + } else { + "update_available" + }; + let message = if status == "installed" { + Some("已安装最新版本浏览器连接器".to_string()) + } else { + Some("检测到用户目录中的连接器版本落后于当前应用".to_string()) + }; + + Ok(BrowserConnectorInstallStatus { + status: status.to_string(), + install_root_dir: Some(path_to_string(root_dir)), + install_dir: Some(path_to_string(&install_dir)), + bundled_name: bundled_manifest.name, + bundled_version: bundled_manifest.version, + installed_name: Some(installed_manifest.name), + installed_version: Some(installed_manifest.version), + message, + }) + } + Err(error) => Ok(BrowserConnectorInstallStatus { + status: "broken".to_string(), + install_root_dir: Some(path_to_string(root_dir)), + install_dir: Some(path_to_string(&install_dir)), + bundled_name: bundled_manifest.name, + bundled_version: bundled_manifest.version, + installed_name: None, + installed_version: None, + message: Some(format!("安装目录存在,但扩展文件不完整: {error}")), + }), + } +} + +pub fn get_browser_connector_install_status( + app: &AppHandle, +) -> Result { + let settings = load_settings_record()?; + let install_root_dir = settings + .install_root_dir + .as_deref() + .map(normalize_install_root_dir) + .transpose()?; + let bundled_extension_dir = resolve_browser_connector_extension_source(app)?; + build_install_status_from_paths(&bundled_extension_dir, install_root_dir.as_deref()) +} + +pub fn write_browser_connector_auto_config( + install_dir: &Path, + auto_config: &BrowserConnectorAutoConfig, +) -> Result { + let auto_config_path = install_dir.join("auto_config.json"); + let content = serde_json::to_string_pretty(auto_config) + .map_err(|error| format!("序列化 auto_config.json 失败: {error}"))?; + fs::write(&auto_config_path, content) + .map_err(|error| format!("写入 auto_config.json 失败 {:?}: {error}", auto_config_path))?; + Ok(auto_config_path) +} + +pub fn install_browser_connector_extension( + app: &AppHandle, + install_root_dir: &Path, + auto_config: &BrowserConnectorAutoConfig, +) -> Result { + let bundled_extension_dir = resolve_browser_connector_extension_source(app)?; + let bundled_manifest = read_manifest_info(&bundled_extension_dir)?; + let install_dir = resolve_browser_connector_install_dir(install_root_dir); + + fs::create_dir_all(install_root_dir).map_err(|error| format!("创建安装根目录失败: {error}"))?; + + if install_dir.exists() { + fs::remove_dir_all(&install_dir) + .map_err(|error| format!("删除旧连接器目录失败 {:?}: {error}", install_dir))?; + } + + copy_dir_recursive(&bundled_extension_dir, &install_dir)?; + let auto_config_path = write_browser_connector_auto_config(&install_dir, auto_config)?; + + Ok(BrowserConnectorInstallResult { + install_root_dir: path_to_string(install_root_dir), + install_dir: path_to_string(&install_dir), + bundled_name: bundled_manifest.name, + bundled_version: bundled_manifest.version.clone(), + installed_version: bundled_manifest.version, + auto_config_path: path_to_string(&auto_config_path), + }) +} + +pub fn sync_browser_connector_auto_config_if_installed( + auto_config: &BrowserConnectorAutoConfig, +) -> Result, String> { + let settings = load_settings_record()?; + let Some(root_dir) = settings.install_root_dir.as_deref() else { + return Ok(None); + }; + let root_dir = normalize_install_root_dir(root_dir)?; + let install_dir = resolve_browser_connector_install_dir(&root_dir); + if !install_dir.exists() { + return Ok(None); + } + + let auto_config_path = write_browser_connector_auto_config(&install_dir, auto_config)?; + Ok(Some(path_to_string(&auto_config_path))) +} + +pub fn get_browser_connector_settings() -> Result { + let record = load_settings_record()?; + Ok(build_settings_snapshot(&record)) +} + +fn build_settings_snapshot( + record: &BrowserConnectorSettingsRecord, +) -> BrowserConnectorSettingsSnapshot { + let install_root_dir = record.install_root_dir.clone(); + let install_dir = install_root_dir + .as_deref() + .map(|raw| normalize_install_root_dir(raw).unwrap_or_else(|_| PathBuf::from(raw))) + .map(|root_dir| path_to_string(&resolve_browser_connector_install_dir(&root_dir))); + + BrowserConnectorSettingsSnapshot { + enabled: record.enabled, + install_root_dir, + install_dir, + system_connectors: SYSTEM_CONNECTOR_DEFINITIONS + .iter() + .map(|(id, label, description)| SystemConnectorSnapshot { + id: (*id).to_string(), + label: (*label).to_string(), + description: (*description).to_string(), + enabled: record.system_connectors.get(*id).copied().unwrap_or(false), + available: cfg!(target_os = "macos"), + }) + .collect(), + } +} + +pub fn update_browser_connector_install_root( + install_root_dir: &str, +) -> Result { + let normalized = normalize_install_root_dir(install_root_dir)?; + fs::create_dir_all(&normalized).map_err(|error| format!("创建安装目录失败: {error}"))?; + + let mut record = load_settings_record()?; + record.install_root_dir = Some(path_to_string(&normalized)); + record.updated_at = Utc::now().to_rfc3339(); + save_settings_record(&record)?; + Ok(build_settings_snapshot(&record)) +} + +pub fn update_browser_connector_enabled( + enabled: bool, +) -> Result { + let mut record = load_settings_record()?; + record.enabled = enabled; + record.updated_at = Utc::now().to_rfc3339(); + save_settings_record(&record)?; + Ok(build_settings_snapshot(&record)) +} + +pub fn update_system_connector_enabled( + id: &str, + enabled: bool, +) -> Result { + if !SYSTEM_CONNECTOR_DEFINITIONS + .iter() + .any(|(entry_id, _, _)| *entry_id == id) + { + return Err(format!("未知的系统连接器: {id}")); + } + + let mut record = load_settings_record()?; + record.system_connectors.insert(id.to_string(), enabled); + record.updated_at = Utc::now().to_rfc3339(); + save_settings_record(&record)?; + Ok(build_settings_snapshot(&record)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_extension_dir(base_dir: &Path, version: &str) -> PathBuf { + let extension_dir = base_dir.join(format!("extension-{version}")); + fs::create_dir_all(&extension_dir).expect("创建扩展目录失败"); + fs::write( + extension_dir.join("manifest.json"), + format!( + r#"{{ + "name": "Lime Browser Connector", + "version": "{version}" +}}"# + ), + ) + .expect("写入 manifest 失败"); + extension_dir + } + + #[test] + fn install_dir_should_use_fixed_child_directory() { + let root = PathBuf::from("/tmp/lime-browser-root"); + let install_dir = resolve_browser_connector_install_dir(&root); + assert_eq!(install_dir, root.join("Lime Browser Connector")); + } + + #[test] + fn status_should_report_not_installed_without_root_dir() { + let temp_dir = tempfile::tempdir().expect("创建临时目录失败"); + let bundled_dir = create_extension_dir(temp_dir.path(), "1.0.0"); + let status = build_install_status_from_paths(&bundled_dir, None).expect("读取状态失败"); + + assert_eq!(status.status, "not_installed"); + assert_eq!(status.bundled_version, "1.0.0"); + assert!(status.install_root_dir.is_none()); + } + + #[test] + fn status_should_report_update_available_when_versions_differ() { + let temp_dir = tempfile::tempdir().expect("创建临时目录失败"); + let bundled_dir = create_extension_dir(temp_dir.path(), "1.1.0"); + let install_root = temp_dir.path().join("user-selected"); + let installed_dir = resolve_browser_connector_install_dir(&install_root); + fs::create_dir_all(&installed_dir).expect("创建安装目录失败"); + fs::write( + installed_dir.join("manifest.json"), + r#"{ + "name": "Lime Browser Connector", + "version": "1.0.0" +}"#, + ) + .expect("写入已安装 manifest 失败"); + + let status = build_install_status_from_paths(&bundled_dir, Some(&install_root)) + .expect("读取状态失败"); + assert_eq!(status.status, "update_available"); + assert_eq!(status.installed_version.as_deref(), Some("1.0.0")); + assert_eq!(status.bundled_version, "1.1.0"); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 72fa3e287..f922ee32b 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -14,6 +14,7 @@ pub mod artifact_prompt_service; pub mod artifact_request_metadata_service; pub mod auto_memory_service; pub mod automation_service; +pub mod browser_connector_service; pub mod browser_environment_service; pub mod browser_profile_service; pub mod browser_runtime_window; diff --git a/src-tauri/src/services/runtime_evidence_pack_service.rs b/src-tauri/src/services/runtime_evidence_pack_service.rs index 873582d82..36922e9f1 100644 --- a/src-tauri/src/services/runtime_evidence_pack_service.rs +++ b/src-tauri/src/services/runtime_evidence_pack_service.rs @@ -709,7 +709,8 @@ fn format_observability_signal_list(observability_summary: &Value, status: &str) values .iter() .filter(|value| { - value.get("status") + value + .get("status") .and_then(Value::as_str) .map(|value| value == status) .unwrap_or(false) diff --git a/src/App.tsx b/src/App.tsx index e659cd960..c1098c2ae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,7 @@ import { toast } from "sonner"; import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { hasTauriInvokeCapability } from "@/lib/tauri-runtime"; +import { SettingsTabs } from "./types/settings"; const AppContainer = styled.div` display: flex; @@ -424,6 +425,21 @@ function AppContent() { toast.success("项目创建成功"); }; + const handleOpenBrowserConnectorSettings = useCallback( + ({ enable }: { enable: boolean }) => { + handleNavigate("settings", { + tab: SettingsTabs.ChromeRelay, + }); + + if (enable) { + toast.info("已打开连接器设置", { + description: "在“连接器”页中开启浏览器连接器或重新同步扩展。", + }); + } + }, + [handleNavigate], + ); + const { connectPayload, relayInfo, @@ -433,7 +449,9 @@ function AppContent() { error, handleConfirm, handleCancel, - } = useDeepLink(); + } = useDeepLink({ + onOpenBrowserConnectorSettings: handleOpenBrowserConnectorSettings, + }); const { error: registryError, refresh: _refreshRegistry } = useRelayRegistry(); diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index f21662339..c36695968 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -15,12 +15,13 @@ import { useRef, } from "react"; import { toast } from "sonner"; -import { useAgentChatUnified, useCompatSubagentRuntime } from "./hooks"; +import { useAgentChatUnified } from "./hooks"; import { type TaskStatusReason } from "./hooks/agentChatShared"; import { settleLiveArtifactAfterStreamStops, useArtifactDisplayState, } from "./hooks/useArtifactDisplayState"; +import { useCompatSubagentRuntime } from "./hooks/useCompatSubagentRuntime"; import type { TopicBranchStatus } from "./hooks/useTopicBranchBoard"; import { useSessionFiles } from "./hooks/useSessionFiles"; import { useContentSync } from "./hooks/useContentSync"; @@ -1991,13 +1992,18 @@ export function AgentChatWorkspace({ contentId, onRunImageWorkbenchCommand: handleImageWorkbenchCommand, }); - const { handleA2UISubmit, handleInputbarA2UISubmit } = - useWorkspaceA2UISubmitActions({ - handlePermissionResponseWithBrowserPreflight, - pendingLegacyQuestionnaireA2UIForm, - pendingPromotedA2UIActionRequest, - sendMessage, - }); + const { handleInputbarA2UISubmit } = useWorkspaceA2UISubmitActions({ + handlePermissionResponseWithBrowserPreflight, + pendingLegacyQuestionnaireA2UIForm, + pendingPromotedA2UIActionRequest, + sendMessage, + }); + const handleMessageA2UISubmit = useCallback( + (formData: Parameters[0], _messageId: string) => { + handleInputbarA2UISubmit(formData); + }, + [handleInputbarA2UISubmit], + ); // 监听主题工作台技能触发 useEffect(() => { @@ -2643,7 +2649,7 @@ export function AgentChatWorkspace({ promoteQueuedTurn, deleteMessage, editMessage, - handleA2UISubmit, + handleA2UISubmit: handleMessageA2UISubmit, handleWriteFile, handleFileClick: handleWorkspaceFileClick, handleOpenArtifactFromTimeline, diff --git a/src/components/agent/chat/components/A2UITaskCard.test.tsx b/src/components/agent/chat/components/A2UITaskCard.test.tsx index 98ca944e5..73417ffb9 100644 --- a/src/components/agent/chat/components/A2UITaskCard.test.tsx +++ b/src/components/agent/chat/components/A2UITaskCard.test.tsx @@ -49,7 +49,7 @@ describe("A2UITaskCard", () => { expect(container.querySelector("[data-testid='agent-a2ui-task-card']")).not.toBeNull(); expect(container.textContent).toContain("补充信息"); - expect(container.textContent).toContain("待完成 1 / 1"); + expect(container.textContent).toContain("等你确认"); clickButtonByText(container, "新写一篇内容"); await flushEffects(); @@ -77,6 +77,6 @@ describe("A2UITaskCard", () => { container.querySelector("[data-testid='agent-a2ui-task-loading-card']"), ).not.toBeNull(); expect(container.textContent).toContain("正在解析结构化问题,请稍等。"); - expect(container.textContent).toContain("表单加载中..."); + expect(container.textContent).toContain("这一步加载中..."); }); }); diff --git a/src/components/agent/chat/components/ActionRequestA2UIPreviewCard.tsx b/src/components/agent/chat/components/ActionRequestA2UIPreviewCard.tsx index 3157c9d80..413d90608 100644 --- a/src/components/agent/chat/components/ActionRequestA2UIPreviewCard.tsx +++ b/src/components/agent/chat/components/ActionRequestA2UIPreviewCard.tsx @@ -21,18 +21,18 @@ interface ActionRequestA2UIPreviewCardProps { function resolveStatusLabel(request: ActionRequired): string { switch (request.status) { case "queued": - return "已记录"; + return "已记下"; case "submitted": return "已确认"; default: - return "待补充"; + return "等你补充"; } } function resolveTitle(request: ActionRequired): string { return request.status === "submitted" || request.status === "queued" - ? "已确认的补充信息" - : "补充信息"; + ? "你补充的信息" + : "等你补充信息"; } function resolveSubtitle( @@ -40,18 +40,18 @@ function resolveSubtitle( context: "chat" | "timeline", ): string { if (request.status === "queued") { - return "答案已记录,等待系统请求就绪后会自动继续执行。"; + return "已经记下了,系统就绪后会继续。"; } if (request.status === "submitted") { return context === "timeline" - ? "该阶段的问答已完成,阶段记录已改为结构化回显。" - : "已收到你的补充信息,助手会继续执行后续流程。"; + ? "这一步已经确认,继续往下做。" + : "收到这一步了,继续往下做。"; } return context === "timeline" - ? "该阶段需要补充信息,请在输入区表单中完成确认后继续。" - : "请先完成这一步,我再继续当前对话。"; + ? "去输入区把这一步补完。" + : "先补这一步,我再继续当前对话。"; } export function ActionRequestA2UIPreviewCard({ diff --git a/src/components/agent/chat/components/AgentRuntimeStrip.tsx b/src/components/agent/chat/components/AgentRuntimeStrip.tsx index 5b3809cc7..1e5cc4270 100644 --- a/src/components/agent/chat/components/AgentRuntimeStrip.tsx +++ b/src/components/agent/chat/components/AgentRuntimeStrip.tsx @@ -7,7 +7,7 @@ import type { } from "@/lib/api/agentRuntime"; import type { ChatToolPreferences } from "../utils/chatToolPreferences"; -import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime"; +import type { CompatSubagentRuntimeStatus } from "../utils/compatSubagentRuntime"; import type { HarnessSessionState } from "../utils/harnessState"; import { getExecutionRuntimeDisplayLabel, @@ -19,10 +19,7 @@ interface AgentRuntimeStripProps { toolPreferences: ChatToolPreferences; harnessState: HarnessSessionState; childSubagentSessions?: AsterSubagentSessionInfo[]; - compatSubagentRuntime: Pick< - CompatSubagentRuntimeSnapshot, - "isRunning" | "progress" - >; + compatSubagentRuntime: CompatSubagentRuntimeStatus; variant?: "standalone" | "embedded"; isSending?: boolean; executionRuntime?: AsterSessionExecutionRuntime | null; diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index a69b2e3d2..dd38709a1 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -13,12 +13,58 @@ import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime"; import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation"; const parseAIResponseMock = vi.fn(); + +function resolveMockToolText(toolCall: { + name: string; + arguments?: string; + status?: string; +}) { + let parsedArguments: Record | null = null; + if (toolCall.arguments) { + try { + parsedArguments = JSON.parse(toolCall.arguments) as Record; + } catch { + parsedArguments = null; + } + } + + if ( + toolCall.name === "browser_navigate" && + typeof parsedArguments?.url === "string" + ) { + return `打开 ${parsedArguments.url}`; + } + + if ( + (toolCall.name === "web_search" || toolCall.name === "search_query") && + typeof parsedArguments?.query === "string" + ) { + return `搜索 ${parsedArguments.query}`; + } + + if ( + toolCall.name === "exec_command" && + typeof parsedArguments?.command === "string" + ) { + return `执行 ${parsedArguments.command}`; + } + + if ( + toolCall.name === "lime_site_run" && + typeof parsedArguments?.adapter_name === "string" + ) { + return `执行 ${parsedArguments.adapter_name}`; + } + + return toolCall.name; +} + const mockToolCallItem = vi.fn( ({ toolCall, onOpenSavedSiteContent, }: { - toolCall: { name: string }; + toolCall: { name: string; arguments?: string; status?: string }; onOpenSavedSiteContent?: (target: { projectId: string; contentId: string; @@ -29,7 +75,8 @@ const mockToolCallItem = vi.fn( data-testid="tool-call-item" data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"} > - {toolCall.name} + {resolveMockToolText(toolCall)} + {toolCall.status === "running" ? " 进行中" : ""}
), ); @@ -51,7 +98,7 @@ vi.mock("./A2UITaskCard", () => ({ vi.mock("./ToolCallDisplay", () => ({ ToolCallItem: (props: { - toolCall: { name: string }; + toolCall: { name: string; arguments?: string; status?: string }; onOpenSavedSiteContent?: (target: { projectId: string; contentId: string; @@ -196,19 +243,6 @@ function renderTimeline( return container; } -function clickTimelineToggle(container: HTMLElement) { - const button = container.querySelector( - '[data-testid="agent-thread-details-toggle"]', - ); - if (!button) { - throw new Error("未找到执行细节切换按钮"); - } - - act(() => { - button.click(); - }); -} - function createFileArtifactItem( overrides: Partial> = {}, ): Extract { @@ -240,14 +274,60 @@ function createFileArtifactItem( } describe("AgentThreadTimeline", () => { + it("默认直接渲染内联时间线,不再显示旧摘要壳", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("summary-1", 1), + type: "turn_summary", + text: "已完成页面检查\n可以继续执行发布。", + }, + { + ...createBaseItem("browser-1", 2), + type: "tool_call", + tool_name: "browser_navigate", + arguments: { url: "https://mp.weixin.qq.com" }, + }, + { + ...createBaseItem("approval-1", 3), + type: "approval_request", + request_id: "req-1", + action_type: "tool_confirmation", + prompt: "请确认是否发布文章", + tool_name: "browser_click", + }, + ]; + + const container = renderTimeline(items, { isCurrentTurn: true }); + + expect( + container.querySelector('[data-testid="agent-thread-flow"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-overview"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-summary-shell"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-details-toggle"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-goal"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-focus"]'), + ).toBeNull(); + expect(container.textContent).toContain("已完成页面检查"); + expect(container.textContent).toContain("打开 https://mp.weixin.qq.com"); + expect(container.textContent).toContain("请确认是否发布文章"); + }); + it("file_artifact 命中多个 block 时应提供精确跳转按钮", () => { const onOpenArtifactFromTimeline = vi.fn(); const container = renderTimeline([createFileArtifactItem()], { onOpenArtifactFromTimeline, }); - clickTimelineToggle(container); - const heroJumpButton = Array.from( container.querySelectorAll("button"), ).find((button) => button.textContent?.includes("跳到 block hero-1")); @@ -285,23 +365,21 @@ describe("AgentThreadTimeline", () => { }, ); - expect( - container.querySelector('[data-testid="agent-thread-details"]'), - ).not.toBeNull(); - expect( - container.querySelector('[data-testid="agent-thread-details-toggle"]'), - ).toBeNull(); - + const block = container.querySelector( + '[data-testid="agent-thread-block:1:browser"]', + ); const focusedEntry = container.querySelector( '[data-thread-item-id="browser-1"]', ); + + expect(block).not.toBeNull(); expect(focusedEntry?.className).toContain("ring-2"); expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled(); }); - it("应向时间线内的工具项透传已保存站点内容打开回调", () => { + it("应向时间线内的工具明细透传已保存站点内容打开回调", () => { const onOpenSavedSiteContent = vi.fn(); - const container = renderTimeline( + renderTimeline( [ { ...createBaseItem("site-tool-1", 1), @@ -322,227 +400,12 @@ describe("AgentThreadTimeline", () => { { onOpenSavedSiteContent }, ); - clickTimelineToggle(container); - expect(mockToolCallItem).toHaveBeenCalledWith( expect.objectContaining({ onOpenSavedSiteContent }), ); }); - it("应在时间线头部展示当前 turn 的 compact outcome 与 incident 徽标", () => { - const container = renderTimeline( - [ - { - ...createBaseItem("summary-1", 1), - type: "turn_summary", - text: "最近一次 Provider 调用失败,等待人工处理。", - }, - ], - { - threadRead: { - thread_id: "thread-1", - status: "failed", - active_turn_id: "turn-1", - pending_requests: [], - last_outcome: { - thread_id: "thread-1", - turn_id: "turn-1", - outcome_type: "failed_provider", - summary: "Provider 请求失败", - primary_cause: "429 rate limited", - retryable: true, - ended_at: at(9), - }, - incidents: [ - { - id: "incident-1", - thread_id: "thread-1", - turn_id: "turn-1", - incident_type: "provider_failure", - severity: "high", - status: "active", - title: "Provider 连续失败", - }, - ], - }, - }, - ); - - expect( - container.querySelector('[data-testid="agent-thread-compact-outcome"]') - ?.textContent, - ).toContain("Provider 失败"); - expect( - container.querySelector('[data-testid="agent-thread-compact-incident"]') - ?.textContent, - ).toContain("1 个 incident"); - - clickTimelineToggle(container); - - expect( - container.querySelector('[data-testid="agent-thread-summary-outcome"]') - ?.textContent, - ).toContain("Provider 失败"); - expect( - container.querySelector('[data-testid="agent-thread-summary-incident"]') - ?.textContent, - ).toContain("1 个 incident"); - }); - - it("应渲染当前阶段概览与按时序组织的分组块", () => { - const items: AgentThreadItem[] = [ - { - ...createBaseItem("plan-1", 1), - type: "plan", - text: "1. 打开 CDP 页面\n2. 检查登录态", - }, - { - ...createBaseItem("summary-1", 2), - type: "turn_summary", - text: "已完成页面检查\n可以继续执行发布。", - }, - { - ...createBaseItem("browser-1", 3), - type: "tool_call", - tool_name: "browser_navigate", - arguments: { url: "https://mp.weixin.qq.com" }, - }, - { - ...createBaseItem("browser-2", 4), - type: "tool_call", - tool_name: "browser_click", - arguments: { selector: "#publish" }, - }, - { - ...createBaseItem("approval-1", 5), - type: "approval_request", - request_id: "req-1", - action_type: "tool_confirmation", - prompt: "请确认是否发布文章", - tool_name: "browser_click", - }, - { - ...createBaseItem("other-1", 6), - type: "tool_call", - tool_name: "workspace_sync", - }, - ]; - - const container = renderTimeline(items, { isCurrentTurn: true }); - - expect( - container.querySelector('[data-testid="agent-thread-overview"]') - ?.textContent, - ).toContain("已完成页面检查"); - expect( - container.querySelector('[data-testid="agent-thread-details-inline-text"]') - ?.textContent, - ).toContain("思考与计划"); - const overviewNode = container.querySelector('[data-testid="agent-thread-overview"]'); - const toggleNode = container.querySelector('[data-testid="agent-thread-details-toggle"]'); - expect( - Boolean( - overviewNode && - toggleNode && - overviewNode.compareDocumentPosition(toggleNode) & - Node.DOCUMENT_POSITION_FOLLOWING, - ), - ).toBe(true); - expect( - container.querySelector('[data-testid="agent-thread-flow"]'), - ).toBeNull(); - - clickTimelineToggle(container); - - expect( - container.querySelector('[data-testid="agent-thread-summary"]'), - ).not.toBeNull(); - expect( - container.querySelector('[data-testid="agent-thread-overview"]'), - ).toBeNull(); - expect( - container.querySelector('[data-testid="agent-thread-details-inline-text"]'), - ).toBeNull(); - expect( - container.querySelector('[data-testid="agent-thread-details-toggle"]'), - ).toBeNull(); - expect( - container.querySelector('[data-testid="agent-thread-summary-collapse"]'), - ).not.toBeNull(); - expect(container.textContent).toContain("当前任务摘要"); - expect( - container.querySelector('[data-testid="agent-thread-summary-header"]') - ?.textContent, - ).not.toContain("段流程"); - expect( - container.querySelector('[data-testid="agent-thread-summary-header"]') - ?.textContent, - ).not.toContain("已完成"); - expect( - container.querySelector('[data-testid="agent-thread-summary-shell"]'), - ).not.toBeNull(); - - expect( - container.querySelector('[data-testid="agent-thread-goal"]')?.textContent, - ).toContain("请检查并发布文章"); - expect( - container.querySelector('[data-testid="agent-thread-focus"]'), - ).not.toBeNull(); - expect(container.textContent).toContain("已完成页面检查"); - expect( - container.querySelector('[data-testid="agent-thread-flow"]'), - ).not.toBeNull(); - expect(container.textContent).toContain("思考与计划"); - expect(container.textContent).toContain("浏览器操作"); - expect(container.textContent).toContain("需要你处理"); - expect(container.textContent).toContain("执行过程"); - }); - - it("展开后应在摘要头提供收起入口,并恢复折叠态头部", () => { - const items: AgentThreadItem[] = [ - { - ...createBaseItem("summary-1", 1), - type: "turn_summary", - text: "已整理出下一步执行顺序。", - }, - { - ...createBaseItem("browser-1", 2), - type: "tool_call", - tool_name: "browser_click", - arguments: { selector: "#publish" }, - }, - ]; - - const container = renderTimeline(items, { - isCurrentTurn: true, - turn: { - status: "running", - }, - }); - - clickTimelineToggle(container); - - const collapseButton = container.querySelector( - '[data-testid="agent-thread-summary-collapse"]', - ); - expect(collapseButton).not.toBeNull(); - - act(() => { - collapseButton?.click(); - }); - - expect( - container.querySelector('[data-testid="agent-thread-summary"]'), - ).toBeNull(); - expect( - container.querySelector('[data-testid="agent-thread-details-toggle"]'), - ).not.toBeNull(); - expect( - container.querySelector('[data-testid="agent-thread-overview"]'), - ).not.toBeNull(); - }); - - it("审批块应默认展开,处理记录块默认折叠", () => { + it("审批项与技术项都应直接落在消息流中", () => { const items: AgentThreadItem[] = [ { ...createBaseItem("approval-1", 1), @@ -560,13 +423,6 @@ describe("AgentThreadTimeline", () => { ]; const container = renderTimeline(items); - - expect( - container.querySelector('[data-testid="agent-thread-flow"]'), - ).toBeNull(); - - clickTimelineToggle(container); - const approvalGroup = container.querySelector( '[data-testid="agent-thread-block:1:approval"]', ); @@ -574,22 +430,10 @@ describe("AgentThreadTimeline", () => { '[data-testid="agent-thread-block:2:other"]', ); - expect(approvalGroup?.hasAttribute("open")).toBe(true); - expect(otherGroup?.hasAttribute("open")).toBe(false); - expect( - container.querySelector('[data-testid="agent-thread-block:1:approval:rail"]'), - ).not.toBeNull(); - expect( - container.querySelector( - '[data-testid="agent-thread-block:1:approval:details"]', - ), - ).not.toBeNull(); - expect( - container.querySelector( - '[data-testid="agent-thread-block:2:other:details"]', - ), - ).not.toBeNull(); - expect(container.textContent).toContain("次要执行记录"); + expect(approvalGroup).not.toBeNull(); + expect(otherGroup).not.toBeNull(); + expect(container.textContent).toContain("请确认是否继续"); + expect(container.textContent).toContain("workspace_sync"); }); it("应按真实发生顺序渲染思考与工具块", () => { @@ -614,14 +458,18 @@ describe("AgentThreadTimeline", () => { ]; const container = renderTimeline(items); - clickTimelineToggle(container); const blockIds = Array.from( container.querySelectorAll( - "details[data-testid^='agent-thread-block:']", + "[data-testid^='agent-thread-block:']", ), ) .map((node) => node.dataset.testid) - .filter((value): value is string => Boolean(value)); + .filter((value): value is string => Boolean(value)) + .filter( + (value) => + !value.endsWith(":shell") && + !value.endsWith(":details"), + ); expect(blockIds).toEqual([ "agent-thread-block:1:browser", @@ -630,49 +478,7 @@ describe("AgentThreadTimeline", () => { ]); }); - it("完成后折叠条仍应保留最近的思考过程", () => { - const items: AgentThreadItem[] = [ - { - ...createBaseItem("browser-1", 1), - type: "tool_call", - tool_name: "browser_navigate", - arguments: { url: "https://example.com" }, - }, - { - ...createBaseItem("plan-1", 2), - type: "plan", - text: "先梳理问题背景,再给出三套方案。", - }, - { - ...createBaseItem("browser-2", 3), - type: "tool_call", - tool_name: "browser_click", - arguments: { selector: "#submit" }, - }, - ]; - - const container = renderTimeline(items, { - isCurrentTurn: true, - turn: { - status: "completed", - }, - }); - - expect( - container.querySelector('[data-testid="agent-thread-overview"]') - ?.textContent, - ).toContain("先梳理问题背景"); - expect( - container.querySelector('[data-testid="agent-thread-details-stage"]') - ?.textContent, - ).toContain("步骤 02"); - expect( - container.querySelector('[data-testid="agent-thread-details-inline-text"]') - ?.textContent, - ).toContain("思考与计划"); - }); - - it("运行中的块应被高亮,已完成块应降噪", () => { + it("运行中的块应高亮,已完成块应降噪", () => { const items: AgentThreadItem[] = [ { ...createBaseItem("browser-1", 1), @@ -697,17 +503,6 @@ describe("AgentThreadTimeline", () => { ]; const container = renderTimeline(items, { isCurrentTurn: true }); - - expect( - container.querySelector('[data-testid="agent-thread-details-inline-icon"]') - ?.getAttribute("data-state"), - ).toBe("running"); - expect( - container.querySelector('[data-testid="agent-thread-details-inline-text"]') - ?.textContent, - ).toContain("Mac mini 最新价格"); - - clickTimelineToggle(container); const browserBlock = container.querySelector( '[data-testid="agent-thread-block:1:browser"]', ); @@ -721,44 +516,10 @@ describe("AgentThreadTimeline", () => { expect(browserBlock?.dataset.emphasis).toBe("quiet"); expect(searchBlock?.dataset.emphasis).toBe("active"); expect(otherBlock?.dataset.emphasis).toBe("quiet"); - expect(browserBlock?.hasAttribute("open")).toBe(true); - expect(searchBlock?.hasAttribute("open")).toBe(true); - expect(otherBlock?.hasAttribute("open")).toBe(false); - expect(container.textContent).toContain("执行中"); + expect(container.textContent).toContain("Mac mini 最新价格"); }); - it("流程展开后不应重复显示顶部当前进展卡片", () => { - const items: AgentThreadItem[] = [ - { - ...createBaseItem("search-1", 1), - status: "in_progress", - completed_at: undefined, - updated_at: at(1), - type: "web_search", - action: "web_search", - query: "team runtime 侧栏高度", - }, - ]; - - const container = renderTimeline(items, { - isCurrentTurn: true, - turn: { - status: "running", - }, - }); - - clickTimelineToggle(container); - - expect( - container.querySelector('[data-testid="agent-thread-details"]'), - ).not.toBeNull(); - expect( - container.querySelector('[data-testid="agent-thread-overview"]'), - ).toBeNull(); - expect(container.textContent).toContain("当前任务摘要"); - }); - - it("浏览器前置等待时不应显示已中断,而应显示待继续", () => { + it("浏览器前置等待时应显示轻量待继续提示", () => { const items: AgentThreadItem[] = [ { ...createBaseItem("browser-1", 1), @@ -785,20 +546,15 @@ describe("AgentThreadTimeline", () => { ], }); - expect(container.textContent).toContain("待继续"); + expect( + container.querySelector('[data-testid="agent-thread-inline-status"]') + ?.textContent, + ).toContain("待继续"); expect(container.textContent).toContain("完成登录"); expect(container.textContent).not.toContain("已中断"); - - clickTimelineToggle(container); - - expect( - container - .querySelector('[data-testid="agent-thread-block:1:browser"]') - ?.hasAttribute("open"), - ).toBe(true); }); - it("普通 aborted 回合应显示已暂停,而不是已中断", () => { + it("普通 aborted 回合应显示已暂停提示", () => { const items: AgentThreadItem[] = [ { ...createBaseItem("other-1", 1), @@ -813,39 +569,13 @@ describe("AgentThreadTimeline", () => { }, }); - expect(container.textContent).toContain("已暂停"); + expect( + container.querySelector('[data-testid="agent-thread-inline-status"]') + ?.textContent, + ).toContain("已暂停"); expect(container.textContent).not.toContain("已中断"); }); - it("单个已完成阶段不应再默认展开", () => { - const items: AgentThreadItem[] = [ - { - ...createBaseItem("summary-1", 1), - type: "turn_summary", - text: "已整理为 notebook 工作方式。", - }, - ]; - - const container = renderTimeline(items, { - isCurrentTurn: true, - turn: { - status: "completed", - }, - }); - - expect( - container.querySelector('[data-testid="agent-thread-flow"]'), - ).toBeNull(); - - clickTimelineToggle(container); - - expect( - container - .querySelector('[data-testid="agent-thread-block:1:thinking"]') - ?.hasAttribute("open"), - ).toBe(false); - }); - it("思考摘要中的 A2UI 代码块应切换为结构化预览", () => { parseAIResponseMock.mockReturnValue({ parts: [ @@ -886,12 +616,6 @@ describe("AgentThreadTimeline", () => { }, }); - expect( - container.querySelector('[data-testid="agent-thread-flow"]'), - ).toBeNull(); - - clickTimelineToggle(container); - expect( container.querySelector('[data-testid="timeline-a2ui-card"]'), ).not.toBeNull(); @@ -899,7 +623,7 @@ describe("AgentThreadTimeline", () => { expect(container.textContent).not.toContain("```a2ui"); }); - it("纯 reasoning 阶段展开后不应重复渲染思考摘要卡", () => { + it("纯 reasoning 阶段仅在时间线中出现一次", () => { const reasoningText = "先核对执行链路,再立即恢复当前运行。"; const items: AgentThreadItem[] = [ { @@ -916,8 +640,6 @@ describe("AgentThreadTimeline", () => { }, }); - clickTimelineToggle(container); - expect( container.querySelector('[data-testid="agent-thread-block:1:thinking:details"]'), ).toBeNull(); @@ -925,6 +647,40 @@ describe("AgentThreadTimeline", () => { expect((container.textContent?.split(reasoningText).length ?? 1) - 1).toBe(1); }); + it("已完成的思考应默认折叠,只保留摘要行", () => { + const reasoningText = "先核对执行链路,再立即恢复当前运行。\n随后补齐自动续提。"; + const items: AgentThreadItem[] = [ + { + ...createBaseItem("reasoning-1", 1), + type: "reasoning", + text: reasoningText, + }, + ]; + + const container = renderTimeline(items, { + turn: { + status: "completed", + }, + }); + + const block = container.querySelector( + '[data-testid="agent-thread-block:1:thinking"]', + ); + const summary = block?.querySelector("summary"); + + expect(block?.open).toBe(false); + expect(summary?.textContent).toContain("已完成思考"); + expect(summary?.textContent).toContain("先核对执行链路,再立即恢复当前运行。"); + expect(container.textContent).not.toContain("随后补齐自动续提。"); + + act(() => { + summary?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(block?.open).toBe(true); + expect(container.textContent).toContain("随后补齐自动续提。"); + }); + it("已完成的 request_user_input 应以只读 A2UI 卡片回显", () => { const items: AgentThreadItem[] = [ { @@ -949,8 +705,6 @@ describe("AgentThreadTimeline", () => { }, }); - clickTimelineToggle(container); - expect( container.querySelector('[data-testid="timeline-a2ui-card"]'), ).not.toBeNull(); @@ -977,8 +731,6 @@ describe("AgentThreadTimeline", () => { onOpenSubagentSession, }); - clickTimelineToggle(container); - expect(container.textContent).toContain("图片任务 1"); expect(container.textContent).not.toContain("Image #1"); diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 952bbed02..649bef1d1 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -2,26 +2,17 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, Bot, - CheckCircle2, ChevronDown, Clock3, FileText, - Globe, + ListChecks, Loader2, - Search, ShieldAlert, Sparkles, - TerminalSquare, - Wrench, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; import type { AgentToolCallState as ToolCallState } from "@/lib/api/agentProtocol"; import type { ActionRequired, @@ -33,7 +24,6 @@ import type { import { buildAgentThreadDisplayModel, type AgentThreadOrderedBlock, - type AgentThreadSummaryChip, } from "../utils/agentThreadGrouping"; import type { AgentRuntimeThreadReadModel } from "@/lib/api/agentRuntime"; import { isActionRequestA2UICompatible } from "../utils/actionRequestA2UI"; @@ -42,23 +32,15 @@ import { parseAIResponse } from "@/components/content-creator/a2ui/parser"; import type { A2UIResponse } from "@/components/content-creator/a2ui/types"; import { TIMELINE_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets"; import { cn } from "@/lib/utils"; -import { - resolveIncidentToneFromSeverity, - resolveOutcomeLabel, - resolveOutcomeTone, - type ThreadReliabilityTone, -} from "../utils/threadReliabilityView"; import { MarkdownRenderer } from "./MarkdownRenderer"; import { ActionRequestA2UIPreviewCard } from "./ActionRequestA2UIPreviewCard"; import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard"; import { ToolCallItem } from "./ToolCallDisplay"; import { DecisionPanel } from "./DecisionPanel"; -import { AgentPlanBlock } from "./AgentPlanBlock"; import { resolveTimelineArtifactNavigation, type ArtifactTimelineOpenTarget, } from "../utils/artifactTimelineNavigation"; -import { TimelineInlineItem } from "./TimelineInlineItem"; interface AgentThreadTimelineProps { turn: AgentThreadTurn; @@ -66,6 +48,7 @@ interface AgentThreadTimelineProps { threadRead?: AgentRuntimeThreadReadModel | null; actionRequests?: ActionRequired[]; isCurrentTurn?: boolean; + placement?: "leading" | "trailing" | "default"; onFileClick?: (fileName: string, content: string) => void; onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void; onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void; @@ -75,26 +58,6 @@ interface AgentThreadTimelineProps { focusRequestKey?: number; } -interface TurnStatusMeta { - label: string; - badgeVariant: "secondary" | "outline" | "destructive"; - badgeClassName?: string; - overviewText: string; -} - -type TimelineCompactTone = - | "running" - | "waiting" - | "failed" - | "paused" - | "done"; - -interface TimelineCompactReliabilityBadge { - key: string; - label: string; - tone: ThreadReliabilityTone; -} - function shortenInlineText( value: string | undefined | null, maxLength = 72, @@ -127,82 +90,6 @@ function formatTimestamp(value?: string): string | null { }); } -function resolveReliabilityBadgeClassName( - tone: ThreadReliabilityTone, -): string { - if (tone === "completed") { - return "border-emerald-200 bg-emerald-50 text-emerald-700"; - } - if (tone === "failed") { - return "border-rose-200 bg-rose-50 text-rose-700"; - } - if (tone === "waiting") { - return "border-amber-200 bg-amber-50 text-amber-700"; - } - if (tone === "running") { - return "border-sky-200 bg-sky-50 text-sky-700"; - } - if (tone === "paused") { - return "border-slate-200 bg-slate-50 text-slate-700"; - } - return "border-slate-200 bg-slate-50 text-slate-700"; -} - -function buildCompactReliabilityBadges( - turn: AgentThreadTurn, - threadRead?: AgentRuntimeThreadReadModel | null, -): TimelineCompactReliabilityBadge[] { - if (!threadRead) { - return []; - } - - const badges: TimelineCompactReliabilityBadge[] = []; - - if (threadRead.last_outcome?.turn_id === turn.id) { - badges.push({ - key: "outcome", - label: resolveOutcomeLabel(threadRead.last_outcome.outcome_type), - tone: resolveOutcomeTone(threadRead.last_outcome.outcome_type), - }); - } - - const activeIncidents = (threadRead.incidents ?? []).filter((incident) => { - const normalizedStatus = (incident.status || "").toLowerCase(); - return ( - incident.turn_id === turn.id && - !incident.cleared_at && - !normalizedStatus.includes("clear") - ); - }); - - if (activeIncidents.length > 0) { - const incidentTone = activeIncidents.reduce( - (currentTone, incident) => { - const nextTone = resolveIncidentToneFromSeverity(incident.severity); - if (currentTone === "failed" || nextTone === currentTone) { - return currentTone; - } - if (nextTone === "failed") { - return nextTone; - } - if (nextTone === "waiting") { - return nextTone; - } - return currentTone; - }, - "neutral", - ); - - badges.push({ - key: "incident", - label: `${activeIncidents.length} 个 incident`, - tone: incidentTone, - }); - } - - return badges; -} - function toQuestionOptions( options: Array<{ label: string; description?: string }> | undefined, ) { @@ -383,38 +270,6 @@ function findLatestPendingAction( return null; } -function findLatestPendingItemAction(items: AgentThreadItem[]) { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if ( - (item.type === "approval_request" || item.type === "request_user_input") && - item.status !== "completed" - ) { - return item; - } - } - - return null; -} - -function resolvePendingItemOverview(item: AgentThreadItem): string { - if ( - (item.type === "approval_request" || item.type === "request_user_input") && - item.prompt?.trim() - ) { - return item.prompt.trim(); - } - - if (item.type === "request_user_input") { - const firstQuestion = item.questions?.find((question) => question.question?.trim()); - if (firstQuestion?.question) { - return firstQuestion.question.trim(); - } - } - - return "当前阶段在等待你确认,完成后会继续后续处理。"; -} - function resolveItemStatusLabel(status: AgentThreadItem["status"]): string { switch (status) { case "in_progress": @@ -427,175 +282,6 @@ function resolveItemStatusLabel(status: AgentThreadItem["status"]): string { } } -function resolveGroupIcon( - kind: AgentThreadOrderedBlock["kind"], -): React.ComponentType<{ className?: string }> { - switch (kind) { - case "thinking": - return Sparkles; - case "approval": - return ShieldAlert; - case "alert": - return AlertTriangle; - case "browser": - return Globe; - case "search": - return Search; - case "file": - return FileText; - case "command": - return TerminalSquare; - case "subagent": - return Bot; - case "other": - default: - return Wrench; - } -} - -function resolveOverviewText( - turn: AgentThreadTurn, - summaryText: string | null, - actionableCount: number, -): string { - if (summaryText) { - return summaryText; - } - - if (turn.status === "running") { - return actionableCount > 0 - ? "正在处理你的请求,最新进展会持续更新。" - : "正在准备处理内容。"; - } - - if (turn.status === "failed") { - return turn.error_message || "当前阶段处理失败,请查看下方异常记录。"; - } - - return actionableCount > 0 - ? "已整理当前阶段的关键进展。" - : "当前阶段没有额外记录。"; -} - -function resolveTurnStatusMeta(params: { - turn: AgentThreadTurn; - items: AgentThreadItem[]; - actionRequests?: ActionRequired[]; - summaryText: string | null; - actionableCount: number; -}): TurnStatusMeta { - const { - turn, - items, - actionRequests, - summaryText, - actionableCount, - } = params; - const pendingAction = findLatestPendingAction(actionRequests); - const hasInProgressItem = items.some((item) => item.status === "in_progress"); - - if (pendingAction?.uiKind === "browser_preflight") { - const phase = pendingAction.browserPrepState || "idle"; - - if (phase === "launching") { - return { - label: "连接浏览器", - badgeVariant: "secondary", - badgeClassName: - "bg-sky-100 text-sky-800 dark:bg-sky-500/15 dark:text-sky-200", - overviewText: - pendingAction.detail?.trim() || - "正在建立浏览器会话,连接成功后会继续当前阶段。", - }; - } - - if (phase === "awaiting_user" || phase === "ready_to_resume") { - return { - label: "待继续", - badgeVariant: "secondary", - badgeClassName: - "bg-amber-100 text-amber-900 dark:bg-amber-500/15 dark:text-amber-200", - overviewText: - pendingAction.detail?.trim() || - pendingAction.prompt?.trim() || - "浏览器已经打开,等待你完成登录、授权或验证后继续。", - }; - } - - return { - label: "浏览器未就绪", - badgeVariant: "secondary", - badgeClassName: - "bg-amber-100 text-amber-900 dark:bg-amber-500/15 dark:text-amber-200", - overviewText: - pendingAction.detail?.trim() || - pendingAction.prompt?.trim() || - "浏览器/CDP 还未连接,请重试启动后继续。", - }; - } - - if (pendingAction) { - return { - label: "待处理", - badgeVariant: "secondary", - badgeClassName: - "bg-amber-100 text-amber-900 dark:bg-amber-500/15 dark:text-amber-200", - overviewText: - pendingAction.prompt?.trim() || - "当前阶段在等待你确认,完成后会继续后续处理。", - }; - } - - const pendingItemAction = findLatestPendingItemAction(items); - if (pendingItemAction) { - return { - label: "待处理", - badgeVariant: "secondary", - badgeClassName: - "bg-amber-100 text-amber-900 dark:bg-amber-500/15 dark:text-amber-200", - overviewText: resolvePendingItemOverview(pendingItemAction), - }; - } - - switch (turn.status) { - case "running": - return { - label: "执行中", - badgeVariant: "secondary", - overviewText: resolveOverviewText(turn, summaryText, actionableCount), - }; - case "failed": - return { - label: "失败", - badgeVariant: "destructive", - overviewText: - turn.error_message || "当前阶段处理失败,请查看下方异常记录。", - }; - case "aborted": - return { - label: "已暂停", - badgeVariant: "outline", - overviewText: - turn.error_message || "当前阶段已暂停,你可以继续处理或开始下一步。", - }; - case "completed": - default: - if (hasInProgressItem) { - return { - label: "执行中", - badgeVariant: "secondary", - overviewText: resolveOverviewText(turn, summaryText, actionableCount), - }; - } - - return { - label: "已完成", - badgeVariant: "outline", - overviewText: resolveOverviewText(turn, summaryText, actionableCount), - }; - } -} - function stringifyItemForDebug(item: AgentThreadItem): string { try { return JSON.stringify(item, null, 2); @@ -618,31 +304,22 @@ function SurfaceCard({ children: React.ReactNode; }) { return ( -
-
-
+
+
+
-
{title}
- {badge ?
{badge}
: null} - {timestamp ? ( -
{timestamp}
- ) : null} +
+
+
{title}
+ {badge ?
{badge}
: null} + {timestamp ? ( +
{timestamp}
+ ) : null} +
+
{children}
+
- {children} -
- ); -} - -function SummaryChip({ - chip, -}: { - chip: AgentThreadSummaryChip; -}) { - return ( -
- {chip.label} - {chip.count}
); } @@ -653,12 +330,6 @@ function ThinkingItemCard({ item: Extract; }) { const parsedContent = useMemo(() => parseAIResponse(item.text, false), [item.text]); - const title = - item.type === "reasoning" - ? "思考摘要" - : item.status === "in_progress" - ? "执行准备" - : "阶段总结"; const hasStructuredPreview = parsedContent.hasA2UI || parsedContent.hasPending; const content = hasStructuredPreview ? ( @@ -687,7 +358,7 @@ function ThinkingItemCard({ key={`timeline-pending-a2ui-${index}`} compact={true} preset={TIMELINE_A2UI_TASK_CARD_PRESET} - subtitle="结构化问答正在整理,请稍等。" + subtitle="这一步还在整理,稍等一下。" /> ); } @@ -711,25 +382,23 @@ function ThinkingItemCard({ ); return ( - +
+
+
{item.status === "in_progress" ? ( - - - 整理中 - + ) : ( - resolveItemStatusLabel(item.status) + )} - - } - timestamp={formatTimestamp(item.completed_at || item.updated_at)} - > - {content} - +
+
+ {item.status === "in_progress" ? ( +
思考中
+ ) : null} +
{content}
+
+
+
); } @@ -748,54 +417,87 @@ function ContextCompactionCard({ : "上下文压缩"; const title = item.stage === "completed" || item.status === "completed" - ? "上下文已压缩" - : "正在压缩上下文"; + ? "压了上下文" + : "正在压上下文"; const detail = item.detail?.trim() || (item.stage === "completed" || item.status === "completed" - ? "较早消息已替换为摘要,后续回复会基于压缩后的上下文继续。" - : "系统正在将较早消息整理为摘要,以释放上下文窗口。"); + ? "把前面的对话压成摘要了,后面接着做。" + : "在把前面的对话压成摘要,马上继续。"); return ( - {item.status === "in_progress" ? ( - - - 压缩中 - - ) : ( - resolveItemStatusLabel(item.status) - )} - - } - timestamp={formatTimestamp(item.completed_at || item.updated_at)} + badge={{triggerLabel}} > -
-
{detail}
-
- {triggerLabel} +
{detail}
+ {item.status === "in_progress" ? ( +
+ + 压缩中 +
+ ) : null} + + ); +} + +function InlinePlanBlock({ + content, + isComplete, +}: { + content: string; + isComplete: boolean; +}) { + if (!content.trim()) { + return null; + } + + return ( +
+
+
+ {isComplete ? ( + + ) : ( + + )} +
+
+
+ {isComplete ? "定了这些步骤" : "还在排步骤"} +
+
+ +
- +
); } function renderThinkingItemDetails(item: AgentThreadItem) { if (item.type === "plan") { - return ( - - ); + return ; } if (item.type === "reasoning") { - return null; + return ( +
+
+
+ {item.status === "in_progress" ? ( + + ) : ( + + )} +
+
+ +
+
+
+ ); } if (item.type === "turn_summary") { @@ -876,14 +578,12 @@ function renderGroupItemDetails( if (toolCall) { return ( -
- -
+ ); } @@ -894,10 +594,10 @@ function renderGroupItemDetails( Boolean(onOpenArtifactFromTimeline) && blockTargets.length === 1; return ( -
+
{onOpenArtifactFromTimeline && blockTargets.length > 1 ? ( @@ -950,12 +657,12 @@ function renderGroupItemDetails( if (item.type === "subagent_activity") { const subagentSessionId = item.session_id?.trim(); const displayTitle = - resolveInternalImageTaskDisplayName(item.title) || "协作成员处理"; + resolveInternalImageTaskDisplayName(item.title) || "协作任务"; return ( {resolveSubagentStatusLabel(item.status_label, item.status)} @@ -992,7 +699,7 @@ function renderGroupItemDetails( return ( {item.type === "warning" ? item.code || "warning" : "失败"} @@ -1014,7 +721,7 @@ function renderGroupItemDetails( } return ( -
+
{item.type} @@ -1028,16 +735,8 @@ function renderGroupItemDetails( ); } -function isCompactTechnicalBlock(block: AgentThreadOrderedBlock): boolean { - return block.kind === "other" && block.status === "completed"; -} - function resolveCompactTechnicalSummary(block: AgentThreadOrderedBlock): string { - const firstPreview = block.previewLines[0]; - if (firstPreview) { - return `已收起 ${block.items.length} 条次要执行记录,最近一条:${firstPreview}`; - } - return `已收起 ${block.items.length} 条次要执行记录。`; + return `处理了 ${block.items.length} 个步骤`; } function resolveActiveBlockIndex(blocks: AgentThreadOrderedBlock[]): number { @@ -1139,9 +838,11 @@ function resolveExpandedBlockIndexes(params: { if (focusBlockIndex >= 0) { const focusBlock = blocks[focusBlockIndex]; + const completedThinkingBlock = + focusBlock?.kind === "thinking" && focusBlock.status === "completed"; const shouldExpandFocus = focusBlock?.status !== "completed" || - turn.status === "running" || + (!completedThinkingBlock && turn.status === "running") || turn.status === "failed" || turn.status === "aborted"; @@ -1160,259 +861,207 @@ function resolveExpandedBlockIndexes(params: { return expanded; } -function resolveTimelineDetailsDefaultExpanded(params: { - turn: AgentThreadTurn; - items: AgentThreadItem[]; - actionRequests?: ActionRequired[]; - isCurrentTurn: boolean; -}): boolean { - void params; - return false; +function hasAnyPrefix(value: string, prefixes: string[]): boolean { + return prefixes.some((prefix) => value.startsWith(prefix)); } -function resolveCompactTone(params: { - turn: AgentThreadTurn; - turnStatusMeta: TurnStatusMeta; -}): TimelineCompactTone { - const { turn, turnStatusMeta } = params; - - if (turn.status === "failed") { - return "failed"; - } - - if (turn.status === "running" || turnStatusMeta.label === "执行中") { - return "running"; +function normalizeBlockPreviewLine( + kind: AgentThreadOrderedBlock["kind"], + line: string, +): string { + const trimmed = line.trim(); + if (!trimmed) { + return line; } if ( - turnStatusMeta.label === "待处理" || - turnStatusMeta.label === "待继续" || - turnStatusMeta.label === "连接浏览器" || - turnStatusMeta.label === "浏览器未就绪" + kind === "file" && + !hasAnyPrefix(trimmed, ["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "]) ) { - return "waiting"; + return `看了 ${trimmed}`; } - if (turn.status === "aborted") { - return "paused"; + if (kind === "command" && !hasAnyPrefix(trimmed, ["执行了 ", "跑了 ", "运行了 "])) { + return `执行了 ${trimmed}`; } - return "done"; + if (kind === "search" && !hasAnyPrefix(trimmed, ["搜了 ", "查了 ", "搜索了 ", "检索了 "])) { + return `搜了 ${trimmed}`; + } + + if ( + kind === "approval" && + !hasAnyPrefix(trimmed, ["等你补充:", "等你确认:", "等你补充信息", "等你确认这一步"]) + ) { + return `等你确认:${trimmed}`; + } + + if (kind === "alert" && !hasAnyPrefix(trimmed, ["收到提醒:", "碰到错误:"])) { + return `收到提醒:${trimmed}`; + } + + if (kind === "subagent" && !hasAnyPrefix(trimmed, ["分给协作成员", "协作成员"])) { + return `分给协作成员处理 ${trimmed}`; + } + + return trimmed; } -function resolveFocusInlineText( - block: AgentThreadOrderedBlock | null, -): string | null { - if (!block) { - return null; - } +function resolveBlockSummaryLines(block: AgentThreadOrderedBlock): string[] { + const normalizedPreviewLines = block.previewLines + .map((line) => normalizeBlockPreviewLine(block.kind, line)) + .filter((line) => line.trim().length > 0) + .map((line) => shortenInlineText(line, 92) || line); - if (isCompactTechnicalBlock(block)) { - return resolveCompactTechnicalSummary(block); - } - - const preview = block.previewLines.find((line) => line.trim().length > 0); - if (preview) { - return preview; - } - - return block.title.trim() || null; -} - -function resolveLatestThinkingPreview( - blocks: AgentThreadOrderedBlock[], -): { - text: string | null; - stageLabel: string | null; -} { - for (let index = blocks.length - 1; index >= 0; index -= 1) { - const block = blocks[index]; - if (block?.kind !== "thinking") { - continue; + if (block.kind === "thinking") { + const headline = block.status === "in_progress" ? "思考中" : "已完成思考"; + if (normalizedPreviewLines.length > 0) { + return [ + headline, + ...normalizedPreviewLines.filter((line) => line !== headline), + ]; } - const latestPreview = [...block.previewLines] - .reverse() - .find((line) => line.trim().length > 0); + return [headline]; + } + if (normalizedPreviewLines.length > 0) { + return normalizedPreviewLines; + } + + if (block.kind === "approval") { + return [block.status === "completed" ? "这一步已经确认" : "等你确认这一步"]; + } + + if (block.kind === "alert") { + return [block.status === "failed" ? "碰到错误" : "收到提醒"]; + } + + if (block.kind === "subagent") { + return [block.status === "completed" ? "协作成员处理完了" : "协作成员在处理"]; + } + + if (block.kind === "other") { + return [resolveCompactTechnicalSummary(block)]; + } + + return [block.title]; +} + +function resolveThreadInlineStatusHint(params: { + turn: AgentThreadTurn; + actionRequests?: ActionRequired[]; +}) { + const pendingAction = findLatestPendingAction(params.actionRequests); + + if (pendingAction?.uiKind === "browser_preflight") { return { - text: latestPreview || resolveFocusInlineText(block), - stageLabel: `步骤 ${String(index + 1).padStart(2, "0")}`, + tone: "warning" as const, + label: "待继续", + detail: + pendingAction.detail?.trim() || + pendingAction.prompt?.trim() || + "浏览器已经打开,等待你完成登录、授权或验证后继续。", }; } - return { - text: null, - stageLabel: null, - }; -} - -function resolveCollapsedProcessSnapshot(params: { - compactTone: TimelineCompactTone; - displayModelSummaryText: string | null; - flowBlockCount: number; - focusBlock: AgentThreadOrderedBlock | null; - focusBlockStageLabel: string | null; - orderedBlocks: AgentThreadOrderedBlock[]; - promptPreview: string | null; - turnStatusMeta: TurnStatusMeta; -}): { - statusLabel: string; - stageLabel: string | null; - detailText: string; - combinedText: string; -} { - const { - compactTone, - displayModelSummaryText, - flowBlockCount, - focusBlock, - focusBlockStageLabel, - orderedBlocks, - promptPreview, - turnStatusMeta, - } = params; - const focusInlineText = resolveFocusInlineText(focusBlock); - const latestThinkingPreview = resolveLatestThinkingPreview(orderedBlocks); - const normalizedOverview = turnStatusMeta.overviewText.trim(); - - const detail = - compactTone === "running" - ? focusInlineText || - displayModelSummaryText || - promptPreview || - focusBlock?.title || - null - : compactTone === "waiting" || - compactTone === "failed" || - compactTone === "paused" - ? focusInlineText || - displayModelSummaryText || - promptPreview || - focusBlock?.title || - null - : latestThinkingPreview.text || - focusInlineText || - displayModelSummaryText || - promptPreview || - null; - - const fallbackDetail = - compactTone === "done" && latestThinkingPreview.stageLabel - ? "思考与计划" - : focusBlock?.title || (flowBlockCount > 0 ? `${flowBlockCount} 段流程` : null); - const distinctDetail = - detail?.trim() && detail.trim() !== normalizedOverview - ? detail.trim() - : fallbackDetail; - - const stageLabel = - compactTone === "running" - ? flowBlockCount > 1 - ? focusBlockStageLabel - : null - : compactTone === "done" && latestThinkingPreview.text && flowBlockCount > 1 - ? latestThinkingPreview.stageLabel - : null; - - const detailText = - shortenInlineText( - distinctDetail || "执行过程已收起,点击查看完整过程。", - compactTone === "running" ? 88 : 78, - ) || "执行过程已收起,点击查看完整过程。"; - const segments = [turnStatusMeta.label]; - if (stageLabel) { - segments.push(stageLabel); - } - if (detailText && detailText !== turnStatusMeta.label) { - segments.push(detailText); + if (pendingAction) { + return { + tone: "warning" as const, + label: "待处理", + detail: + pendingAction.prompt?.trim() || + "当前阶段在等待你确认,完成后会继续后续处理。", + }; } - return { - statusLabel: turnStatusMeta.label, - stageLabel, - detailText, - combinedText: segments.join(" · "), - }; + if (params.turn.status === "aborted") { + return { + tone: "neutral" as const, + label: "已暂停", + detail: + params.turn.error_message?.trim() || + "当前阶段已暂停,你可以处理后继续下一步。", + }; + } + + if (params.turn.status === "failed" && params.turn.error_message?.trim()) { + return { + tone: "error" as const, + label: "失败", + detail: params.turn.error_message.trim(), + }; + } + + return null; } -function TimelineCompactStatusIcon({ - tone, +function ThreadInlineStatusHint({ + hint, }: { - tone: TimelineCompactTone; + hint: NonNullable>; }) { - if (tone === "running") { - return ( - - - - - - - ); - } - - if (tone === "waiting") { - return ( - - - - ); - } - - if (tone === "failed") { - return ( - - - - ); - } - - if (tone === "paused") { - return ( - - - - ); - } - return ( - - - +
+
+ {hint.label} + {hint.detail} +
+
); } +function TimelineBlockStatusIndicator({ + block, +}: { + block: AgentThreadOrderedBlock; +}) { + if (block.kind === "approval" && block.status !== "completed") { + return ( + + + + ); + } + + if (block.status === "in_progress") { + return ( + + + + ); + } + + if (block.status === "failed" || block.kind === "alert") { + return ( + + + + ); + } + + return ; +} + function TimelineBlockCard({ block, index, - isLast, emphasis, isExpanded, onFileClick, @@ -1425,7 +1074,6 @@ function TimelineBlockCard({ }: { block: AgentThreadOrderedBlock; index: number; - isLast: boolean; emphasis: "active" | "default" | "quiet"; isExpanded: boolean; onFileClick?: (fileName: string, content: string) => void; @@ -1436,13 +1084,11 @@ function TimelineBlockCard({ focusedItemId?: string | null; focusRequestKey?: number; }) { - const Icon = resolveGroupIcon(block.kind); - const timestamp = formatTimestamp(block.startedAt); const dataTestId = `agent-thread-block:${index + 1}:${block.kind}`; - const isCompact = isCompactTechnicalBlock(block); - const isActive = emphasis === "active"; - const isQuiet = emphasis === "quiet"; - const stageLabel = `步骤 ${String(index + 1).padStart(2, "0")}`; + const isThinkingBlock = block.kind === "thinking"; + const summaryLines = resolveBlockSummaryLines(block); + const headline = summaryLines[0] || block.title; + const supportingLines = summaryLines.slice(1, 3); const focusedEntryRef = useRef(null); const hasFocusedItem = Boolean( focusedItemId && block.items.some((item) => item.id === focusedItemId), @@ -1463,133 +1109,137 @@ function TimelineBlockCard({ return content ? [{ id: item.id, content }] : []; }); const hasDetailEntries = detailEntries.length > 0; - const detailsExpanded = isExpanded || hasFocusedItem; - const cardClassName = isActive - ? "overflow-hidden rounded-2xl border border-primary/25 bg-primary/[0.045] shadow-md shadow-primary/10" - : isCompact - ? "overflow-hidden rounded-2xl border border-border/45 bg-background/60" - : isQuiet - ? "overflow-hidden rounded-2xl border border-border/45 bg-background/60" - : "overflow-hidden rounded-2xl border border-border/60 bg-background/75"; - const summaryClassName = isCompact - ? "flex items-start gap-3 px-4 py-2.5" - : "flex items-start gap-3 px-4 py-3"; - const interactiveSummaryClassName = cn( - summaryClassName, - hasDetailEntries ? "cursor-pointer" : "cursor-default", - ); + const [open, setOpen] = useState(isExpanded || hasFocusedItem); + + useEffect(() => { + setOpen(isExpanded || hasFocusedItem); + }, [block.id, hasFocusedItem, isExpanded]); useEffect(() => { if (!hasFocusedItem || !focusRequestKey) { return; } + setOpen(true); focusedEntryRef.current?.scrollIntoView({ behavior: "smooth", block: "center", }); }, [focusRequestKey, hasFocusedItem]); - return ( -
- {!isLast ? ( + const singleItemContent = + block.items.length === 1 && (!isThinkingBlock || block.status !== "completed") + ? (block.kind === "thinking" + ? renderThinkingItemDetails(block.items[0]!) + : renderGroupItemDetails( + block.items[0]!, + onFileClick, + onOpenArtifactFromTimeline, + onOpenSavedSiteContent, + onOpenSubagentSession, + onPermissionResponse, + )) + : null; + + if (singleItemContent) { + return ( +
- ) : null} -
-
- {index + 1} -
-
- + {singleItemContent}
- {hasDetailEntries ? ( -
+
+ { + if (!hasDetailEntries) { + event.preventDefault(); + return; + } + + event.preventDefault(); + setOpen((current) => !current); + }} > - +
+
+ +
+
-
- - {stageLabel} - - - {block.title} - - {block.countLabel} - - {block.status === "in_progress" ? ( - - - {resolveItemStatusLabel(block.status)} - - ) : ( - resolveItemStatusLabel(block.status) +
+ - {timestamp ? ( - - {timestamp} - - ) : null} + > + {visibleHeadline} +
- {isCompact ? ( -
- {resolveCompactTechnicalSummary(block)} -
- ) : block.previewLines.length > 0 ? ( -
- {block.previewLines.map((line) => ( -
+ + {visibleSupportingLines.length > 0 ? ( +
+ {visibleSupportingLines.map((line) => ( +
{line}
))}
- ) : ( -
- 该分组记录已收起,可按需展开查看。 -
- )} + ) : null}
-
- - {isCompact ? "展开查看" : block.rawDetailLabel} - - -
-
+ + {hasDetailEntries ? ( + + ) : null} +
+ + + {hasDetailEntries && open ? (
{detailEntries.map((entry) => ( @@ -1606,63 +1256,8 @@ function TimelineBlockCard({
))}
- - ) : ( -
-
-
-
- - {stageLabel} - - - {block.title} - - {block.countLabel} - - {block.status === "in_progress" ? ( - - - {resolveItemStatusLabel(block.status)} - - ) : ( - resolveItemStatusLabel(block.status) - )} - - {timestamp ? ( - - {timestamp} - - ) : null} -
- {isCompact ? ( -
- {resolveCompactTechnicalSummary(block)} -
- ) : block.previewLines.length > 0 ? ( -
- {block.previewLines.map((line) => ( -
- {line} -
- ))} -
- ) : ( -
- 该分组记录已收起,可按需展开查看。 -
- )} -
-
- 已展示完整内容 -
-
-
- )} + ) : null} +
); } @@ -1670,9 +1265,10 @@ function TimelineBlockCard({ export const AgentThreadTimeline: React.FC = ({ turn, items, - threadRead, + threadRead: _threadRead, actionRequests = [], isCurrentTurn = false, + placement = "default", onFileClick, onOpenArtifactFromTimeline, onOpenSavedSiteContent, @@ -1693,11 +1289,6 @@ export const AgentThreadTimeline: React.FC = ({ () => buildAgentThreadDisplayModel(visibleItems), [visibleItems], ); - const actionableCount = displayModel.groups.reduce( - (count, group) => count + group.items.length, - 0, - ); - const flowBlockCount = displayModel.orderedBlocks.length; const activeBlockIndex = resolveActiveBlockIndex(displayModel.orderedBlocks); const focusBlockIndex = resolveFocusBlockIndex({ blocks: displayModel.orderedBlocks, @@ -1711,400 +1302,51 @@ export const AgentThreadTimeline: React.FC = ({ focusBlockIndex, turn, }); - const focusBlock = - focusBlockIndex >= 0 ? displayModel.orderedBlocks[focusBlockIndex] : null; - const focusBlockStageLabel = - focusBlockIndex >= 0 - ? `步骤 ${String(focusBlockIndex + 1).padStart(2, "0")}` - : null; - const promptPreview = shortenInlineText(turn.prompt_text, 78); - const turnStatusMeta = resolveTurnStatusMeta({ + const inlineStatusHint = resolveThreadInlineStatusHint({ turn, - items: visibleItems, actionRequests, - summaryText: displayModel.summaryText, - actionableCount, }); - const defaultDetailsExpanded = true; // 强制始终展开,显示时间线 - const [detailsExpanded, setDetailsExpanded] = useState(defaultDetailsExpanded); - const lastTurnIdRef = useRef(turn.id); - - useEffect(() => { - if (lastTurnIdRef.current !== turn.id) { - lastTurnIdRef.current = turn.id; - setDetailsExpanded(defaultDetailsExpanded); - return; - } - - if (defaultDetailsExpanded) { - setDetailsExpanded(true); - } - }, [defaultDetailsExpanded, turn.id]); - - const compactReliabilityBadges = useMemo( - () => buildCompactReliabilityBadges(turn, threadRead), - [threadRead, turn], - ); - const hasFocusedItem = useMemo( - () => - Boolean( - focusedItemId && - displayModel.orderedBlocks.some((block) => - block.items.some((item) => item.id === focusedItemId), - ), - ), - [displayModel.orderedBlocks, focusedItemId], - ); - - useEffect(() => { - if (!hasFocusedItem || focusRequestKey <= 0) { - return; - } - - setDetailsExpanded(true); - }, [focusRequestKey, hasFocusedItem]); if (visibleItems.length === 0) { return null; } - const toggleActionLabel = detailsExpanded - ? "收起执行过程" - : isCurrentTurn - ? "查看当前进展细节" - : "展开执行过程"; - const compactTone = resolveCompactTone({ turn, turnStatusMeta }); - const collapsedProcess = resolveCollapsedProcessSnapshot({ - compactTone, - displayModelSummaryText: displayModel.summaryText, - flowBlockCount, - focusBlock, - focusBlockStageLabel, - orderedBlocks: displayModel.orderedBlocks, - promptPreview, - turnStatusMeta, - }); - const showRunningAccent = compactTone === "running"; - const timelineOverviewText = turnStatusMeta.overviewText.trim() || null; - const hasSummarySupportContent = - Boolean(promptPreview) || - Boolean(focusBlock) || - displayModel.summaryChips.length > 0; - const focusBlockPreviewText = - focusBlock?.previewLines.find((line) => line.trim().length > 0)?.trim() || null; - const summaryPanelTextCandidate = - displayModel.summaryText?.trim() && - displayModel.summaryText.trim() !== timelineOverviewText - ? displayModel.summaryText.trim() - : timelineOverviewText; - const summaryPanelText = - summaryPanelTextCandidate && - (summaryPanelTextCandidate !== timelineOverviewText || - !hasSummarySupportContent) && - summaryPanelTextCandidate !== focusBlockPreviewText - ? summaryPanelTextCandidate - : null; - const overviewShellClassName = cn( - "mb-2 max-w-4xl rounded-2xl border px-3 py-2.5 shadow-sm shadow-slate-950/5", - compactTone === "running" && - "border-sky-200/70 bg-sky-50/72", - compactTone === "waiting" && - "border-amber-200/70 bg-amber-50/78", - compactTone === "failed" && - "border-rose-200/70 bg-rose-50/78", - compactTone === "paused" && - "border-slate-200/80 bg-slate-50/82", - compactTone === "done" && - "border-border/55 bg-background/58", - ); - const overviewLabelClassName = cn( - "text-[11px] font-medium", - compactTone === "running" && "text-sky-700", - compactTone === "waiting" && "text-amber-700", - compactTone === "failed" && "text-rose-700", - compactTone === "paused" && "text-slate-600", - compactTone === "done" && "text-muted-foreground", - ); - const overviewTextClassName = cn( - "mt-1.5 text-sm leading-6", - compactTone === "running" && "text-sky-950/90", - compactTone === "waiting" && "text-amber-950/90", - compactTone === "failed" && "text-rose-950/90", - compactTone === "paused" && "text-slate-700", - compactTone === "done" && "text-foreground/90", - ); - return ( -
- {timelineOverviewText && !detailsExpanded ? ( -
-
- 当前进展 - - {turnStatusMeta.label} - - {compactReliabilityBadges.map((badge) => ( - - {badge.label} - - ))} - - {isCurrentTurn ? "当前任务" : "历史记录"} - - - - {formatTimestamp(turn.started_at) || "刚刚"} - -
-
{timelineOverviewText}
-
- ) : null} +
+ {inlineStatusHint ? : null} + {displayModel.orderedBlocks.map((block, index) => { + const blockHasFocusedItem = Boolean( + focusedItemId && + block.items.some((item) => item.id === focusedItemId), + ); - - {!detailsExpanded ? ( - - - - ) : null} - - -
-
-
- -
-
-
-
- {isCurrentTurn ? "当前任务摘要" : "任务摘要"} -
- {compactReliabilityBadges.map((badge) => ( - - {badge.label} - - ))} - -
- - {formatTimestamp(turn.started_at) || "刚刚"} -
-
- - {summaryPanelText ? ( -
- {summaryPanelText} -
- ) : null} - - {promptPreview || focusBlock ? ( -
- {promptPreview ? ( -
-
- 用户目标 -
-
- {promptPreview} -
-
- ) : null} - - {focusBlock ? ( -
-
- 当前聚焦 -
-
- {focusBlockStageLabel ? ( - {focusBlockStageLabel} - ) : null} - - {focusBlock.title} - -
-
- ) : null} -
- ) : null} - - {displayModel.summaryChips.length > 0 ? ( -
- {displayModel.summaryChips.map((chip) => ( - - ))} -
- ) : null} - - {turn.error_message && - turnStatusMeta.badgeVariant === "destructive" ? ( -
- {turn.error_message} -
- ) : null} -
-
- -
- {/* 新的时间线设计:将工具调用内联展示 */} - {items - .filter( - (item) => - item.type === "tool_call" || - item.type === "command_execution" || - item.type === "web_search" - ) - .map((item, index, filteredItems) => ( - - ))} - - {/* 保留原有的步骤列表作为备用 */} - {false && displayModel.orderedBlocks.map((block, index) => ( - - ))} -
-
-
-
+ return ( + + ); + })}
); }; diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx index 5db274d7f..d4efe8571 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ArtifactDocumentV1 } from "@/lib/artifact-document"; import type { Artifact } from "@/lib/artifact/types"; import { emitCompactRightPanelOpen } from "@/lib/compactRightPanelEvents"; import type { TaskFile } from "./TaskFiles"; @@ -10,6 +11,7 @@ import { type CanvasWorkbenchDefaultPreview, type CanvasWorkbenchPreviewTarget, } from "./CanvasWorkbenchLayout"; +import type { ArtifactWorkbenchDocumentController } from "../workspace/artifactWorkbenchDocument"; type MockResizeObserverCallback = ( entries: Array<{ @@ -94,6 +96,170 @@ function createTaskFile( }; } +function createMockArtifactDocumentController( + overrides: Partial = {}, +): ArtifactWorkbenchDocumentController { + const versionHistory = [ + { + id: "artifact-document:demo:v1", + artifactId: "artifact-document:demo", + versionNo: 1, + title: "董事会季度复盘", + summary: "第一版摘要", + status: "ready" as const, + createdAt: "2026-03-25T10:00:00Z", + }, + { + id: "artifact-document:demo:v2", + artifactId: "artifact-document:demo", + versionNo: 2, + title: "董事会季度复盘", + summary: "补齐来源与版本信息", + status: "ready" as const, + createdAt: "2026-03-26T10:00:00Z", + }, + ]; + const currentVersionDiff = { + baseVersionId: "artifact-document:demo:v1", + baseVersionNo: 1, + targetVersionId: "artifact-document:demo:v2", + targetVersionNo: 2, + updatedCount: 1, + addedCount: 0, + removedCount: 0, + movedCount: 0, + changedBlocks: [ + { + blockId: "body-1", + changeType: "updated" as const, + beforeText: "旧正文", + afterText: "正文内容", + summary: "更新 block 内容", + }, + ], + }; + const editableDraft = { + editorKind: "rich_text" as const, + markdown: "正文内容", + }; + const selectedEditableBlock = { + blockId: "body-1", + label: "正文块 1", + detail: "正文", + editorKind: "rich_text" as const, + draft: editableDraft, + }; + const document: ArtifactDocumentV1 = { + schemaVersion: "artifact_document.v1", + artifactId: "artifact-document:demo", + kind: "analysis" as const, + title: "董事会季度复盘", + status: "ready" as const, + language: "zh-CN", + summary: "需要优先补齐来源与版本线索。", + blocks: [ + { + id: "body-1", + type: "rich_text" as const, + markdown: "正文内容", + }, + ], + sources: [ + { + id: "source-1", + title: "OpenAI Blog", + url: "https://openai.com", + }, + ], + metadata: { + currentVersionId: "artifact-document:demo:v2", + currentVersionNo: 2, + currentVersionDiff, + versionHistory, + }, + }; + + return { + artifact: createArtifact( + "artifact-doc", + ".lime/artifacts/thread-1/board-review.artifact.json", + JSON.stringify(document), + 40, + ), + document, + currentVersion: versionHistory[1], + currentVersionDiff, + versionHistory, + sourceLinks: [ + { + artifactId: "artifact-document:demo", + blockId: "body-1", + sourceId: "source-1", + sourceType: "web", + sourceRef: "https://openai.com", + label: "OpenAI Blog", + }, + ], + timelineLinksByBlockId: {}, + recoveryPresentation: null, + canEditDocument: true, + canMarkAsReady: false, + inspectorTab: "overview", + setInspectorTab: vi.fn(), + editableBlocks: [selectedEditableBlock], + draftByBlockId: { + "body-1": editableDraft, + }, + selectedEditableBlock, + selectedEditableDraft: editableDraft, + selectedTimelineLink: null, + isSavingEdit: false, + isUpdatingRecoveryState: false, + editSaveError: null, + recoveryActionError: null, + lastSavedAt: null, + rendererViewportRef: { current: null }, + focusBlock: vi.fn(), + selectEditableBlock: vi.fn(), + handleEditDraftChange: vi.fn(), + handleEditCancel: vi.fn(), + handleEditSave: vi.fn(async () => undefined), + handleContinueEditing: vi.fn(), + handleMarkAsReady: vi.fn(async () => undefined), + onJumpToTimelineItem: vi.fn(), + ...overrides, + }; +} + +function MockArtifactDocumentPreview({ + controller, + target, + onArtifactDocumentControllerChange, + artifactDocumentLayoutMode, +}: { + controller: ArtifactWorkbenchDocumentController | null; + target: CanvasWorkbenchPreviewTarget; + onArtifactDocumentControllerChange?: ( + controller: ArtifactWorkbenchDocumentController | null, + ) => void; + artifactDocumentLayoutMode?: "full" | "canvas-only"; +}) { + React.useEffect(() => { + onArtifactDocumentControllerChange?.( + target.kind === "artifact" ? controller : null, + ); + return () => { + onArtifactDocumentControllerChange?.(null); + }; + }, [controller, onArtifactDocumentControllerChange, target.kind]); + + return ( +
+ {artifactDocumentLayoutMode}:{target.kind}:{target.title} +
+ ); +} + function mount( props: React.ComponentProps, ): HTMLDivElement { @@ -420,6 +586,65 @@ describe("CanvasWorkbenchLayout", () => { expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1); }); + it("命中文档产物时应把文稿 inspector 收口到右侧工作台", async () => { + const controller = createMockArtifactDocumentController(); + const previewOptions: Array<{ + artifactDocumentLayoutMode?: "full" | "canvas-only"; + onArtifactDocumentControllerChange?: ( + value: ArtifactWorkbenchDocumentController | null, + ) => void; + }> = []; + + const container = mount({ + artifacts: [controller.artifact], + canvasState: null, + taskFiles: [], + workspaceRoot: "/workspace", + workspaceUnavailable: false, + defaultPreview: null, + loadFilePreview: vi.fn(async (path: string) => ({ + path, + content: null, + isBinary: true, + size: 0, + error: null, + })), + onOpenPath: vi.fn(async () => undefined), + onRevealPath: vi.fn(async () => undefined), + renderPreview: (target, options) => { + previewOptions.push({ + artifactDocumentLayoutMode: options?.artifactDocumentLayoutMode, + onArtifactDocumentControllerChange: + options?.onArtifactDocumentControllerChange, + }); + return ( + + ); + }, + }); + + await flushEffects(); + + expect( + container.querySelector('[data-testid="preview-panel"]')?.textContent, + ).toContain("canvas-only:artifact:board-review.artifact.json"); + expect(previewOptions.at(-1)?.artifactDocumentLayoutMode).toBe("canvas-only"); + expect( + container.querySelector('[data-testid="canvas-workbench-document-inspector"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("当前文稿"); + expect(container.textContent).toContain("统一在右侧切换产物与版本"); + expect(container.textContent).toContain("董事会季度复盘"); + expect(container.textContent).toContain("需要优先补齐来源与版本线索。"); + }); + it("工作区文件为二进制时应展示不支持预览提示", async () => { const previewTargets: CanvasWorkbenchPreviewTarget[] = []; diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx index d9a01e800..d30c309c0 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx @@ -57,6 +57,10 @@ import { extractFileNameFromPath, resolveAbsoluteWorkspacePath, } from "../workspace/workspacePath"; +import { + ArtifactWorkbenchDocumentInspector, + type ArtifactWorkbenchDocumentController, +} from "../workspace/artifactWorkbenchDocument"; type CanvasWorkbenchTab = | "artifacts" @@ -192,6 +196,10 @@ export interface CanvasWorkbenchLayoutProps { target: CanvasWorkbenchPreviewTarget, options?: { stackedWorkbenchTrigger?: ReactNode; + artifactDocumentLayoutMode?: "full" | "canvas-only"; + onArtifactDocumentControllerChange?: ( + controller: ArtifactWorkbenchDocumentController | null, + ) => void; }, ) => ReactNode; onLayoutModeChange?: (mode: CanvasWorkbenchLayoutMode) => void; @@ -494,6 +502,8 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ null, ); const [selectedKey, setSelectedKey] = useState(null); + const [artifactDocumentController, setArtifactDocumentController] = + useState(null); const [directoryCache, setDirectoryCache] = useState>( {}, ); @@ -791,6 +801,21 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ const selectedWorkspaceFile = effectiveKey?.startsWith("workspace-file:") ? workspaceFileSelections[effectiveKey] || null : null; + const handleArtifactDocumentControllerChange = useCallback( + (controller: ArtifactWorkbenchDocumentController | null) => { + setArtifactDocumentController((previous) => + previous === controller ? previous : controller, + ); + }, + [], + ); + + useEffect(() => { + if (selectedEntry?.source === "artifact") { + return; + } + setArtifactDocumentController(null); + }, [selectedEntry]); const currentTarget = useMemo(() => { if (activeTab === "team" && teamView?.enabled) { @@ -1026,55 +1051,99 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ ); } + const showDocumentInspector = Boolean( + selectedEntry?.source === "artifact" && artifactDocumentController?.document, + ); + return ( -
- {entries.map((entry) => ( - + ))} +
+ + + {showDocumentInspector && artifactDocumentController ? ( + +
+ 当前文稿
- {entry.subtitle ? ( -
- {entry.subtitle} -
- ) : null} - {entry.previewText ? ( -
- {entry.previewText} -
- ) : null} +
+ 概览、来源、版本与编辑统一收口 +
+

+ 当前选中的结构化文稿不再在左侧重复展开,所有上下文与编辑入口都固定在这里。 +

- {entry.badgeLabel ? ( - - {entry.badgeLabel} - - ) : null} -
- - ))} + } + /> + ) : null}
); }; @@ -1625,6 +1694,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ ) : renderPreview(currentTarget, { stackedWorkbenchTrigger, + artifactDocumentLayoutMode: "canvas-only", + onArtifactDocumentControllerChange: + handleArtifactDocumentControllerChange, })}
diff --git a/src/components/agent/chat/components/DecisionPanel.test.tsx b/src/components/agent/chat/components/DecisionPanel.test.tsx index 4700a5a13..64cd3b57b 100644 --- a/src/components/agent/chat/components/DecisionPanel.test.tsx +++ b/src/components/agent/chat/components/DecisionPanel.test.tsx @@ -191,7 +191,7 @@ describe("DecisionPanel elicitation", () => { }); describe("DecisionPanel ask_user", () => { - it("缺少 options 时应从问题文本提取可点击选项并支持提交", () => { + it("缺少 options 时应从问题文本提取可点击选项,并在点击提交按钮后发送", () => { const request = createAskUserRequest("req-ask-user-fallback"); const { container, onSubmit } = renderDecisionPanel(request); @@ -202,18 +202,19 @@ describe("DecisionPanel ask_user", () => { expect(container.textContent).toContain("只读模式"); clickButton(findButtonByText(container, "自动执行(Auto)")); + clickButton(findButtonByText(container, "提交答案")); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledWith({ requestId: "req-ask-user-fallback", confirmed: true, - response: "自动执行(Auto)", + response: JSON.stringify({ answer: "自动执行(Auto)" }), actionType: "ask_user", userData: { answer: "自动执行(Auto)" }, }); }); - it("编号列表文本应提取为可点击选项", () => { + it("编号列表文本应提取为可点击选项,并显式提交", () => { const request = createAskUserNumberedRequest("req-ask-user-numbered"); const { container, onSubmit } = renderDecisionPanel(request); @@ -222,18 +223,19 @@ describe("DecisionPanel ask_user", () => { expect(container.textContent).toContain("品牌展示海报"); clickButton(findButtonByText(container, "活动推广海报")); + clickButton(findButtonByText(container, "提交答案")); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledWith({ requestId: "req-ask-user-numbered", confirmed: true, - response: "活动推广海报", + response: JSON.stringify({ answer: "活动推广海报" }), actionType: "ask_user", userData: { answer: "活动推广海报" }, }); }); - it("questions.options 为字符串数组时应归一化并可点击提交", () => { + it("questions.options 为字符串数组时应归一化,并显式提交", () => { const request: ActionRequired = { requestId: "req-ask-user-string-options", actionType: "ask_user", @@ -247,12 +249,13 @@ describe("DecisionPanel ask_user", () => { const { container, onSubmit } = renderDecisionPanel(request); clickButton(findButtonByText(container, "确认后执行(Ask)")); + clickButton(findButtonByText(container, "提交答案")); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledWith({ requestId: "req-ask-user-string-options", confirmed: true, - response: "确认后执行(Ask)", + response: JSON.stringify({ answer: "确认后执行(Ask)" }), actionType: "ask_user", userData: { answer: "确认后执行(Ask)" }, }); @@ -278,11 +281,13 @@ describe("DecisionPanel ask_user", () => { const optionButton = findButtonByText(container, "自动执行(Auto)"); expect(optionButton.disabled).toBe(false); clickButton(optionButton); + expect(waitingSubmitButton.disabled).toBe(false); + clickButton(waitingSubmitButton); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledWith({ requestId: "fallback:tool-1", confirmed: true, - response: "自动执行(Auto)", + response: JSON.stringify({ answer: "自动执行(Auto)" }), actionType: "ask_user", userData: { answer: "自动执行(Auto)" }, }); @@ -300,7 +305,7 @@ describe("DecisionPanel ask_user", () => { expect(container.textContent).not.toContain("取消"); }); - it("自动提交选项等待回调完成时,应展示提交中并禁用交互", async () => { + it("显式提交答案等待回调完成时,应展示提交中并禁用交互", async () => { const request = createAskUserRequest("req-ask-user-loading"); let resolveSubmit: (() => void) | null = null; const { container, onSubmit } = renderDecisionPanel(request); @@ -318,8 +323,16 @@ describe("DecisionPanel ask_user", () => { await Promise.resolve(); }); + const submitButton = findButtonByText(container, "提交答案"); + + await act(async () => { + submitButton.click(); + await Promise.resolve(); + }); + expect(onSubmit).toHaveBeenCalledTimes(1); expect(optionButton.disabled).toBe(true); + expect(submitButton.disabled).toBe(true); expect(container.querySelector("svg.animate-spin")).not.toBeNull(); await act(async () => { diff --git a/src/components/agent/chat/components/DecisionPanel.tsx b/src/components/agent/chat/components/DecisionPanel.tsx index 550756a23..2d270c8b0 100644 --- a/src/components/agent/chat/components/DecisionPanel.tsx +++ b/src/components/agent/chat/components/DecisionPanel.tsx @@ -478,40 +478,24 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { } const answers = buildAnswers(); - const response = questions.length > 0 ? JSON.stringify(answers) : undefined; + const normalizedAnswers = + questions.length === 1 && typeof Object.values(answers)[0] === "string" + ? { answer: Object.values(answers)[0] as string } + : answers; + const response = + questions.length > 0 ? JSON.stringify(normalizedAnswers) : undefined; void submitResponse( { requestId: request.requestId, confirmed: true, response, actionType: request.actionType, - userData: questions.length > 0 ? answers : undefined, + userData: questions.length > 0 ? normalizedAnswers : undefined, }, { key: "allow", kind: "allow" }, ); }; - const handleAutoSubmitOption = ( - optionLabel: string, - qIndex: number, - actionType: ActionRequired["actionType"], - ) => { - setSelectedOptions((prev) => ({ - ...prev, - [qIndex]: [optionLabel], - })); - void submitResponse( - { - requestId: request.requestId, - confirmed: true, - response: optionLabel, - actionType, - userData: { answer: optionLabel }, - }, - { key: `option:${qIndex}:${optionLabel}`, kind: "allow" }, - ); - }; - const handleDeny = () => { void submitResponse( { @@ -898,8 +882,6 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) { const isSelected = (selectedOptions[qIndex] ?? []).includes( option.label, ); - const shouldAutoSubmit = - questions.length === 1 && !q.multiSelect; return (
) : msg.role === "assistant" ? ( - onA2UISubmit(formData, msg.id) - : undefined - } - a2uiFormId={a2uiFormDataMap?.[msg.id]?.formId} - a2uiInitialFormData={a2uiFormDataMap?.[msg.id]?.formData} - onA2UIFormChange={onA2UIFormChange} - renderA2UIInline={renderA2UIInline} - onWriteFile={ - onWriteFile - ? (content, fileName, context) => - onWriteFile(content, fileName, { - ...context, - sourceMessageId: context?.sourceMessageId || msg.id, - source: context?.source || "message_content", - }) - : undefined - } - onFileClick={onFileClick} - onOpenSavedSiteContent={onOpenSavedSiteContent} - onPermissionResponse={onPermissionResponse} - collapseCodeBlocks={collapseCodeBlocks} - shouldCollapseCodeBlock={shouldCollapseCodeBlock} - onCodeBlockClick={onCodeBlockClick} - promoteActionRequestsToA2UI={promoteActionRequestsToA2UI} - renderProposedPlanBlocks={!timeline} - /> + <> + {primaryTimeline ? ( + + ) : null} + + onA2UISubmit(formData, msg.id) + : undefined + } + a2uiFormId={a2uiFormDataMap?.[msg.id]?.formId} + a2uiInitialFormData={a2uiFormDataMap?.[msg.id]?.formData} + onA2UIFormChange={onA2UIFormChange} + renderA2UIInline={renderA2UIInline} + onWriteFile={ + onWriteFile + ? (content, fileName, context) => + onWriteFile(content, fileName, { + ...context, + sourceMessageId: context?.sourceMessageId || msg.id, + source: context?.source || "message_content", + }) + : undefined + } + onFileClick={onFileClick} + onOpenSavedSiteContent={onOpenSavedSiteContent} + onPermissionResponse={onPermissionResponse} + collapseCodeBlocks={collapseCodeBlocks} + shouldCollapseCodeBlock={shouldCollapseCodeBlock} + onCodeBlockClick={onCodeBlockClick} + promoteActionRequestsToA2UI={promoteActionRequestsToA2UI} + renderProposedPlanBlocks={!timeline} + /> + ) : ( displayContent ? ( = ({ {msg.role === "assistant" && renderArtifactCards(msg.artifacts)} - {msg.role === "assistant" && timeline ? ( + {msg.role === "assistant" && trailingTimeline ? ( void; - /** 创建项目回调 */ - onCreateProject?: () => void; -} - -export function ProjectSelector({ - activeTheme = "general", - onSelectProject, - onCreateProject: _onCreateProject, -}: ProjectSelectorProps) { - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(""); - const [createDialogOpen, setCreateDialogOpen] = useState(false); - - // 加载项目列表 - useEffect(() => { - loadProjects(); - }, []); - - const loadProjects = async () => { - setLoading(true); - try { - const allProjects = await listProjects(); - setProjects(allProjects); - } catch (error) { - console.error("加载项目失败:", error); - toast.error("加载项目失败"); - } finally { - setLoading(false); - } - }; - - // 过滤项目(按主题和搜索关键词) - const filteredProjects = useMemo(() => { - let result = projects; - - // 按主题过滤 - if (activeTheme !== "general") { - result = result.filter((p) => p.workspaceType === activeTheme); - } - - // 搜索过滤 - if (searchQuery) { - const query = searchQuery.toLowerCase(); - result = result.filter( - (p) => - p.name.toLowerCase().includes(query) || - p.tags.some((t) => t.toLowerCase().includes(query)), - ); - } - - // 排除归档项目 - result = result.filter((p) => !p.isArchived); - - // 按更新时间排序 - result.sort( - (a, b) => - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), - ); - - return result; - }, [projects, activeTheme, searchQuery]); - - const defaultProjectType = useMemo(() => { - const themeType = activeTheme as ProjectType; - if (Object.prototype.hasOwnProperty.call(TYPE_CONFIGS, themeType)) { - return themeType; - } - return "general" as ProjectType; - }, [activeTheme]); - - const handleCreateProject = async (name: string, type: ProjectType) => { - try { - const projectPath = await resolveProjectRootPath(name); - - const newProject = await createProject({ - name, - rootPath: projectPath, - workspaceType: type, - }); - - notifyProjectCreatedWithRuntimeAgentsGuide(newProject, "项目创建成功"); - await loadProjects(); - onSelectProject(newProject.id); - } catch (error) { - console.error("创建项目失败:", error); - const errorMessage = extractErrorMessage(error); - const friendlyMessage = getCreateProjectErrorMessage(errorMessage); - toast.error(`创建项目失败: ${friendlyMessage}`); - throw error; - } - }; - - // 格式化时间 - const formatTime = (timestamp: number) => { - const date = new Date(timestamp); - const now = new Date(); - const diff = now.getTime() - date.getTime(); - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - - if (days === 0) return "今天"; - if (days === 1) return "昨天"; - if (days < 7) return `${days} 天前`; - if (days < 30) return `${Math.floor(days / 7)} 周前`; - return `${Math.floor(days / 30)} 月前`; - }; - - return ( -
- {/* 搜索和快速创建 */} -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
- -
- - {/* 项目列表 */} - - {loading ? ( -
-
加载中...
-
- ) : filteredProjects.length === 0 ? ( -
- -

- {searchQuery ? "没有找到匹配的项目" : "还没有项目"} -

- {!searchQuery && ( - - )} -
- ) : ( -
- {filteredProjects.map((project) => ( - - ))} -
- )} -
- - -
- ); -} diff --git a/src/components/agent/chat/components/TaskFiles/README.md b/src/components/agent/chat/components/TaskFiles/README.md deleted file mode 100644 index 13ca04e67..000000000 --- a/src/components/agent/chat/components/TaskFiles/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# TaskFiles 任务文件组件 - -显示任务过程中生成的文件列表,支持点击查看文件内容。 - -## 功能 - -- 可折叠的文件列表,显示在输入框上方 -- 支持文件夹和文档类型 -- 点击文件后在右侧画布显示内容 -- 文件类型过滤器 - -## 文件索引 - -| 文件 | 说明 | -|------|------| -| `index.ts` | 组件导出入口 | -| `types.ts` | 类型定义 | -| `TaskFileList.tsx` | 文件列表主组件 | -| `TaskFileItem.tsx` | 单个文件项组件 | - -## 使用示例 - -```tsx -import { TaskFileList, type TaskFile } from './components/TaskFiles'; - -const [files, setFiles] = useState([]); -const [expanded, setExpanded] = useState(false); - - console.log('点击文件:', file)} - expanded={expanded} - onExpandedChange={setExpanded} -/> -``` - -## 依赖 - -- `@/lib/utils` - cn 工具函数 -- `lucide-react` - 图标 diff --git a/src/components/agent/chat/components/TaskFiles/TaskFileItem.tsx b/src/components/agent/chat/components/TaskFiles/TaskFileItem.tsx deleted file mode 100644 index d87c18d49..000000000 --- a/src/components/agent/chat/components/TaskFiles/TaskFileItem.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/** - * @file 任务文件项组件 - * @description 单个文件/文件夹的展示组件 - * @module components/agent/chat/components/TaskFiles/TaskFileItem - */ - -import React, { useState } from "react"; -import { cn } from "@/lib/utils"; -import { - FileText, - Folder, - FolderOpen, - Image, - Code, - Database, - ChevronRight, - MoreHorizontal, -} from "lucide-react"; -import type { TaskFile, TaskFileType } from "./types"; - -/** 文件类型图标映射 */ -const FILE_ICONS: Record = { - document: FileText, - folder: Folder, - image: Image, - code: Code, - data: Database, -}; - -/** 文件类型颜色映射 */ -const FILE_COLORS: Record = { - document: "text-blue-500", - folder: "text-amber-500", - image: "text-green-500", - code: "text-purple-500", - data: "text-orange-500", -}; - -/** 文件类型背景色映射 */ -const FILE_BG_COLORS: Record = { - document: "bg-blue-500/10", - folder: "bg-amber-500/10", - image: "bg-green-500/10", - code: "bg-purple-500/10", - data: "bg-orange-500/10", -}; - -interface TaskFileItemProps { - file: TaskFile; - isSelected?: boolean; - onClick: (file: TaskFile) => void; - level?: number; -} - -export const TaskFileItem: React.FC = ({ - file, - isSelected = false, - onClick, - level = 0, -}) => { - const [isExpanded, setIsExpanded] = useState(false); - const isFolder = file.type === "folder"; - const Icon = isFolder - ? isExpanded - ? FolderOpen - : Folder - : FILE_ICONS[file.type]; - const iconColor = FILE_COLORS[file.type]; - const iconBgColor = FILE_BG_COLORS[file.type]; - - const handleClick = () => { - if (isFolder) { - setIsExpanded(!isExpanded); - } else { - onClick(file); - } - }; - - const formatTime = (timestamp: number) => { - const now = Date.now(); - const diff = now - timestamp; - const minutes = Math.floor(diff / (1000 * 60)); - - if (minutes < 1) return "刚刚"; - if (minutes < 60) return `${minutes} 分钟前`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `大约 ${hours} 小时前`; - const days = Math.floor(hours / 24); - return `大约 ${days} 天前`; - }; - - return ( -
-
- {/* 展开箭头(仅文件夹) */} - {isFolder && ( - - )} - - {/* 文件图标 - 带背景色 */} -
- -
- - {/* 文件信息 */} -
-
{file.name}
-
- {file.version && `Version ${file.version} · `} - {formatTime(file.updatedAt)} -
-
- - {/* 更多操作 */} - -
- - {/* 子文件(文件夹展开时) */} - {isFolder && isExpanded && file.children && ( -
- {file.children.map((child) => ( - - ))} -
- )} -
- ); -}; diff --git a/src/components/agent/chat/components/TaskFiles/TaskFileList.tsx b/src/components/agent/chat/components/TaskFiles/TaskFileList.tsx deleted file mode 100644 index ec2e75ac4..000000000 --- a/src/components/agent/chat/components/TaskFiles/TaskFileList.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/** - * @file 任务文件列表组件 - * @description 显示任务过程中生成的所有文件,底部弹出面板形式 - * @module components/agent/chat/components/TaskFiles/TaskFileList - */ - -import React, { useState, useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { - FolderOpen, - ChevronUp, - ChevronDown, - FileText, - Image, - Code, - LayoutGrid, - X, -} from "lucide-react"; -import { TaskFileItem } from "./TaskFileItem"; -import type { TaskFilesProps } from "./types"; - -/** 文件类型过滤器 */ -type FileFilter = "all" | "document" | "image" | "code"; - -const FILTER_ICONS: Record = { - all: LayoutGrid, - document: FileText, - image: Image, - code: Code, -}; - -export const TaskFileList: React.FC = ({ - files, - selectedFileId, - onFileClick, - expanded = false, - onExpandedChange, -}) => { - const [filter, setFilter] = useState("all"); - - // 过滤文件 - const filteredFiles = files.filter((file) => { - if (filter === "all") return true; - if (file.type === "folder") return true; - return file.type === filter; - }); - - // 统计文件数量 - const fileCount = files.reduce((count, file) => { - if (file.type === "folder" && file.children) { - return count + file.children.length; - } - return count + 1; - }, 0); - - // 切换展开状态 - const handleToggle = useCallback(() => { - onExpandedChange?.(!expanded); - }, [expanded, onExpandedChange]); - - // 关闭面板 - const handleClose = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onExpandedChange?.(false); - }, - [onExpandedChange], - ); - - if (files.length === 0) { - return null; - } - - return ( -
- {/* 触发按钮 - 居中显示 */} -
- -
- - {/* 弹出面板 - 参考 AnyGen 样式 */} - {expanded && ( -
- {/* 面板头部 */} -
-
- - 所有文件 -
-
- {/* 过滤器 */} -
- {(Object.keys(FILTER_ICONS) as FileFilter[]).map((key) => { - const Icon = FILTER_ICONS[key]; - return ( - - ); - })} -
- {/* 关闭按钮 */} - -
-
- - {/* 文件列表 */} -
- {filteredFiles.length === 0 ? ( -
- 暂无文件 -
- ) : ( -
- {filteredFiles.map((file) => ( - - ))} -
- )} -
-
- )} -
- ); -}; diff --git a/src/components/agent/chat/components/TaskFiles/index.ts b/src/components/agent/chat/components/TaskFiles/index.ts deleted file mode 100644 index 017d6168a..000000000 --- a/src/components/agent/chat/components/TaskFiles/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @file 任务文件组件导出 - * @description 导出任务文件相关组件和类型 - * @module components/agent/chat/components/TaskFiles - */ - -export { TaskFileList } from "./TaskFileList"; -export { TaskFileItem } from "./TaskFileItem"; -export type { TaskFile, TaskFileType, TaskFilesProps } from "./types"; diff --git a/src/components/agent/chat/components/TaskFiles/types.ts b/src/components/agent/chat/components/TaskFiles/types.ts deleted file mode 100644 index 44e900725..000000000 --- a/src/components/agent/chat/components/TaskFiles/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @file 任务文件类型定义 - * @description 定义任务过程中生成的文件类型 - * @module components/agent/chat/components/TaskFiles/types - */ - -/** - * 文件类型 - */ -export type TaskFileType = "document" | "folder" | "image" | "code" | "data"; - -/** - * 任务文件 - */ -export interface TaskFile { - /** 文件 ID */ - id: string; - /** 文件名 */ - name: string; - /** 文件类型 */ - type: TaskFileType; - /** 文件内容(文档类型) */ - content?: string; - /** 版本号 */ - version?: number; - /** 创建时间 */ - createdAt: number; - /** 更新时间 */ - updatedAt: number; - /** 子文件(文件夹类型) */ - children?: TaskFile[]; -} - -/** - * 任务文件列表 Props - */ -export interface TaskFilesProps { - /** 文件列表 */ - files: TaskFile[]; - /** 当前选中的文件 ID */ - selectedFileId?: string; - /** 文件点击回调 */ - onFileClick: (file: TaskFile) => void; - /** 是否展开 */ - expanded?: boolean; - /** 展开状态变更回调 */ - onExpandedChange?: (expanded: boolean) => void; -} diff --git a/src/components/agent/chat/components/TimelineFlowDemo.tsx b/src/components/agent/chat/components/TimelineFlowDemo.tsx deleted file mode 100644 index 116917dbb..000000000 --- a/src/components/agent/chat/components/TimelineFlowDemo.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from "react"; -import { TimelineInlineItem } from "./TimelineInlineItem"; -import type { AgentThreadItem } from "@/lib/api/agentProtocol"; - -interface TimelineFlowDemoProps { - items: AgentThreadItem[]; -} - -/** - * 时间线流式展示演示组件 - * - * 这是一个简化的演示,展示新的时间线 + 内联操作设计 - */ -export function TimelineFlowDemo({ items }: TimelineFlowDemoProps) { - // 过滤出需要在时间线上显示的项目 - const timelineItems = items.filter( - (item) => - item.type === "tool_call" || - item.type === "command_execution" || - item.type === "web_search" - ); - - // 分组:将 Agent 消息和工具调用交错显示 - const groupedItems: Array<{ type: "text" | "timeline"; content: any }> = []; - - items.forEach((item, index) => { - if (item.type === "agent_message") { - groupedItems.push({ - type: "text", - content: item, - }); - } else if ( - item.type === "tool_call" || - item.type === "command_execution" || - item.type === "web_search" - ) { - // 检查是否已经有一个 timeline 组 - const lastGroup = groupedItems[groupedItems.length - 1]; - if (lastGroup && lastGroup.type === "timeline") { - lastGroup.content.push(item); - } else { - groupedItems.push({ - type: "timeline", - content: [item], - }); - } - } - }); - - return ( -
- {groupedItems.map((group, groupIndex) => { - if (group.type === "text") { - // Agent 文本消息 - const item = group.content; - return ( -
- {item.text} -
- ); - } else { - // 时间线组 - const timelineItems = group.content as AgentThreadItem[]; - return ( -
- {timelineItems.map((item, itemIndex) => ( - - ))} -
- ); - } - })} -
- ); -} diff --git a/src/components/agent/chat/components/TimelineInlineItem.tsx b/src/components/agent/chat/components/TimelineInlineItem.tsx deleted file mode 100644 index cebf0e508..000000000 --- a/src/components/agent/chat/components/TimelineInlineItem.tsx +++ /dev/null @@ -1,287 +0,0 @@ -import React, { useState } from "react"; -import { - ChevronDown, - ChevronUp, - FileText, - Globe, - Loader2, - Search, - TerminalSquare, - Edit3, - FileEdit, - AlertTriangle, - CheckCircle2, - XCircle, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import type { - AgentThreadItem, - AgentThreadToolCallItem, - AgentThreadCommandExecutionItem, - AgentThreadWebSearchItem, -} from "@/lib/api/agentProtocol"; - -interface TimelineInlineItemProps { - item: AgentThreadItem; - isLast?: boolean; -} - -/** - * 类型守卫:检查是否为工具调用类型 - */ -function isToolCallItem(item: AgentThreadItem): item is AgentThreadToolCallItem { - return item.type === "tool_call"; -} - -function isCommandExecutionItem(item: AgentThreadItem): item is AgentThreadCommandExecutionItem { - return item.type === "command_execution"; -} - -function isWebSearchItem(item: AgentThreadItem): item is AgentThreadWebSearchItem { - return item.type === "web_search"; -} - -/** - * 获取工具调用的图标 - */ -function getToolIcon(toolName: string) { - const normalized = toolName.toLowerCase(); - - if (normalized.includes("read") || normalized.includes("file")) { - return FileText; - } - if (normalized.includes("bash") || normalized.includes("command")) { - return TerminalSquare; - } - if (normalized.includes("web") || normalized.includes("fetch")) { - return Globe; - } - if (normalized.includes("search") || normalized.includes("grep")) { - return Search; - } - if (normalized.includes("edit")) { - return Edit3; - } - if (normalized.includes("write")) { - return FileEdit; - } - - return TerminalSquare; -} - -/** - * 获取状态图标 - */ -function getStatusIcon(status: string) { - if (status === "running" || status === "pending") { - return Loader2; - } - if (status === "completed" || status === "success") { - return CheckCircle2; - } - if (status === "failed" || status === "error") { - return XCircle; - } - return CheckCircle2; -} - -/** - * 格式化工具调用的标题 - */ -function formatToolCallTitle(item: AgentThreadItem): string { - // 命令执行 - if (isCommandExecutionItem(item)) { - const cmd = item.command.trim(); - const shortCmd = cmd.length > 50 ? cmd.slice(0, 50) + "..." : cmd; - return `执行命令 ${shortCmd}`; - } - - // Web 搜索 - if (isWebSearchItem(item)) { - return item.query ? `搜索 ${item.query}` : "Web 搜索"; - } - - // 工具调用 - if (isToolCallItem(item)) { - const toolName = item.tool_name || "操作"; - const args = item.arguments; - - // 尝试提取有意义的参数 - if (args && typeof args === "object" && args !== null) { - // Read 工具:显示文件路径 - if ("file_path" in args && typeof args.file_path === "string") { - const fileName = args.file_path.split("/").pop() || args.file_path; - return `查看 ${fileName}`; - } - - // Bash 工具:显示命令 - if ("command" in args && typeof args.command === "string") { - const cmd = args.command.trim(); - const shortCmd = cmd.length > 50 ? cmd.slice(0, 50) + "..." : cmd; - return `执行命令 ${shortCmd}`; - } - - // WebFetch 工具:显示 URL - if ("url" in args && typeof args.url === "string") { - return `访问 ${args.url}`; - } - - // Grep 工具:显示搜索模式 - if ("pattern" in args && typeof args.pattern === "string") { - return `搜索 ${args.pattern}`; - } - - // Edit 工具:显示文件路径 - if ("file_path" in args && typeof args.file_path === "string") { - const fileName = args.file_path.split("/").pop() || args.file_path; - return `编辑 ${fileName}`; - } - - // Write 工具:显示文件路径 - if ("file_path" in args && typeof args.file_path === "string") { - const fileName = args.file_path.split("/").pop() || args.file_path; - return `写入 ${fileName}`; - } - } - - return toolName; - } - - return "操作"; -} - -/** - * 截断长文本 - */ -function truncateText(text: string, maxLines: number = 3): { preview: string; hasMore: boolean } { - const lines = text.split("\n"); - - if (lines.length <= maxLines) { - return { preview: text, hasMore: false }; - } - - const preview = lines.slice(0, maxLines).join("\n"); - return { preview, hasMore: true }; -} - -export function TimelineInlineItem({ item, isLast }: TimelineInlineItemProps) { - const [isExpanded, setIsExpanded] = useState(false); - - // 获取工具名称 - let toolName = ""; - if (isToolCallItem(item)) { - toolName = item.tool_name; - } else if (isCommandExecutionItem(item)) { - toolName = "Bash"; - } else if (isWebSearchItem(item)) { - toolName = "WebSearch"; - } - - const ToolIcon = getToolIcon(toolName); - const StatusIcon = getStatusIcon(item.status); - const title = formatToolCallTitle(item); - - const isRunning = item.status === "in_progress"; - const isFailed = item.status === "failed"; - - // 获取输出内容 - let output = ""; - if (isToolCallItem(item)) { - output = item.output || item.error || ""; - } else if (isCommandExecutionItem(item)) { - output = item.aggregated_output || item.error || ""; - } else if (isWebSearchItem(item)) { - output = item.output || ""; - } - - const { preview, hasMore } = truncateText(output, 3); - - // 默认展开失败的工具调用 - const shouldDefaultExpand = isFailed; - - return ( -
- {/* 左侧时间线 */} -
- {/* 状态图标 */} -
- -
- - {/* 连接线 */} - {!isLast && ( -
- )} -
- - {/* 右侧内容 */} -
- - {/* 标题行 */} -
- - {title} - - {isRunning && ( - 正在执行... - )} -
- - {/* 输出内容 */} - {output && ( -
- {/* 预览 */} -
-
-                  {shouldDefaultExpand || isExpanded ? output : preview}
-                
-
- - {/* 展开/收起按钮 */} - {hasMore && !shouldDefaultExpand && ( - - - - )} -
- )} -
-
-
- ); -} diff --git a/src/components/agent/chat/hooks/index.ts b/src/components/agent/chat/hooks/index.ts index f46368ebd..d404925e2 100644 --- a/src/components/agent/chat/hooks/index.ts +++ b/src/components/agent/chat/hooks/index.ts @@ -37,6 +37,6 @@ export function useAgentChatUnified(options: UseAgentChatUnifiedOptions) { export { useAsterAgentChat } from "./useAsterAgentChat"; export { useRuntimeTeamFormation } from "./useRuntimeTeamFormation"; export { useTeamWorkspaceRuntime } from "./useTeamWorkspaceRuntime"; -export { useCompatSubagentRuntime } from "./useCompatSubagentRuntime"; +// compat subagent 适配仅允许内部直连,不继续从统一 hooks 入口扩散。 export { useThemeContextWorkspace } from "./useThemeContextWorkspace"; export { useTopicBranchBoard } from "./useTopicBranchBoard"; diff --git a/src/components/agent/chat/hooks/useAgentTools.ts b/src/components/agent/chat/hooks/useAgentTools.ts index b049ca9e9..1058a5b5c 100644 --- a/src/components/agent/chat/hooks/useAgentTools.ts +++ b/src/components/agent/chat/hooks/useAgentTools.ts @@ -185,6 +185,7 @@ export function useAgentTools(options: UseAgentToolsOptions) { ), })), ); + await refreshSessionReadModel(activeSessionId); toast.info("已记录你的回答,等待系统请求就绪后自动提交"); return; } diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index f557b3743..c6ac55a2e 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -176,6 +176,9 @@ vi.mock("./hooks", () => ({ useThemeContextWorkspace: mockUseThemeContextWorkspace, useTopicBranchBoard: mockUseTopicBranchBoard, useTeamWorkspaceRuntime: mockUseTeamWorkspaceRuntime, +})); + +vi.mock("./hooks/useCompatSubagentRuntime", () => ({ useCompatSubagentRuntime: mockUseCompatSubagentRuntime, })); @@ -1724,9 +1727,7 @@ describe("AgentChatPage 通用工作台", () => { }); await flushEffects(4); - expect( - container.querySelector('[data-testid="chat-sidebar"]'), - ).not.toBeNull(); + expect(container.querySelector('[data-testid="chat-sidebar"]')).toBeNull(); }); it("发送时不再先调用本地 Team 规划模型,而是直接发送并透传已选 Team 约束", async () => { @@ -5199,7 +5200,7 @@ describe("AgentChatPage legacy 问卷 A2UI", () => { .map((component) => [component.label, component.id]), ); - await act(async () => { + act(() => { latestInputbarProps?.onA2UISubmit?.({ [componentIdByLabel["这次内容主要面向谁?"]]: ["客户"], [componentIdByLabel["这次最想达成的目标是什么?"]]: @@ -5208,7 +5209,6 @@ describe("AgentChatPage legacy 问卷 A2UI", () => { [componentIdByLabel["是否需要加入明确行动号召?"]]: ["是"], }); }); - await flushEffects(10); expect(sharedSendMessageMock).toHaveBeenCalledWith( `我的选择: diff --git a/src/components/agent/chat/utils/agentThreadGrouping.test.ts b/src/components/agent/chat/utils/agentThreadGrouping.test.ts index c2bfef995..832cfef29 100644 --- a/src/components/agent/chat/utils/agentThreadGrouping.test.ts +++ b/src/components/agent/chat/utils/agentThreadGrouping.test.ts @@ -70,11 +70,12 @@ describe("agentThreadGrouping", () => { "browser", ]); expect(model.groups[0]?.items).toHaveLength(2); - expect(model.groups[0]?.previewLines).toContain("打开 https://example.com"); - expect(model.groups[1]?.previewLines).toContain("Lime CDP 并行渲染"); + expect(model.groups[0]?.previewLines).toContain("打开了 https://example.com"); + expect(model.groups[0]?.previewLines).toContain("点了 #submit"); + expect(model.groups[1]?.previewLines).toContain("搜了 Lime CDP 并行渲染"); expect(model.summaryChips).toEqual([ - { kind: "browser", label: "浏览器操作", count: 3 }, - { kind: "search", label: "联网检索", count: 1 }, + { kind: "browser", label: "页面操作", count: 3 }, + { kind: "search", label: "联网搜索", count: 1 }, ]); }); @@ -108,15 +109,15 @@ describe("agentThreadGrouping", () => { const model = buildAgentThreadDisplayModel(items); - expect(model.summaryText).toBe("已完成 CDP 页面检查"); + expect(model.summaryText).toBe("已决定:已完成 CDP 页面检查"); expect(model.groups.map((group) => group.kind)).toEqual(["file", "command"]); - expect(model.groups[0]?.previewLines).toEqual(["wechat-draft.md"]); + expect(model.groups[0]?.previewLines).toEqual(["产出了 wechat-draft.md"]); expect(model.groups[1]?.previewLines).toEqual([ - "npm test -- AgentThreadTimeline", + "执行了 npm test -- AgentThreadTimeline", ]); expect(model.summaryChips).toEqual([ - { kind: "file", label: "文件与产物", count: 1 }, - { kind: "command", label: "命令执行", count: 1 }, + { kind: "file", label: "文件和产物", count: 1 }, + { kind: "command", label: "命令", count: 1 }, ]); }); @@ -137,7 +138,7 @@ describe("agentThreadGrouping", () => { const model = buildAgentThreadDisplayModel(items); expect(model.groups.map((group) => group.kind)).toEqual(["file"]); - expect(model.groups[0]?.previewLines).toEqual(["nested-draft.md"]); + expect(model.groups[0]?.previewLines).toEqual(["写了 nested-draft.md"]); }); it("应通过 filesystem event protocol 识别目录与输出文件位置线索", () => { @@ -163,7 +164,7 @@ describe("agentThreadGrouping", () => { const model = buildAgentThreadDisplayModel(items); expect(model.groups.map((group) => group.kind)).toEqual(["file"]); - expect(model.groups[0]?.previewLines).toEqual(["reports", "run.log"]); + expect(model.groups[0]?.previewLines).toEqual(["看了 reports", "动了 run.log"]); }); it("思考块应保留在真实时序中,而不是整体前置", () => { @@ -195,7 +196,7 @@ describe("agentThreadGrouping", () => { "search", ]); expect(model.groups.map((group) => group.kind)).toEqual(["browser", "search"]); - expect(model.orderedBlocks[1]?.previewLines).toEqual(["已打开公众号后台"]); + expect(model.orderedBlocks[1]?.previewLines).toEqual(["已决定:已打开公众号后台"]); }); it("结构化问答摘要不应回退为原始 a2ui 代码块", () => { @@ -215,7 +216,7 @@ describe("agentThreadGrouping", () => { const model = buildAgentThreadDisplayModel(items); - expect(model.summaryText).toBe("请先确认以下选项:"); - expect(model.orderedBlocks[0]?.previewLines).toEqual(["请先确认以下选项:"]); + expect(model.summaryText).toBe("已决定:请先确认以下选项:"); + expect(model.orderedBlocks[0]?.previewLines).toEqual(["已决定:请先确认以下选项:"]); }); }); diff --git a/src/components/agent/chat/utils/agentThreadGrouping.ts b/src/components/agent/chat/utils/agentThreadGrouping.ts index 5a9dfe4be..8d267bd9b 100644 --- a/src/components/agent/chat/utils/agentThreadGrouping.ts +++ b/src/components/agent/chat/utils/agentThreadGrouping.ts @@ -115,11 +115,11 @@ function extractThinkingPreviewLine(text: string | undefined | null): string | n const parsed = parseAIResponse(normalized, false); for (const part of parsed.parts) { if (part.type === "pending_a2ui") { - return "结构化问答整理中"; + return "在整理表单"; } if (part.type === "a2ui") { - return "已生成结构化问答预览"; + return "已整理成表单"; } if (typeof part.content === "string") { @@ -144,6 +144,28 @@ function shortenText(value: string | null | undefined, maxLength = 72): string | return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; } +function startsWithAnyPrefix(value: string, prefixes: string[]): boolean { + return prefixes.some((prefix) => value.startsWith(prefix)); +} + +function prefixAction( + value: string | null | undefined, + prefix: string, + knownPrefixes: string[], + maxLength = 72, +): string | null { + const normalized = value?.trim(); + if (!normalized) { + return null; + } + + if (startsWithAnyPrefix(normalized, knownPrefixes)) { + return shortenText(normalized, maxLength); + } + + return shortenText(`${prefix}${normalized}`, maxLength); +} + function resolveItemTimestamp(item: AgentThreadItem): string { return item.completed_at || item.updated_at || item.started_at; } @@ -346,17 +368,17 @@ function classifyItemKind(item: AgentThreadItem): AgentThreadGroupKind { function resolveGroupTitle(kind: Exclude): string { switch (kind) { case "approval": - return "需要你处理"; + return "等你确认"; case "alert": - return "异常与提醒"; + return "提醒和错误"; case "browser": - return "浏览器操作"; + return "页面操作"; case "search": - return "联网检索"; + return "联网搜索"; case "file": - return "文件与产物"; + return "文件和产物"; case "command": - return "命令执行"; + return "命令"; case "subagent": return "协作成员"; case "other": @@ -367,7 +389,7 @@ function resolveGroupTitle(kind: Exclude): str function resolveBlockTitle(kind: AgentThreadGroupKind): string { if (kind === "thinking") { - return "思考与计划"; + return "思考"; } return resolveGroupTitle(kind); @@ -395,27 +417,47 @@ function summarizeBrowserItem(item: AgentThreadItem): string | null { const normalized = normalizeToolName(item.tool_name); const url = resolveUrlFromItem(item); + const args = asRecord(item.arguments); + const target = readString(args, [ + "selector", + "element", + "target", + "label", + "text", + "ref", + "uid", + ]); + if (normalized.includes("navigate") || normalized.includes("goto")) { - return shortenText(url ? `打开 ${url}` : "打开页面"); + return shortenText(url ? `打开了 ${url}` : "打开了页面"); } if (normalized.includes("click")) { - return "点击页面元素"; + return shortenText(target ? `点了 ${target}` : "点了页面元素"); } - if (normalized.includes("type") || normalized.includes("presskey")) { - return "输入页面内容"; + if ( + normalized.includes("type") || + normalized.includes("presskey") || + normalized.includes("fill") || + normalized.includes("selectoption") + ) { + return shortenText(target ? `填了 ${target}` : "填了页面内容"); } if (normalized.includes("screenshot") || normalized.includes("snapshot")) { - return "抓取页面快照"; + return shortenText(url ? `抓了 ${url} 的快照` : "抓了页面快照"); } if (normalized.includes("evaluate") || normalized.includes("runtime")) { - return "提取页面信息"; + return shortenText(url ? `看了 ${url} 的页面信息` : "看了页面信息"); } - return shortenText(url ? `操作 ${url}` : resolveToolDisplayLabel(item.tool_name)); + return shortenText(url ? `做了 ${url} 的页面操作` : "做了页面操作"); } function summarizeSearchItem(item: AgentThreadItem): string | null { if (item.type === "web_search") { - return shortenText(item.query || item.action || "联网检索"); + return prefixAction( + item.query || item.action || "联网搜索", + "搜了 ", + ["搜了 ", "查了 ", "搜索了 ", "检索了 "], + ); } if (item.type !== "tool_call") { @@ -423,20 +465,74 @@ function summarizeSearchItem(item: AgentThreadItem): string | null { } const args = asRecord(item.arguments); - return shortenText( + return prefixAction( readString(args, ["query", "q", "pattern", "search", "url"]) || resolveToolDisplayLabel(item.tool_name), + "搜了 ", + ["搜了 ", "查了 ", "搜索了 ", "检索了 "], ); } function summarizeFileItem(item: AgentThreadItem): string | null { const path = resolvePathFromItem(item); - if (path) { - return `${fileNameFromPath(path)}`; + const fileLabel = path ? fileNameFromPath(path) : null; + + if (item.type === "file_artifact") { + return prefixAction( + fileLabel || item.path, + "产出了 ", + ["产出了 ", "写了 ", "改了 ", "看了 ", "动了 "], + ); } if (item.type === "tool_call") { - return shortenText(resolveToolDisplayLabel(item.tool_name)); + const normalized = normalizeToolName(item.tool_name); + + if ( + normalized.includes("read") || + normalized.includes("view") || + normalized.includes("cat") || + normalized.includes("open") || + normalized.includes("list") + ) { + return prefixAction( + fileLabel || resolveToolDisplayLabel(item.tool_name), + "看了 ", + ["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "], + ); + } + + if ( + normalized.includes("write") || + normalized.includes("create") || + normalized.includes("mkdir") || + normalized.includes("save") + ) { + return prefixAction( + fileLabel || resolveToolDisplayLabel(item.tool_name), + "写了 ", + ["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "], + ); + } + + if ( + normalized.includes("edit") || + normalized.includes("patch") || + normalized.includes("replace") || + normalized.includes("update") + ) { + return prefixAction( + fileLabel || resolveToolDisplayLabel(item.tool_name), + "改了 ", + ["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "], + ); + } + + return prefixAction( + fileLabel || resolveToolDisplayLabel(item.tool_name), + "动了 ", + ["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "], + ); } return null; @@ -444,14 +540,21 @@ function summarizeFileItem(item: AgentThreadItem): string | null { function summarizeCommandItem(item: AgentThreadItem): string | null { if (item.type === "command_execution") { - return shortenText(item.command, 64); + return prefixAction( + item.command, + "执行了 ", + ["执行了 ", "跑了 ", "运行了 "], + 64, + ); } if (item.type === "tool_call") { const args = asRecord(item.arguments); - return shortenText( + return prefixAction( readString(args, ["command", "cmd", "script"]) || resolveToolDisplayLabel(item.tool_name), + "执行了 ", + ["执行了 ", "跑了 ", "运行了 "], 64, ); } @@ -463,48 +566,79 @@ function summarizeSubagentItem(item: AgentThreadItem): string | null { if (item.type !== "subagent_activity") { return null; } - return shortenText( + return prefixAction( resolveInternalImageTaskDisplayName(item.title) || item.summary || - item.status_label, + item.status_label || + "协作任务", + "分给协作成员处理 ", + ["分给协作成员", "协作成员"], ); } function summarizeAlertItem(item: AgentThreadItem): string | null { if (item.type === "warning") { - return shortenText(item.message); + return prefixAction( + item.message, + "收到提醒:", + ["收到提醒:", "碰到错误:"], + ); } if (item.type === "error") { - return shortenText(item.message); + return prefixAction( + item.message, + "碰到错误:", + ["收到提醒:", "碰到错误:"], + ); } return null; } function summarizeOtherItem(item: AgentThreadItem): string | null { if (item.type === "tool_call") { - return shortenText(resolveToolDisplayLabel(item.tool_name)); + return prefixAction( + resolveToolDisplayLabel(item.tool_name), + "执行了 ", + ["执行了 ", "跑了 ", "运行了 "], + ); } return null; } function summarizeThinkingItem(item: AgentThreadItem): string | null { if (item.type === "turn_summary") { - return extractThinkingPreviewLine(item.text); + const preview = extractThinkingPreviewLine(item.text); + if (!preview) { + return item.status === "in_progress" ? "思考中" : "已完成思考"; + } + + if (startsWithAnyPrefix(preview, ["在整理表单", "已整理成表单"])) { + return preview; + } + + return prefixAction( + preview, + "已决定:", + ["已决定:", "决定了:", "思考中", "已完成思考"], + ); } if (item.type === "context_compaction") { return shortenText( item.detail || - (item.stage === "completed" ? "上下文已压缩" : "正在压缩上下文"), + (item.stage === "completed" ? "压了上下文" : "正在压上下文"), ); } if (item.type === "reasoning") { - return extractThinkingPreviewLine(item.summary?.join(";") || item.text); + return ( + extractThinkingPreviewLine(item.summary?.join(";") || item.text) || + (item.status === "in_progress" ? "思考中" : "已完成思考") + ); } if (item.type === "plan") { - return firstMeaningfulLine(item.text); + return item.status === "in_progress" ? "还在排步骤" : "定了执行步骤"; } return null; @@ -529,13 +663,18 @@ function summarizeGroupPreviewLine( return summarizeAlertItem(item); case "approval": if (item.type === "approval_request" || item.type === "request_user_input") { - return shortenText( - item.prompt || - (item.action_type === "ask_user" - ? "需要补充信息" - : item.action_type === "elicitation" - ? "需要进一步确认" - : "需要你确认"), + const fallback = + item.action_type === "ask_user" + ? "等你补充信息" + : item.action_type === "elicitation" + ? "等你进一步确认" + : "等你确认这一步"; + const promptPrefix = item.action_type === "ask_user" ? "等你补充:" : "等你确认:"; + + return prefixAction( + item.prompt || fallback, + promptPrefix, + ["等你补充:", "等你确认:", "等你补充信息", "等你确认这一步"], ); } return null; diff --git a/src/components/agent/chat/utils/compatSubagentRuntime.ts b/src/components/agent/chat/utils/compatSubagentRuntime.ts index 60d453e26..bdbef25dc 100644 --- a/src/components/agent/chat/utils/compatSubagentRuntime.ts +++ b/src/components/agent/chat/utils/compatSubagentRuntime.ts @@ -20,12 +20,23 @@ export interface CompatSubagentRuntimeActivity { summary: string; } -export interface CompatSubagentRuntimeSnapshot - extends CompatSubagentRuntimeState { +export interface CompatSubagentRuntimeStatus { + isRunning: boolean; + progress: CompatSubagentProgress | null; +} + +export interface CompatSubagentRuntimeDisplaySnapshot + extends CompatSubagentRuntimeStatus { + error: string | null; + result: SchedulerExecutionResult | null; recentActivity: CompatSubagentRuntimeActivity[]; hasSignals: boolean; } +export interface CompatSubagentRuntimeSnapshot + extends CompatSubagentRuntimeState, + CompatSubagentRuntimeDisplaySnapshot {} + export function summarizeCompatSubagentEvent( event: CompatSubagentEvent, ): string { diff --git a/src/components/agent/chat/workspace/ArtifactWorkbenchShell.test.tsx b/src/components/agent/chat/workspace/ArtifactWorkbenchShell.test.tsx index 39c0917e9..87e781322 100644 --- a/src/components/agent/chat/workspace/ArtifactWorkbenchShell.test.tsx +++ b/src/components/agent/chat/workspace/ArtifactWorkbenchShell.test.tsx @@ -348,6 +348,29 @@ describe("ArtifactWorkbenchShell", () => { expect(container.textContent).toContain("block hero-1"); }); + it("canvas-only 模式应只保留正文画布,不再渲染 inspector 侧栏", async () => { + const container = renderShell(createArtifactDocumentArtifact(), { + layoutMode: "canvas-only", + onSaveArtifactDocument: vi.fn().mockResolvedValue(undefined), + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect( + container + .querySelector('[data-testid="artifact-workbench-shell"]') + ?.getAttribute("data-layout-mode"), + ).toBe("canvas-only"); + + const tabLabels = ["概览", "来源", "版本", "差异", "编辑"]; + const buttons = Array.from(container.querySelectorAll("button")); + for (const label of tabLabels) { + expect(buttons.find((button) => button.textContent?.includes(label))).toBeUndefined(); + } + }); + it("恢复为草稿时应展示低压状态说明", async () => { const container = renderShell( createArtifactDocumentArtifact({ diff --git a/src/components/agent/chat/workspace/ArtifactWorkbenchShell.tsx b/src/components/agent/chat/workspace/ArtifactWorkbenchShell.tsx index 9341ba642..cc68cf2df 100644 --- a/src/components/agent/chat/workspace/ArtifactWorkbenchShell.tsx +++ b/src/components/agent/chat/workspace/ArtifactWorkbenchShell.tsx @@ -1,57 +1,20 @@ -import React, { - memo, - useEffect, - useMemo, - useRef, - useState, - useCallback, -} from "react"; -import { - BookMarked, - FilePenLine, - FileClock, - FileStack, - GitCompare, - Info, - Link2, - Save, - ScrollText, - RotateCcw, -} from "lucide-react"; +import React, { memo } from "react"; import { ArtifactCanvasOverlay, ArtifactRenderer, ArtifactToolbar, } from "@/components/artifact"; -import { Badge } from "@/components/ui/badge"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - resolveArtifactDocumentCurrentVersion, - resolveArtifactDocumentCurrentVersionDiff, - resolveArtifactDocumentSourceLinks, - resolveArtifactDocumentVersionHistory, - extractPortableText, - type ArtifactDocumentBlock, - type ArtifactDocumentSource, - type ArtifactDocumentVersionDiff, - type ArtifactDocumentSourceLink, - type ArtifactDocumentV1, - type ArtifactDocumentVersionSummary, -} from "@/lib/artifact-document"; -import { resolveArtifactProtocolDocumentPayload } from "@/lib/artifact-protocol"; +import type { ArtifactDocumentV1 } from "@/lib/artifact-document"; import type { Artifact } from "@/lib/artifact/types"; -import { buildCanvasWorkbenchDiff } from "@/components/agent/chat/utils/canvasWorkbenchDiff"; -import { updateArtifactDocumentStatus } from "./artifactWorkbenchActions"; +import { cn } from "@/lib/utils"; import type { AgentThreadItem } from "../types"; import { - buildArtifactTimelineLinkIndex, - type ArtifactTimelineLink, -} from "../utils/artifactTimelineNavigation"; -import { cn } from "@/lib/utils"; -import { - NotionEditor, - type NotionEditorHandle, -} from "@/components/content-creator/canvas/document/editor"; + ArtifactWorkbenchDocumentInspector, + ArtifactWorkbenchEditSurface, + type ArtifactWorkbenchDocumentController, + type ArtifactWorkbenchLayoutMode, + useArtifactWorkbenchDocumentController, +} from "./artifactWorkbenchDocument"; interface ArtifactWorkbenchShellProps { artifact: Artifact; @@ -76,1831 +39,163 @@ interface ArtifactWorkbenchShellProps { onJumpToTimelineItem?: (itemId: string) => void; onCloseCanvas: () => void; actionsSlot?: React.ReactNode; + layoutMode?: ArtifactWorkbenchLayoutMode; + documentController?: ArtifactWorkbenchDocumentController | null; } -interface EditableArtifactBlockEntry { - blockId: string; - label: string; - detail?: string; - editorKind: EditableArtifactBlockDraft["editorKind"]; - draft: EditableArtifactBlockDraft; -} +function ArtifactWorkbenchShellLayout({ + artifact, + artifactOverlay, + isStreaming, + showPreviousVersionBadge, + viewMode, + onViewModeChange, + previewSize, + onPreviewSizeChange, + onCloseCanvas, + actionsSlot, + layoutMode = "full", + controller, +}: Omit< + ArtifactWorkbenchShellProps, + | "onSaveArtifactDocument" + | "threadItems" + | "focusedBlockId" + | "blockFocusRequestKey" + | "onJumpToTimelineItem" + | "documentController" +> & { + controller: ArtifactWorkbenchDocumentController; +}) { + const showInspector = layoutMode === "full"; -type EditableArtifactBlockDraft = - | { - editorKind: "rich_text"; - markdown: string; - } - | { - editorKind: "section_header"; - title: string; - description: string; - } - | { - editorKind: "hero_summary"; - eyebrow: string; - title: string; - summary: string; - highlights: string; - } - | { - editorKind: "callout"; - title: string; - body: string; - tone: string; - }; - -function normalizeText(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function normalizeStringArray(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - - return value - .map((item) => normalizeText(item)) - .filter((item): item is string => Boolean(item)); -} - -function normalizeBoolean(value: unknown): boolean { - return value === true; -} - -function formatVersionDate(value?: string): string | undefined { - if (!value) { - return undefined; - } - - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - - return new Intl.DateTimeFormat("zh-CN", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }).format(date); -} - -function getStatusLabel(status: ArtifactDocumentV1["status"]): string { - switch (status) { - case "draft": - return "草稿"; - case "streaming": - return "生成中"; - case "ready": - return "可阅读"; - case "failed": - return "失败"; - case "archived": - return "已归档"; - default: - return status; - } -} - -function getKindLabel(kind: ArtifactDocumentV1["kind"]): string { - switch (kind) { - case "report": - return "报告"; - case "roadmap": - return "路线图"; - case "prd": - return "PRD"; - case "brief": - return "简报"; - case "analysis": - return "分析"; - case "comparison": - return "对比"; - case "plan": - return "计划"; - case "table_report": - return "表格报告"; - default: - return kind; - } -} - -function getEditableBlockKindLabel( - editorKind: EditableArtifactBlockDraft["editorKind"], -): string { - switch (editorKind) { - case "rich_text": - return "编辑正文"; - case "section_header": - return "编辑章节"; - case "hero_summary": - return "编辑摘要卡"; - case "callout": - return "编辑提示块"; - default: - return "编辑 block"; - } -} - -function buildSourceLookup( - sources: ArtifactDocumentSource[], -): Map { - return new Map(sources.map((source) => [source.id, source])); -} - -function resolveSourceDisplayLabel( - link: ArtifactDocumentSourceLink, - source?: ArtifactDocumentSource, -): string { return ( - normalizeText(link.label) || - normalizeText(source?.title) || - normalizeText(source?.url) || - link.sourceRef - ); -} - -function resolveSourceMeta( - link: ArtifactDocumentSourceLink, - source?: ArtifactDocumentSource, -): string | undefined { - return ( - normalizeText(source?.note) || - normalizeText(source?.quote) || - normalizeText(source?.publishedAt) || - normalizeText(typeof link.locator === "string" ? link.locator : undefined) - ); -} - -function getDiffLabel(changeType: string): string { - switch (changeType) { - case "added": - return "新增"; - case "removed": - return "删除"; - case "updated": - return "更新"; - case "moved": - return "移动"; - default: - return changeType; - } -} - -function getDiffBadgeClassName(changeType: string): string { - switch (changeType) { - case "added": - return "border-emerald-200 bg-emerald-50 text-emerald-700"; - case "removed": - return "border-rose-200 bg-rose-50 text-rose-700"; - case "updated": - return "border-sky-200 bg-sky-50 text-sky-700"; - case "moved": - return "border-amber-200 bg-amber-50 text-amber-700"; - default: - return "border-slate-200 bg-slate-50 text-slate-700"; - } -} - -interface ArtifactRecoveryPresentation { - kind: "recovered_draft" | "recovered_failed" | "repaired_structure"; - tone: "info" | "warning"; - title: string; - detail: string; - badgeLabel: string; -} - -function resolveArtifactRecoveryPresentation( - artifact: Artifact, - document: ArtifactDocumentV1, -): ArtifactRecoveryPresentation | null { - const issues = normalizeStringArray(artifact.meta.artifactValidationIssues); - const fallbackUsed = normalizeBoolean(artifact.meta.artifactFallbackUsed); - const repaired = normalizeBoolean(artifact.meta.artifactValidationRepaired); - const recoveredFromMarkdown = issues.some((issue) => - issue.includes("Markdown 正文自动恢复"), - ); - const recoveredFromTruncatedJson = issues.some((issue) => - issue.includes("不完整的 ArtifactDocument JSON"), - ); - - if (document.status === "failed") { - return { - kind: "recovered_failed", - tone: "warning", - badgeLabel: "恢复稿", - title: "已保留恢复稿", - detail: - "模型这次没有完整生成结构化文稿,正文已经保留在工作台里。建议先继续编辑补齐,再作为正式文稿使用。", - }; - } - - if ( - document.status === "draft" && - (fallbackUsed || recoveredFromMarkdown) - ) { - return { - kind: "recovered_draft", - tone: "info", - badgeLabel: "恢复稿", - title: "已整理为可继续编辑的草稿", - detail: - "系统已先把可用正文整理成恢复稿。你可以直接继续编辑,确认内容顺畅后,再手动标记为可阅读。", - }; - } - - if (repaired || recoveredFromTruncatedJson) { - return { - kind: "repaired_structure", - tone: "info", - badgeLabel: "已补全", - title: "文稿结构已自动补全", - detail: "系统已补齐中断的结构化内容,当前文稿可以继续预览和编辑。", - }; - } - - return null; -} - -function resolveRichTextContent(block: ArtifactDocumentBlock): string { - return ( - normalizeText(block.markdown) || - normalizeText(block.text) || - normalizeText(block.content) || - extractPortableText(block.content) || - extractPortableText(block.tiptap) || - extractPortableText(block.proseMirror) || - "" - ); -} - -function resolveCalloutContent(block: ArtifactDocumentBlock): string { - return ( - normalizeText(block.content) || - normalizeText(block.text) || - extractPortableText(block.content) || - "" - ); -} - -function normalizeHighlightsDraft(value: string): string { - return value - .split(/\r?\n/) - .map((item) => item.trim()) - .filter(Boolean) - .join("\n"); -} - -function serializeEditableArtifactDraft(draft: EditableArtifactBlockDraft): string { - switch (draft.editorKind) { - case "rich_text": - return JSON.stringify({ - editorKind: draft.editorKind, - markdown: draft.markdown, - }); - case "section_header": - return JSON.stringify({ - editorKind: draft.editorKind, - title: draft.title.trim(), - description: draft.description.trim(), - }); - case "hero_summary": - return JSON.stringify({ - editorKind: draft.editorKind, - eyebrow: draft.eyebrow.trim(), - title: draft.title.trim(), - summary: draft.summary.trim(), - highlights: normalizeHighlightsDraft(draft.highlights), - }); - case "callout": - return JSON.stringify({ - editorKind: draft.editorKind, - title: draft.title.trim(), - body: draft.body.trim(), - tone: draft.tone.trim(), - }); - default: - return JSON.stringify(draft); - } -} - -function resolveEditableArtifactBlocks( - document: ArtifactDocumentV1, -): EditableArtifactBlockEntry[] { - let currentSectionLabel: string | undefined; - let richTextIndex = 0; - let sectionHeaderIndex = 0; - let heroSummaryIndex = 0; - let calloutIndex = 0; - const entries: EditableArtifactBlockEntry[] = []; - - for (const block of document.blocks) { - if (block.type === "section_header") { - sectionHeaderIndex += 1; - currentSectionLabel = normalizeText(block.title) || currentSectionLabel; - entries.push({ - blockId: block.id, - label: normalizeText(block.title) || `章节 ${sectionHeaderIndex}`, - detail: "章节标题", - editorKind: "section_header", - draft: { - editorKind: "section_header", - title: normalizeText(block.title) || "", - description: normalizeText(block.description) || "", - }, - }); - continue; - } - - if (block.type === "hero_summary") { - heroSummaryIndex += 1; - entries.push({ - blockId: block.id, - label: normalizeText(block.title) || `摘要卡 ${heroSummaryIndex}`, - detail: currentSectionLabel || "摘要卡", - editorKind: "hero_summary", - draft: { - editorKind: "hero_summary", - eyebrow: normalizeText(block.eyebrow) || "", - title: normalizeText(block.title) || "", - summary: normalizeText(block.summary) || "", - highlights: normalizeStringArray(block.highlights).join("\n"), - }, - }); - continue; - } - - if (block.type === "callout") { - calloutIndex += 1; - entries.push({ - blockId: block.id, - label: normalizeText(block.title) || `提示块 ${calloutIndex}`, - detail: currentSectionLabel || "提示信息", - editorKind: "callout", - draft: { - editorKind: "callout", - title: normalizeText(block.title) || "", - body: resolveCalloutContent(block), - tone: - normalizeText(block.tone) || - normalizeText(block.variant) || - "", - }, - }); - continue; - } - - if (block.type === "rich_text") { - richTextIndex += 1; - entries.push({ - blockId: block.id, - label: - normalizeText(block.title) || - currentSectionLabel || - `正文块 ${richTextIndex}`, - detail: currentSectionLabel, - editorKind: "rich_text", - draft: { - editorKind: "rich_text", - markdown: resolveRichTextContent(block), - }, - }); - } - } - - return entries; -} - -function replaceEditableArtifactBlockContent( - document: ArtifactDocumentV1, - blockId: string, - draft: EditableArtifactBlockDraft, -): ArtifactDocumentV1 { - return { - ...document, - blocks: document.blocks.map((block) => { - if (block.id !== blockId) { - return block; - } - - if (block.type === "rich_text" && draft.editorKind === "rich_text") { - return { - ...block, - markdown: draft.markdown, - }; - } - - if (block.type === "section_header" && draft.editorKind === "section_header") { - const title = draft.title.trim(); - const description = draft.description.trim(); - return { - ...block, - title, - description: description || undefined, - }; - } - - if (block.type === "hero_summary" && draft.editorKind === "hero_summary") { - const eyebrow = draft.eyebrow.trim(); - const title = draft.title.trim(); - const summary = draft.summary.trim(); - const highlights = normalizeHighlightsDraft(draft.highlights) - .split("\n") - .map((item) => item.trim()) - .filter(Boolean); - return { - ...block, - eyebrow: eyebrow || undefined, - title: title || undefined, - summary: summary || undefined, - highlights: highlights.length > 0 ? highlights : undefined, - }; - } - - if (block.type === "callout" && draft.editorKind === "callout") { - const title = draft.title.trim(); - const body = draft.body.trim(); - const tone = draft.tone.trim(); - return { - ...block, - title: title || undefined, - content: body || undefined, - text: body || undefined, - tone: tone || undefined, - variant: tone || undefined, - }; - } - - return block; - }), - }; -} - -const EmptyInspectorState: React.FC<{ - icon: React.ReactNode; - title: string; - detail: string; -}> = memo(({ icon, title, detail }) => ( -
-
- {icon} -
-
{title}
-

{detail}

-
-)); -EmptyInspectorState.displayName = "EmptyInspectorState"; - -const OverviewPanel: React.FC<{ - document: ArtifactDocumentV1; - currentVersion: ArtifactDocumentVersionSummary | null; - versionHistory: ArtifactDocumentVersionSummary[]; - sourceLinks: ArtifactDocumentSourceLink[]; - recoveryPresentation: ArtifactRecoveryPresentation | null; - canEditDocument: boolean; - canMarkAsReady: boolean; - isUpdatingRecoveryState: boolean; - recoveryActionError: string | null; - onContinueEditing: () => void; - onMarkAsReady: () => void; -}> = memo( - ({ - document, - currentVersion, - versionHistory, - sourceLinks, - recoveryPresentation, - canEditDocument, - canMarkAsReady, - isUpdatingRecoveryState, - recoveryActionError, - onContinueEditing, - onMarkAsReady, - }) => { - return ( -
-
-
- {getKindLabel(document.kind)} - - {getStatusLabel(document.status)} - - {recoveryPresentation ? ( - - {recoveryPresentation.badgeLabel} - - ) : null} - {currentVersion ? ( - v{currentVersion.versionNo} - ) : null} +
+ +
+
} + className={cn( + "relative min-h-0 bg-white", + showInspector && "lg:border-r lg:border-slate-200", + controller.inspectorTab === "edit" && controller.canEditDocument + ? "overflow-hidden" + : "overflow-auto", + )} + > + {controller.inspectorTab === "edit" && controller.canEditDocument ? ( + + ) : ( + <> + + {artifactOverlay ? : null} + + )}
- {document.summary ? ( -

{document.summary}

+ {showInspector ? ( + ) : null} -
- - {recoveryPresentation ? ( -
-
- - - -
-
- {recoveryPresentation.title} -
-

- {recoveryPresentation.detail} -

- {canEditDocument || - (recoveryPresentation.kind === "recovered_draft" && - canMarkAsReady) ? ( -
- {canEditDocument ? ( - - ) : null} - {recoveryPresentation.kind === "recovered_draft" && - canMarkAsReady ? ( - - ) : null} -
- ) : null} - {recoveryActionError ? ( -

{recoveryActionError}

- ) : null} -
-
-
- ) : null} - -
-
-
- Blocks -
-
- {document.blocks.length} -
-
-
-
- Sources -
-
- {document.sources.length} -
-
-
-
- Links -
-
- {sourceLinks.length} -
-
-
-
- Versions -
-
- {versionHistory.length || (currentVersion ? 1 : 0)} -
-
-
-
- ); -}); -OverviewPanel.displayName = "OverviewPanel"; - -const SourcesPanel: React.FC<{ - links: ArtifactDocumentSourceLink[]; - sources: ArtifactDocumentSource[]; - onSelectBlock?: (blockId: string) => void; - resolveTimelineLink?: (blockId: string) => ArtifactTimelineLink | null; - onSelectTimelineItem?: (itemId: string) => void; -}> = memo( - ({ - links, - sources, - onSelectBlock, - resolveTimelineLink, - onSelectTimelineItem, - }) => { - if (links.length === 0 && sources.length === 0) { - return ( - } - title="还没有来源抽屉" - detail="当前文档还没有可展示的来源绑定,后续搜索、文件或工具引用会沉淀到这里。" - /> - ); - } - - const sourceLookup = buildSourceLookup(sources); - const items = links.length - ? links - : sources.map((source) => ({ - artifactId: "artifact-document", - blockId: "document", - sourceId: source.id, - sourceType: normalizeText(source.kind) || "unknown", - sourceRef: source.url || source.id, - label: source.title, - })); - - return ( -
- {items.map((link, index) => { - const source = link.sourceId ? sourceLookup.get(link.sourceId) : undefined; - const timelineLink = resolveTimelineLink?.(link.blockId) || null; - return ( -
-
-
- -
- {link.sourceType} - · - block {link.blockId} -
-
- - {link.sourceId || "linked"} - -
- {resolveSourceMeta(link, source) ? ( -

- {resolveSourceMeta(link, source)} -

- ) : null} - {timelineLink && onSelectTimelineItem ? ( -
- -
- ) : null} -
- ); - })} -
- ); -}, -); -SourcesPanel.displayName = "SourcesPanel"; - -const VersionsPanel: React.FC<{ - currentVersion: ArtifactDocumentVersionSummary | null; - versionHistory: ArtifactDocumentVersionSummary[]; -}> = memo(({ currentVersion, versionHistory }) => { - const versions = - versionHistory.length > 0 - ? versionHistory - : currentVersion - ? [currentVersion] - : []; - - if (versions.length === 0) { - return ( - } - title="版本历史还未建立" - detail="当前还没有可回看的版本摘要。接下来每次正式持久化都会在这里沉淀一个版本快照。" - /> - ); - } - - return ( -
- {versions.map((version) => ( -
-
-
-
v{version.versionNo}
-
- {formatVersionDate(version.createdAt) || "时间未知"} -
-
- - {currentVersion?.id === version.id ? "当前" : version.createdBy || "快照"} - -
-
- {version.title || "未命名交付物"} -
- {version.summary ? ( -

- {version.summary} -

- ) : null} -
- ))} -
- ); -}); -VersionsPanel.displayName = "VersionsPanel"; - -const DiffPanel: React.FC<{ - diff: ArtifactDocumentVersionDiff | null; - onSelectBlock?: (blockId: string) => void; - resolveTimelineLink?: (blockId: string) => ArtifactTimelineLink | null; - onSelectTimelineItem?: (itemId: string) => void; -}> = memo(({ diff, onSelectBlock, resolveTimelineLink, onSelectTimelineItem }) => { - if (!diff || diff.changedBlocks.length === 0) { - return ( - } - title="还没有版本差异" - detail="当前文档还没有可展示的 block diff。后续版本生成后,会在这里展示新增、更新、删除与位置变化。" - /> - ); - } - - return ( -
-
-
-
Updated
-
- {diff.updatedCount || 0} -
-
-
-
Added
-
- {diff.addedCount || 0} -
-
-
-
Removed
-
- {diff.removedCount || 0} -
-
-
-
Moved
-
- {diff.movedCount || 0} -
-
-
- -
- {diff.changedBlocks.map((changedBlock) => { - const diffLines = buildCanvasWorkbenchDiff( - changedBlock.beforeText || "", - changedBlock.afterText || "", - ).slice(0, 12); - const canJump = changedBlock.changeType !== "removed"; - const timelineLink = resolveTimelineLink?.(changedBlock.blockId) || null; - - return ( -
-
-
-
- - {getDiffLabel(changedBlock.changeType)} - - - {changedBlock.blockId} - -
-

- {changedBlock.summary || "检测到 block 发生变化。"} -

-
-
- {timelineLink && onSelectTimelineItem ? ( - - ) : null} - {canJump ? ( - - ) : null} -
-
- - {diffLines.length > 0 ? ( -
-
- {diffLines.map((line, index) => ( -
- - {line.type === "add" - ? "+" - : line.type === "remove" - ? "-" - : " "} - - {line.value || " "} -
- ))} -
-
- ) : changedBlock.afterText || changedBlock.beforeText ? ( -
- {changedBlock.afterText || changedBlock.beforeText} -
- ) : null} -
- ); - })}
); -}); -DiffPanel.displayName = "DiffPanel"; +} -const EditInspectorPanel: React.FC<{ - editableBlocks: EditableArtifactBlockEntry[]; - selectedBlockId: string | null; - draftByBlockId: Record; - saveError: string | null; - lastSavedAt: string | null; - isSaving: boolean; - onSelectBlock: (blockId: string) => void; -}> = memo( - ({ - editableBlocks, - selectedBlockId, - draftByBlockId, - saveError, - lastSavedAt, - isSaving, - onSelectBlock, - }) => { - if (editableBlocks.length === 0) { - return ( - } - title="当前文档没有可编辑 block" - detail="当前编辑态已支持章节头、摘要卡、正文块与提示块;如果这里为空,说明该交付物暂时只包含只读结构。" - /> - ); - } - - return ( -
-
-
Workbench 编辑
-

- 当前支持章节头、摘要卡、正文块与提示块原位编辑,保存后会回写到同一份 - ArtifactDocument JSON,不再把正文打回聊天区。 -

-
- -
- {editableBlocks.map((block) => { - const currentDraft = draftByBlockId[block.blockId] ?? block.draft; - const isDirty = - serializeEditableArtifactDraft(currentDraft) !== - serializeEditableArtifactDraft(block.draft); - const isActive = selectedBlockId === block.blockId; - - return ( - - ); - })} -
- - {saveError ? ( -
- {saveError} -
- ) : null} - - {lastSavedAt ? ( -
- 最近保存于 {formatVersionDate(lastSavedAt) || lastSavedAt} - {isSaving ? ",正在同步…" : ""} -
- ) : isSaving ? ( -
正在同步编辑结果…
- ) : null} -
- ); - }, -); -EditInspectorPanel.displayName = "EditInspectorPanel"; - -const ArtifactEditSurface: React.FC<{ - entry: EditableArtifactBlockEntry | null; - draft: EditableArtifactBlockDraft | null; - timelineLink?: ArtifactTimelineLink | null; - isSaving: boolean; - isStreaming: boolean; - onChange: (draft: EditableArtifactBlockDraft) => void; - onSave: (draft?: EditableArtifactBlockDraft) => Promise | void; - onCancel: () => void; - onJumpToTimelineItem?: (itemId: string) => void; -}> = memo( - ({ - entry, - draft, - timelineLink, - isSaving, - isStreaming, - onChange, - onSave, - onCancel, +const LocalArtifactWorkbenchShell = ({ + artifact, + artifactOverlay, + isStreaming, + showPreviousVersionBadge, + viewMode, + onViewModeChange, + previewSize, + onPreviewSizeChange, + onSaveArtifactDocument, + threadItems = [], + focusedBlockId = null, + blockFocusRequestKey = 0, + onJumpToTimelineItem, + onCloseCanvas, + actionsSlot, + layoutMode = "full", +}: Omit) => { + const controller = useArtifactWorkbenchDocumentController({ + artifact, + onSaveArtifactDocument, + threadItems, + focusedBlockId, + blockFocusRequestKey, onJumpToTimelineItem, - }) => { - const editorRef = useRef(null); + }); - const handleSave = useCallback(() => { - if (!entry || !draft) { - return; - } - - if (entry.editorKind === "rich_text" && draft.editorKind === "rich_text") { - const latestDraft: EditableArtifactBlockDraft = { - ...draft, - markdown: editorRef.current?.flushContent() ?? draft.markdown, - }; - onChange(latestDraft); - void onSave(latestDraft); - return; - } - - void onSave(draft); - }, [draft, entry, onChange, onSave]); - - if (!entry || !draft) { - return ( -
-
- 选择一个正文块后,这里会切换到可编辑的文档视图。 -
-
- ); - } - - return ( -
-
-
-
-
- - {getEditableBlockKindLabel(entry.editorKind)} - - - {entry.label} - -
- {entry.detail ? ( -

{entry.detail}

- ) : null} -
-
- {timelineLink && onJumpToTimelineItem ? ( - - ) : null} - - -
-
-
- -
-
- {entry.editorKind === "rich_text" && draft.editorKind === "rich_text" ? ( - { - onChange({ - ...draft, - markdown: content, - }); - }} - onSave={(latestContent) => { - const nextDraft: EditableArtifactBlockDraft = { - ...draft, - markdown: - typeof latestContent === "string" - ? latestContent - : draft.markdown, - }; - onChange(nextDraft); - void onSave(nextDraft); - }} - onCancel={onCancel} - /> - ) : ( -
- {entry.editorKind === "section_header" && - draft.editorKind === "section_header" ? ( - <> - -