mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: refresh latest v0.97.0 release payload
This commit is contained in:
@@ -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'
|
||||
|
||||
+19
-17
@@ -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/<session_id>/...` 下沉淀更多制品;如有路径清理策略,请同步评估磁盘与归档规则
|
||||
- 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` 最新提交
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 命令契约的五个事实源
|
||||
|
||||
命令边界不是单文件事实,至少要同时看下面五处:
|
||||
|
||||
@@ -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. 进入同一工作区中的两个话题
|
||||
|
||||
@@ -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 回归,确认导出入口、保存弹窗、状态与制品展示正常
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+317
-147
@@ -3,242 +3,412 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lime Browser Bridge</title>
|
||||
<title>Lime Browser Connector</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7fafc;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f8fafc;
|
||||
--border: #dbe4f0;
|
||||
--text: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #64748b;
|
||||
--primary: #0f172a;
|
||||
--primary-hover: #1e293b;
|
||||
--primary-text: #ffffff;
|
||||
--success-bg: #dcfce7;
|
||||
--success-text: #166534;
|
||||
--warning-bg: #fef3c7;
|
||||
--warning-text: #92400e;
|
||||
--neutral-bg: #e2e8f0;
|
||||
--neutral-text: #475569;
|
||||
--shadow: 0 12px 30px -18px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
width: 420px;
|
||||
width: 380px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
background: #f9fafb;
|
||||
padding: 16px;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(125, 211, 252, 0.18), transparent 42%),
|
||||
linear-gradient(180deg, #f8fbff 0%, var(--bg) 56%, #f4f8fb 100%);
|
||||
padding: 14px;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
.shell {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(219, 228, 240, 0.92);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 16px;
|
||||
.hero {
|
||||
padding: 16px 16px 12px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(240, 249, 255, 0.95) 0%,
|
||||
rgba(255, 255, 255, 0.98) 52%,
|
||||
rgba(236, 253, 245, 0.92) 100%
|
||||
);
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(125, 211, 252, 0.5);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
color: #0369a1;
|
||||
}
|
||||
.hero h1 {
|
||||
margin-top: 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text);
|
||||
}
|
||||
.header p {
|
||||
.hero p {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.status-row {
|
||||
.content {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid rgba(219, 228, 240, 0.95);
|
||||
border-radius: 18px;
|
||||
background: var(--surface);
|
||||
padding: 14px;
|
||||
}
|
||||
.card-muted {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.card-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.status-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 12px;
|
||||
}
|
||||
.status-tile {
|
||||
border: 1px solid rgba(219, 228, 240, 0.95);
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
padding: 12px;
|
||||
}
|
||||
.status-tile-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.status-tile-value {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.status-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 58px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-on {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
.badge-off {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
background: var(--neutral-bg);
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
.quick-config {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.quick-config-title {
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(191, 219, 254, 0.92);
|
||||
background: rgba(239, 246, 255, 0.92);
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #1e40af;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quick-config-hint {
|
||||
font-size: 11px;
|
||||
color: #3b82f6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quick-config-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
line-height: 1.6;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
color: var(--text);
|
||||
background: #ffffff;
|
||||
}
|
||||
.form-input:focus {
|
||||
border-color: #3b82f6;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
.endpoint-preview {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.92);
|
||||
}
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: white;
|
||||
color: #374151;
|
||||
background: #ffffff;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
background: var(--primary);
|
||||
color: var(--primary-text);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
.btn-small {
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
.btn-inline {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.page-info {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(219, 228, 240, 0.95);
|
||||
background: #ffffff;
|
||||
padding: 14px;
|
||||
}
|
||||
.page-info-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #111;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.page-info-url {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
details {
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(219, 228, 240, 0.95);
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
summary {
|
||||
list-style: none;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.summary-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.summary-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.summary-indicator {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
details[open] .summary-indicator {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.advanced-body {
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Lime Browser Bridge</h1>
|
||||
<p>连接 Chrome 与 Lime 服务</p>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<span class="status-label">Observer 连接:</span>
|
||||
<span id="bridgeStatus" class="badge badge-off">未连接</span>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<span class="status-label">页面监控:</span>
|
||||
<span id="monitorStatus" class="badge badge-off">关闭</span>
|
||||
</div>
|
||||
|
||||
<div class="quick-config">
|
||||
<div class="quick-config-title">🚀 一键配置</div>
|
||||
<div class="quick-config-hint">从剪贴板粘贴配置(在 Lime 页面点击"复制配置")</div>
|
||||
<div class="quick-config-buttons">
|
||||
<button id="pasteConfigBtn" class="btn btn-small">粘贴配置</button>
|
||||
<button id="clearConfigBtn" class="btn btn-small">清除配置</button>
|
||||
<div class="shell">
|
||||
<div class="hero">
|
||||
<span class="eyebrow">BROWSER CONNECTOR</span>
|
||||
<h1>Lime 浏览器连接器</h1>
|
||||
<p>扩展安装、连接器开关与高级配置统一在 Lime 的“连接器”页里管理。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="serverUrl">Server URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serverUrl"
|
||||
class="form-input"
|
||||
placeholder="ws://127.0.0.1:8999"
|
||||
/>
|
||||
</div>
|
||||
<div class="content">
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">当前状态</div>
|
||||
<p class="card-desc">先看连接状态;未连通时优先打开 Lime 连接器页完成启用或重新同步。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="bridgeKey">Bridge Key</label>
|
||||
<input
|
||||
type="text"
|
||||
id="bridgeKey"
|
||||
class="form-input"
|
||||
placeholder="输入 Lime API Key"
|
||||
/>
|
||||
</div>
|
||||
<div class="status-grid">
|
||||
<div class="status-tile">
|
||||
<div class="status-tile-label">Observer 连接</div>
|
||||
<div class="status-tile-value">
|
||||
<span id="bridgeStatus" class="badge badge-off">未连接</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-tile">
|
||||
<div class="status-tile-label">页面监控</div>
|
||||
<div class="status-tile-value">
|
||||
<span id="monitorStatus" class="badge badge-off">关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="profileKey">Profile Key</label>
|
||||
<input
|
||||
type="text"
|
||||
id="profileKey"
|
||||
class="form-input"
|
||||
placeholder="default"
|
||||
/>
|
||||
</div>
|
||||
<div id="statusHint" class="hint">
|
||||
首次安装时,请先在 Lime 的“连接器”页里选择目录导出扩展,然后到 Chrome 扩展页加载已解压目录。
|
||||
</div>
|
||||
|
||||
<div class="endpoint-preview" id="endpointPreview">
|
||||
Observer URL: 未配置
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button id="openConnectorPageBtn" class="btn btn-primary">
|
||||
打开 Lime 连接器页
|
||||
</button>
|
||||
<button id="refreshStatusBtn" class="btn btn-inline">刷新状态</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="saveBtn" class="btn btn-primary">保存并重连</button>
|
||||
<button id="toggleConnBtn" class="btn">断开</button>
|
||||
</div>
|
||||
<section class="page-info">
|
||||
<div class="page-info-title" id="pageTitle">无页面信息</div>
|
||||
<div class="page-info-url" id="pageUrl">当前还没有收到最近页面快照。</div>
|
||||
</section>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="toggleMonitorBtn" class="btn">切换监控</button>
|
||||
<button id="captureBtn" class="btn">立即抓取</button>
|
||||
</div>
|
||||
<details>
|
||||
<summary>
|
||||
<div>
|
||||
<div class="summary-title">高级配置</div>
|
||||
<div class="summary-desc">保留手动配置、手动重连、页面抓取和监控切换。只在自动配置失效或排查链路时使用。</div>
|
||||
</div>
|
||||
<span class="summary-indicator">⌄</span>
|
||||
</summary>
|
||||
|
||||
<div class="page-info">
|
||||
<div class="page-info-title" id="pageTitle">无页面信息</div>
|
||||
<div class="page-info-url" id="pageUrl"></div>
|
||||
<div class="advanced-body">
|
||||
<section class="card card-muted">
|
||||
<div class="card-title">快速处理</div>
|
||||
<p class="card-desc">如果你刚从 Lime 复制了配置,可以直接粘贴到这里完成覆盖。</p>
|
||||
<div class="button-group">
|
||||
<button id="pasteConfigBtn" class="btn">粘贴配置</button>
|
||||
<button id="clearConfigBtn" class="btn">清除配置</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="serverUrl">Server URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serverUrl"
|
||||
class="form-input"
|
||||
placeholder="ws://127.0.0.1:8999"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="bridgeKey">Bridge Key</label>
|
||||
<input
|
||||
type="text"
|
||||
id="bridgeKey"
|
||||
class="form-input"
|
||||
placeholder="输入 Lime API Key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="profileKey">Profile Key</label>
|
||||
<input
|
||||
type="text"
|
||||
id="profileKey"
|
||||
class="form-input"
|
||||
placeholder="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="endpoint-preview" id="endpointPreview">
|
||||
Observer URL: 未配置
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="saveBtn" class="btn btn-primary">保存并重连</button>
|
||||
<button id="toggleConnBtn" class="btn">立即连接</button>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button id="toggleMonitorBtn" class="btn">关闭页面监控</button>
|
||||
<button id="captureBtn" class="btn">立即抓取</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="popup.js"></script>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
pub profile_key: Option<String>,
|
||||
}
|
||||
|
||||
#[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<BrowserConnectorAutoConfig, String> {
|
||||
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<BrowserConnectorSettingsSnapshot, String> {
|
||||
get_browser_connector_settings()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_browser_connector_install_root_cmd(
|
||||
request: SetBrowserConnectorInstallRootRequest,
|
||||
) -> Result<BrowserConnectorSettingsSnapshot, String> {
|
||||
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<BrowserConnectorSettingsSnapshot, String> {
|
||||
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<BrowserConnectorSettingsSnapshot, String> {
|
||||
update_system_connector_enabled(&request.id, request.enabled)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_browser_connector_install_status_cmd(
|
||||
app: AppHandle,
|
||||
) -> Result<BrowserConnectorInstallStatus, String> {
|
||||
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<BrowserConnectorInstallResult, String> {
|
||||
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<bool, String> {
|
||||
#[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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
pub install_dir: Option<String>,
|
||||
pub bundled_name: String,
|
||||
pub bundled_version: String,
|
||||
pub installed_name: Option<String>,
|
||||
pub installed_version: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub install_dir: Option<String>,
|
||||
pub system_connectors: Vec<SystemConnectorSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct BrowserConnectorSettingsRecord {
|
||||
enabled: bool,
|
||||
install_root_dir: Option<String>,
|
||||
system_connectors: HashMap<String, bool>,
|
||||
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<String, bool> {
|
||||
SYSTEM_CONNECTOR_DEFINITIONS
|
||||
.iter()
|
||||
.map(|(id, _, _)| ((*id).to_string(), false))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn browser_connector_settings_path() -> Result<PathBuf, String> {
|
||||
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<BrowserConnectorSettingsRecord, String> {
|
||||
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<PathBuf, String> {
|
||||
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<ManifestInfo, String> {
|
||||
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<PathBuf, String> {
|
||||
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<BrowserConnectorInstallStatus, String> {
|
||||
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<BrowserConnectorInstallStatus, String> {
|
||||
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<PathBuf, String> {
|
||||
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<BrowserConnectorInstallResult, String> {
|
||||
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<Option<String>, 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<BrowserConnectorSettingsSnapshot, String> {
|
||||
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<BrowserConnectorSettingsSnapshot, String> {
|
||||
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<BrowserConnectorSettingsSnapshot, String> {
|
||||
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<BrowserConnectorSettingsSnapshot, String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
+19
-1
@@ -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();
|
||||
|
||||
@@ -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<typeof handleInputbarA2UISubmit>[0], _messageId: string) => {
|
||||
handleInputbarA2UISubmit(formData);
|
||||
},
|
||||
[handleInputbarA2UISubmit],
|
||||
);
|
||||
|
||||
// 监听主题工作台技能触发
|
||||
useEffect(() => {
|
||||
@@ -2643,7 +2649,7 @@ export function AgentChatWorkspace({
|
||||
promoteQueuedTurn,
|
||||
deleteMessage,
|
||||
editMessage,
|
||||
handleA2UISubmit,
|
||||
handleA2UISubmit: handleMessageA2UISubmit,
|
||||
handleWriteFile,
|
||||
handleFileClick: handleWorkspaceFileClick,
|
||||
handleOpenArtifactFromTimeline,
|
||||
|
||||
@@ -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("这一步加载中...");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown> | null = null;
|
||||
if (toolCall.arguments) {
|
||||
try {
|
||||
parsedArguments = JSON.parse(toolCall.arguments) as Record<string, unknown>;
|
||||
} 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" ? " 进行中" : ""}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
@@ -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<HTMLButtonElement>(
|
||||
'[data-testid="agent-thread-details-toggle"]',
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error("未找到执行细节切换按钮");
|
||||
}
|
||||
|
||||
act(() => {
|
||||
button.click();
|
||||
});
|
||||
}
|
||||
|
||||
function createFileArtifactItem(
|
||||
overrides: Partial<Extract<AgentThreadItem, { type: "file_artifact" }>> = {},
|
||||
): Extract<AgentThreadItem, { type: "file_artifact" }> {
|
||||
@@ -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<HTMLButtonElement>("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<HTMLDetailsElement>(
|
||||
'[data-testid="agent-thread-block:1:browser"]',
|
||||
);
|
||||
const focusedEntry = container.querySelector<HTMLElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[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<HTMLElement>(
|
||||
'[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<HTMLElement>(
|
||||
"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<HTMLElement>(
|
||||
'[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<HTMLElement>('[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<HTMLElement>('[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<HTMLDetailsElement>(
|
||||
'[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");
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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> = {},
|
||||
): 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 (
|
||||
<div data-testid="preview-panel">
|
||||
{artifactDocumentLayoutMode}:{target.kind}:{target.title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mount(
|
||||
props: React.ComponentProps<typeof CanvasWorkbenchLayout>,
|
||||
): 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 (
|
||||
<MockArtifactDocumentPreview
|
||||
controller={controller}
|
||||
target={target}
|
||||
artifactDocumentLayoutMode={options?.artifactDocumentLayoutMode}
|
||||
onArtifactDocumentControllerChange={
|
||||
options?.onArtifactDocumentControllerChange
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
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[] = [];
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [artifactDocumentController, setArtifactDocumentController] =
|
||||
useState<ArtifactWorkbenchDocumentController | null>(null);
|
||||
const [directoryCache, setDirectoryCache] = useState<Record<string, DirectoryListing>>(
|
||||
{},
|
||||
);
|
||||
@@ -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<CanvasWorkbenchPreviewTarget>(() => {
|
||||
if (activeTab === "team" && teamView?.enabled) {
|
||||
@@ -1026,55 +1051,99 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
);
|
||||
}
|
||||
|
||||
const showDocumentInspector = Boolean(
|
||||
selectedEntry?.source === "artifact" && artifactDocumentController?.document,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
aria-label={`选择画布产物-${entry.title}`}
|
||||
onClick={() => setSelectedKey(entry.key)}
|
||||
className={cn(
|
||||
"w-full rounded-[22px] border px-3.5 py-3.5 text-left shadow-sm shadow-slate-950/5 transition-colors",
|
||||
effectiveKey === entry.key
|
||||
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
|
||||
: WORKBENCH_BUTTON_CLASSNAME,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-slate-200/80 bg-slate-50/90 px-2 py-0.5 text-[10px] font-medium text-slate-500">
|
||||
{entry.kindLabel}
|
||||
</span>
|
||||
{entry.isCurrent ? (
|
||||
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
当前
|
||||
</span>
|
||||
<div className="space-y-4">
|
||||
<section className={cn(WORKBENCH_PANEL_CLASSNAME, "p-3")}>
|
||||
<div className="border-b border-slate-200/80 px-1 pb-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-slate-500">
|
||||
工作项导航
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-slate-900">
|
||||
统一在右侧切换产物与版本
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">
|
||||
左侧只保留正文画布,这里作为唯一的产物入口与文稿上下文区。
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
aria-label={`选择画布产物-${entry.title}`}
|
||||
onClick={() => setSelectedKey(entry.key)}
|
||||
className={cn(
|
||||
"w-full rounded-[22px] border px-3.5 py-3.5 text-left shadow-sm shadow-slate-950/5 transition-colors",
|
||||
effectiveKey === entry.key
|
||||
? WORKBENCH_ACTIVE_BUTTON_CLASSNAME
|
||||
: WORKBENCH_BUTTON_CLASSNAME,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-slate-200/80 bg-slate-50/90 px-2 py-0.5 text-[10px] font-medium text-slate-500">
|
||||
{entry.kindLabel}
|
||||
</span>
|
||||
{entry.isCurrent ? (
|
||||
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
当前
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 truncate text-sm font-medium text-foreground">
|
||||
{entry.title}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 truncate text-xs text-slate-500">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.previewText ? (
|
||||
<div className="mt-2 line-clamp-3 text-xs leading-5 text-slate-500">
|
||||
{entry.previewText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{entry.badgeLabel ? (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{entry.badgeLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 truncate text-sm font-medium text-foreground">
|
||||
{entry.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{showDocumentInspector && artifactDocumentController ? (
|
||||
<ArtifactWorkbenchDocumentInspector
|
||||
controller={artifactDocumentController}
|
||||
testId="canvas-workbench-document-inspector"
|
||||
containerClassName={cn(
|
||||
WORKBENCH_PANEL_CLASSNAME,
|
||||
"min-h-0 overflow-hidden bg-slate-50/80",
|
||||
)}
|
||||
tabsClassName="flex h-full min-h-0 flex-col p-4"
|
||||
header={
|
||||
<div className="mb-4 rounded-[20px] border border-slate-200 bg-white px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-slate-500">
|
||||
当前文稿
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 truncate text-xs text-slate-500">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
{entry.previewText ? (
|
||||
<div className="mt-2 line-clamp-3 text-xs leading-5 text-slate-500">
|
||||
{entry.previewText}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-1 text-sm font-semibold text-slate-900">
|
||||
概览、来源、版本与编辑统一收口
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">
|
||||
当前选中的结构化文稿不再在左侧重复展开,所有上下文与编辑入口都固定在这里。
|
||||
</p>
|
||||
</div>
|
||||
{entry.badgeLabel ? (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{entry.badgeLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1625,6 +1694,9 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
|
||||
)
|
||||
: renderPreview(currentTarget, {
|
||||
stackedWorkbenchTrigger,
|
||||
artifactDocumentLayoutMode: "canvas-only",
|
||||
onArtifactDocumentControllerChange:
|
||||
handleArtifactDocumentControllerChange,
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
@@ -913,23 +895,11 @@ export function DecisionPanel({ request, onSubmit }: DecisionPanelProps) {
|
||||
isSubmitting && "cursor-not-allowed opacity-70",
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
onClick={() => {
|
||||
if (shouldAutoSubmit) {
|
||||
handleAutoSubmitOption(
|
||||
option.label,
|
||||
qIndex,
|
||||
request.actionType,
|
||||
);
|
||||
return;
|
||||
}
|
||||
toggleOption(qIndex, option.label, q.multiSelect);
|
||||
}}
|
||||
onClick={() =>
|
||||
toggleOption(qIndex, option.label, q.multiSelect)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{submissionState?.key ===
|
||||
`option:${qIndex}:${option.label}` ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
{option.description && (
|
||||
|
||||
@@ -99,7 +99,6 @@ function renderPanel(
|
||||
compatSubagentRuntime={{
|
||||
isRunning: false,
|
||||
progress: null,
|
||||
events: [],
|
||||
result: null,
|
||||
error: null,
|
||||
recentActivity: [],
|
||||
@@ -1361,7 +1360,6 @@ describe("HarnessStatusPanel", () => {
|
||||
currentTasks: ["legacy-task-1"],
|
||||
percentage: 50,
|
||||
},
|
||||
events: [{ type: "started", totalTasks: 2 }],
|
||||
result: null,
|
||||
error: null,
|
||||
recentActivity: [
|
||||
|
||||
@@ -103,7 +103,7 @@ import {
|
||||
} from "../utils/toolDisplayInfo";
|
||||
import { buildThreadReliabilityView } from "../utils/threadReliabilityView";
|
||||
import { resolveTeamWorkspaceStableProcessingLabel } from "../utils/teamWorkspaceCopy";
|
||||
import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime";
|
||||
import type { CompatSubagentRuntimeDisplaySnapshot } from "../utils/compatSubagentRuntime";
|
||||
import type { TeamRoleDefinition } from "../utils/teamDefinitions";
|
||||
import { AgentThreadReliabilityPanel } from "./AgentThreadReliabilityPanel";
|
||||
import { RuntimeReviewDecisionDialog } from "./RuntimeReviewDecisionDialog";
|
||||
@@ -128,7 +128,7 @@ export interface HarnessFilePreviewResult {
|
||||
|
||||
interface HarnessStatusPanelProps {
|
||||
harnessState: HarnessSessionState;
|
||||
compatSubagentRuntime: CompatSubagentRuntimeSnapshot;
|
||||
compatSubagentRuntime: CompatSubagentRuntimeDisplaySnapshot;
|
||||
environment: HarnessEnvironmentSummary;
|
||||
layout?: "default" | "sidebar" | "dialog";
|
||||
onLoadFilePreview?: (path: string) => Promise<HarnessFilePreviewResult>;
|
||||
@@ -1440,7 +1440,7 @@ function CompatSubagentFallbackCard({
|
||||
condensed = false,
|
||||
onOpenUrl,
|
||||
}: {
|
||||
snapshot: CompatSubagentRuntimeSnapshot;
|
||||
snapshot: CompatSubagentRuntimeDisplaySnapshot;
|
||||
condensed?: boolean;
|
||||
onOpenUrl: (url: string) => void | Promise<void>;
|
||||
}) {
|
||||
|
||||
@@ -36,6 +36,7 @@ const mockAgentThreadTimeline = vi.fn(
|
||||
({
|
||||
actionRequests,
|
||||
onOpenSavedSiteContent,
|
||||
placement,
|
||||
}: {
|
||||
actionRequests?: Array<Record<string, unknown>>;
|
||||
onOpenSavedSiteContent?: (target: {
|
||||
@@ -43,9 +44,10 @@ const mockAgentThreadTimeline = vi.fn(
|
||||
contentId: string;
|
||||
title?: string;
|
||||
}) => void;
|
||||
placement?: "leading" | "trailing" | "default";
|
||||
}) => (
|
||||
<div
|
||||
data-testid="agent-thread-timeline"
|
||||
data-testid={`agent-thread-timeline:${placement || "default"}`}
|
||||
data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"}
|
||||
>
|
||||
执行轨迹{actionRequests?.length ? `:${actionRequests.length}` : ""}
|
||||
@@ -67,6 +69,7 @@ vi.mock("./TokenUsageDisplay", () => ({
|
||||
vi.mock("./AgentThreadTimeline", () => ({
|
||||
AgentThreadTimeline: (props: {
|
||||
actionRequests?: Array<Record<string, unknown>>;
|
||||
placement?: "leading" | "trailing" | "default";
|
||||
}) => mockAgentThreadTimeline(props),
|
||||
}));
|
||||
|
||||
@@ -344,7 +347,7 @@ describe("MessageList", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("应先渲染正文与产物,再渲染执行轨迹", () => {
|
||||
it("应先渲染思考与过程,再渲染正文,最后再落产物", () => {
|
||||
const now = new Date();
|
||||
const messages: Message[] = [
|
||||
{
|
||||
@@ -401,23 +404,25 @@ describe("MessageList", () => {
|
||||
});
|
||||
|
||||
const streaming = container.querySelector('[data-testid="streaming-renderer"]');
|
||||
const timeline = container.querySelector('[data-testid="agent-thread-timeline"]');
|
||||
const leadingTimeline = container.querySelector(
|
||||
'[data-testid="agent-thread-timeline:leading"]',
|
||||
);
|
||||
const artifactButton = Array.from(container.querySelectorAll("button")).find((node) =>
|
||||
node.textContent?.includes("publish.md"),
|
||||
);
|
||||
|
||||
expect(streaming).not.toBeNull();
|
||||
expect(artifactButton).toBeDefined();
|
||||
expect(timeline).not.toBeNull();
|
||||
expect(leadingTimeline).not.toBeNull();
|
||||
const streamingNode = streaming as Node;
|
||||
const timelineNode = timeline as Node;
|
||||
const timelineNode = leadingTimeline as Node;
|
||||
const artifactButtonNode = artifactButton as Node;
|
||||
expect(
|
||||
streamingNode.compareDocumentPosition(timelineNode) &
|
||||
timelineNode.compareDocumentPosition(streamingNode) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
artifactButtonNode.compareDocumentPosition(timelineNode) &
|
||||
streamingNode.compareDocumentPosition(artifactButtonNode) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
@@ -475,9 +480,11 @@ describe("MessageList", () => {
|
||||
const timelineProps = mockAgentThreadTimeline.mock.calls.at(-1)?.[0] as
|
||||
| {
|
||||
actionRequests?: Array<Record<string, unknown>>;
|
||||
placement?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(timelineProps?.placement).toBe("leading");
|
||||
expect(timelineProps?.actionRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
requestId: "req-browser",
|
||||
@@ -585,13 +592,17 @@ describe("MessageList", () => {
|
||||
container.querySelectorAll('[data-testid="streaming-renderer"]'),
|
||||
);
|
||||
const timelineNodes = Array.from(
|
||||
container.querySelectorAll('[data-testid="agent-thread-timeline"]'),
|
||||
container.querySelectorAll('[data-testid="agent-thread-timeline:leading"]'),
|
||||
);
|
||||
|
||||
expect(streamingNodes).toHaveLength(2);
|
||||
expect(timelineNodes).toHaveLength(1);
|
||||
expect(
|
||||
(streamingNodes[1] as Node).compareDocumentPosition(timelineNodes[0] as Node) &
|
||||
(streamingNodes[0] as Node).compareDocumentPosition(timelineNodes[0] as Node) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(timelineNodes[0] as Node).compareDocumentPosition(streamingNodes[1] as Node) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
@@ -653,7 +664,9 @@ describe("MessageList", () => {
|
||||
});
|
||||
|
||||
const timelineNodes = Array.from(
|
||||
container.querySelectorAll('[data-testid="agent-thread-timeline"]'),
|
||||
container.querySelectorAll(
|
||||
'[data-testid^="agent-thread-timeline:"]',
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -124,6 +124,10 @@ interface MessageListProps {
|
||||
timelineFocusRequestKey?: number;
|
||||
}
|
||||
|
||||
function isDeferredTimelineItem(item: AgentThreadItem): boolean {
|
||||
return item.type === "file_artifact";
|
||||
}
|
||||
|
||||
const MessageListInner: React.FC<MessageListProps> = ({
|
||||
messages,
|
||||
turns = [],
|
||||
@@ -321,6 +325,24 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
: mappedTimeline?.turn.id === currentTurnTimeline?.turn.id
|
||||
? null
|
||||
: mappedTimeline || null;
|
||||
const primaryTimelineItems = timeline
|
||||
? timeline.items.filter((item) => !isDeferredTimelineItem(item))
|
||||
: [];
|
||||
const trailingTimelineItems = timeline
|
||||
? timeline.items.filter(isDeferredTimelineItem)
|
||||
: [];
|
||||
const primaryTimeline =
|
||||
timeline && primaryTimelineItems.length > 0
|
||||
? { ...timeline, items: primaryTimelineItems }
|
||||
: null;
|
||||
const trailingTimeline =
|
||||
timeline && trailingTimelineItems.length > 0
|
||||
? { ...timeline, items: trailingTimelineItems }
|
||||
: null;
|
||||
const primaryActionRequests =
|
||||
primaryTimelineItems.length > 0 ? msg.actionRequests : undefined;
|
||||
const trailingActionRequests =
|
||||
primaryTimelineItems.length === 0 ? msg.actionRequests : undefined;
|
||||
const showIdentity = options?.showIdentity ?? true;
|
||||
|
||||
return (
|
||||
@@ -390,43 +412,63 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : msg.role === "assistant" ? (
|
||||
<StreamingRenderer
|
||||
content={displayContent}
|
||||
isStreaming={msg.isThinking}
|
||||
toolCalls={msg.toolCalls}
|
||||
showCursor={msg.isThinking && !displayContent}
|
||||
thinkingContent={msg.thinkingContent}
|
||||
runtimeStatus={msg.runtimeStatus}
|
||||
contentParts={displayContentParts}
|
||||
actionRequests={msg.actionRequests}
|
||||
onA2UISubmit={
|
||||
onA2UISubmit
|
||||
? (formData) => 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 ? (
|
||||
<AgentThreadTimeline
|
||||
turn={primaryTimeline.turn}
|
||||
items={primaryTimeline.items}
|
||||
threadRead={threadRead}
|
||||
actionRequests={primaryActionRequests}
|
||||
isCurrentTurn={primaryTimeline.turn.id === currentTurnId}
|
||||
placement="leading"
|
||||
onFileClick={onFileClick}
|
||||
onOpenArtifactFromTimeline={onOpenArtifactFromTimeline}
|
||||
onOpenSavedSiteContent={onOpenSavedSiteContent}
|
||||
onOpenSubagentSession={onOpenSubagentSession}
|
||||
onPermissionResponse={onPermissionResponse}
|
||||
focusedItemId={focusedTimelineItemId}
|
||||
focusRequestKey={timelineFocusRequestKey}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<StreamingRenderer
|
||||
content={displayContent}
|
||||
isStreaming={msg.isThinking}
|
||||
toolCalls={msg.toolCalls}
|
||||
showCursor={msg.isThinking && !displayContent}
|
||||
thinkingContent={msg.thinkingContent}
|
||||
runtimeStatus={msg.runtimeStatus}
|
||||
contentParts={displayContentParts}
|
||||
actionRequests={msg.actionRequests}
|
||||
onA2UISubmit={
|
||||
onA2UISubmit
|
||||
? (formData) => 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 ? (
|
||||
<MarkdownRenderer
|
||||
@@ -456,13 +498,14 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
|
||||
{msg.role === "assistant" && renderArtifactCards(msg.artifacts)}
|
||||
|
||||
{msg.role === "assistant" && timeline ? (
|
||||
{msg.role === "assistant" && trailingTimeline ? (
|
||||
<AgentThreadTimeline
|
||||
turn={timeline.turn}
|
||||
items={timeline.items}
|
||||
turn={trailingTimeline.turn}
|
||||
items={trailingTimeline.items}
|
||||
threadRead={threadRead}
|
||||
actionRequests={msg.actionRequests}
|
||||
isCurrentTurn={timeline.turn.id === currentTurnId}
|
||||
actionRequests={trailingActionRequests}
|
||||
isCurrentTurn={trailingTimeline.turn.id === currentTurnId}
|
||||
placement="trailing"
|
||||
onFileClick={onFileClick}
|
||||
onOpenArtifactFromTimeline={onOpenArtifactFromTimeline}
|
||||
onOpenSavedSiteContent={onOpenSavedSiteContent}
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* 项目选择器组件
|
||||
*
|
||||
* 在 EmptyState 中显示项目列表,支持搜索和快速创建
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Search, Plus, FileText, Clock } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Project,
|
||||
ProjectType,
|
||||
createProject,
|
||||
getCreateProjectErrorMessage,
|
||||
extractErrorMessage,
|
||||
listProjects,
|
||||
resolveProjectRootPath,
|
||||
TYPE_CONFIGS,
|
||||
} from "@/lib/api/project";
|
||||
import { toast } from "sonner";
|
||||
import { CreateProjectDialog } from "@/components/projects/CreateProjectDialog";
|
||||
import { notifyProjectCreatedWithRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService";
|
||||
|
||||
interface ProjectSelectorProps {
|
||||
/** 当前激活的主题(用于过滤项目) */
|
||||
activeTheme?: string;
|
||||
/** 选择项目回调 */
|
||||
onSelectProject: (projectId: string) => void;
|
||||
/** 创建项目回调 */
|
||||
onCreateProject?: () => void;
|
||||
}
|
||||
|
||||
export function ProjectSelector({
|
||||
activeTheme = "general",
|
||||
onSelectProject,
|
||||
onCreateProject: _onCreateProject,
|
||||
}: ProjectSelectorProps) {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
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 (
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
{/* 搜索和快速创建 */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索项目..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
新建项目
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 项目列表 */}
|
||||
<ScrollArea className="h-[400px] rounded-lg border bg-card">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="text-sm text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mb-2 opacity-20" />
|
||||
<p className="text-sm mb-4">
|
||||
{searchQuery ? "没有找到匹配的项目" : "还没有项目"}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
创建第一个项目
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-2">
|
||||
{filteredProjects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => onSelectProject(project.id)}
|
||||
className={cn(
|
||||
"w-full p-4 rounded-lg border bg-background",
|
||||
"hover:bg-accent hover:border-primary/50",
|
||||
"transition-all duration-200",
|
||||
"text-left",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">
|
||||
{TYPE_CONFIGS[project.workspaceType].icon}
|
||||
</span>
|
||||
<h3 className="font-medium truncate">{project.name}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>{project.stats?.content_count || 0} 个内容</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatTime(project.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{TYPE_CONFIGS[project.workspaceType].label}
|
||||
</Badge>
|
||||
{project.stats?.total_words && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{project.stats.total_words.toLocaleString()} 字
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<CreateProjectDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSubmit={handleCreateProject}
|
||||
defaultType={defaultProjectType}
|
||||
defaultName={`${TYPE_CONFIGS[defaultProjectType].label}项目`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<TaskFile[]>([]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
<TaskFileList
|
||||
files={files}
|
||||
selectedFileId={selectedId}
|
||||
onFileClick={(file) => console.log('点击文件:', file)}
|
||||
expanded={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
/>
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
- `@/lib/utils` - cn 工具函数
|
||||
- `lucide-react` - 图标
|
||||
@@ -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<TaskFileType, React.ElementType> = {
|
||||
document: FileText,
|
||||
folder: Folder,
|
||||
image: Image,
|
||||
code: Code,
|
||||
data: Database,
|
||||
};
|
||||
|
||||
/** 文件类型颜色映射 */
|
||||
const FILE_COLORS: Record<TaskFileType, string> = {
|
||||
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<TaskFileType, string> = {
|
||||
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<TaskFileItemProps> = ({
|
||||
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 (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-3 px-3 py-2.5 cursor-pointer rounded-lg transition-colors",
|
||||
"hover:bg-muted/50",
|
||||
isSelected && "bg-primary/10",
|
||||
)}
|
||||
style={{ paddingLeft: `${12 + level * 16}px` }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 展开箭头(仅文件夹) */}
|
||||
{isFolder && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"w-4 h-4 text-muted-foreground transition-transform flex-shrink-0",
|
||||
isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文件图标 - 带背景色 */}
|
||||
<div className={cn("p-2 rounded-lg flex-shrink-0", iconBgColor)}>
|
||||
<Icon className={cn("w-5 h-5", iconColor)} />
|
||||
</div>
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{file.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{file.version && `Version ${file.version} · `}
|
||||
{formatTime(file.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 更多操作 */}
|
||||
<button
|
||||
className="p-1.5 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// TODO: 显示更多操作菜单
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 子文件(文件夹展开时) */}
|
||||
{isFolder && isExpanded && file.children && (
|
||||
<div>
|
||||
{file.children.map((child) => (
|
||||
<TaskFileItem
|
||||
key={child.id}
|
||||
file={child}
|
||||
isSelected={isSelected}
|
||||
onClick={onClick}
|
||||
level={level + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<FileFilter, React.ElementType> = {
|
||||
all: LayoutGrid,
|
||||
document: FileText,
|
||||
image: Image,
|
||||
code: Code,
|
||||
};
|
||||
|
||||
export const TaskFileList: React.FC<TaskFilesProps> = ({
|
||||
files,
|
||||
selectedFileId,
|
||||
onFileClick,
|
||||
expanded = false,
|
||||
onExpandedChange,
|
||||
}) => {
|
||||
const [filter, setFilter] = useState<FileFilter>("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 (
|
||||
<div className="relative">
|
||||
{/* 触发按钮 - 居中显示 */}
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-1.5 rounded-full",
|
||||
"text-sm text-muted-foreground",
|
||||
"border border-border bg-background",
|
||||
"hover:bg-muted/50 transition-colors",
|
||||
expanded && "bg-muted/50",
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
<span>任务文件 ({fileCount})</span>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 弹出面板 - 参考 AnyGen 样式 */}
|
||||
{expanded && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2",
|
||||
"bg-background border border-border rounded-xl shadow-lg",
|
||||
"z-50 overflow-hidden",
|
||||
"w-[360px] max-h-[320px]",
|
||||
)}
|
||||
>
|
||||
{/* 面板头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>所有文件</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 过滤器 */}
|
||||
<div className="flex items-center gap-1 bg-muted/50 rounded-lg p-1">
|
||||
{(Object.keys(FILTER_ICONS) as FileFilter[]).map((key) => {
|
||||
const Icon = FILTER_ICONS[key];
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-colors",
|
||||
filter === key
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setFilter(key)}
|
||||
title={key === "all" ? "全部" : key}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件列表 */}
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{filteredFiles.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
暂无文件
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2 px-2">
|
||||
{filteredFiles.map((file) => (
|
||||
<TaskFileItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
isSelected={file.id === selectedFileId}
|
||||
onClick={onFileClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
{groupedItems.map((group, groupIndex) => {
|
||||
if (group.type === "text") {
|
||||
// Agent 文本消息
|
||||
const item = group.content;
|
||||
return (
|
||||
<div key={`text-${groupIndex}`} className="text-sm text-slate-700">
|
||||
{item.text}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// 时间线组
|
||||
const timelineItems = group.content as AgentThreadItem[];
|
||||
return (
|
||||
<div key={`timeline-${groupIndex}`} className="space-y-0">
|
||||
{timelineItems.map((item, itemIndex) => (
|
||||
<TimelineInlineItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isLast={itemIndex === timelineItems.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="relative flex gap-3">
|
||||
{/* 左侧时间线 */}
|
||||
<div className="relative flex flex-col items-center">
|
||||
{/* 状态图标 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full border-2",
|
||||
isRunning && "border-sky-300 bg-sky-50",
|
||||
isFailed && "border-rose-300 bg-rose-50",
|
||||
!isRunning && !isFailed && "border-slate-300 bg-white"
|
||||
)}
|
||||
>
|
||||
<StatusIcon
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
isRunning && "animate-spin text-sky-600",
|
||||
isFailed && "text-rose-600",
|
||||
!isRunning && !isFailed && "text-emerald-600"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 连接线 */}
|
||||
{!isLast && (
|
||||
<div className="w-0.5 flex-1 bg-slate-200 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧内容 */}
|
||||
<div className="flex-1 pb-4">
|
||||
<Collapsible
|
||||
open={shouldDefaultExpand || isExpanded}
|
||||
onOpenChange={setIsExpanded}
|
||||
>
|
||||
{/* 标题行 */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ToolIcon className="h-4 w-4 text-slate-500" />
|
||||
<span className="font-medium text-slate-700">{title}</span>
|
||||
|
||||
{isRunning && (
|
||||
<span className="text-xs text-slate-500">正在执行...</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 输出内容 */}
|
||||
{output && (
|
||||
<div className="mt-2">
|
||||
{/* 预览 */}
|
||||
<div className={cn(
|
||||
"rounded-md border px-3 py-2 text-xs font-mono",
|
||||
isFailed ? "border-rose-200 bg-rose-50 text-rose-900" : "border-slate-200 bg-slate-50 text-slate-700"
|
||||
)}>
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
{shouldDefaultExpand || isExpanded ? output : preview}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* 展开/收起按钮 */}
|
||||
{hasMore && !shouldDefaultExpand && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className="mt-2 flex items-center gap-1 text-xs text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
收起
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
查看完整输出
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -185,6 +185,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
),
|
||||
})),
|
||||
);
|
||||
await refreshSessionReadModel(activeSessionId);
|
||||
toast.info("已记录你的回答,等待系统请求就绪后自动提交");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
`我的选择:
|
||||
|
||||
@@ -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(["已决定:请先确认以下选项:"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AgentThreadGroupKind, "thinking">): 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<AgentThreadGroupKind, "thinking">): 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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,28 @@ interface UseWorkspaceA2UIRuntimeParams {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
function isSamePendingA2UIResolution(
|
||||
previous: PendingA2UIResolution | null,
|
||||
next: PendingA2UIResolution,
|
||||
): boolean {
|
||||
if (!previous || previous.source.kind !== next.source.kind) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
previous.source.kind === "action_request" &&
|
||||
next.source.kind === "action_request"
|
||||
) {
|
||||
return previous.source.requestId === next.source.requestId;
|
||||
}
|
||||
|
||||
return (
|
||||
previous.source.kind !== "action_request" &&
|
||||
next.source.kind !== "action_request" &&
|
||||
previous.source.messageId === next.source.messageId
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceA2UIRuntime({
|
||||
messages,
|
||||
}: UseWorkspaceA2UIRuntimeParams): {
|
||||
@@ -168,8 +190,13 @@ export function useWorkspaceA2UIRuntime({
|
||||
}
|
||||
|
||||
if (pendingPromotedA2UIActionRequest) {
|
||||
const form = buildActionRequestA2UI(pendingPromotedA2UIActionRequest);
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
form: buildActionRequestA2UI(pendingPromotedA2UIActionRequest),
|
||||
form,
|
||||
source: {
|
||||
kind: "action_request",
|
||||
requestId: pendingPromotedA2UIActionRequest.requestId,
|
||||
@@ -243,7 +270,11 @@ export function useWorkspaceA2UIRuntime({
|
||||
|
||||
useEffect(() => {
|
||||
if (resolvedPendingA2UI) {
|
||||
setRetainedPendingA2UI(resolvedPendingA2UI);
|
||||
setRetainedPendingA2UI((previous) =>
|
||||
isSamePendingA2UIResolution(previous, resolvedPendingA2UI)
|
||||
? previous
|
||||
: resolvedPendingA2UI,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -257,17 +288,18 @@ export function useWorkspaceA2UIRuntime({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (previous.source.kind === "action_request") {
|
||||
const source = previous.source;
|
||||
if (source.kind === "action_request") {
|
||||
const requestStillExists = messages.some((message) =>
|
||||
(message.actionRequests || []).some(
|
||||
(request) => request.requestId === previous.source.requestId,
|
||||
(request) => request.requestId === source.requestId,
|
||||
),
|
||||
);
|
||||
return requestStillExists ? previous : null;
|
||||
}
|
||||
|
||||
const sourceMessageStillExists = messages.some(
|
||||
(message) => message.id === previous.source.messageId,
|
||||
(message) => message.id === source.messageId,
|
||||
);
|
||||
return sourceMessageStillExists ? previous : null;
|
||||
});
|
||||
|
||||
@@ -9,6 +9,15 @@ import type { CanvasStateUnion } from "@/components/content-creator/canvas/canva
|
||||
import { isCanvasStateEmpty } from "./themeWorkbenchHelpers";
|
||||
import type { WorkspaceHandleSend } from "./useWorkspaceSendActions";
|
||||
|
||||
const shouldLogWorkspaceInfo = import.meta.env.MODE !== "test";
|
||||
|
||||
function logWorkspaceInfo(...args: Parameters<typeof console.log>) {
|
||||
if (!shouldLogWorkspaceInfo) {
|
||||
return;
|
||||
}
|
||||
console.log(...args);
|
||||
}
|
||||
|
||||
interface UseWorkspaceAutoGuideRuntimeParams {
|
||||
contentId?: string | null;
|
||||
sessionId?: string | null;
|
||||
@@ -110,7 +119,7 @@ export function useWorkspaceAutoGuideRuntime({
|
||||
let disposed = false;
|
||||
consumedInitialPromptRef.current = initialDispatchKey;
|
||||
hasTriggeredGuideRef.current = true;
|
||||
console.log("[AgentChatPage] 自动发送首条创作意图消息");
|
||||
logWorkspaceInfo("[AgentChatPage] 自动发送首条创作意图消息");
|
||||
|
||||
void (async () => {
|
||||
const started = await handleSend(
|
||||
@@ -154,12 +163,12 @@ export function useWorkspaceAutoGuideRuntime({
|
||||
}
|
||||
|
||||
hasTriggeredGuideRef.current = true;
|
||||
console.log("[AgentChatPage] 主题工作台:触发 AI 引导,创建后端工作流");
|
||||
logWorkspaceInfo("[AgentChatPage] 主题工作台:触发 AI 引导,创建后端工作流");
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await contentWorkflowApi.create(contentId, mappedTheme, creationMode);
|
||||
console.log("[AgentChatPage] 后端工作流创建成功");
|
||||
logWorkspaceInfo("[AgentChatPage] 后端工作流创建成功");
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[AgentChatPage] 后端工作流创建失败(不影响主流程):",
|
||||
@@ -173,7 +182,7 @@ export function useWorkspaceAutoGuideRuntime({
|
||||
}
|
||||
|
||||
hasTriggeredGuideRef.current = true;
|
||||
console.log("[AgentChatPage] 自动触发 AI 创作引导");
|
||||
logWorkspaceInfo("[AgentChatPage] 自动触发 AI 创作引导");
|
||||
triggerAIGuideRef.current();
|
||||
}, [
|
||||
canvasState,
|
||||
@@ -282,7 +291,7 @@ export function useWorkspaceAutoGuideRuntime({
|
||||
const completedCount = workflow.steps.filter(
|
||||
(step) => step.status === "completed" || step.status === "skipped",
|
||||
).length;
|
||||
console.log(
|
||||
logWorkspaceInfo(
|
||||
`[AgentChatPage] 找到已有工作流: ${workflow.id},已完成步骤 ${completedCount}/${workflow.steps.length}`,
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -154,4 +154,50 @@ describe("useWorkspaceCanvasLayoutRuntime", () => {
|
||||
setLayoutMode.mock.calls.some((call) => call[0] === "chat"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stacked 自动收起侧栏后,不应在同一轮 general chat-canvas 中立刻反向展开", async () => {
|
||||
const setShowSidebar = vi.fn();
|
||||
const autoCollapsedTopicSidebarRef = { current: false };
|
||||
const { render } = renderHook({
|
||||
setShowSidebar,
|
||||
autoCollapsedTopicSidebarRef,
|
||||
canvasWorkbenchLayoutMode: "stacked",
|
||||
showSidebar: true,
|
||||
layoutMode: "chat-canvas",
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(setShowSidebar).toHaveBeenCalledWith(false);
|
||||
expect(autoCollapsedTopicSidebarRef.current).toBe(true);
|
||||
|
||||
setShowSidebar.mockClear();
|
||||
|
||||
await render({
|
||||
canvasWorkbenchLayoutMode: "split",
|
||||
showSidebar: false,
|
||||
autoCollapsedTopicSidebarRef,
|
||||
layoutMode: "chat-canvas",
|
||||
});
|
||||
|
||||
expect(setShowSidebar).not.toHaveBeenCalled();
|
||||
expect(autoCollapsedTopicSidebarRef.current).toBe(true);
|
||||
});
|
||||
|
||||
it("自动收起的侧栏在离开 general chat-canvas 主路径后应恢复", async () => {
|
||||
const setShowSidebar = vi.fn();
|
||||
const autoCollapsedTopicSidebarRef = { current: true };
|
||||
const { render } = renderHook({
|
||||
setShowSidebar,
|
||||
autoCollapsedTopicSidebarRef,
|
||||
showSidebar: false,
|
||||
canvasWorkbenchLayoutMode: "split",
|
||||
layoutMode: "chat",
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(setShowSidebar).toHaveBeenCalledWith(true);
|
||||
expect(autoCollapsedTopicSidebarRef.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,7 +221,15 @@ export function useWorkspaceCanvasLayoutRuntime({
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoCollapsedTopicSidebarRef.current) {
|
||||
const shouldRestoreAutoCollapsedSidebar =
|
||||
autoCollapsedTopicSidebarRef.current &&
|
||||
!showSidebar &&
|
||||
(!showChatPanel ||
|
||||
isThemeWorkbench ||
|
||||
activeTheme !== "general" ||
|
||||
layoutMode !== "chat-canvas");
|
||||
|
||||
if (shouldRestoreAutoCollapsedSidebar) {
|
||||
autoCollapsedTopicSidebarRef.current = false;
|
||||
setShowSidebar(true);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
ArtifactWorkbenchPreview,
|
||||
WorkspaceLiveCanvasPreview,
|
||||
} from "./workbenchPreview";
|
||||
import { renderCanvasWorkbenchPreviewTarget } from "./workbenchPreviewHelpers";
|
||||
import {
|
||||
renderCanvasWorkbenchPreviewTarget,
|
||||
type RenderArtifactWorkbenchPreviewOptions,
|
||||
} from "./workbenchPreviewHelpers";
|
||||
import { buildCanvasWorkbenchDefaultPreview } from "./canvasWorkbenchDefaultPreview";
|
||||
import {
|
||||
useTeamWorkbenchPresentation,
|
||||
@@ -41,7 +44,10 @@ import { hasRenderableGeneralCanvasPreview } from "./generalCanvasPreviewState";
|
||||
|
||||
type ArtifactPreviewBaseProps = Omit<
|
||||
ComponentProps<typeof ArtifactWorkbenchPreview>,
|
||||
"artifact" | "stackedWorkbenchTrigger"
|
||||
| "artifact"
|
||||
| "stackedWorkbenchTrigger"
|
||||
| "artifactDocumentLayoutMode"
|
||||
| "onArtifactDocumentControllerChange"
|
||||
>;
|
||||
type ImageWorkbenchCanvasProps = ComponentProps<typeof ImageWorkbenchCanvas>;
|
||||
type GeneralCanvasPanelProps = Omit<
|
||||
@@ -433,11 +439,18 @@ export function useWorkspaceCanvasPreviewPresentation({
|
||||
);
|
||||
|
||||
const renderArtifactWorkbenchPreview = useCallback(
|
||||
(artifact: Artifact, stackedWorkbenchTrigger?: ReactNode) => (
|
||||
(
|
||||
artifact: Artifact,
|
||||
options?: RenderArtifactWorkbenchPreviewOptions,
|
||||
) => (
|
||||
<ArtifactWorkbenchPreview
|
||||
{...artifactWorkbenchPreviewBaseProps}
|
||||
artifact={artifact}
|
||||
stackedWorkbenchTrigger={stackedWorkbenchTrigger}
|
||||
stackedWorkbenchTrigger={options?.stackedWorkbenchTrigger}
|
||||
artifactDocumentLayoutMode={options?.artifactDocumentLayoutMode}
|
||||
onArtifactDocumentControllerChange={
|
||||
options?.onArtifactDocumentControllerChange
|
||||
}
|
||||
/>
|
||||
),
|
||||
[artifactWorkbenchPreviewBaseProps],
|
||||
|
||||
@@ -29,6 +29,15 @@ import {
|
||||
} from "./themeWorkbenchHelpers";
|
||||
import type { GeneralArtifactSyncResult } from "./useWorkspaceGeneralResourceSync";
|
||||
|
||||
const shouldLogWorkspaceWriteInfo = import.meta.env.MODE !== "test";
|
||||
|
||||
function logWorkspaceWriteInfo(...args: Parameters<typeof console.log>) {
|
||||
if (!shouldLogWorkspaceWriteInfo) {
|
||||
return;
|
||||
}
|
||||
console.log(...args);
|
||||
}
|
||||
|
||||
interface ThemeWorkbenchActiveQueueSummary {
|
||||
run_id?: string | null;
|
||||
title?: string | null;
|
||||
@@ -102,7 +111,7 @@ export function useWorkspaceWriteFileAction({
|
||||
}: UseWorkspaceWriteFileActionParams) {
|
||||
return useCallback(
|
||||
(content: string, fileName: string, context?: WriteArtifactContext) => {
|
||||
console.log(
|
||||
logWorkspaceWriteInfo(
|
||||
"[AgentChatPage] 收到文件写入:",
|
||||
fileName,
|
||||
content.length,
|
||||
@@ -277,7 +286,7 @@ export function useWorkspaceWriteFileAction({
|
||||
console.error("[AgentChatPage] 检查内容存在性失败:", error);
|
||||
});
|
||||
} else if (isThemeWorkbench && !shouldApplyToMainDocument) {
|
||||
console.log("[AgentChatPage] 主题工作台非成文阶段,跳过主稿写入:", {
|
||||
logWorkspaceWriteInfo("[AgentChatPage] 主题工作台非成文阶段,跳过主稿写入:", {
|
||||
gate: currentGateKey,
|
||||
fileName,
|
||||
isPrimaryArtifact,
|
||||
@@ -291,7 +300,7 @@ export function useWorkspaceWriteFileAction({
|
||||
stepIndex === currentStepIndex &&
|
||||
isContentCreationMode
|
||||
) {
|
||||
console.log(
|
||||
logWorkspaceWriteInfo(
|
||||
"[AgentChatPage] 推进工作流步骤:",
|
||||
stepIndex,
|
||||
"->",
|
||||
@@ -355,12 +364,12 @@ export function useWorkspaceWriteFileAction({
|
||||
const existing = previous[existingIndex];
|
||||
|
||||
if (existing.content === content) {
|
||||
console.log("[AgentChatPage] 文件内容相同,跳过:", fileName);
|
||||
logWorkspaceWriteInfo("[AgentChatPage] 文件内容相同,跳过:", fileName);
|
||||
setSelectedFileId(existing.id);
|
||||
return previous;
|
||||
}
|
||||
|
||||
console.log("[AgentChatPage] 更新文件:", fileName);
|
||||
logWorkspaceWriteInfo("[AgentChatPage] 更新文件:", fileName);
|
||||
const nextFiles = [...previous];
|
||||
nextFiles[existingIndex] = {
|
||||
...existing,
|
||||
@@ -380,7 +389,7 @@ export function useWorkspaceWriteFileAction({
|
||||
return nextFiles;
|
||||
}
|
||||
|
||||
console.log("[AgentChatPage] 创建新文件:", fileName);
|
||||
logWorkspaceWriteInfo("[AgentChatPage] 创建新文件:", fileName);
|
||||
const newFile: TaskFile = {
|
||||
id: crypto.randomUUID(),
|
||||
name: fileName,
|
||||
@@ -406,7 +415,7 @@ export function useWorkspaceWriteFileAction({
|
||||
}
|
||||
|
||||
setCanvasState((previous) => {
|
||||
console.log("[AgentChatPage] 更新画布状态:", {
|
||||
logWorkspaceWriteInfo("[AgentChatPage] 更新画布状态:", {
|
||||
prevType: previous?.type,
|
||||
mappedTheme,
|
||||
contentLength: content.length,
|
||||
@@ -425,7 +434,7 @@ export function useWorkspaceWriteFileAction({
|
||||
if (titleMatch) {
|
||||
musicState.spec.title = titleMatch[1].trim();
|
||||
}
|
||||
console.log("[AgentChatPage] 创建新音乐状态");
|
||||
logWorkspaceWriteInfo("[AgentChatPage] 创建新音乐状态");
|
||||
return musicState;
|
||||
}
|
||||
return {
|
||||
@@ -439,7 +448,7 @@ export function useWorkspaceWriteFileAction({
|
||||
}
|
||||
|
||||
if (!previous || previous.type !== "document") {
|
||||
console.log("[AgentChatPage] 创建新文档状态");
|
||||
logWorkspaceWriteInfo("[AgentChatPage] 创建新文档状态");
|
||||
const initialDocumentState = createInitialDocumentState(content);
|
||||
if (!effectiveDocumentVersionId) {
|
||||
if (!socialArtifact) {
|
||||
@@ -535,7 +544,7 @@ export function useWorkspaceWriteFileAction({
|
||||
};
|
||||
}
|
||||
|
||||
console.log("[AgentChatPage] 更新现有文档状态");
|
||||
logWorkspaceWriteInfo("[AgentChatPage] 更新现有文档状态");
|
||||
return {
|
||||
...previous,
|
||||
content,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
ArtifactCanvasOverlay,
|
||||
@@ -20,6 +21,11 @@ import { wrapPreviewWithWorkbenchTrigger } from "./workbenchPreviewHelpers";
|
||||
import { resolveArtifactProtocolDocumentPayload } from "@/lib/artifact-protocol";
|
||||
import type { ArtifactDocumentV1 } from "@/lib/artifact-document";
|
||||
import type { AgentThreadItem } from "../types";
|
||||
import {
|
||||
type ArtifactWorkbenchDocumentController,
|
||||
type ArtifactWorkbenchLayoutMode,
|
||||
useArtifactWorkbenchDocumentController,
|
||||
} from "./artifactWorkbenchDocument";
|
||||
|
||||
interface ArtifactWorkbenchPreviewProps {
|
||||
artifact: Artifact;
|
||||
@@ -47,6 +53,10 @@ interface ArtifactWorkbenchPreviewProps {
|
||||
onJumpToTimelineItem?: (itemId: string) => void;
|
||||
onCloseCanvas: () => void;
|
||||
stackedWorkbenchTrigger?: ReactNode;
|
||||
artifactDocumentLayoutMode?: ArtifactWorkbenchLayoutMode;
|
||||
onArtifactDocumentControllerChange?: (
|
||||
controller: ArtifactWorkbenchDocumentController | null,
|
||||
) => void;
|
||||
renderToolbarActions?: (params: {
|
||||
artifact: Artifact;
|
||||
document: ArtifactDocumentV1 | null;
|
||||
@@ -70,6 +80,8 @@ export function ArtifactWorkbenchPreview({
|
||||
onJumpToTimelineItem,
|
||||
onCloseCanvas,
|
||||
stackedWorkbenchTrigger,
|
||||
artifactDocumentLayoutMode = "full",
|
||||
onArtifactDocumentControllerChange,
|
||||
renderToolbarActions,
|
||||
}: ArtifactWorkbenchPreviewProps) {
|
||||
const isLiveSelectedArtifact =
|
||||
@@ -96,6 +108,30 @@ export function ArtifactWorkbenchPreview({
|
||||
content: previewArtifact.content,
|
||||
metadata: previewArtifact.meta,
|
||||
});
|
||||
const documentController = useArtifactWorkbenchDocumentController({
|
||||
artifact: previewArtifact,
|
||||
onSaveArtifactDocument,
|
||||
threadItems,
|
||||
focusedBlockId,
|
||||
blockFocusRequestKey,
|
||||
onJumpToTimelineItem,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!onArtifactDocumentControllerChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
onArtifactDocumentControllerChange(artifactDocument ? documentController : null);
|
||||
return () => {
|
||||
onArtifactDocumentControllerChange(null);
|
||||
};
|
||||
}, [
|
||||
artifactDocument,
|
||||
documentController,
|
||||
onArtifactDocumentControllerChange,
|
||||
]);
|
||||
|
||||
const combinedActionsSlot = (
|
||||
<>
|
||||
{renderToolbarActions?.({
|
||||
@@ -145,6 +181,8 @@ export function ArtifactWorkbenchPreview({
|
||||
onJumpToTimelineItem={onJumpToTimelineItem}
|
||||
onCloseCanvas={onCloseCanvas}
|
||||
actionsSlot={combinedActionsSlot}
|
||||
layoutMode={artifactDocumentLayoutMode}
|
||||
documentController={documentController}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -197,7 +235,13 @@ interface WorkspaceLiveCanvasPreviewProps {
|
||||
hasDisplayedLiveArtifact: boolean;
|
||||
renderArtifactPreview: (
|
||||
artifact: Artifact,
|
||||
stackedWorkbenchTrigger?: ReactNode,
|
||||
options?: {
|
||||
stackedWorkbenchTrigger?: ReactNode;
|
||||
artifactDocumentLayoutMode?: ArtifactWorkbenchLayoutMode;
|
||||
onArtifactDocumentControllerChange?: (
|
||||
controller: ArtifactWorkbenchDocumentController | null,
|
||||
) => void;
|
||||
},
|
||||
) => ReactNode;
|
||||
generalCanvasPanelProps: Omit<
|
||||
ComponentProps<typeof GeneralCanvasPanel>,
|
||||
@@ -234,7 +278,9 @@ export function WorkspaceLiveCanvasPreview({
|
||||
liveArtifact &&
|
||||
hasDisplayedLiveArtifact
|
||||
) {
|
||||
return renderArtifactPreview(liveArtifact, stackedWorkbenchTrigger);
|
||||
return renderArtifactPreview(liveArtifact, {
|
||||
stackedWorkbenchTrigger,
|
||||
});
|
||||
}
|
||||
|
||||
if (canvasRenderTheme === "general") {
|
||||
|
||||
@@ -2,6 +2,18 @@ import type { ReactNode } from "react";
|
||||
import type { DocumentVersion } from "@/components/content-creator/canvas/document/types";
|
||||
import type { Artifact } from "@/lib/artifact/types";
|
||||
import type { CanvasWorkbenchPreviewTarget } from "../components/CanvasWorkbenchLayout";
|
||||
import type {
|
||||
ArtifactWorkbenchDocumentController,
|
||||
ArtifactWorkbenchLayoutMode,
|
||||
} from "./artifactWorkbenchDocument";
|
||||
|
||||
export interface RenderArtifactWorkbenchPreviewOptions {
|
||||
stackedWorkbenchTrigger?: ReactNode;
|
||||
artifactDocumentLayoutMode?: ArtifactWorkbenchLayoutMode;
|
||||
onArtifactDocumentControllerChange?: (
|
||||
controller: ArtifactWorkbenchDocumentController | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function resolvePreviousDocumentVersionContent(
|
||||
version: DocumentVersion | null | undefined,
|
||||
@@ -69,7 +81,7 @@ export function renderCanvasWorkbenchPreviewTarget(params: {
|
||||
renderDefaultCanvasPreview: (stackedWorkbenchTrigger?: ReactNode) => ReactNode;
|
||||
renderArtifactPreview: (
|
||||
artifact: Artifact,
|
||||
stackedWorkbenchTrigger?: ReactNode,
|
||||
options?: RenderArtifactWorkbenchPreviewOptions,
|
||||
) => ReactNode;
|
||||
renderTeamWorkbenchPreview: (
|
||||
stackedWorkbenchTrigger?: ReactNode,
|
||||
@@ -82,7 +94,9 @@ export function renderCanvasWorkbenchPreviewTarget(params: {
|
||||
return params.renderDefaultCanvasPreview(stackedWorkbenchTrigger);
|
||||
case "artifact":
|
||||
case "synthetic-artifact":
|
||||
return params.renderArtifactPreview(target.artifact, stackedWorkbenchTrigger);
|
||||
return params.renderArtifactPreview(target.artifact, {
|
||||
stackedWorkbenchTrigger,
|
||||
});
|
||||
case "loading":
|
||||
return renderWorkbenchStatePreview("loading", {
|
||||
text: "正在准备预览...",
|
||||
|
||||
@@ -7,34 +7,34 @@ export interface A2UITaskCardPreset {
|
||||
}
|
||||
|
||||
export const DEFAULT_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
|
||||
title: "补充信息",
|
||||
subtitle: "请先完成这一步,我再继续后续处理。",
|
||||
statusLabel: "待完成 1 / 1",
|
||||
loadingText: "表单加载中...",
|
||||
title: "等你补充信息",
|
||||
subtitle: "先补这一步,我再继续后续处理。",
|
||||
statusLabel: "等你确认",
|
||||
loadingText: "这一步加载中...",
|
||||
};
|
||||
|
||||
export const CHAT_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
|
||||
...DEFAULT_A2UI_TASK_CARD_PRESET,
|
||||
subtitle: "请先完成这一步,我再继续当前对话。",
|
||||
subtitle: "先补这一步,我再继续当前对话。",
|
||||
};
|
||||
|
||||
export const CHAT_FLOATING_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
|
||||
...DEFAULT_A2UI_TASK_CARD_PRESET,
|
||||
subtitle: "请先完成这一步,我再继续。",
|
||||
subtitle: "先补这一步,我再继续。",
|
||||
};
|
||||
|
||||
export const REVIEW_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
|
||||
title: "结构化补充信息",
|
||||
subtitle: "评审结果已切换为结构化预览,仅展示字段与提示,不直接允许提交。",
|
||||
statusLabel: "评审预览",
|
||||
title: "评审预览",
|
||||
subtitle: "结构化补充信息",
|
||||
statusLabel: "只读回显",
|
||||
loadingText: "结构化评审结果加载中...",
|
||||
};
|
||||
|
||||
export const TIMELINE_A2UI_TASK_CARD_PRESET: A2UITaskCardPreset = {
|
||||
title: "结构化问答预览",
|
||||
subtitle: "当前阶段已整理结构化字段,仅作为回合记录预览展示。",
|
||||
statusLabel: "阶段预览",
|
||||
loadingText: "结构化问答整理中...",
|
||||
title: "这一步的信息",
|
||||
subtitle: "这是这一步的回显,我按这个继续。",
|
||||
statusLabel: "回合记录",
|
||||
loadingText: "这一步还在整理...",
|
||||
};
|
||||
|
||||
export const WORKSPACE_CREATE_CONFIRMATION_TASK_PRESET: A2UITaskCardPreset = {
|
||||
|
||||
@@ -248,7 +248,7 @@ function renderSettingsContent(
|
||||
case SettingsTabs.ChromeRelay:
|
||||
return (
|
||||
<>
|
||||
<SettingHeader title="Chrome Relay" />
|
||||
<SettingHeader title="连接器" />
|
||||
<ChromeRelaySettings />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ const groupMeta: Record<
|
||||
icon: Brain,
|
||||
},
|
||||
system: {
|
||||
description: "渠道、MCP、环境变量与安全性能设置。",
|
||||
description: "连接器、渠道、MCP、环境变量与安全性能设置。",
|
||||
accentClassName:
|
||||
"from-amber-200/65 via-white to-white",
|
||||
iconClassName: "border-amber-200 bg-amber-100 text-amber-700",
|
||||
|
||||
@@ -170,7 +170,7 @@ export function useSettingsCategory(): CategoryGroup[] {
|
||||
},
|
||||
{
|
||||
key: SettingsTabs.ChromeRelay,
|
||||
label: t("settings.tab.chromeRelay", "Chrome Relay"),
|
||||
label: t("settings.tab.chromeRelay", "连接器"),
|
||||
icon: Monitor,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,7 +3,15 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockOpenDialog,
|
||||
mockGetConfig,
|
||||
mockSetBrowserConnectorInstallRoot,
|
||||
mockGetBrowserConnectorSettings,
|
||||
mockGetBrowserConnectorInstallStatus,
|
||||
mockInstallBrowserConnectorExtension,
|
||||
mockSetBrowserConnectorEnabled,
|
||||
mockSetSystemConnectorEnabled,
|
||||
mockOpenBrowserExtensionsPage,
|
||||
mockLaunchBrowserSession,
|
||||
mockOpenBrowserRuntimeDebuggerWindow,
|
||||
mockGetChromeProfileSessions,
|
||||
@@ -12,7 +20,15 @@ const {
|
||||
mockGetBrowserBackendPolicy,
|
||||
mockGetBrowserBackendsStatus,
|
||||
} = vi.hoisted(() => ({
|
||||
mockOpenDialog: vi.fn(),
|
||||
mockGetConfig: vi.fn(),
|
||||
mockSetBrowserConnectorInstallRoot: vi.fn(),
|
||||
mockGetBrowserConnectorSettings: vi.fn(),
|
||||
mockGetBrowserConnectorInstallStatus: vi.fn(),
|
||||
mockInstallBrowserConnectorExtension: vi.fn(),
|
||||
mockSetBrowserConnectorEnabled: vi.fn(),
|
||||
mockSetSystemConnectorEnabled: vi.fn(),
|
||||
mockOpenBrowserExtensionsPage: vi.fn(),
|
||||
mockLaunchBrowserSession: vi.fn(),
|
||||
mockOpenBrowserRuntimeDebuggerWindow: vi.fn(),
|
||||
mockGetChromeProfileSessions: vi.fn(),
|
||||
@@ -26,6 +42,10 @@ vi.mock("@/lib/api/appConfig", () => ({
|
||||
getConfig: mockGetConfig,
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: (...args: unknown[]) => mockOpenDialog(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/browser-runtime", () => ({
|
||||
BrowserRuntimeDebugPanel: () => <div data-testid="browser-runtime-panel" />,
|
||||
}));
|
||||
@@ -34,6 +54,13 @@ vi.mock("@/lib/webview-api", async () => {
|
||||
const actual = await vi.importActual<object>("@/lib/webview-api");
|
||||
return {
|
||||
...actual,
|
||||
getBrowserConnectorSettings: mockGetBrowserConnectorSettings,
|
||||
setBrowserConnectorInstallRoot: mockSetBrowserConnectorInstallRoot,
|
||||
getBrowserConnectorInstallStatus: mockGetBrowserConnectorInstallStatus,
|
||||
installBrowserConnectorExtension: mockInstallBrowserConnectorExtension,
|
||||
setBrowserConnectorEnabled: mockSetBrowserConnectorEnabled,
|
||||
setSystemConnectorEnabled: mockSetSystemConnectorEnabled,
|
||||
openBrowserExtensionsPage: mockOpenBrowserExtensionsPage,
|
||||
launchBrowserSession: mockLaunchBrowserSession,
|
||||
openBrowserRuntimeDebuggerWindow: mockOpenBrowserRuntimeDebuggerWindow,
|
||||
getChromeProfileSessions: mockGetChromeProfileSessions,
|
||||
@@ -57,6 +84,7 @@ interface Mounted {
|
||||
}
|
||||
|
||||
const mounted: Mounted[] = [];
|
||||
const mockWriteClipboardText = vi.fn();
|
||||
|
||||
function renderComponent() {
|
||||
const container = document.createElement("div");
|
||||
@@ -104,12 +132,97 @@ beforeEach(() => {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: mockWriteClipboardText,
|
||||
},
|
||||
});
|
||||
mockWriteClipboardText.mockResolvedValue(undefined);
|
||||
|
||||
mockOpenDialog.mockResolvedValue("/Users/test/connectors");
|
||||
|
||||
mockGetConfig.mockResolvedValue({
|
||||
web_search: {
|
||||
engine: "google",
|
||||
},
|
||||
});
|
||||
mockGetBrowserConnectorSettings.mockResolvedValue({
|
||||
enabled: true,
|
||||
install_root_dir: null,
|
||||
install_dir: null,
|
||||
system_connectors: [
|
||||
{
|
||||
id: "calendar",
|
||||
label: "日历",
|
||||
description: "读取和管理你的日历事件。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
mockSetBrowserConnectorInstallRoot.mockResolvedValue({
|
||||
enabled: true,
|
||||
install_root_dir: "/Users/test/connectors",
|
||||
install_dir: "/Users/test/connectors/Lime Browser Connector",
|
||||
system_connectors: [
|
||||
{
|
||||
id: "calendar",
|
||||
label: "日历",
|
||||
description: "读取和管理你的日历事件。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
mockGetBrowserConnectorInstallStatus.mockResolvedValue({
|
||||
status: "not_installed",
|
||||
install_root_dir: null,
|
||||
install_dir: null,
|
||||
bundled_name: "Lime Browser Connector",
|
||||
bundled_version: "0.2.0",
|
||||
installed_name: null,
|
||||
installed_version: null,
|
||||
message: "尚未选择浏览器连接器安装目录",
|
||||
});
|
||||
mockInstallBrowserConnectorExtension.mockResolvedValue({
|
||||
install_root_dir: "/Users/test/connectors",
|
||||
install_dir: "/Users/test/connectors/Lime Browser Connector",
|
||||
bundled_name: "Lime Browser Connector",
|
||||
bundled_version: "0.2.0",
|
||||
installed_version: "0.2.0",
|
||||
auto_config_path:
|
||||
"/Users/test/connectors/Lime Browser Connector/auto_config.json",
|
||||
});
|
||||
mockSetBrowserConnectorEnabled.mockResolvedValue({
|
||||
enabled: false,
|
||||
install_root_dir: null,
|
||||
install_dir: null,
|
||||
system_connectors: [
|
||||
{
|
||||
id: "calendar",
|
||||
label: "日历",
|
||||
description: "读取和管理你的日历事件。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
mockSetSystemConnectorEnabled.mockResolvedValue({
|
||||
enabled: true,
|
||||
install_root_dir: null,
|
||||
install_dir: null,
|
||||
system_connectors: [
|
||||
{
|
||||
id: "calendar",
|
||||
label: "日历",
|
||||
description: "读取和管理你的日历事件。",
|
||||
enabled: true,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
mockOpenBrowserExtensionsPage.mockResolvedValue(true);
|
||||
mockLaunchBrowserSession.mockResolvedValue({
|
||||
profile: {
|
||||
success: true,
|
||||
@@ -177,7 +290,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ChromeRelaySettings", () => {
|
||||
it("应通过页签切换到浏览器实时调试面板", async () => {
|
||||
it("应在展开高级控制后切换到浏览器实时调试面板", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
|
||||
@@ -185,6 +298,12 @@ describe("ChromeRelaySettings", () => {
|
||||
container.querySelector('[data-testid="browser-runtime-panel"]'),
|
||||
).toBeNull();
|
||||
|
||||
const expandButton = findButton(container, "展开高级控制");
|
||||
await act(async () => {
|
||||
expandButton.click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
const tabButton = findTabButton(container, "调试");
|
||||
await act(async () => {
|
||||
tabButton.click();
|
||||
@@ -196,6 +315,49 @@ describe("ChromeRelaySettings", () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("选择目录后应安装浏览器连接器到固定子目录", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
|
||||
const button = findButton(container, "选择目录并安装");
|
||||
await act(async () => {
|
||||
button.click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockOpenDialog).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetBrowserConnectorInstallRoot).toHaveBeenCalledWith(
|
||||
"/Users/test/connectors",
|
||||
);
|
||||
expect(mockInstallBrowserConnectorExtension).toHaveBeenCalledWith({
|
||||
install_root_dir: "/Users/test/connectors",
|
||||
profile_key: "default",
|
||||
});
|
||||
expect(container.textContent).toContain(
|
||||
"浏览器连接器已同步到 /Users/test/connectors/Lime Browser Connector",
|
||||
);
|
||||
});
|
||||
|
||||
it("应复制默认连接器配置到剪贴板", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
|
||||
const button = findButton(container, "复制配置");
|
||||
await act(async () => {
|
||||
button.click();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(mockWriteClipboardText).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteClipboardText.mock.calls[0]?.[0]).toContain(
|
||||
'"profileKey": "default"',
|
||||
);
|
||||
expect(mockWriteClipboardText.mock.calls[0]?.[0]).toContain(
|
||||
'"bridgeKey": "proxy_cast"',
|
||||
);
|
||||
expect(container.textContent).toContain("默认浏览器连接器 配置已复制到剪贴板");
|
||||
});
|
||||
|
||||
it("点击一键按钮时应启动浏览器协助", async () => {
|
||||
const container = renderComponent();
|
||||
await flushEffects();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -186,6 +186,32 @@ export interface UseDeepLinkReturn {
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export interface UseDeepLinkOptions {
|
||||
onOpenBrowserConnectorSettings?: (params: { enable: boolean }) => void;
|
||||
}
|
||||
|
||||
function parseBrowserConnectorDeepLink(
|
||||
url: string,
|
||||
): { enable: boolean } | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (
|
||||
parsed.protocol !== "lime:" ||
|
||||
parsed.host !== "connectors" ||
|
||||
parsed.pathname !== "/browser"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enableParam = parsed.searchParams.get("enable");
|
||||
return {
|
||||
enable: enableParam === "true" || enableParam === "1",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep Link 事件处理 Hook
|
||||
*
|
||||
@@ -225,7 +251,12 @@ export interface UseDeepLinkReturn {
|
||||
*
|
||||
* @returns Hook 返回值
|
||||
*/
|
||||
export function useDeepLink(): UseDeepLinkReturn {
|
||||
export function useDeepLink(
|
||||
options?: UseDeepLinkOptions,
|
||||
): UseDeepLinkReturn {
|
||||
const onOpenBrowserConnectorSettings =
|
||||
options?.onOpenBrowserConnectorSettings;
|
||||
|
||||
// 状态
|
||||
const [connectPayload, setConnectPayload] = useState<ConnectPayload | null>(
|
||||
null,
|
||||
@@ -426,6 +457,12 @@ export function useDeepLink(): UseDeepLinkReturn {
|
||||
console.log("[useDeepLink] 收到 Deep Link URL:", urls);
|
||||
|
||||
for (const url of urls) {
|
||||
const connectorParams = parseBrowserConnectorDeepLink(url);
|
||||
if (connectorParams) {
|
||||
onOpenBrowserConnectorSettings?.(connectorParams);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await handleOauthCallbackUrl(url)) {
|
||||
continue;
|
||||
}
|
||||
@@ -507,7 +544,12 @@ export function useDeepLink(): UseDeepLinkReturn {
|
||||
}
|
||||
console.log("[useDeepLink] 已取消 Deep Link 监听器");
|
||||
};
|
||||
}, [handleDeepLinkEvent, handleDeepLinkError, handleOauthCallbackUrl]);
|
||||
}, [
|
||||
handleDeepLinkEvent,
|
||||
handleDeepLinkError,
|
||||
handleOauthCallbackUrl,
|
||||
onOpenBrowserConnectorSettings,
|
||||
]);
|
||||
|
||||
return {
|
||||
connectPayload,
|
||||
|
||||
@@ -57,6 +57,13 @@ const mockPriorityCommands = new Set<string>([
|
||||
"get_webview_panels",
|
||||
"focus_webview_panel",
|
||||
"navigate_webview_panel",
|
||||
"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",
|
||||
"launch_browser_session",
|
||||
"launch_browser_profile_runtime_assist_cmd",
|
||||
"get_browser_action_audit_logs",
|
||||
|
||||
@@ -133,6 +133,45 @@
|
||||
"targets": ["src/lib/api/contextMemory.ts"],
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "agent-chat-timeline-flow-demo",
|
||||
"classification": "dead-candidate",
|
||||
"description": "已删除的聊天时间线演示组件",
|
||||
"targets": [
|
||||
"src/components/agent/chat/components/TimelineFlowDemo.tsx"
|
||||
],
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "agent-chat-legacy-project-selector",
|
||||
"classification": "dead-candidate",
|
||||
"description": "已删除的聊天目录旧项目选择器",
|
||||
"targets": [
|
||||
"src/components/agent/chat/components/ProjectSelector.tsx"
|
||||
],
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "agent-chat-timeline-inline-item",
|
||||
"classification": "dead-candidate",
|
||||
"description": "已删除的聊天时间线内联原型项组件",
|
||||
"targets": [
|
||||
"src/components/agent/chat/components/TimelineInlineItem.tsx"
|
||||
],
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "agent-chat-task-files-directory-impl",
|
||||
"classification": "dead-candidate",
|
||||
"description": "已删除的聊天任务文件旧目录实现",
|
||||
"targets": [
|
||||
"src/components/agent/chat/components/TaskFiles/index.ts",
|
||||
"src/components/agent/chat/components/TaskFiles/types.ts",
|
||||
"src/components/agent/chat/components/TaskFiles/TaskFileList.tsx",
|
||||
"src/components/agent/chat/components/TaskFiles/TaskFileItem.tsx"
|
||||
],
|
||||
"allowedPaths": []
|
||||
},
|
||||
{
|
||||
"id": "team-subagent-scheduler-hook",
|
||||
"classification": "compat",
|
||||
|
||||
+162
-4
@@ -15,6 +15,14 @@ import { shouldPreferMockInBrowser } from "../dev-bridge/mockPriorityCommands";
|
||||
|
||||
// 模拟的命令处理器
|
||||
const mockCommands = new Map<string, (...args: any[]) => any>();
|
||||
const shouldLogMockInfo = import.meta.env.MODE !== "test";
|
||||
|
||||
function logMockInfo(...args: Parameters<typeof console.log>) {
|
||||
if (!shouldLogMockInfo) {
|
||||
return;
|
||||
}
|
||||
console.log(...args);
|
||||
}
|
||||
|
||||
const createDeprecatedCommandMock =
|
||||
(command: string, replacement: string) => () => {
|
||||
@@ -73,6 +81,30 @@ type MockBrowserEnvironmentPresetRecord = {
|
||||
archived_at: string | null;
|
||||
};
|
||||
|
||||
type MockBrowserConnectorSettings = {
|
||||
enabled: boolean;
|
||||
install_root_dir: string | null;
|
||||
install_dir: string | null;
|
||||
system_connectors: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
type MockBrowserConnectorInstallStatus = {
|
||||
status: string;
|
||||
install_root_dir: string | null;
|
||||
install_dir: string | null;
|
||||
bundled_name: string;
|
||||
bundled_version: string;
|
||||
installed_name: string | null;
|
||||
installed_version: string | null;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
const mockBrowserProfiles: MockBrowserProfileRecord[] = [
|
||||
{
|
||||
id: "browser-profile-general",
|
||||
@@ -115,6 +147,60 @@ const mockBrowserEnvironmentPresets: MockBrowserEnvironmentPresetRecord[] = [
|
||||
},
|
||||
];
|
||||
|
||||
let mockBrowserConnectorSettings: MockBrowserConnectorSettings = {
|
||||
enabled: true,
|
||||
install_root_dir: "/mock/path/to/connectors",
|
||||
install_dir: "/mock/path/to/connectors/Lime Browser Connector",
|
||||
system_connectors: [
|
||||
{
|
||||
id: "reminders",
|
||||
label: "提醒事项",
|
||||
description: "读取和管理你的提醒事项和任务列表。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "calendar",
|
||||
label: "日历",
|
||||
description: "读取和管理你的日历事件。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
label: "备忘录",
|
||||
description: "读取和创建你的备忘录。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "mail",
|
||||
label: "邮件",
|
||||
description: "读取邮件和创建草稿。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "contacts",
|
||||
label: "通讯录",
|
||||
description: "搜索、读取和创建联系人。",
|
||||
enabled: false,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let mockBrowserConnectorInstallStatus: MockBrowserConnectorInstallStatus = {
|
||||
status: "not_installed",
|
||||
install_root_dir: "/mock/path/to/connectors",
|
||||
install_dir: "/mock/path/to/connectors/Lime Browser Connector",
|
||||
bundled_name: "Lime Browser Connector",
|
||||
bundled_version: "0.1.0",
|
||||
installed_name: null,
|
||||
installed_version: null,
|
||||
message: "尚未导出浏览器连接器",
|
||||
};
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
const mockBrowserSessionStates = new Map<string, any>();
|
||||
let mockExistingSessionTabs = [
|
||||
@@ -875,7 +961,7 @@ const defaultMocks: Record<string, any> = {
|
||||
}),
|
||||
|
||||
save_config: (config: any) => {
|
||||
console.log("[Mock] Config saved:", config);
|
||||
logMockInfo("[Mock] Config saved:", config);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
@@ -885,7 +971,7 @@ const defaultMocks: Record<string, any> = {
|
||||
get_default_provider: () => "kiro",
|
||||
set_default_provider: (args: any) => {
|
||||
const provider = args?.provider ?? args;
|
||||
console.log("[Mock] Default provider set to:", provider);
|
||||
logMockInfo("[Mock] Default provider set to:", provider);
|
||||
return provider;
|
||||
},
|
||||
get_available_models: () => [],
|
||||
@@ -1650,6 +1736,78 @@ const defaultMocks: Record<string, any> = {
|
||||
controls: [],
|
||||
pending_commands: [],
|
||||
}),
|
||||
get_browser_connector_settings_cmd: () => mockBrowserConnectorSettings,
|
||||
set_browser_connector_install_root_cmd: (args: any) => {
|
||||
const installRootDir =
|
||||
typeof args?.request?.install_root_dir === "string" &&
|
||||
args.request.install_root_dir.trim()
|
||||
? args.request.install_root_dir.trim()
|
||||
: "/mock/path/to/connectors";
|
||||
mockBrowserConnectorSettings = {
|
||||
...mockBrowserConnectorSettings,
|
||||
install_root_dir: installRootDir,
|
||||
install_dir: `${installRootDir}/Lime Browser Connector`,
|
||||
};
|
||||
mockBrowserConnectorInstallStatus = {
|
||||
...mockBrowserConnectorInstallStatus,
|
||||
install_root_dir: installRootDir,
|
||||
install_dir: `${installRootDir}/Lime Browser Connector`,
|
||||
};
|
||||
return mockBrowserConnectorSettings;
|
||||
},
|
||||
set_browser_connector_enabled_cmd: (args: any) => {
|
||||
mockBrowserConnectorSettings = {
|
||||
...mockBrowserConnectorSettings,
|
||||
enabled: args?.enabled !== false,
|
||||
};
|
||||
return mockBrowserConnectorSettings;
|
||||
},
|
||||
set_system_connector_enabled_cmd: (args: any) => {
|
||||
const request = args?.request ?? {};
|
||||
mockBrowserConnectorSettings = {
|
||||
...mockBrowserConnectorSettings,
|
||||
system_connectors: mockBrowserConnectorSettings.system_connectors.map(
|
||||
(connector) =>
|
||||
connector.id === request.id
|
||||
? { ...connector, enabled: request.enabled === true }
|
||||
: connector,
|
||||
),
|
||||
};
|
||||
return mockBrowserConnectorSettings;
|
||||
},
|
||||
get_browser_connector_install_status_cmd: () => mockBrowserConnectorInstallStatus,
|
||||
install_browser_connector_extension_cmd: (args: any) => {
|
||||
const installRootDir =
|
||||
typeof args?.request?.install_root_dir === "string" &&
|
||||
args.request.install_root_dir.trim()
|
||||
? args.request.install_root_dir.trim()
|
||||
: mockBrowserConnectorSettings.install_root_dir ??
|
||||
"/mock/path/to/connectors";
|
||||
const installDir = `${installRootDir}/Lime Browser Connector`;
|
||||
mockBrowserConnectorSettings = {
|
||||
...mockBrowserConnectorSettings,
|
||||
install_root_dir: installRootDir,
|
||||
install_dir: installDir,
|
||||
};
|
||||
mockBrowserConnectorInstallStatus = {
|
||||
...mockBrowserConnectorInstallStatus,
|
||||
status: "installed",
|
||||
install_root_dir: installRootDir,
|
||||
install_dir: installDir,
|
||||
installed_name: "Lime Browser Connector",
|
||||
installed_version: mockBrowserConnectorInstallStatus.bundled_version,
|
||||
message: "已安装最新版本浏览器连接器",
|
||||
};
|
||||
return {
|
||||
install_root_dir: installRootDir,
|
||||
install_dir: installDir,
|
||||
bundled_name: "Lime Browser Connector",
|
||||
bundled_version: mockBrowserConnectorInstallStatus.bundled_version,
|
||||
installed_version: mockBrowserConnectorInstallStatus.bundled_version,
|
||||
auto_config_path: `${installDir}/auto_config.json`,
|
||||
};
|
||||
},
|
||||
open_browser_extensions_page_cmd: () => true,
|
||||
chrome_bridge_execute_command: (args: any) => ({
|
||||
success: true,
|
||||
request_id: `mock-${Date.now()}`,
|
||||
@@ -3369,7 +3527,7 @@ export async function invoke<T = any>(
|
||||
cmd: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
console.log(`[Mock] invoke: ${cmd}`, args);
|
||||
logMockInfo(`[Mock] invoke: ${cmd}`, args);
|
||||
|
||||
// 检查是否有自定义 mock
|
||||
if (mockCommands.has(cmd)) {
|
||||
@@ -3422,7 +3580,7 @@ export function clearMocks() {
|
||||
export function convertFileSrc(filePath: string, _protocol?: string): string {
|
||||
// 在 mock 环境中,返回一个占位符或原始路径
|
||||
// 实际图片无法在 web 环境中显示,但不会导致构建错误
|
||||
console.log(`[Mock] convertFileSrc: ${filePath}`);
|
||||
logMockInfo(`[Mock] convertFileSrc: ${filePath}`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ type UnlistenFn = () => void;
|
||||
|
||||
// 存储事件监听器
|
||||
const listeners = new Map<string, Set<EventCallback<any>>>();
|
||||
const shouldLogMockEventInfo = import.meta.env.MODE !== "test";
|
||||
|
||||
function logMockEventInfo(...args: Parameters<typeof console.log>) {
|
||||
if (!shouldLogMockEventInfo) {
|
||||
return;
|
||||
}
|
||||
console.log(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock listen function
|
||||
@@ -15,7 +23,7 @@ export async function listen<T = any>(
|
||||
event: string,
|
||||
handler: EventCallback<T>,
|
||||
): Promise<UnlistenFn> {
|
||||
console.log(`[Mock] listen: ${event}`);
|
||||
logMockEventInfo(`[Mock] listen: ${event}`);
|
||||
|
||||
if (!listeners.has(event)) {
|
||||
listeners.set(event, new Set());
|
||||
@@ -32,7 +40,7 @@ export async function listen<T = any>(
|
||||
listeners.delete(event);
|
||||
}
|
||||
}
|
||||
console.log(`[Mock] unlisten: ${event}`);
|
||||
logMockEventInfo(`[Mock] unlisten: ${event}`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,7 +51,7 @@ export async function once<T = any>(
|
||||
event: string,
|
||||
handler: EventCallback<T>,
|
||||
): Promise<UnlistenFn> {
|
||||
console.log(`[Mock] once: ${event}`);
|
||||
logMockEventInfo(`[Mock] once: ${event}`);
|
||||
|
||||
const wrappedHandler = (data: T) => {
|
||||
handler(data);
|
||||
@@ -61,7 +69,7 @@ export async function once<T = any>(
|
||||
* Mock emit function - 用于触发事件
|
||||
*/
|
||||
export async function emit(event: string, payload?: any): Promise<void> {
|
||||
console.log(`[Mock] emit: ${event}`, payload);
|
||||
logMockEventInfo(`[Mock] emit: ${event}`, payload);
|
||||
|
||||
const set = listeners.get(event);
|
||||
if (set) {
|
||||
|
||||
@@ -276,6 +276,46 @@ export interface ChromeBridgeCommandResult {
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface SystemConnectorSnapshot {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserConnectorSettingsSnapshot {
|
||||
enabled: boolean;
|
||||
install_root_dir?: string | null;
|
||||
install_dir?: string | null;
|
||||
system_connectors: SystemConnectorSnapshot[];
|
||||
}
|
||||
|
||||
export interface BrowserConnectorInstallStatus {
|
||||
status: "not_installed" | "installed" | "update_available" | "broken";
|
||||
install_root_dir?: string | null;
|
||||
install_dir?: string | null;
|
||||
bundled_name: string;
|
||||
bundled_version: string;
|
||||
installed_name?: string | null;
|
||||
installed_version?: string | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface BrowserConnectorInstallRequest {
|
||||
install_root_dir?: string;
|
||||
profile_key?: string;
|
||||
}
|
||||
|
||||
export interface BrowserConnectorInstallResult {
|
||||
install_root_dir: string;
|
||||
install_dir: string;
|
||||
bundled_name: string;
|
||||
bundled_version: string;
|
||||
installed_version: string;
|
||||
auto_config_path: string;
|
||||
}
|
||||
|
||||
export type BrowserBackendType =
|
||||
| "aster_compat"
|
||||
| "lime_extension_bridge"
|
||||
@@ -798,6 +838,69 @@ export async function getChromeBridgeStatus(): Promise<ChromeBridgeStatusSnapsho
|
||||
return safeInvoke<ChromeBridgeStatusSnapshot>("get_chrome_bridge_status");
|
||||
}
|
||||
|
||||
export async function getBrowserConnectorSettings(): Promise<BrowserConnectorSettingsSnapshot> {
|
||||
return safeInvoke<BrowserConnectorSettingsSnapshot>(
|
||||
"get_browser_connector_settings_cmd",
|
||||
);
|
||||
}
|
||||
|
||||
export async function setBrowserConnectorInstallRoot(
|
||||
installRootDir: string,
|
||||
): Promise<BrowserConnectorSettingsSnapshot> {
|
||||
return safeInvoke<BrowserConnectorSettingsSnapshot>(
|
||||
"set_browser_connector_install_root_cmd",
|
||||
{
|
||||
request: {
|
||||
install_root_dir: installRootDir,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function setBrowserConnectorEnabled(
|
||||
enabled: boolean,
|
||||
): Promise<BrowserConnectorSettingsSnapshot> {
|
||||
return safeInvoke<BrowserConnectorSettingsSnapshot>(
|
||||
"set_browser_connector_enabled_cmd",
|
||||
{
|
||||
enabled,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function setSystemConnectorEnabled(request: {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
}): Promise<BrowserConnectorSettingsSnapshot> {
|
||||
return safeInvoke<BrowserConnectorSettingsSnapshot>(
|
||||
"set_system_connector_enabled_cmd",
|
||||
{
|
||||
request,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBrowserConnectorInstallStatus(): Promise<BrowserConnectorInstallStatus> {
|
||||
return safeInvoke<BrowserConnectorInstallStatus>(
|
||||
"get_browser_connector_install_status_cmd",
|
||||
);
|
||||
}
|
||||
|
||||
export async function installBrowserConnectorExtension(
|
||||
request: BrowserConnectorInstallRequest,
|
||||
): Promise<BrowserConnectorInstallResult> {
|
||||
return safeInvoke<BrowserConnectorInstallResult>(
|
||||
"install_browser_connector_extension_cmd",
|
||||
{
|
||||
request,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function openBrowserExtensionsPage(): Promise<boolean> {
|
||||
return safeInvoke<boolean>("open_browser_extensions_page_cmd");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ChromeBridge 发送测试命令
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user