chore: release v0.98.0

This commit is contained in:
coso
2026-03-29 14:15:34 +08:00
parent dfbbb3d204
commit deceacc699
435 changed files with 35673 additions and 8535 deletions
+2
View File
@@ -59,6 +59,8 @@
- **架构概览**:`docs/aiprompts/overview.md`
- **工程质量**:`docs/aiprompts/quality-workflow.md`
- **治理收口**:`docs/aiprompts/governance.md`
- **技能标准**:`docs/aiprompts/skill-standard.md`
- **站点适配器标准**:`docs/aiprompts/site-adapter-standard.md`
- **UI 规范**:`docs/aiprompts/design-language.md`
- **Tauri 命令边界**:`docs/aiprompts/commands.md`
- **凭证与路径**:`docs/aiprompts/credential-pool.md`
+16 -20
View File
@@ -1,45 +1,41 @@
## Lime v0.97.0
## Lime v0.98.0
### ✨ 主要更新
- **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 与文档再度漂移
- **A2UI、Service Skill 与工作台运行时稳定性继续加固**:修正 legacy 问卷场景下 `useWorkspaceA2UIRuntime` 的自循环更新,补齐 Action Request / A2UI 预览与画布布局回归,并继续收敛 `site adapter`、`Browser Assist`、`current content` 写回主链
- **站点适配器与运行时网关进一步收口**:`site_*` 能力统一回挂到现役 `agentRuntime` 网关,减少业务层散落命令名;配合 `Service Skill` 启动、运行与保存链路,前端命令边界和契约口径保持一致
- **测试执行与发布流程继续稳定化**:Vitest 默认切到智能分批 + 单 fork 模式,收敛 mock/info 日志风暴;macOS release workflow 会在构建前显式探测 Tauri CLI native binding,缺失时自动清理 `node_modules` 并重装,规避 `@tauri-apps/cli-darwin-*` 丢失导致的发布失败;本轮也修复了 DevBridge SSE 路由的 axum handler 编译断点,避免发布前 Rust 校验被阻塞
### ⚠️ 兼容性说明
- 本次仍然使用同一个版本号 `v0.97.0`,但 release/tag 会重指向 `main` 上的最新提交,用于覆盖之前的同版发布
- 正式发布仍由 `v*` tag 触发 `.github/workflows/release.yml`;`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文
- 正式发布由 `v0.98.0` tag 触发 `.github/workflows/release.yml`;`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文
- GUI 冒烟依赖本机可启动 headless Tauri、`DevBridge`、默认 workspace 和系统 Chrome;目标环境如果缺少对应条件,Browser Runtime/站点适配器相关能力会被降级
- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.22.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖
- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.23.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖
### 🔗 依赖同步
- 应用版本已同步提升到 `v0.97.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 `src-tauri/Cargo.lock`
- 当前仓库声明的 `aster-rust` 依赖仍为 `v0.22.0`;本地覆盖仓库已核对为干净 `v0.22.0` 状态
- `src-tauri/Cargo.lock` 已随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `0.97.0` 对齐
- 应用版本已同步提升到 `v0.98.0`,覆盖 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` 与 `src-tauri/Cargo.lock`
- 当前仓库声明的 `aster-rust` 依赖已同步到 `v0.23.0`;本地覆盖仓库也应保持在干净 `v0.23.0` 状态
- `src-tauri/Cargo.lock` 已随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `0.98.0` 对齐
### 🧪 测试
- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml`
- 发布前执行:`cargo test --manifest-path src-tauri/Cargo.toml`
- 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml`
- 发布前执行:`cargo fmt --manifest-path src-tauri/Cargo.toml --all`
- 发布前执行:`CARGO_TARGET_DIR="src-tauri/target-release-v0.98.0" cargo test --manifest-path src-tauri/Cargo.toml`
- 发布前执行:`CARGO_TARGET_DIR="src-tauri/target-release-v0.98.0" cargo clippy --manifest-path src-tauri/Cargo.toml`
- 发布前执行:`npm run lint`
- 发布前执行:`npm run typecheck`
- 发布前执行:`npm test`
- 发布前执行:`npm run test:contracts`
- 发布前执行:`npm run verify:app-version`
- 发布前执行:`npm run test:contracts`
- 发布前执行:`npm run verify:gui-smoke`
- 验证结果:上述命令已在当前工作区全部通过,`verify:gui-smoke` 已验证 `DevBridge`、默认 workspace、Browser Runtime 与 site adapter catalog 主路径
- 备注:`cargo clippy` 当前通过,但仍保留 3 条既有 warning,未在本次同版发布中额外扩范围消除
- 验证结果:上述命令已在当前工作区通过;Rust 校验使用独立 `CARGO_TARGET_DIR` 完成,以避免与本机其他 Cargo 进程争抢默认构建目录;GUI smoke 已验证 `DevBridge`、默认 workspace、Browser Runtime 与 site adapter catalog 主路径
### 📝 文档
- 发布说明已切换到当前这次 `v0.97.0` 同版重发内容,供 GitHub Release 直接读取
- 发布说明已切换到当前这次 `v0.98.0` 正式发布内容,供 GitHub Release 直接读取
- 工程质量、命令边界与 Playwright / GUI 冒烟文档已同步更新到最新实现
---
**完整变更**: `v0.97.0` 同版重发,对齐 `main` 最新提交
**完整变更**: `v0.97.0` -> `v0.98.0`
+4
View File
@@ -17,6 +17,8 @@
- `overview.md` - 项目架构总览与模块分层
- `governance.md` - 新旧并存治理、迁移收口、禁止回流
- `quality-workflow.md` - 本地校验、GUI smoke、契约检查、CI 门禁
- `skill-standard.md` - 统一技能标准、skill / adapter / runtime binding 边界
- `site-adapter-standard.md` - 站点适配器标准、来源导入边界、运行时收敛规则
- `project-heatmap.md` - 仓库热力图与治理候选分析
- `limecore-collaboration-entry.md` - 跨仓库联动入口
- `../tech/harness/README.md` - Lime Harness Engineering 总入口与实施蓝图
@@ -53,6 +55,8 @@
- **改 UI / 页面结构**:先读 `design-language.md`,再看 `quality-workflow.md`
- **改 Tauri 命令 / Bridge / mock**:先读 `commands.md`,再看 `quality-workflow.md`
- **改 Claw 技能 / Service Skill / 统一 Skills 标准**:先读 `skill-standard.md`
- **改站点适配器 / 导入外部 adapter**:先读 `site-adapter-standard.md`,再看 `quality-workflow.md`
- **改 Workspace / GUI 壳 / 主路径**:先读 `workspace.md`、`quality-workflow.md`、`playwright-e2e.md`
- **做迁移 / 收口 / 去兼容层**:先读 `governance.md`
- **改 Provider / 凭证加载 / Token 刷新**:先读 `providers.md`、`credential-pool.md`
+10 -5
View File
@@ -33,6 +33,7 @@
- `get_browser_connector_install_status_cmd`
- `install_browser_connector_extension_cmd`
- `open_browser_extensions_page_cmd`
- `disconnect_browser_connector_session`
这些命令属于当前设置主路径,不应再在页面组件里散落裸 `invoke`。
@@ -157,9 +158,10 @@ npm run verify:local
如果命令边界改动影响会话运行时恢复语义,例如:
- `agent_runtime_update_session` 新增或调整 `provider_name / model_name / execution_strategy / recent_preferences / recent_team_selection`
- `getSession/listSessions` 的 `execution_runtime` 新增或调整 `recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id`
- 话题切换时的 provider/model、工具偏好、Team 选择,或 `theme / session_mode / gate_key / run_title / content_id` 恢复从本地 fallback 向 `execution_runtime` 收敛
- `agent_runtime_submit_turn.turn_config` 新增或调整 `approval_policy / sandbox_policy`
- `agent_runtime_update_session` 新增或调整 `provider_name / model_name / execution_strategy / recent_access_mode / recent_preferences / recent_team_selection`
- `getSession/listSessions` 的 `execution_runtime` 新增或调整 `recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id`
- 话题切换时的 provider/model、权限 accessMode、工具偏好、Team 选择,或 `theme / session_mode / gate_key / run_title / content_id` 恢复从本地 fallback 向 `execution_runtime` 收敛
除了契约检查,还应补对应 Hook / UI 稳定回归,确认切换话题后模型选择器恢复的是会话 runtime,而不是陈旧本地缓存。
@@ -201,7 +203,8 @@ npm run verify:local
以下是仓库当前已经明确收敛的几个方向:
- **Agent / Codex 主命令**:继续收敛到 `agent_runtime_*`
- **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略、session provider/model、`recent_preferences` 以及 `recent_team_selection` 的轻量持久化回写
- **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略、session provider/model、`recent_access_mode`、`recent_preferences` 以及 `recent_team_selection` 的轻量持久化回写
- **会话权限主链**:`agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` 是正式 turn context 权限协议;`getSession` 返回的 `execution_runtime.recent_access_mode` 负责承接会话最近一次 accessMode。当前端已命中同一 steady-state 权限时,不应继续依赖 `harness.access_mode` 作为唯一事实源
- **运行时交接导出主链**:继续收敛到 `agent_runtime_export_handoff_bundle`;前端统一通过 `src/lib/api/agentRuntime.ts` 网关进入,当前 GUI 入口位于 `HarnessStatusPanel`
- **运行时证据导出主链**:继续收敛到 `agent_runtime_export_evidence_pack`,用于把 runtime / timeline / artifacts 打包成最小问题证据
- **运行时 replay 样本主链**:继续收敛到 `agent_runtime_export_replay_case`,复用 handoff bundle + evidence pack 生成 `input / expected / grader / evidence-links`
@@ -221,9 +224,11 @@ npm run verify:local
补充约定:
- **站点能力主链**:继续收敛到 `site_list_adapters / site_recommend_adapters / site_search_adapters / site_get_adapter_info / site_run_adapter`
- **站点能力主链**:继续收敛到 `site_list_adapters / site_recommend_adapters / site_search_adapters / site_get_adapter_info / site_get_adapter_launch_readiness / site_get_adapter_catalog_status / site_import_adapter_yaml_bundle / site_run_adapter`
- **站点适配器导入主链**:`site_import_adapter_yaml_bundle` 只负责把外部 YAML 来源编译为 Lime 标准并写入 `imported` 目录,不允许带入第二套 runtime、daemon 或自动唤醒浏览器链路
- **站点 Agent 工具主链**:继续收敛到 `lime_site_list / lime_site_recommend / lime_site_search / lime_site_info / lime_site_run`
- **站点结果沉淀主线**:`site_run_adapter` / `lime_site_run` 优先透传 `content_id` 写回当前主稿;只有缺少 `content_id` 时,才回退到 `project_id` 新建结果文档
- **Claw 站点直跑门禁主链**:`site_get_adapter_launch_readiness` 只负责检测“是否存在已附着的真实浏览器会话 + 目标站点上下文”;`site_run_adapter.require_attached_session = true` 时,后端必须拒绝 managed/default fallback,不能后台偷偷起 Chrome
- **站点运行失败语义**:`SiteAdapterRunResult` 至少统一输出 `auth_required / no_matching_context / adapter_runtime_error`,并在前端与 Agent 结果里保留 `report_hint`
- **浏览器资料 / 环境预设主链**:`list/save/archive/restore_browser_profile_cmd` 与 `list/save/archive/restore_browser_environment_preset_cmd` 已进入真实 DevBridge 主路径;浏览器模式下不应再默认放进 `mockPriorityCommands`,仅在 DevBridge 不可用时才允许回落 `defaultMocks`
+7
View File
@@ -143,6 +143,13 @@ npm run test:contracts
**不是鼓励走新路,而是先封住老路。**
这同样适用于已经删除的旧 UI 壳或旧组件路径:
- 删除旧文件后,仍应在治理目录册里补 import / 文本守卫,防止后续 AI 或人工把旧路径重新接回主链
- 如果已经把重复 UI 的扁平 props 收口为共享契约,也应补对应文本 / 正则守卫,防止父层透传和子层接口一起长回旧面
- 如果共享契约还依赖单独的构造器或归一化 helper,应继续限制只有事实源边界能调用它,不要让运行时代码到处重新拼装
- 如果多个页面或面板展示的是同一份状态,也要把状态文案收敛到共享 helper,不要让首页、下拉面板、状态徽标各自重新命名
### 6. 主链路和旁路一起治理
如果只迁:
+15 -1
View File
@@ -22,6 +22,14 @@ Lime 是一个以创作为中心的本地优先 AI Agent 交互工作台,基
在 Lime 中,Skills 处于比 MCP 更贴近产品的一层:它不是底层原语,而是将领域经验、交互方式和执行流程打包后的编排单元。
对 Lime 来说,Skills 还必须继续区分:
- `skill`:产品入口与业务语义
- `adapter / tool`:底层能力工件
- `runtime binding`:最终执行绑定
统一技能标准见 [skill-standard.md](skill-standard.md),站点工件子标准见 [site-adapter-standard.md](site-adapter-standard.md)。
## 项目结构
```
@@ -53,7 +61,7 @@ lime/
| `workspace/` | 工作区与项目边界,承载文件、会话与配置上下文 |
| `components/agent/` | Agent 对话主入口,负责会话、流式事件与交互 |
| `components/content-creator/` | 主题化创作工作台与画布联动 |
| `skills/` | 技能加载、标准校验与经验编排能力 |
| `skills/` | 技能加载、标准校验与经验编排能力;统一遵循 `skill-standard.md` |
| `lib/artifact/` | Artifact 解析、状态与轻量渲染器 |
| `memory / style / personas` | 项目记忆、风格策略与人设沉淀 |
@@ -109,6 +117,10 @@ lime/
| `lib/artifact/` | Artifact 状态与解析 |
| `pages/` | 独立窗口与页面入口 |
补充约束:
- `开发者中心 -> 处理工作台与信息收集` 由 `config.developer.workspace_harness_enabled` 控制,默认关闭;关闭时通用对话不应显示“处理工作台”入口,也不应继续触发对应运行态信息收集链路。
## 数据流
```
@@ -211,6 +223,7 @@ lime/
### 产品与工作台
- [workspace.md](workspace.md) - Workspace 边界与工作区设计
- [content-creator.md](content-creator.md) - 主题化创作工作台
- [skill-standard.md](skill-standard.md) - 统一技能标准、目录与运行边界
- [../../src-tauri/src/skills/README.md](../../src-tauri/src/skills/README.md) - Skills 标准与集成
- [terminal.md](terminal.md) - 终端能力
- [mcp.md](mcp.md) - MCP 服务器
@@ -230,6 +243,7 @@ lime/
### 配置、服务与数据
- [commands.md](commands.md) - Tauri 命令
- [site-adapter-standard.md](site-adapter-standard.md) - 站点适配器标准与外部来源接入边界
- [services.md](services.md) - 业务服务
- [database.md](database.md) - 数据库层
- [performance-profiling.md](performance-profiling.md) - 性能分析与火焰图
+30 -2
View File
@@ -142,14 +142,33 @@ npm run test:contracts
7. 如工作台模式开启自动保存,再确认执行成功后保存态文案与打开入口正常
8. 打开控制台并确认浏览器资料 / 环境预设读取没有落回 web mock,尤其不应出现 `[Mock] invoke: list_browser_profiles_cmd` 或 `[Mock] invoke: list_browser_environment_presets_cmd`
### Claw 站点技能直跑门禁验证
1. 在 `Claw` 首页打开一个站点型技能弹窗
2. 如果当前没有附着真实浏览器会话,确认主按钮保持禁用,并出现“需要浏览器工作台 / 重新检测会话”的门禁提示
3. 点击 `去浏览器工作台`,确认只发生页面跳转,不会后台偷偷拉起 Chrome
4. 在浏览器工作台附着到真实浏览器并打开目标站点后,回到 `Claw` 再次打开同一技能
5. 确认此时主按钮变为可执行,点击后进入 `Claw` 工作区
6. 确认消息流顶部出现独立的站点技能执行卡,状态依次体现 `执行中 / 已完成` 或明确阻断原因,而不是伪装成普通聊天消息
### 开发者页站点来源导入验证
1. 进入 `设置 -> 开发者`
2. 在 `站点脚本目录联调` 区块找到 `外部来源 YAML 导入`
3. 粘贴一份仅包含 Lime 支持子集的 YAML 来源,点击 `导入到 Lime 标准`
4. 验证摘要区来源切换为 `外部导入`,且适配器列表出现新导入名称
5. 再点击 `清空站点目录缓存`,确认来源恢复为 `应用内置`,列表回退到 bundled 目录
6. 导入与清理全过程都不应出现后台自动拉起浏览器、自动唤醒 Chrome 或常驻浏览器控制进程
### 连接器页验证
1. 进入 `设置 -> 连接器`
2. 确认首页能看到“我的浏览器”“macOS 连接器/系统连接器”“高级控制”三块主区域
2. macOS 下确认首页能看到“我的浏览器”“macOS 连接器”“高级控制”三块主区域;Windows 与其他非 macOS 平台默认不应出现系统连接器卡片
3. 点击“展开高级控制”,确认 `总览 / Profile / 桥接 / 后端 / 调试` 页签可切换
4. 如当前环境允许目录选择,点击“选择目录并安装”或“同步更新扩展”,确认安装目录最终落到固定子目录 `Lime Browser Connector`
5. 点击“复制配置”,确认剪贴板内容包含 `serverUrl / bridgeKey / profileKey`
6. 如当前环境接通真实后端,再确认“打开 Chrome 扩展页”可成功唤起浏览器扩展管理页
6. 如当前环境已有 observer 连接,再确认“断开已连接扩展”能把页面状态回退到等待连接
7. 如当前环境接通真实后端,再确认“打开 Chrome 扩展页”可成功唤起浏览器扩展管理页
### 话题模型恢复验证
@@ -159,6 +178,15 @@ npm run test:contracts
4. 验证模型选择器恢复的是该话题最近一次 session runtime,而不是陈旧的 localStorage 默认值
5. 如页面暴露运行时摘要条,再确认 provider/model 文案与选择器一致
### 话题权限恢复验证
1. 进入同一工作区中的两个话题
2. 在话题 A 选择 `只读`,在话题 B 选择 `当前工作区` 或 `完全访问`
3. 在两个话题之间来回切换,必要时刷新页面后再切回
4. 验证输入框权限选择器恢复的是该话题最近一次 accessMode,而不是工作区级默认值
5. 如页面暴露运行时摘要、调试面板或开发日志,继续确认恢复依据是当前话题最近一次 `execution_runtime.recent_access_mode`
6. 再立即发送一条消息,确认本轮会沿用该 accessMode 对应的正式权限策略,而不是只在 metadata 里残留旧的 `harness.access_mode`
### 话题工具偏好恢复验证
1. 进入同一工作区中的两个话题
+10 -5
View File
@@ -164,11 +164,12 @@ npm run bridge:health -- --timeout-ms 120000
高频场景:
- 修改 `safeInvoke` / `invoke`
- 修改 `agent_runtime_update_session` 或会话 provider/model / recent_preferences / recent_team_selection 恢复语义
- 修改 `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`
- 修改 `agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy`
- 修改 `agent_runtime_update_session` 或会话 provider/model / recent_access_mode / recent_preferences / recent_team_selection 恢复语义
- 修改 `execution_runtime.recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` 恢复语义,或前端 `harness.access_mode / harness.theme / harness.session_mode / harness.gate_key / harness.run_title / harness.content_id` steady-state 去重逻辑
- 修改 `site_*` 站点适配器命令族,例如 `site_recommend_adapters`、`site_get_adapter_launch_readiness`、`site_import_adapter_yaml_bundle`、`site_run_adapter`
- 修改浏览器资料 / 环境预设命令族,或调整它们在 `mockPriorityCommands` 里的优先级
- 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器和扩展安装状态
- 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器、扩展安装状态或主动断开扩展连接
- 修改 `src/lib/dev-bridge/`
- 修改 `src/lib/tauri-mock/`
- 修改 `src-tauri/src/app/runner.rs`
@@ -212,10 +213,14 @@ npm run bridge:health -- --timeout-ms 120000
- 如果这次改动把 `theme / session_mode` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明:
- session 已有 `execution_runtime.recent_theme / recent_session_mode` 时,前端不会重复提交相同 `harness.theme / harness.session_mode`
- 切换到新 theme 或 `theme_workbench` 但 runtime 尚未同步时,前端仍会保留显式 `theme / session_mode`
- 如果这次改动把 `accessMode` steady-state 从“只写 harness metadata”收敛到正式 turn context 与 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明:
- turn 提交始终携带正式 `approval_policy / sandbox_policy`
- session 已有 `execution_runtime.recent_access_mode` 时,切换话题会恢复对应 accessMode,而不是回退到工作区默认值
- execution_runtime 缺失但本地 shadow 已命中时,前端仍会回填 `recent_access_mode` 到 session
- 如果这次改动把 `gate_key / run_title` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明:
- session 已有 `execution_runtime.recent_gate_key / recent_run_title` 时,前端不会重复提交相同 `harness.gate_key / harness.run_title`
- 切换到新的 Theme Workbench gate 或运行标题、但 runtime 尚未同步时,前端仍会保留显式 `gate_key / run_title`
- 如果这次改动影响浏览器工作台里的站点采集链路,例如推荐区、资料自动选择、`report_hint` 展示、`lime_site_recommend`,或“优先写回当前 `content_id` 而不是新建资源文档”的主线收敛,除了契约检查,还应补对应 `*.test.tsx` 回归并执行 `verify:gui-smoke`。
- 如果这次改动影响浏览器工作台里的站点采集链路,例如推荐区、资料自动选择、`site_get_adapter_launch_readiness` 门禁、`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 导出 / 审核主链,除了契约检查,还应至少补:
+381
View File
@@ -0,0 +1,381 @@
# Lime 站点适配器标准
## 这份文档回答什么
本文件定义 Lime 仓库中站点适配器能力的唯一工程标准,主要回答:
- Lime 自己认可的站点适配器标准长什么样
- 外部来源为什么不能直接成为 Lime 的事实源
- 站点适配器应该如何接入、执行、校验与治理
- 如何避免因为接入外部适配器库而把 Lime 做成“万国牌”
它是 **站点适配器能力的工程标准文档**,不是某个外部项目的引入说明书。
如果讨论的是 Skills 总模型、`skill / adapter / runtime binding` 总边界,先读 [skill-standard.md](skill-standard.md),再回到本文。
## 第一原则
**Lime 有自己的标准。来源可以有多个,但标准只能有一个。**
对 Lime 来说:
- 可以有多个 adapter 来源
- 仓库内手写
- 服务端下发
- 外部项目导入
- 但 Lime 内部只能存在一个继续演进的适配器标准
从现在开始,站点适配器能力的唯一事实源应收敛到:
> `Lime Site Adapter Spec`
外部项目只能提供“原料”,不能提供 Lime 的运行时标准、协议标准或状态标准。
换句话说:
- 可以接更多来源
- 不能接更多“标准”
- 不能让来源格式反过来定义 Lime 的产品边界
如果某个外部来源和 Lime 标准冲突,优先保留 Lime 标准,而不是为了兼容把 Lime 改成第二套产品。
## 什么时候先读
出现以下任一情况时,先读本文件,再决定是否写代码:
- 想新增一个站点适配器
- 想从外部项目导入适配器
- 想扩展站点适配器字段、参数类型或执行语义
- 想调整 `site_*` 命令族的输入输出结构
- 想新增第二套站点执行引擎、pipeline 或 bridge
- 发现站点采集能力开始出现多套定义、多套错误语义或多套运行时
如果问题已经上升为“业务 skill 如何引用 adapter、服务端如何下发统一技能目录、用户入口应该如何表达”,先回到 [skill-standard.md](skill-standard.md)。
## 非目标
本标准明确不负责以下目标:
- 定义另一套浏览器 runtime
- 为外部项目保留原生执行模型
- 支持所有外部适配器语义的 100% 兼容
- 为了“接得更多”而放松 Lime 的产品边界
尤其不要把“支持更多站点”误解成:
- 再引一套 daemon
- 再引一套浏览器扩展协议
- 再引一套站点 pipeline runtime
## 标准分层
Lime 的站点适配器能力必须分成三层:
### 1. 来源层
作用:
- 提供原始 adapter 定义
- 可以来自仓库内、服务端或外部项目
特点:
- 不直接参与 Lime 执行
- 不直接决定 Lime 错误语义
- 不直接决定 Lime 前端展示模型
### 2. 编译层
作用:
- 把来源层 adapter 转换为 Lime 标准
- 做字段收敛、语义校验、步骤白名单检查
特点:
- 是外部来源进入 Lime 的唯一入口
- 是站点适配器治理边界
- 负责拒绝不符合 Lime 标准的来源 adapter
### 3. 执行层
作用:
- 执行已经被编译为 Lime 标准的适配器
特点:
- 只能使用 Lime 当前浏览器执行主链
- 不允许外部来源自带执行内核绕过 Lime runtime
## Lime Site Adapter Spec v1
Lime 内部适配器标准至少包含以下字段语义:
- `name`
- 唯一标识,推荐 `site/name` 形式
- `domain`
- 目标站点主域名
- `description`
- 面向用户和开发者可读的说明
- `read_only`
- 是否只读
- `capabilities`
- 当前适配器暴露的能力标签
- `args`
- 已归一化后的参数定义
- `example`
- 最小可运行示例
- `auth_hint`
- 登录态或上下文要求
- `entry`
- 入口 URL 规则
- `script`
- Lime 当前唯一执行脚本
- `source_kind`
- 来源类型,例如 `bundled` / `server_synced` / `imported`
- `source_version`
- 来源版本号或快照标识
现有实现的主承载结构为:
- `src-tauri/src/services/site_adapter_registry.rs` 中的 `SiteAdapterSpec`
如果未来字段扩展,仍然必须收敛到 Lime 自己的标准模型,而不是向外部项目的原始结构靠拢。
## 标准优先级
站点适配器相关决策的优先级固定如下:
1. Lime 产品边界
2. Lime Site Adapter Spec
3. Lime 当前浏览器运行时主链
4. 外部来源可提供的原始 adapter 定义
这意味着:
- 外部来源只能被编译、裁剪、白名单化后进入 Lime
- 不能为了保留来源格式的完整性,引入第二套 runtime、协议或错误语义
- 不能因为“某来源支持某能力”就直接判定 Lime 也应该支持
## 命名标准
适配器唯一标识统一使用:
- `site/name`
示例:
- `reddit/hot`
- `zhihu/hot`
- `github/search`
禁止:
- 让来源项目自己的内部 ID 成为 Lime 对外主标识
- 同时维护多套命名规则
## 参数标准
参数定义必须先归一化,再进入 Lime 主链。
`v1` 建议先稳定在最小集合:
- `string`
- `integer`
每个参数至少应有:
- `name`
- `description`
- `required`
- `arg_type`
- `example`
不要在第一阶段为了兼容外部来源而引入复杂参数系统。
## 执行标准
### 1. 运行时只能走 Lime 主链
站点适配器的真实执行,只允许走 Lime 当前运行时路径:
- `managed_cdp`
- `existing_session`
禁止:
- 直接调用外部项目自带 daemon 作为 Lime 主执行链
- 直接让外部项目控制 Chrome / Chromium 生命周期
- 在 Lime 内部并行保留第二套 site adapter runtime
### 2. 当前执行模型以 script 为唯一主格式
对 Lime 来说,站点适配器当前主格式是:
- 归一化 manifest
- 归一化 script
- 通过现有 runtime 执行 script
如果外部来源使用:
- YAML pipeline
- 自定义表达式系统
- 特定 bridge 协议
都必须先编译为 Lime 现有 script 模型。
不要直接把外部 pipeline engine 搬进 Lime。
### 3. 步骤兼容必须采用白名单
对外部 adapter 的语义兼容,必须采用白名单,而不是黑名单。
`v1` 推荐只允许:
- `navigate`
- `evaluate`
- `map`
- `filter`
- `limit`
- `sort`
`v1` 明确不允许:
- `intercept`
- `tap`
- `Desktop` 模式
- 依赖浏览器扩展上下文的动作
- 依赖 daemon 会话协议的动作
- 隐式启动、唤醒、关闭浏览器的动作
对不支持步骤,必须在导入阶段直接失败。
## 错误语义标准
Lime 的适配器错误语义必须统一,不能跟着来源项目漂移。
至少保持以下错误类型继续收敛:
- `auth_required`
- `no_matching_context`
- `adapter_runtime_error`
- `site_unreachable`
- `internal_error`
错误信息可以引用来源 adapter 的上下文,但错误分类、前端提示和结果结构必须以 Lime 为准。
## 产品边界标准
这是站点适配器能力不可突破的边界。
### 明确禁止
- 无任务时后台自动执行适配器
- 自动启动浏览器
- 自动唤醒浏览器
- 自动连接外部 daemon
- 常驻后台的浏览器控制进程
- 用户未明确发起时预热站点运行时
### 只允许
- 用户显式发起一次站点任务
- Lime 在可见状态下执行
- 用户可感知当前使用的浏览器上下文
- 执行完成后及时收口
如果某个外部来源天然依赖“后台常驻自动化”,它就不能原样进入 Lime 主链。
## 外部来源接入规则
外部来源接入必须遵守以下顺序:
1. 先盘点来源能力
2. 再定义支持子集
3. 再做编译器
4. 再导入白名单 adapter
5. 最后才允许进入 Lime 主链
禁止:
- 未经过编译层直接执行来源 adapter
- 全量导入来源仓库的全部 adapter
- 把来源项目的 runtime 一并当成捷径接入
## 外部适配器来源 的定位
`外部适配器来源` 在 Lime 中的定位必须被明确限定为:
- **站点适配器来源**
而不能是:
- Lime 的浏览器 runtime
- Lime 的适配器事实源
- Lime 的协议事实源
也就是说:
> Lime 可以借 YAML 来源 的 adapter,但不能把 YAML 来源 变成 Lime。
## 治理标准
治理时统一沿用仓库的 `current / compat / deprecated / dead` 语言。
对站点适配器能力,建议这样判断:
- `current`
- `Lime Site Adapter Spec`
- 当前 `site_*` 命令族
- 当前 `managed_cdp / existing_session` 执行主链
- `compat`
- 仅为迁移期保留的来源转换层
- `deprecated`
- 已经不再建议新增依赖的旧 adapter 表示格式
- `dead`
- 已无入口、无引用、无导入计划的旧来源代码
任何新的来源项目接入,如果引入了第二套运行时、第二套错误语义或第二套前端结果模型,就说明已经偏离本标准。
## 校验与交付
修改站点适配器能力时,除了常规工程校验,还应至少回答以下问题:
1. 这次改动是否仍然收敛到 `Lime Site Adapter Spec`
2. 是否新增了第二套执行主链
3. 是否破坏了当前 `site_*` 命令族的统一语义
4. 是否引入了后台自动化副作用
5. 是否补了站点适配器目录、搜索、推荐、运行的最小验证
推荐最小校验:
```bash
npm run test:contracts
npm run verify:gui-smoke
npm run smoke:site-adapters
```
如果只是新增或调整适配器来源规则,也应至少补对应文档和最小 smoke 说明。
## 实施建议
如果下一步要把外部 adapter 引入 Lime,推荐按以下顺序推进:
1. 先冻结 `Lime Site Adapter Spec v1`
2. 建立来源导入服务
3. 建立步骤白名单
4. 只导入只读、安全、无后台副作用的 adapter
5. 跑通 3 到 5 个站点后再扩面
不要一开始就追求:
- 全量兼容
- 全量站点导入
- 全量步骤支持
站点适配器能力的目标是 **标准化扩展**,不是 **来源堆砌**。
## 一句话版本
> Lime 可以吸收外部 adapter 能力,但所有来源都必须先编译成 Lime 标准,再交给 Lime 自己的 runtime 执行。
+442
View File
@@ -0,0 +1,442 @@
# Lime Skills 标准
## 这份文档回答什么
本文件定义 Lime 仓库里 `skill` 能力的统一工程标准,主要回答:
- Lime 自己认可的 skill 标准长什么样
- `skill`、`adapter`、`runtime binding` 的边界分别是什么
- 为什么外部 `SKILL.md` 仓库只能作为说明层参考,不能直接成为 Lime 的正式标准
- 以后新增 Claw 业务技能、站点技能、提示词技能时,应该如何保持一致
它是 **Lime 技能能力的总标准文档**。
其中:
- [site-adapter-standard.md](site-adapter-standard.md) 是站点适配器子标准
- 本文负责技能总模型、事实源、分发和 UI 表达边界
## 第一原则
**Lime 有自己的 skills 标准。来源可以多个,但标准只能有一个。**
对 Lime 来说,可以同时存在:
- 服务端下发的技能目录
- 仓库内 seeded 技能目录
- 外部项目提供的 `SKILL.md` / YAML / adapter 来源
但 Lime 内部继续演进的标准只能有一套。
从现在开始,技能能力的唯一长期事实源应收敛到:
> `Lime Skill Spec`
外部仓库只能提供:
- 说明层模板
- 来源层原料
- 触发语义参考
不能直接提供:
- Lime 的运行时协议
- Lime 的分发协议
- Lime 的 UI 表达标准
- Lime 的自动化与浏览器行为边界
## 什么时候先读
出现以下任一情况时,先读本文件,再决定是否写代码:
- 想新增一个 Claw 业务技能
- 想把站点 adapter 封装成业务 skill
- 想新增 prompt-only 技能或说明型技能
- 想扩展服务端 `serviceSkillCatalog` / 未来 `skillCatalog`
- 想修改 `ServiceSkillItem`、`ClientServiceSkillCatalog` 或对应 UI 入口
- 想讨论 skill 与 adapter、Tool Hub、Scene、Claw 的边界
- 发现仓库里开始出现多套 skill 定义、多套入口术语或多套运行语义
如果问题已经缩小到站点适配器字段、脚本、导入和执行,先回到 [site-adapter-standard.md](site-adapter-standard.md)。
## 非目标
本标准明确不负责以下目标:
- 定义另一套浏览器 runtime
- 让外部 `SKILL.md` 直接成为 Lime 运行时协议
- 把 adapter 当成 skill 本体
- 为了兼容来源而长期维护第二套 skill 协议
- 在第一阶段把所有既有实现一次性重命名重构完
尤其不要把“支持更多技能”误解成:
- 再造一个平级的 `service skill` 协议
- 再造一个平级的 `site skill` 协议
- 再造一个平级的 `prompt package` 协议
## 标准分层
Lime 的技能标准必须分成四层:
### 1. 说明层
作用:
- 回答“这是什么技能、何时使用、依赖什么、怎么触发”
- 给用户、模型、运营和后台治理看得懂
来源可以参考外部 `SKILL.md` 的优点,例如:
- `name`
- `description`
- `when to use`
- `setup`
- `examples`
但说明层不是 Lime 的运行时事实源。
### 2. 输入层
作用:
- 定义技能参数、默认值、校验和补参表单
当前主承载结构是:
- `src/lib/api/serviceSkills.ts` 里的 `ServiceSkillItem`
- `slotSchema`
- `readinessRequirements`
新增技能时,优先补结构化输入字段,不要继续把参数要求散落在 prompt 和按钮文案里。
### 3. 运行时层
作用:
- 定义技能最终走哪种执行器
当前允许的主执行绑定为:
- `agent_turn`
- `browser_assist`
- `automation_job`
- `cloud_scene`
- `native_skill`
运行时层回答的是“怎么执行”,不是“对用户如何命名”。
### 4. 分发层
作用:
- 定义技能如何被服务端发布、客户端缓存、bootstrap 注入和独立刷新
当前已存在的事实源包括:
- Lime 本地 seeded catalog
- `client/service-skills`
- `bootstrap.serviceSkillCatalog`
长期目标应收敛到统一的 `client/skills` 与 `bootstrap.skillCatalog`,但兼容期内允许保留现有 `serviceSkillCatalog` 投影。
## 统一对象关系
Lime 技能能力必须明确区分三个对象:
### 1. Skill
作用:
- 面向用户和产品表达业务入口
- 解决“为什么用、何时触发、输出去哪”
### 2. Adapter / Tool
作用:
- 提供底层站点、工具或外部能力的执行工件
- 解决“怎么访问、参数是什么、脚本怎么跑”
### 3. Runtime Binding
作用:
- 把 skill 绑定到具体执行面
- 解决“最终交给谁执行”
必须遵守:
- adapter 不是 skill
- skill 可以引用 adapter,但 adapter 不能冒充 skill
- 一个 skill 只能有一个主执行绑定
- 多 adapter 编排不属于普通 site skill,属于后续 scene / orchestration 范畴
## Lime Skill Spec v1
### 1. 技能分类
第一阶段只允许三类技能:
- `service`
- `site`
- `prompt`
含义如下:
- `service`
- 业务交付型技能,通常产出主稿、方案、报告、草案
- `site`
- 业务语义入口,但底层依赖 adapter / 站点工件执行
- `prompt`
- 说明型或提示词型技能,强调触发语义和使用约束,不强制要求结构化 runtime
### 2. 统一信息清单
无论哪一类技能,新增时都必须回答以下信息。
#### 身份字段
- `id`
- `skillKey`
- `version`
- `source`
#### 展示字段
- `title`
- `summary`
- `entryHint`
- `aliases`
- `category`
- `outputHint`
#### 触发字段
- `surfaceScopes`
- `triggerHints`
说明:
- 当前结构化模型尚未正式包含 `triggerHints`
- 在结构化字段补齐前,新增技能也必须在服务端模板或伴随文档中写清楚,不允许缺失
#### 输入字段
- `slotSchema`
- `default values`
- `validation`
#### 执行字段
- `defaultExecutorBinding`
- `executionLocation`
- `readinessRequirements`
#### 运行时引用字段
- `siteCapabilityBinding`
- `promptTemplateKey`
- 未来可扩展的 `toolHubBinding`
#### 产物字段
- `defaultArtifactKind`
- `output destination`
说明:
- 产品投影层可以直接提供 `outputDestination`
- 标准摘要层统一收敛到 `skillBundle.metadata.Lime_output_destination`
- 新增技能时必须明确写清结果会回到:当前主稿、资源文档、工作区消息、自动化结果还是云端运行结果
#### 说明字段
- `usageGuidelines`
- `setupRequirements`
- `examples`
说明:
- 这些字段可以先由服务端模板或说明文档承接
- 长期目标是结构化,而不是永久只写在 README / prompt 里
## 执行绑定标准
### 1. `agent_turn`
适用于:
- Claw 业务技能
- 结构化 prompt + 当前工作区继续执行
要求:
- 输出是业务结果,不是“进入某个工作台”
- 不能把底层技术入口当成用户动作文案
### 2. `browser_assist`
适用于:
- 必须依赖真实浏览器登录态或页面上下文的技能
要求:
- 业务 skill 可以引用 adapter
- 不能把“浏览器工作台 / 调试面板”作为主产品语义
- 不允许隐式后台自动化
### 3. `automation_job`
适用于:
- 定时或持续跟踪技能
要求:
- 必须说明首轮结果、后续调度、失败处理和结果回流方式
### 4. `cloud_scene`
适用于:
- 必须由云端托管执行的技能
要求:
- 客户端默认只做目录消费、提交和结果回流
- 不把普通本地即时技能错误迁成云端必跑
## UI 表达标准
技能 UI 必须表达业务动作,而不是暴露底层实现。
### 1. 卡片与入口
每个 skill 卡片至少要能回答:
- 这是什么
- 何时用
- 怎么执行
- 需要什么依赖
- 结果去哪
### 2. 启动弹窗
启动弹窗统一应包含:
- 技能摘要
- 补参表单
- 执行方式说明
- 依赖条件说明
- 结果写入位置说明
### 3. 文案禁止项
禁止把以下内容直接当成主产品术语:
- 浏览器工作台
- 调试面板
- 脚本目录
- runtime debug
- adapter 执行器
这些只能作为实现说明,不能作为用户主动作文案。
## 分发与事实源标准
### 标准层与产品层的边界
当前必须明确区分两件事:
- `skillBundle`
- 对外对齐 Agent Skills 思路的**标准摘要层**
- 负责表达:`name`、`description`、`license`、`compatibility`、`metadata`、`allowedTools`
- 以及 Lime 运行时真正需要的标准状态:`resourceSummary`、`standardCompliance`
- `ServiceSkillCatalog` / `ClientServiceSkillCatalog`
- Lime 面向 Claw / 工作区 / 启动弹窗的**产品投影层**
- 负责表达:卡片文案、补参表单、执行绑定、结果去向、主题目标、自动化入口等业务语义
强约束:
- 不要把 `ServiceSkillItem` 上的产品展示字段误认为标准本体
- 也不要把外部 `SKILL.md` 原文直接当成 Lime 客户端协议
- 标准层与产品层可以共存,但标准层必须有唯一投影:`skillBundle`
### 当前事实源
客户端现状:
- 本地 seeded skill catalog
- `bootstrap.serviceSkillCatalog`
- `client/service-skills`
- `siteAdapterCatalog`
服务端现状:
- `control-plane-svc` 负责客户端技能目录聚合
- Tool Hub 方向负责 tool / adapter 工件真相源
### 长期收敛方向
长期收敛规则固定如下:
1. 统一 skill 目录收敛到 `client/skills`
2. 兼容期保留 `client/service-skills`
3. adapter / tool 工件目录继续独立,不与 skill 目录混用
4. bootstrap 与独立刷新必须消费同一份目录协议
## 外部 `SKILL.md` 参考边界
外部 `SKILL.md` 仓库对 Lime 只有三类帮助:
- 触发语义怎么写更清楚
- `when to use / setup / examples` 怎么组织更清楚
- 说明层如何让人和模型都容易理解
它不能直接成为:
- Lime 的目录协议
- Lime 的执行绑定协议
- Lime 的客户端 UI 标准
- Lime 的租户分发标准
一句话:
> 外部 `SKILL.md` 只可借“说明书结构”,不可借“产品标准定义权”。
## 新增技能的最低检查单
新增一个技能时,至少要回答以下问题:
1. 它属于 `service / site / prompt` 哪一类
2. 它的主执行绑定是什么
3. 它是否依赖 adapter、浏览器、模型、项目或云端运行
4. 它的结果会写回哪里
5. 它的用户主动作文案是否仍然是业务语义,而不是底层实现
6. 它是否继续沿用当前主目录协议,而不是再造平级协议
7. 如果它引用 adapter,是否仍然遵守 [site-adapter-standard.md](site-adapter-standard.md)
## 当前主链
在统一 `client/skills` 正式落地前,当前新增能力的主链固定如下:
- 业务技能目录:继续收敛到 `ServiceSkillCatalog`
- 标准摘要层:继续收敛到 `skillBundle`
- 站点工件目录:继续收敛到 `siteAdapterCatalog`
- 业务 skill 引用站点能力:通过 `siteCapabilityBinding.adapterName`
不要在这个阶段再引入:
- 平级 `skill.json` 目录协议
- 平级 Markdown-only 技能协议
- 平级浏览器技能协议
## 相关文档
- [overview.md](overview.md)
- [site-adapter-standard.md](site-adapter-standard.md)
- [commands.md](commands.md)
- [quality-workflow.md](quality-workflow.md)
- [limecore-collaboration-entry.md](limecore-collaboration-entry.md)
@@ -0,0 +1,512 @@
# 基于外部站点适配器来源的引入方案
> 目标:在不引入后台浏览器自动化副作用的前提下,利用外部站点适配器来源扩充 Lime 的站点覆盖面,同时坚持 Lime 自己的标准。
## 一、结论
外部站点适配器来源可以接,但只能作为来源层,不能成为 Lime 的第二套产品标准。
必须同时满足三条边界:
- 只引入站点适配器定义,不引入浏览器运行时
- 只引入可编译的安全子集,不引入完整来源语义
- Lime 继续以 `managed_cdp / existing_session` 作为唯一浏览器执行事实源
如果目标是“更多站点适配器”,这是合理方向。
如果目标变成“更多浏览器自动化模式”或“多套浏览器控制系统”,这条路不适合 Lime。
## 二、为什么不能原样接入
### 2.1 外部来源的价值在站点覆盖,而不在浏览器控制
外部来源真正有价值的是:
- 已沉淀的大量站点定义
- 声明式 YAML adapter 结构
- 可复用的 pipeline 表达方式
- 对站点字段抽取的经验
它解决的是“站点覆盖面”问题,不是 Lime 缺少的“浏览器内核抽象”问题。
### 2.2 原生浏览器链路与 Lime 产品边界冲突
很多外部来源自带这些行为:
- 后台拉起守护进程
- 自动连接浏览器扩展
- 连接失败时尝试唤醒 Chrome
- 把 Chrome/Chromium 会话当成默认主链
这和 Lime 当前已经明确收紧的产品边界直接冲突:
- 禁止无任务时后台自运行
- 禁止用户无感知地拉起浏览器
- 禁止常驻式浏览器自动化观感
- 禁止像“后门”一样持续消耗 CPU / 内存
因此,外部来源不能以“浏览器运行时方案”的身份进入 Lime。
### 2.3 原样并入会把 Lime 做成两套系统
Lime 当前已经有自己的主链:
- 浏览器来源:`system | playwright`
- 上下文接入:`managed_cdp | existing_session`
- 站点适配器注册表
- 站点适配器执行分发链
如果再把外部来源的 `daemon + extension + protocol + page bridge` 整套搬进来,结果就是:
1. Lime 自己的浏览器控制链
2. 外部来源自己的浏览器控制链
这会直接导致:
- 事实源分裂
- 排障复杂度翻倍
- 用户心智混乱
- Windows / macOS 资源问题更难治理
这不符合 Lime 的产品判断,也不符合仓库治理原则。
## 三、应该引入什么,不应该引入什么
### 3.1 应该引入
建议只引入这些“来源材料”:
- 外部来源中的 adapter 定义文件
- adapter 元数据结构
- `site`
- `name`
- `description`
- `domain`
- `args`
- `columns`
- `pipeline`
- 表达式与步骤设计思路
- 已验证过的字段映射经验
### 3.2 不应该引入
明确不引入这些运行时能力:
- 浏览器桥接实现
- 守护进程生命周期
- 浏览器扩展连接模型
- 后台自动拉起浏览器
- 自动唤醒 Chrome
- 常驻后台的浏览器控制进程
- 任何绕过 Lime 当前 browser runtime 的执行主链
### 3.3 核心边界
一句话定义边界:
> 外部来源是 Lime 的站点适配器来源,不是 Lime 的浏览器执行内核。
## 四、Lime 当前切入点
Lime 现有站点适配器主链已经存在,关键边界如下:
- 站点适配器注册:`src-tauri/src/services/site_adapter_registry.rs`
- 站点适配器执行:`src-tauri/src/services/site_capability_service.rs`
- 前端命令网关:`src/lib/webview-api.ts`
- 浏览器执行路线:
- `existing_session`
- `managed_cdp`
Lime 当前主格式本质上是:
- manifest 元数据
- entry URL 规则
- script 脚本执行
- 通过 Lime 现有 runtime 执行 script
而外部来源通常是:
- YAML 元数据
- pipeline 步骤
- 来源自己的运行时抽象
- 可选浏览器桥接
这两者不是直接兼容关系,因此正确切入点不是“并列保留两套格式”,而是增加一层导入编译边界。
## 五、推荐落地方案
### 5.1 三层结构
建议固定为三层:
1. 来源层
- 外部 adapter 定义文件
2. Lime 编译层
- 把来源 YAML 编译成 Lime 标准
3. Lime 执行层
- 继续使用 Lime 现有 `managed_cdp / existing_session`
这样可以同时保证:
- 站点覆盖面扩展
- 浏览器执行事实源不分裂
- 不引入后台自动化副作用
- Lime 标准仍然是唯一事实源
### 5.2 第一阶段只支持安全子集
第一阶段不要追求完整兼容来源 pipeline。
建议只支持以下安全子集:
- `navigate`
- `evaluate`
- `map`
- `filter`
- `limit`
- `sort`
第一阶段明确不支持:
- `tap`
- `intercept`
- `Desktop` 模式
- 依赖扩展上下文的动作
- 依赖守护进程协议的动作
- 自动关闭、拉起、唤醒浏览器的动作
这能把复杂度和产品风险都压在可控范围内。
### 5.3 编译层职责
建议通过独立导入服务承接,例如:
- `src-tauri/src/services/site_adapter_import_service.rs`
职责只保留三件事:
1. 读取来源 YAML
2. 按 Lime 白名单规则校验
3. 编译为 Lime `SiteAdapterSpec` 所需结构
原则是:
- 不污染现有站点执行链
- 不把来源原始模型散落到整个仓库
- 不让来源格式越过编译边界直达运行时
## 六、接入路线对比
### 方案 A:直接调用外部 CLI
实现方式:
- Lime 在用户触发时调用外部命令
- 读取输出结果
- 回填到 Lime 的结果链路
优点:
- 接入快
- 便于短期验证少量站点价值
缺点:
- 仍可能间接触发来源自己的浏览器自动化模型
- 输出结构和错误语义受外部 CLI 限制
- 难以对齐 Lime 当前 `managed_cdp / existing_session`
- 长期治理成本高
判断:
- 只适合作为一次性实验
- 不适合作为 Lime 主方案
### 方案 B:只导入来源定义,由 Lime 执行
实现方式:
- Lime 只消费来源 adapter 定义
- 编译后交给 Lime 现有 runtime 执行
优点:
- 浏览器控制事实源统一
- 产品边界一致
- 用户体验一致
- 可复用 Lime 当前审计、状态、结果链路
缺点:
- 首轮需要实现编译层
- 需要维护 pipeline 子集映射规则
判断:
- 这是 Lime 应采用的长期主方案
---
## 七、推荐实施阶段
### 阶段 0:只做静态评估
目标:
- 建立 YAML 来源 adapter 能力盘点
- 标注哪些站点适合迁入
- 标注哪些步骤当前不支持
输出:
- 站点白名单
- 不支持步骤清单
- 优先迁移顺序
建议优先挑选的站点特征:
- 只依赖 `navigate + evaluate + map + limit`
- 不依赖 Chrome extension
- 不依赖桌面应用上下文
- 只读采集,不带写操作
### 阶段 1:做 adapter importer PoC
目标:
- 支持读取 YAML 来源 YAML
- 转换为 Lime 内部适配器结构
- 跑通 3 到 5 个代表性站点
建议首批站点:
- Reddit
- Zhihu
- Yahoo Finance
- Hacker News
- Xueqiu 中不依赖拦截链的只读项
阶段完成标准:
- 站点可被 Lime 列出
- 可被搜索和推荐
- 可通过现有站点执行链跑出稳定结果
- 不引入任何后台自动拉起浏览器行为
### 阶段 2:扩展步骤子集
目标:
- 在验证稳定后,再考虑增加更多 pipeline 步骤
建议顺序:
1. `filter`
2. `sort`
3. 更复杂的 `map` 表达式
4. 更完整的参数类型支持
仍然不建议过早支持:
- `intercept`
- `tap`
- 浏览器扩展相关上下文
### 阶段 3:建立上游同步机制
目标:
- 让 Lime 能稳定跟随 YAML 来源 adapter 增长
建议做法:
- 明确一份允许导入的 adapter 白名单
- 通过脚本做同步与编译
- 同步时生成变更报告
不要做成:
- 自动无审核拉取上游全部 adapter
- 每次构建时动态扫描外部仓库
同步必须可审计、可回滚、可控。
---
## 八、适配器编译规则建议
### 8.1 名称映射
建议内部统一命名:
- `site/name` 形式作为 adapter 唯一标识
- 例如:
- `reddit/hot`
- `zhihu/hot`
- `xiaohongshu/feed`
### 8.2 参数映射
YAML 来源 常见参数类型先收敛到 Lime 已支持的简单类型:
- `str` -> `string`
- `int` -> `integer`
首轮不要扩展复杂参数系统。
### 8.3 pipeline 到 script 的映射
推荐生成单一脚本执行体,而不是在 Lime 再实现一整套 YAML 来源 pipeline engine。
理由:
- 当前 Lime 站点执行模型本来就是 script 型
- 可复用现有运行时
- 更容易与 `existing_session / managed_cdp` 对齐
建议做法:
- `navigate` 映射为脚本前置导航意图
- `evaluate` 直接转为脚本主体
- `map/filter/limit/sort` 优先编译到脚本尾部的数据整形
也就是说:
> 不把 YAML 来源 pipeline 原样搬进 Lime,而是把它编译成 Lime 现有 script 执行模型。
### 8.4 不支持步骤的处理
对于当前不支持的步骤,必须在导入阶段就失败,并给出清晰原因。
例如:
- `adapter uses intercept step`
- `adapter requires extension runtime`
- `adapter requires desktop mode`
不要进入运行时才失败。
---
## 九、用户体验与产品边界
这是本方案最重要的非功能约束。
### 9.1 明确禁止
- 无任务时后台自动运行站点适配器
- 自动启动 Chrome
- 自动唤醒 Chrome
- 自动挂接扩展
- 常驻 daemon
- 用户未点击执行时预热站点运行时
### 9.2 只允许的执行方式
只允许:
- 用户显式点击运行某个适配器
- 或者用户明确发起某次站点采集任务
并且执行过程必须满足:
- 可见
- 可中断
- 有状态反馈
- 有完成结果
- 结束即收口
### 9.3 UX 文案要求
任何未来如果接入 YAML 来源 来源的适配器,都不应对用户暴露“YAML 来源 daemon”“extension reconnect”这类技术词。
用户只需要知道:
- 这是一个站点适配器
- 需要哪个浏览器上下文
- 是否需要登录态
- 执行后会得到什么结果
---
## 十、风险清单
### 10.1 表达式兼容风险
YAML 来源 的表达式和 Lime 当前脚本模板体系不完全相同。
需要一个明确的受支持表达式子集。
### 10.2 站点易碎性风险
很多站点适配器本质上依赖页面结构、接口字段、前端状态树。
即使成功导入,也需要接受它们会失效。
### 10.3 维护成本风险
如果一次性导入过多站点,后续维护成本会很高。
必须建立白名单,不要全量照搬 55 个站点。
### 10.4 写操作风险
带有发送、发布、点赞、交互类动作的适配器,不应在第一阶段导入。
首轮只应覆盖只读采集类适配器。
### 10.5 协议漂移风险
如果 Lime 自己新做一套 pipeline,又试图兼容 YAML 来源 全量语义,最后会长成第三套事实源。
必须坚持“导入后编译到 Lime 当前 script 执行模型”。
---
## 十一、推荐的首轮范围
首轮建议只做以下范围:
- 只读适配器
- 无扩展依赖
- 无 daemon 依赖
- 无桌面应用依赖
- 无写操作
- 无登录强依赖,或登录态可复用现有 `existing_session`
不建议首轮纳入:
- 小红书 `intercept / tap` 型复杂适配器
- ChatGPT / Chatwise / Discord-App 这类 Desktop 模式适配器
- 依赖扩展上下文或浏览器网络拦截的适配器
---
## 十二、建议的下一步落地顺序
1. 建立 YAML 来源 adapter 能力审计脚本
2. 产出支持步骤白名单
3. 新增 `site_adapter_import_service`
4. 先导入 3 到 5 个只读 adapter
5. 接入现有 `site_list_adapters / site_search_adapters / site_run_adapter`
6. 补 smoke,证明不会触发后台自动化副作用
7. 再决定是否扩展更多步骤
---
## 十三、最终决策
最终建议如下:
- **决策一**:采纳 `外部适配器来源`,但仅作为站点适配器来源
- **决策二**:不采纳 `外部适配器来源` 的浏览器 runtime
- **决策三**:Lime 继续保持浏览器 runtime 单一事实源
- **决策四**:先做 importer + 编译层,不做 runtime 并入
- **决策五**:首轮仅支持只读、安全、无后台副作用的 adapter 子集
这条路径同时满足:
- 更多站点适配器
- 不引入第二套浏览器内核
- 不破坏当前产品边界
- 便于渐进式扩展
---
## 附:一句话版本
> 要引的是 YAML 来源 的“站点适配器库”,不是它的“后台浏览器自动化模式”。
+15 -1
View File
@@ -4,12 +4,13 @@
## 功能
- Observer 通道自动连接:`/lime-chrome-observer/Lime_Key=...`
- Observer 通道自动连接:`/lime-chrome-observer/<bridge_key>?profileKey=...`
- 页面信息上报:标题、URL、Markdown
- 远程指令执行:`open_url` / `click` / `type` / `scroll` / `switch_tab` / `list_tabs` / `go_back` 等
- 弹窗入口:直接打开 Lime 的“连接器”页,并保留高级手动配置
- 自动配置文件:导出后自动写入 `auto_config.json`
- 弹窗配置:`serverUrl`、`bridgeKey`、`profileKey`、监控开关、手动抓取
- 桌面端主动断开:当 Lime 设置页点击“断开已连接扩展”时,扩展会关闭 observer 连接并停止自动重连,直到用户手动重连
## 安装
@@ -67,12 +68,25 @@
npm run bridge:e2e -- --server ws://127.0.0.1:8787 --key proxy_cast --profile default
```
默认还会通过 `http://127.0.0.1:3030/invoke` 调用 `disconnect_browser_connector_session`,继续验证桌面端主动断开链路:
- observer/control 均收到 `force_disconnect`
- `disconnect_browser_connector_session` 返回断开计数
- 在干净环境下,`get_chrome_bridge_status` 最终归零
如果你当前只想验证纯 WebSocket 握手和命令回路,而不依赖 DevBridge invoke,可显式跳过该阶段:
```bash
npm run bridge:e2e -- --server ws://127.0.0.1:8787 --key proxy_cast --profile default --skip-force-disconnect
```
脚本会验证:
- observer/control 握手
- 双向心跳 ack
- `wait_for_page_info=true` 命令链路(`command_result` + `page_info_update`)
- 普通命令链路(`command_result`)
- 桌面端主动断开链路(默认开启)
## 兼容说明
+60 -14
View File
@@ -19,6 +19,7 @@ let activeTabId = null;
let monitoringEnabled = true;
let latestPageInfo = null;
let lastSettings = { ...DEFAULT_SETTINGS };
const expectedClosedSockets = new WeakSet();
function logInfo(message, payload) {
if (payload === undefined) {
@@ -62,6 +63,10 @@ function buildObserverUrl(settings) {
return `${normalized}/lime-chrome-observer/${encodeURIComponent(bridgeKey)}?profileKey=${profileKey}`;
}
function isCapturableUrl(url) {
return /^https?:\/\//i.test(String(url || "").trim());
}
async function connectObserver(forceReconnect = false) {
if (ws && ws.readyState === WebSocket.OPEN && !forceReconnect) {
return;
@@ -83,15 +88,14 @@ async function connectObserver(forceReconnect = false) {
}
if (forceReconnect && ws) {
try {
ws.close();
} catch (_) {}
closeObserverSocket(ws, { shouldReconnect: false });
}
logInfo(`连接 observer: ${url}`);
ws = new WebSocket(url);
const socket = new WebSocket(url);
ws = socket;
ws.onopen = () => {
socket.onopen = () => {
reconnectAttempts = 0;
setConnectionState(true);
startHeartbeat();
@@ -99,7 +103,7 @@ async function connectObserver(forceReconnect = false) {
triggerPageCapture("ws_open");
};
ws.onmessage = async (event) => {
socket.onmessage = async (event) => {
try {
const payload = JSON.parse(event.data);
await handleObserverMessage(payload);
@@ -108,14 +112,22 @@ async function connectObserver(forceReconnect = false) {
}
};
ws.onclose = () => {
socket.onclose = () => {
const shouldReconnect = !expectedClosedSockets.has(socket);
if (ws === socket) {
ws = null;
}
setConnectionState(false);
clearHeartbeatTimer();
scheduleReconnect();
if (shouldReconnect) {
scheduleReconnect();
} else {
reconnectAttempts = 0;
}
broadcastStatus();
};
ws.onerror = (error) => {
socket.onerror = (error) => {
logWarn("WebSocket 错误", error?.message || error);
};
}
@@ -124,9 +136,7 @@ function disconnectObserver(manual = true) {
clearReconnectTimer();
clearHeartbeatTimer();
if (ws) {
try {
ws.close();
} catch (_) {}
closeObserverSocket(ws, { shouldReconnect: false });
}
if (manual) {
setConnectionState(false);
@@ -134,6 +144,18 @@ function disconnectObserver(manual = true) {
}
}
function closeObserverSocket(socket, { shouldReconnect }) {
if (!socket) {
return;
}
if (!shouldReconnect) {
expectedClosedSockets.add(socket);
}
try {
socket.close();
} catch (_) {}
}
function setConnectionState(connected) {
isConnected = connected;
chrome.action.setBadgeText({ text: connected ? "ON" : "OFF" });
@@ -211,6 +233,11 @@ async function handleObserverMessage(payload) {
if (type === "heartbeat_ack" || type === "connection_ack") {
return;
}
if (type === "force_disconnect") {
logInfo("收到桌面端主动断开指令");
disconnectObserver(true);
return;
}
if (type !== "command" || !payload.data) {
return;
}
@@ -530,6 +557,17 @@ async function triggerPageCapture(reason, retry = 0) {
return;
}
let tab = null;
try {
tab = await chrome.tabs.get(tabId);
} catch (_) {
tab = null;
}
if (!tab || !isCapturableUrl(tab.url)) {
return;
}
try {
await sendCommandToTab(tabId, {
type: "REQUEST_PAGE_CAPTURE",
@@ -736,8 +774,16 @@ async function loadAutoConfig() {
logWarn("自动配置缺少必要字段", config);
}
} catch (error) {
// 文件不存在或解析失败时记录错误
logWarn("加载自动配置失败", error?.message || String(error));
const message = error?.message || String(error);
if (
/failed to fetch/i.test(message) ||
/not found/i.test(message) ||
/networkerror/i.test(message)
) {
logInfo("未检测到 auto_config.json,继续使用本地设置");
return;
}
logWarn("加载自动配置失败", message);
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ function buildObserverEndpoint(serverUrl, bridgeKey, profileKey) {
if (!base || !key) {
return "Observer URL: 未配置";
}
return `Observer URL: ${base}/lime-chrome-observer/Lime_Key=${encodeURIComponent(key)}?profileKey=${profile}`;
return `Observer URL: ${base}/lime-chrome-observer/${encodeURIComponent(key)}?profileKey=${profile}`;
}
function applyStatus(status) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "0.97.0",
"version": "0.98.0",
"type": "module",
"engines": {
"node": ">=22.0.0"
+249 -3
View File
@@ -5,9 +5,13 @@ import process from "node:process";
const DEFAULTS = {
server: "ws://127.0.0.1:8787",
healthUrl: "http://127.0.0.1:3030/health",
invokeUrl: "http://127.0.0.1:3030/invoke",
key: "",
profile: "default",
timeoutMs: 15000,
intervalMs: 1000,
verifyForceDisconnect: true,
};
function parseArgs(argv) {
@@ -19,6 +23,16 @@ function parseArgs(argv) {
i += 1;
continue;
}
if (arg === "--health-url" && argv[i + 1]) {
args.healthUrl = argv[i + 1];
i += 1;
continue;
}
if (arg === "--invoke-url" && argv[i + 1]) {
args.invokeUrl = argv[i + 1];
i += 1;
continue;
}
if (arg === "--key" && argv[i + 1]) {
args.key = argv[i + 1];
i += 1;
@@ -34,6 +48,15 @@ function parseArgs(argv) {
i += 1;
continue;
}
if (arg === "--interval-ms" && argv[i + 1]) {
args.intervalMs = Number(argv[i + 1]);
i += 1;
continue;
}
if (arg === "--skip-force-disconnect") {
args.verifyForceDisconnect = false;
continue;
}
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
@@ -51,13 +74,18 @@ Lime Chrome Bridge E2E 联调脚本
选项:
--server <ws_url> 服务地址,默认 ws://127.0.0.1:8787
--health-url <url> DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health
--invoke-url <url> DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke
--key <api_key> Lime API Key(必填)
--profile <profile_key> profileKey,默认 default
--timeout-ms <ms> 单步超时毫秒,默认 15000
--interval-ms <ms> 状态轮询间隔毫秒,默认 1000
--skip-force-disconnect 跳过桌面端主动断开验证,仅校验 WebSocket 命令链路
-h, --help 显示帮助
示例:
node scripts/chrome-bridge-e2e.mjs --server ws://127.0.0.1:8787 --key proxy_cast --profile default
node scripts/chrome-bridge-e2e.mjs --server ws://127.0.0.1:8787 --key proxy_cast --profile default --skip-force-disconnect
`);
}
@@ -76,6 +104,124 @@ function normalizeServer(server) {
.replace(/\/$/, "");
}
function appendProfileKey(url, profileKey) {
const endpoint = new URL(url);
endpoint.searchParams.set("profileKey", profileKey);
return endpoint.toString();
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function invoke(invokeUrl, cmd, args) {
const response = await fetch(invokeUrl, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ cmd, args }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const payload = await response.json();
if (payload?.error) {
throw new Error(String(payload.error));
}
return payload?.result;
}
async function waitForHealth(options) {
const startedAt = Date.now();
let lastError = null;
while (Date.now() - startedAt < options.timeoutMs) {
try {
const response = await fetch(options.healthUrl, { method: "GET" });
const payload = await response.json();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
console.log(
`[E2E] DevBridge 已就绪 (${Date.now() - startedAt}ms)${
payload?.status ? ` status=${payload.status}` : ""
}`,
);
return;
} catch (error) {
lastError = error;
await sleep(options.intervalMs);
}
}
const detail =
lastError instanceof Error
? lastError.message
: String(lastError || "unknown error");
throw new Error(
`[E2E] DevBridge 未就绪,请先启动 npm run tauri:dev 或 npm run tauri:dev:headless。最后错误: ${detail}`,
);
}
function summarizeStatus(status) {
if (!status || typeof status !== "object") {
return "unknown";
}
return `observer=${status.observer_count ?? "?"}, control=${
status.control_count ?? "?"
}, pending=${status.pending_command_count ?? "?"}`;
}
function isStatusEmpty(status) {
return (
Number(status?.observer_count || 0) === 0 &&
Number(status?.control_count || 0) === 0 &&
Number(status?.pending_command_count || 0) === 0
);
}
function hasObserverForProfile(status, profileKey) {
return (status?.observers || []).some(
(observer) => observer?.profile_key === profileKey,
);
}
async function getBridgeStatus(invokeUrl) {
return invoke(invokeUrl, "get_chrome_bridge_status");
}
async function waitForStatus(invokeUrl, predicate, timeoutMs, intervalMs, desc) {
const startedAt = Date.now();
let lastStatus = null;
while (Date.now() - startedAt < timeoutMs) {
lastStatus = await getBridgeStatus(invokeUrl);
if (predicate(lastStatus)) {
return lastStatus;
}
await sleep(intervalMs);
}
throw new Error(
`[E2E] 等待桥接状态超时(${timeoutMs}ms): ${desc}\n最近状态: ${JSON.stringify(
lastStatus,
null,
2,
)}`,
);
}
function toText(data) {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString("utf8");
@@ -186,6 +332,9 @@ async function closeClient(client) {
}
async function main() {
if (typeof fetch !== "function") {
throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+");
}
assertGlobalWebSocket();
const args = parseArgs(process.argv.slice(2));
@@ -196,12 +345,44 @@ async function main() {
if (!Number.isFinite(args.timeoutMs) || args.timeoutMs < 1000) {
throw new Error("--timeout-ms 必须是 >= 1000 的数字");
}
if (!Number.isFinite(args.intervalMs) || args.intervalMs < 100) {
throw new Error("--interval-ms 必须是 >= 100 的数字");
}
const server = normalizeServer(args.server);
const key = encodeURIComponent(args.key);
const profile = encodeURIComponent(args.profile || "default");
const observerUrl = `${server}/lime-chrome-observer/Lime_Key=${key}?profileKey=${profile}`;
const controlUrl = `${server}/lime-chrome-control/Lime_Key=${key}`;
const profile = String(args.profile || "default").trim() || "default";
let observerBaseUrl = `${server}/lime-chrome-observer/${key}`;
let controlUrl = `${server}/lime-chrome-control/${key}`;
let baselineStatus = null;
if (args.verifyForceDisconnect) {
console.log("[E2E] invoke :", args.invokeUrl);
await waitForHealth(args);
const endpointInfo = await invoke(args.invokeUrl, "get_chrome_bridge_endpoint_info");
if (typeof endpointInfo?.observer_ws_url === "string" && endpointInfo.observer_ws_url) {
observerBaseUrl = endpointInfo.observer_ws_url;
}
if (typeof endpointInfo?.control_ws_url === "string" && endpointInfo.control_ws_url) {
controlUrl = endpointInfo.control_ws_url;
}
if (
typeof endpointInfo?.bridge_key === "string" &&
endpointInfo.bridge_key &&
endpointInfo.bridge_key !== args.key
) {
console.warn(
`[E2E] 传入 key 与当前运行态 bridge_key 不一致,将以运行态 endpoint 为准: cli=${args.key} runtime=${endpointInfo.bridge_key}`,
);
}
baselineStatus = await getBridgeStatus(args.invokeUrl);
console.log("[E2E] 基线状态:", summarizeStatus(baselineStatus));
if (!isStatusEmpty(baselineStatus)) {
console.warn(
"[E2E] 检测到已有桥接连接,本次将校验 force_disconnect 消息和目标 profile 清理,但不强制要求全局状态归零。",
);
}
}
const observerUrl = appendProfileKey(observerBaseUrl, profile);
console.log("[E2E] observer:", observerUrl);
console.log("[E2E] control :", controlUrl);
@@ -341,6 +522,71 @@ async function main() {
);
console.log("[E2E] 非 wait_for_page_info 命令链路通过");
if (args.verifyForceDisconnect) {
const forceDisconnectObserver = waitForMessage(
observer,
(msg) => msg.type === "force_disconnect",
args.timeoutMs,
"observer 收到 force_disconnect",
);
const forceDisconnectControl = waitForMessage(
control,
(msg) => msg.type === "force_disconnect",
args.timeoutMs,
"control 收到 force_disconnect",
);
const disconnectResult = await invoke(
args.invokeUrl,
"disconnect_browser_connector_session",
{
profileKey: args.profile,
},
);
assert(
Number(disconnectResult?.disconnected_observer_count || 0) >= 1,
`disconnect_browser_connector_session 未断开 observer: ${JSON.stringify(
disconnectResult,
null,
2,
)}`,
);
assert(
Number(disconnectResult?.disconnected_control_count || 0) >= 1,
`disconnect_browser_connector_session 未断开 control: ${JSON.stringify(
disconnectResult,
null,
2,
)}`,
);
await Promise.all([forceDisconnectObserver, forceDisconnectControl]);
console.log("[E2E] force_disconnect 消息链路通过");
const finalStatus = await waitForStatus(
args.invokeUrl,
(status) =>
isStatusEmpty(baselineStatus)
? isStatusEmpty(status)
: !hasObserverForProfile(status, args.profile),
args.timeoutMs,
args.intervalMs,
isStatusEmpty(baselineStatus)
? "桥接状态归零"
: `profile=${args.profile} observer 已清理`,
);
if (isStatusEmpty(baselineStatus)) {
console.log("[E2E] force_disconnect 后状态归零:", summarizeStatus(finalStatus));
} else {
console.log(
"[E2E] force_disconnect 后目标 profile 已清理:",
summarizeStatus(finalStatus),
);
}
}
console.log("\n[E2E] ✅ Chrome Bridge 联调通过");
} finally {
await closeClient(control);
+75 -9
View File
@@ -1,22 +1,88 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import process from 'node:process';
const DEV_URL = process.env.LIME_WEB_BRIDGE_URL?.trim() || 'http://127.0.0.1:1420/';
const DEV_URL_TIMEOUT_MS = 1_500;
const ROOT_MARKERS = ['<title>Lime</title>', '<div id="root"></div>'];
const env = { ...process.env };
delete env.TAURI_ENV_PLATFORM;
env.LIME_BROWSER_BRIDGE = '1';
const child = spawn('npx', ['vite'], {
stdio: 'inherit',
shell: true,
env,
});
function isLimeDevShell(html) {
return ROOT_MARKERS.some((marker) => html.includes(marker));
}
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
async function probeExistingDevServer(url) {
if (typeof fetch !== 'function') {
return { reachable: false };
}
try {
const response = await fetch(url, {
method: 'GET',
signal: AbortSignal.timeout(DEV_URL_TIMEOUT_MS),
});
const html = await response.text();
return {
reachable: true,
status: response.status,
statusText: response.statusText,
isLimeDevShell: response.ok && isLimeDevShell(html),
};
} catch {
return { reachable: false };
}
}
async function waitForExitSignal() {
await new Promise((resolve) => {
const handleExit = () => resolve();
process.once('SIGINT', handleExit);
process.once('SIGTERM', handleExit);
});
}
function startVite() {
const child = spawn('npx', ['vite'], {
stdio: 'inherit',
shell: true,
env,
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
}
async function main() {
const existingServer = await probeExistingDevServer(DEV_URL);
if (existingServer.reachable) {
if (!existingServer.isLimeDevShell) {
const statusLabel = `${existingServer.status} ${existingServer.statusText}`.trim();
throw new Error(
`[dev:web-bridge] ${DEV_URL} 已被其他服务占用,且返回内容不是 Lime dev shell(${statusLabel})。请先关闭占用进程后重试。`,
);
}
console.log(`[dev:web-bridge] 复用已存在的 Lime dev server: ${DEV_URL}`);
await waitForExitSignal();
return;
}
process.exit(code ?? 0);
startVite();
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+31 -31
View File
@@ -378,7 +378,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "aster-core"
version = "0.22.0"
version = "0.23.0"
dependencies = [
"ahash",
"anyhow",
@@ -470,7 +470,7 @@ dependencies = [
[[package]]
name = "aster-models"
version = "0.22.0"
version = "0.23.0"
dependencies = [
"serde",
"serde_json",
@@ -2417,7 +2417,7 @@ dependencies = [
"dtoa-short",
"itoa",
"matches",
"phf 0.10.1",
"phf 0.8.0",
"proc-macro2",
"quote",
"smallvec",
@@ -2433,7 +2433,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.11.3",
"phf 0.8.0",
"smallvec",
]
@@ -4374,7 +4374,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.57.0",
"windows-core 0.56.0",
]
[[package]]
@@ -5101,7 +5101,7 @@ dependencies = [
[[package]]
name = "lime"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"anyhow",
"arboard",
@@ -5205,7 +5205,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5234,7 +5234,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"chrono",
"futures",
@@ -5251,7 +5251,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5267,7 +5267,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"aster-models",
"async-trait",
@@ -5307,7 +5307,7 @@ dependencies = [
[[package]]
name = "lime-credential"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5342,7 +5342,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"aes",
"axum 0.7.9",
@@ -5372,7 +5372,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5392,7 +5392,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5424,7 +5424,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5443,7 +5443,7 @@ dependencies = [
[[package]]
name = "lime-providers"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"anyhow",
"async-stream",
@@ -5498,7 +5498,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"aster-core",
"async-stream",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5558,7 +5558,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5600,7 +5600,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5618,7 +5618,7 @@ dependencies = [
[[package]]
name = "lime-terminal"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -5645,7 +5645,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "0.97.0"
version = "0.98.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -6324,7 +6324,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
dependencies = [
"proc-macro-crate 2.0.2",
"proc-macro-crate 1.3.1",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -7077,7 +7077,9 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
dependencies = [
"phf_macros 0.8.0",
"phf_shared 0.8.0",
"proc-macro-hack",
]
[[package]]
@@ -7086,9 +7088,7 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
dependencies = [
"phf_macros 0.10.0",
"phf_shared 0.10.0",
"proc-macro-hack",
]
[[package]]
@@ -7192,12 +7192,12 @@ dependencies = [
[[package]]
name = "phf_macros"
version = "0.10.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
dependencies = [
"phf_generator 0.10.0",
"phf_shared 0.10.0",
"phf_generator 0.8.0",
"phf_shared 0.8.0",
"proc-macro-hack",
"proc-macro2",
"quote",
@@ -7609,7 +7609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.14.0",
"itertools 0.12.1",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -9108,7 +9108,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
dependencies = [
"dirs 6.0.0",
"dirs 4.0.0",
]
[[package]]
+4 -4
View File
@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.97.0"
version = "0.98.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -127,8 +127,8 @@ enigo = "0.3"
# 如需联调本地 aster-rust,请运行:
# npm run setup:local-aster -- /path/to/aster-rust
# 脚本会在仓库根 .cargo/config.toml 写入本地 patch 覆盖;该文件已被 .gitignore 忽略。
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.22.0" }
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.22.0" }
aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.23.0" }
aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.23.0" }
# MCP (Model Context Protocol)
rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] }
@@ -192,7 +192,7 @@ version = "2.4"
[package]
name = "lime"
version = "0.97.0"
version = "0.98.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+52 -8
View File
@@ -21,6 +21,9 @@ pub use crate::protocol::{
AgentTokenUsage as TauriTokenUsage, AgentToolImage as TauriToolImage,
AgentToolResult as TauriToolResult,
};
use crate::text_normalization::{
normalize_legacy_runtime_status_title, normalize_legacy_turn_summary_text,
};
use crate::tool_io_offload::{maybe_offload_tool_arguments, maybe_offload_tool_result_payload};
const JSON_RECURSION_LIMIT: usize = 50;
@@ -679,9 +682,9 @@ fn convert_item_status(
fn format_runtime_status_text(title: &str, detail: &str, checkpoints: &[String]) -> String {
let mut lines = Vec::new();
let trimmed_title = title.trim();
let trimmed_title = normalize_legacy_runtime_status_title(title);
if !trimmed_title.is_empty() {
lines.push(trimmed_title.to_string());
lines.push(trimmed_title);
}
let trimmed_detail = detail.trim();
@@ -696,7 +699,7 @@ fn format_runtime_status_text(title: &str, detail: &str, checkpoints: &[String])
}
}
lines.join("\n")
normalize_legacy_turn_summary_text(&lines.join("\n"))
}
fn convert_item_payload(payload: ItemRuntimePayload) -> AgentThreadItemPayload {
@@ -727,10 +730,9 @@ fn convert_item_payload(payload: ItemRuntimePayload) -> AgentThreadItemPayload {
content,
metadata,
},
ItemRuntimePayload::Reasoning { text } => AgentThreadItemPayload::Reasoning {
text,
summary: None,
},
ItemRuntimePayload::Reasoning { text, summary } => {
AgentThreadItemPayload::Reasoning { text, summary }
}
ItemRuntimePayload::ToolCall {
tool_name,
arguments,
@@ -1440,6 +1442,47 @@ mod tests {
}
}
#[test]
fn test_convert_item_started_reasoning_runtime_item_preserves_summary() {
let now = chrono::Utc::now();
let item = ItemRuntime {
id: "reasoning-1".to_string(),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
sequence: 3,
status: ItemStatus::InProgress,
started_at: now,
completed_at: None,
updated_at: now,
payload: ItemRuntimePayload::Reasoning {
text: "先判断任务类型\n\n再决定是否联网".to_string(),
summary: Some(vec![
"先判断任务类型".to_string(),
"再决定是否联网".to_string(),
]),
},
};
let events = convert_agent_event(AgentEvent::ItemStarted { item });
assert_eq!(events.len(), 1);
match &events[0] {
TauriAgentEvent::ItemStarted { item } => match &item.payload {
AgentThreadItemPayload::Reasoning { text, summary } => {
assert_eq!(text, "先判断任务类型\n\n再决定是否联网");
assert_eq!(
summary.as_ref(),
Some(&vec![
"先判断任务类型".to_string(),
"再决定是否联网".to_string(),
])
);
}
other => panic!("Unexpected payload: {other:?}"),
},
other => panic!("Expected ItemStarted event, got {other:?}"),
}
}
#[test]
fn test_convert_item_started_file_artifact_runtime_item() {
let now = chrono::Utc::now();
@@ -1512,7 +1555,8 @@ mod tests {
match &events[0] {
TauriAgentEvent::ItemUpdated { item } => match &item.payload {
AgentThreadItemPayload::TurnSummary { text } => {
assert!(text.contains("已决定:先规划再输出"));
assert!(text.contains("先规划再输出"));
assert!(!text.contains("已决定:"));
assert!(text.contains("当前请求更像计划拆解"));
assert!(text.contains("• 检测到计划需求"));
}
+4 -2
View File
@@ -44,6 +44,7 @@ pub mod subagent_control;
pub mod subagent_profiles;
pub mod subagent_scheduler;
pub mod team_runtime_governor;
mod text_normalization;
pub mod tool_io_offload;
pub mod tools;
pub mod turn_input_envelope;
@@ -103,8 +104,9 @@ pub use runtime_queue::{
};
pub use session_execution_runtime::{
build_session_execution_runtime, extract_recent_content_id_from_runtime_snapshot,
persist_session_recent_preferences, persist_session_recent_team_selection,
SessionExecutionRuntime, SessionExecutionRuntimePreferences,
persist_session_recent_access_mode, persist_session_recent_preferences,
persist_session_recent_team_selection, SessionExecutionRuntime,
SessionExecutionRuntimeAccessMode, SessionExecutionRuntimePreferences,
SessionExecutionRuntimeRecentTeamRole, SessionExecutionRuntimeRecentTeamSelection,
SessionExecutionRuntimeSource,
};
@@ -1,7 +1,9 @@
use crate::session_query::read_session;
use crate::session_update::persist_session_extension_data;
use aster::session::extension_data::{ExtensionData, ExtensionState};
use aster::session::{Session, SessionRuntimeSnapshot, TurnOutputSchemaRuntime, TurnStatus};
use aster::session::{
Session, SessionRuntimeSnapshot, TurnContextOverride, TurnOutputSchemaRuntime, TurnStatus,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -23,6 +25,92 @@ pub enum SessionExecutionRuntimeSource {
ModelChange,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SessionExecutionRuntimeAccessMode {
ReadOnly,
Current,
FullAccess,
}
impl ExtensionState for SessionExecutionRuntimeAccessMode {
const EXTENSION_NAME: &'static str = "lime_recent_access_mode";
const VERSION: &'static str = "v0";
}
impl SessionExecutionRuntimeAccessMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::Current => "current",
Self::FullAccess => "full-access",
}
}
pub fn approval_policy(&self) -> &'static str {
match self {
Self::FullAccess => "never",
Self::ReadOnly | Self::Current => "on-request",
}
}
pub fn sandbox_policy(&self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::Current => "workspace-write",
Self::FullAccess => "danger-full-access",
}
}
pub fn from_access_mode_text(value: Option<&str>) -> Option<Self> {
match value.map(str::trim) {
Some("read-only") => Some(Self::ReadOnly),
Some("current") => Some(Self::Current),
Some("full-access") => Some(Self::FullAccess),
_ => None,
}
}
pub fn from_runtime_policies(
_approval_policy: Option<&str>,
sandbox_policy: Option<&str>,
) -> Option<Self> {
match sandbox_policy.map(str::trim) {
Some("read-only") => Some(Self::ReadOnly),
Some("workspace-write") => Some(Self::Current),
Some("danger-full-access") => Some(Self::FullAccess),
_ => None,
}
}
fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
<Self as ExtensionState>::from_extension_data(extension_data)
}
fn from_session(session: &Session) -> Option<Self> {
Self::from_extension_data(&session.extension_data)
}
fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> {
<Self as ExtensionState>::to_extension_data(self, extension_data)
.map_err(|error| error.to_string())
}
fn into_updated_extension_data(self, session: &Session) -> Result<ExtensionData, String> {
let mut extension_data = session.extension_data.clone();
self.to_extension_data(&mut extension_data)?;
Ok(extension_data)
}
fn from_turn_context_override(turn_context: &TurnContextOverride) -> Option<Self> {
Self::from_runtime_policies(
turn_context.approval_policy.as_deref(),
turn_context.sandbox_policy.as_deref(),
)
.or_else(|| extract_recent_access_mode_from_metadata(&turn_context.metadata))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SessionExecutionRuntimePreferences {
@@ -212,6 +300,8 @@ pub struct SessionExecutionRuntime {
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_turn_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_access_mode: Option<SessionExecutionRuntimeAccessMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_preferences: Option<SessionExecutionRuntimePreferences>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recent_team_selection: Option<SessionExecutionRuntimeRecentTeamSelection>,
@@ -341,6 +431,17 @@ fn extract_recent_preferences_from_metadata(
})
}
fn extract_recent_access_mode_from_metadata(
metadata: &std::collections::HashMap<String, Value>,
) -> Option<SessionExecutionRuntimeAccessMode> {
let harness = metadata.get("harness").and_then(Value::as_object);
let access_mode = harness
.and_then(|value| extract_text_from_object(value, &["access_mode", "accessMode"]))
.or_else(|| extract_text_from_metadata(metadata, &["access_mode", "accessMode"]));
SessionExecutionRuntimeAccessMode::from_access_mode_text(access_mode.as_deref())
}
fn extract_recent_team_roles_from_values(
values: Vec<Value>,
) -> Option<Vec<SessionExecutionRuntimeRecentTeamRole>> {
@@ -479,6 +580,35 @@ pub fn extract_recent_content_id_from_runtime_snapshot(
extract_recent_harness_context_from_runtime_snapshot(snapshot).content_id
}
fn extract_recent_access_mode_from_runtime_snapshot(
snapshot: &SessionRuntimeSnapshot,
) -> Option<SessionExecutionRuntimeAccessMode> {
snapshot
.threads
.iter()
.flat_map(|thread| thread.turns.iter())
.filter_map(|turn| {
let access_mode = turn
.context_override
.as_ref()
.and_then(SessionExecutionRuntimeAccessMode::from_turn_context_override)?;
Some((turn.updated_at, access_mode))
})
.max_by_key(|(updated_at, _)| *updated_at)
.map(|(_, access_mode)| access_mode)
}
pub async fn persist_session_recent_access_mode(
session_id: &str,
recent_access_mode: SessionExecutionRuntimeAccessMode,
) -> Result<(), String> {
let session = read_session(session_id, false, "读取会话 recent_access_mode 失败").await?;
let extension_data = recent_access_mode.into_updated_extension_data(&session)?;
persist_session_extension_data(session_id, extension_data, "持久化会话 recent_access_mode")
.await?;
Ok(())
}
pub async fn persist_session_recent_preferences(
session_id: &str,
preferences: SessionExecutionRuntimePreferences,
@@ -547,6 +677,7 @@ pub fn build_session_execution_runtime(
mode: None,
latest_turn_id: None,
latest_turn_status: None,
recent_access_mode: None,
recent_preferences: None,
recent_team_selection: None,
recent_theme: None,
@@ -563,6 +694,7 @@ pub fn build_session_execution_runtime(
runtime.recent_gate_key = recent_harness_context.gate_key;
runtime.recent_run_title = recent_harness_context.run_title;
runtime.recent_content_id = recent_harness_context.content_id;
runtime.recent_access_mode = extract_recent_access_mode_from_runtime_snapshot(snapshot);
if let Some(latest_turn) = resolve_latest_turn(snapshot) {
runtime.latest_turn_id = Some(latest_turn.id.clone());
@@ -596,6 +728,11 @@ pub fn build_session_execution_runtime(
}
}
if runtime.recent_access_mode.is_none() {
runtime.recent_access_mode =
session.and_then(SessionExecutionRuntimeAccessMode::from_session);
}
if runtime.recent_preferences.is_none() {
runtime.recent_preferences =
session.and_then(SessionExecutionRuntimePreferences::from_session);
@@ -610,6 +747,7 @@ pub fn build_session_execution_runtime(
&& runtime.provider_name.is_none()
&& runtime.model_name.is_none()
&& runtime.output_schema_runtime.is_none()
&& runtime.recent_access_mode.is_none()
&& runtime.recent_preferences.is_none()
&& runtime.recent_team_selection.is_none()
&& runtime.recent_theme.is_none()
@@ -627,9 +765,9 @@ pub fn build_session_execution_runtime(
#[cfg(test)]
mod tests {
use super::{
build_session_execution_runtime, SessionExecutionRuntimePreferences,
SessionExecutionRuntimeRecentTeamRole, SessionExecutionRuntimeRecentTeamSelection,
SessionExecutionRuntimeSource,
build_session_execution_runtime, SessionExecutionRuntimeAccessMode,
SessionExecutionRuntimePreferences, SessionExecutionRuntimeRecentTeamRole,
SessionExecutionRuntimeRecentTeamSelection, SessionExecutionRuntimeSource,
};
use aster::model::ModelConfig;
use aster::session::{
@@ -813,6 +951,73 @@ mod tests {
);
}
#[test]
fn keeps_recent_access_mode_from_latest_turn_context_override() {
let now = Utc::now();
let latest_turn = TurnRuntime {
id: "turn-access".to_string(),
session_id: "session-access".to_string(),
thread_id: "thread-1".to_string(),
status: TurnStatus::Completed,
input_text: Some("hello".to_string()),
error_message: None,
context_override: Some(TurnContextOverride {
approval_policy: Some("never".to_string()),
sandbox_policy: Some("danger-full-access".to_string()),
..TurnContextOverride::default()
}),
output_schema_runtime: None,
created_at: now - Duration::seconds(10),
started_at: Some(now - Duration::seconds(10)),
completed_at: Some(now - Duration::seconds(1)),
updated_at: now,
};
let snapshot = SessionRuntimeSnapshot {
session_id: "session-access".to_string(),
threads: vec![ThreadRuntimeSnapshot {
thread: ThreadRuntime::new(
"thread-1",
"session-access",
PathBuf::from("/tmp/workspace"),
),
turns: vec![latest_turn],
items: Vec::new(),
}],
};
let runtime =
build_session_execution_runtime("session-access", None, None, Some(&snapshot), None)
.expect("runtime");
assert_eq!(
runtime.recent_access_mode,
Some(SessionExecutionRuntimeAccessMode::FullAccess)
);
}
#[test]
fn falls_back_to_session_recent_access_mode_when_runtime_snapshot_missing() {
let mut session = Session::default();
session.id = "session-access-fallback".to_string();
SessionExecutionRuntimeAccessMode::ReadOnly
.to_extension_data(&mut session.extension_data)
.expect("persist access mode");
let runtime = build_session_execution_runtime(
"session-access-fallback",
Some(&session),
Some("react".to_string()),
None,
None,
)
.expect("runtime");
assert_eq!(
runtime.recent_access_mode,
Some(SessionExecutionRuntimeAccessMode::ReadOnly)
);
}
#[test]
fn keeps_recent_team_selection_from_latest_turn_metadata() {
let now = Utc::now();
@@ -0,0 +1,73 @@
const LEGACY_DECISION_PREFIXES: [&str; 2] = ["已决定:", "已决定:"];
pub fn normalize_legacy_runtime_status_title(title: &str) -> String {
let trimmed = title.trim();
for prefix in LEGACY_DECISION_PREFIXES {
if let Some(stripped) = trimmed.strip_prefix(prefix) {
return stripped.trim().to_string();
}
}
trimmed.to_string()
}
pub fn normalize_legacy_turn_summary_text(text: &str) -> String {
let trimmed = text.trim();
if trimmed.is_empty() {
return String::new();
}
let mut lines = trimmed.lines();
let Some(first_line) = lines.next() else {
return String::new();
};
let normalized_first_line = normalize_legacy_runtime_status_title(first_line);
let remaining = lines.collect::<Vec<_>>();
if remaining.is_empty() {
return normalized_first_line;
}
if normalized_first_line.is_empty() {
return remaining.join("\n").trim().to_string();
}
format!("{normalized_first_line}\n{}", remaining.join("\n"))
}
#[cfg(test)]
mod tests {
use super::{normalize_legacy_runtime_status_title, normalize_legacy_turn_summary_text};
#[test]
fn test_normalize_legacy_runtime_status_title_strips_decision_prefix() {
assert_eq!(
normalize_legacy_runtime_status_title("已决定:先深度思考"),
"先深度思考"
);
assert_eq!(
normalize_legacy_runtime_status_title("已决定: 直接回答优先"),
"直接回答优先"
);
assert_eq!(
normalize_legacy_runtime_status_title("直接回答优先"),
"直接回答优先"
);
}
#[test]
fn test_normalize_legacy_turn_summary_text_strips_only_first_line_prefix() {
assert_eq!(
normalize_legacy_turn_summary_text(
"已决定:先规划再输出\n当前请求更像计划拆解。\n• 检测到计划需求"
),
"先规划再输出\n当前请求更像计划拆解。\n• 检测到计划需求"
);
assert_eq!(
normalize_legacy_turn_summary_text("已决定:直接回答优先"),
"直接回答优先"
);
}
}
@@ -166,6 +166,8 @@ pub struct TurnInputEnvelope {
working_dir: Option<String>,
effective_user_message: String,
include_context_trace: bool,
approval_policy: Option<String>,
sandbox_policy: Option<String>,
turn_context_metadata: Option<Map<String, Value>>,
}
@@ -196,11 +198,17 @@ impl TurnInputEnvelope {
}
pub fn turn_context_override(&self) -> Option<TurnContextOverride> {
self.merged_turn_context_metadata()
.map(|metadata| TurnContextOverride {
metadata: metadata.into_iter().collect(),
..TurnContextOverride::default()
})
let metadata = self.merged_turn_context_metadata();
if metadata.is_none() && self.approval_policy.is_none() && self.sandbox_policy.is_none() {
return None;
}
Some(TurnContextOverride {
approval_policy: self.approval_policy.clone(),
sandbox_policy: self.sandbox_policy.clone(),
metadata: metadata.unwrap_or_default().into_iter().collect(),
..TurnContextOverride::default()
})
}
pub fn diagnostics_snapshot(&self) -> TurnDiagnosticsSnapshot {
@@ -266,6 +274,8 @@ impl TurnInputEnvelopeBuilder {
working_dir: None,
effective_user_message: String::new(),
include_context_trace: false,
approval_policy: None,
sandbox_policy: None,
turn_context_metadata: None,
},
}
@@ -345,6 +355,16 @@ impl TurnInputEnvelopeBuilder {
self
}
pub fn set_approval_policy(&mut self, approval_policy: Option<String>) -> &mut Self {
self.envelope.approval_policy = normalize_optional_string(approval_policy);
self
}
pub fn set_sandbox_policy(&mut self, sandbox_policy: Option<String>) -> &mut Self {
self.envelope.sandbox_policy = normalize_optional_string(sandbox_policy);
self
}
pub fn set_turn_context_metadata_from_value(&mut self, metadata: Option<&Value>) -> &mut Self {
self.envelope.turn_context_metadata = match metadata {
Some(Value::Object(map)) => Some(map.clone()),
@@ -556,4 +576,18 @@ mod tests {
}))
);
}
#[test]
fn test_turn_input_envelope_keeps_runtime_access_policies() {
let mut builder = TurnInputEnvelopeBuilder::new("session-4", "workspace-4");
builder
.set_approval_policy(Some("on-request".to_string()))
.set_sandbox_policy(Some("read-only".to_string()));
let envelope = builder.build();
let turn_context = envelope.turn_context_override().expect("turn context");
assert_eq!(turn_context.approval_policy.as_deref(), Some("on-request"));
assert_eq!(turn_context.sandbox_policy.as_deref(), Some("read-only"));
}
}
+19
View File
@@ -419,6 +419,9 @@ pub struct Config {
/// 用户资料
#[serde(default)]
pub user_profile: UserProfile,
/// 开发者功能开关
#[serde(default)]
pub developer: DeveloperConfig,
/// 速率限制配置
#[serde(default)]
pub rate_limit: RateLimitSettings,
@@ -2168,6 +2171,7 @@ impl Default for Config {
voice: VoiceConfig::default(),
image_gen: ImageGenConfig::default(),
user_profile: UserProfile::default(),
developer: DeveloperConfig::default(),
rate_limit: RateLimitSettings::default(),
crash_reporting: CrashReportingConfig::default(),
conversation: ConversationSettings::default(),
@@ -2453,6 +2457,14 @@ pub struct ChatAppearanceConfig {
pub append_selected_text_to_recommendation: Option<bool>,
}
/// 开发者能力配置
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct DeveloperConfig {
/// 是否允许在工作区启用处理工作台与信息收集
#[serde(default)]
pub workspace_harness_enabled: bool,
}
/// 记忆管理配置
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct MemoryProfileConfig {
@@ -3229,6 +3241,12 @@ mod unit_tests {
assert_eq!(parsed, config);
}
#[test]
fn test_developer_config_default() {
let config = DeveloperConfig::default();
assert!(!config.workspace_harness_enabled);
}
#[test]
fn test_config_with_experimental() {
let config = Config::default();
@@ -3244,6 +3262,7 @@ mod unit_tests {
config.experimental.voice_input.shortcut,
"CommandOrControl+Shift+V"
);
assert!(!config.developer.workspace_harness_enabled);
assert!(config.tool_calling.enabled);
assert!(config.tool_calling.dynamic_filtering);
assert!(!config.tool_calling.native_input_examples);
@@ -82,6 +82,13 @@ pub struct ChromeBridgeStatusSnapshot {
pub pending_commands: Vec<ChromeBridgePendingCommandSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChromeBridgeDisconnectResult {
pub disconnected_observer_count: usize,
pub disconnected_control_count: usize,
pub status: ChromeBridgeStatusSnapshot,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChromeBridgeCommandRequest {
#[serde(default)]
@@ -284,6 +291,118 @@ impl ChromeBridgeHub {
}
}
pub async fn disconnect_connections(
&self,
profile_key: Option<&str>,
) -> ChromeBridgeDisconnectResult {
let normalized_profile =
profile_key.map(|value| normalize_profile_key(Some(value.to_string())));
let (observer_senders, control_senders, pending) = {
let mut inner = self.inner.lock().await;
let observer_ids: Vec<String> = inner
.observers
.iter()
.filter_map(|(client_id, conn)| {
let matches_profile = normalized_profile
.as_ref()
.map(|profile| conn.profile_key == *profile)
.unwrap_or(true);
if matches_profile {
Some(client_id.clone())
} else {
None
}
})
.collect();
let observer_senders = observer_ids
.iter()
.filter_map(|client_id| {
inner
.observers
.get(client_id)
.map(|conn| conn.sender.clone())
})
.collect::<Vec<_>>();
let mut pending = Vec::new();
for client_id in &observer_ids {
inner.observers.remove(client_id);
pending.extend(take_pending_by_observer(
&mut inner.pending_commands,
client_id,
));
}
let control_ids: Vec<String> = if observer_ids.is_empty() {
Vec::new()
} else {
inner.controls.keys().cloned().collect()
};
let control_senders = control_ids
.iter()
.filter_map(|client_id| {
inner
.controls
.get(client_id)
.map(|conn| conn.sender.clone())
})
.collect::<Vec<_>>();
for client_id in &control_ids {
inner.controls.remove(client_id);
let pending_ids: Vec<String> = inner
.pending_commands
.iter()
.filter_map(|(request_id, pending)| match &pending.source {
PendingSource::Control { control_client_id }
if control_client_id == client_id =>
{
Some(request_id.clone())
}
_ => None,
})
.collect();
for request_id in pending_ids {
inner.pending_commands.remove(&request_id);
}
}
(observer_senders, control_senders, pending)
};
for sender in &observer_senders {
let _ = sender.send(
json!({
"type": "force_disconnect",
"message": "Lime 已主动断开当前扩展连接。",
})
.to_string(),
);
}
for sender in &control_senders {
let _ = sender.send(
json!({
"type": "force_disconnect",
"message": "Lime 已主动断开当前控制连接。",
})
.to_string(),
);
}
self.resolve_pending_with_disconnect(pending).await;
let status = self.get_status_snapshot().await;
ChromeBridgeDisconnectResult {
disconnected_observer_count: observer_senders.len(),
disconnected_control_count: control_senders.len(),
status,
}
}
pub async fn execute_api_command(
&self,
request: ChromeBridgeCommandRequest,
@@ -1147,6 +1266,82 @@ mod tests {
assert!(result.error.unwrap_or_default().contains("observer"));
}
#[tokio::test]
async fn should_force_disconnect_connections_and_notify_clients() {
let hub = Arc::new(ChromeBridgeHub::new());
let (observer_tx, mut observer_rx) = mpsc::unbounded_channel::<String>();
hub.register_observer(
"observer-a".to_string(),
Some("search_google".to_string()),
None,
observer_tx,
)
.await;
let (control_tx, mut control_rx) = mpsc::unbounded_channel::<String>();
hub.register_control("control-a".to_string(), None, control_tx)
.await;
let (result_tx, result_rx) = oneshot::channel();
{
let mut inner = hub.inner.lock().await;
inner.pending_commands.insert(
"req-disconnect".to_string(),
PendingCommand {
request_id: "req-disconnect".to_string(),
source: PendingSource::Api(result_tx),
command: "click".to_string(),
observer_client_id: "observer-a".to_string(),
wait_for_page_info: false,
command_completed: false,
execution_message: None,
created_at: Utc::now(),
expires_at: Instant::now() + Duration::from_secs(30),
},
);
}
let result = hub.disconnect_connections(Some("search_google")).await;
assert_eq!(result.disconnected_observer_count, 1);
assert_eq!(result.disconnected_control_count, 1);
assert_eq!(result.status.observer_count, 0);
assert_eq!(result.status.control_count, 0);
assert_eq!(result.status.pending_command_count, 0);
let observer_message = observer_rx
.recv()
.await
.expect("observer should receive force_disconnect");
let control_message = control_rx
.recv()
.await
.expect("control should receive force_disconnect");
let observer_payload: Value =
serde_json::from_str(&observer_message).expect("observer message should be valid json");
let control_payload: Value =
serde_json::from_str(&control_message).expect("control message should be valid json");
assert_eq!(
observer_payload.get("type").and_then(Value::as_str),
Some("force_disconnect")
);
assert_eq!(
control_payload.get("type").and_then(Value::as_str),
Some("force_disconnect")
);
let pending_result = result_rx
.await
.expect("pending api command should resolve after disconnect");
assert!(!pending_result.success);
assert!(pending_result
.error
.unwrap_or_default()
.contains("observer"));
}
#[tokio::test]
async fn wait_for_page_info_should_resolve_after_update() {
let hub = Arc::new(ChromeBridgeHub::new());
@@ -130,6 +130,114 @@
"script_file": "scripts/github-search.js",
"source_version": "2026-03-25"
},
{
"name": "linux-do/categories",
"domain": "linux.do",
"description": "读取 linux.do 分类列表。",
"read_only": true,
"capabilities": ["categories", "community", "research"],
"args": [
{
"name": "limit",
"description": "返回分类数量上限",
"required": false,
"arg_type": "integer",
"example": 10
}
],
"example": "linux-do/categories {\"limit\":10}",
"auth_hint": "请先在浏览器中登录 linux.do,再重试该命令。",
"entry": {
"kind": "fixed_url",
"url": "https://linux.do"
},
"script_file": "scripts/linux-do-categories.js",
"source_version": "2026-03-28"
},
{
"name": "linux-do/hot",
"domain": "linux.do",
"description": "读取 linux.do 热门话题。",
"read_only": true,
"capabilities": ["hot", "topics", "community", "research"],
"args": [
{
"name": "limit",
"description": "返回话题数量上限",
"required": false,
"arg_type": "integer",
"example": 10
},
{
"name": "period",
"description": "热门周期,可选 all/daily/weekly/monthly/yearly",
"required": false,
"arg_type": "string",
"example": "weekly"
}
],
"example": "linux-do/hot {\"period\":\"weekly\",\"limit\":10}",
"auth_hint": "请先在浏览器中登录 linux.do,再重试该命令。",
"entry": {
"kind": "fixed_url",
"url": "https://linux.do"
},
"script_file": "scripts/linux-do-hot.js",
"source_version": "2026-03-28"
},
{
"name": "smzdm/search",
"domain": "search.smzdm.com",
"description": "按关键词采集什么值得买搜索结果。",
"read_only": true,
"capabilities": ["search", "shopping", "deals", "research"],
"args": [
{
"name": "query",
"description": "搜索关键词",
"required": true,
"arg_type": "string",
"example": "Mac mini"
},
{
"name": "limit",
"description": "返回条目数量上限",
"required": false,
"arg_type": "integer",
"example": 5
}
],
"example": "smzdm/search {\"query\":\"Mac mini\",\"limit\":5}",
"entry": {
"kind": "url_template",
"template": "https://search.smzdm.com/?c=home&s={{query|urlencode}}&v=b"
},
"script_file": "scripts/smzdm-search.js",
"source_version": "2026-03-28"
},
{
"name": "yahoo-finance/quote",
"domain": "finance.yahoo.com",
"description": "读取 Yahoo Finance 股票行情摘要。",
"read_only": true,
"capabilities": ["quote", "finance", "research"],
"args": [
{
"name": "symbol",
"description": "股票代码,例如 AAPL、MSFT",
"required": true,
"arg_type": "string",
"example": "AAPL"
}
],
"example": "yahoo-finance/quote {\"symbol\":\"AAPL\"}",
"entry": {
"kind": "url_template",
"template": "https://finance.yahoo.com/quote/{{symbol|urlencode}}/"
},
"script_file": "scripts/yahoo-finance-quote.js",
"source_version": "2026-03-28"
},
{
"name": "zhihu/hot",
"domain": "www.zhihu.com",
@@ -0,0 +1,48 @@
async (args, helpers) => {
const limit = helpers.number(args.limit, 10);
try {
const response = await fetch("/categories.json", { credentials: "include" });
if (!response.ok) {
return {
ok: false,
error_code: "auth_required",
error_message: "linux.do 分类列表暂不可用,可能需要先登录。",
};
}
const payload = await response.json();
const categories = Array.isArray(payload?.category_list?.categories)
? payload.category_list.categories
: [];
const items = helpers.take(
categories.map((category) => ({
name: String(category?.name || "").trim(),
slug: String(category?.slug || "").trim(),
id: category?.id ?? null,
topics: category?.topic_count ?? 0,
description: String(category?.description_text || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80),
})),
limit,
).filter((item) => item.name);
return {
ok: true,
data: {
items,
count: items.length,
},
source_url: location.href,
};
} catch (error) {
return {
ok: false,
error_code: "runtime_error",
error_message:
error instanceof Error ? error.message : "读取 linux.do 分类列表失败。",
};
}
};
@@ -0,0 +1,63 @@
async (args, helpers) => {
const limit = helpers.number(args.limit, 10);
const allowedPeriods = new Set(["all", "daily", "weekly", "monthly", "yearly"]);
const period = String(args.period || "weekly").trim().toLowerCase();
const normalizedPeriod = allowedPeriods.has(period) ? period : "weekly";
try {
const response = await fetch(
"/top.json?period=" + encodeURIComponent(normalizedPeriod),
{ credentials: "include" },
);
if (!response.ok) {
return {
ok: false,
error_code: "auth_required",
error_message: "linux.do 热门话题暂不可用,可能需要先登录。",
};
}
const payload = await response.json();
const topics = Array.isArray(payload?.topic_list?.topics)
? payload.topic_list.topics
: [];
const categories = Array.isArray(payload?.topic_list?.categories)
? payload.topic_list.categories
: Array.isArray(payload?.categories)
? payload.categories
: [];
const categoryMap = new Map(
categories.map((category) => [category?.id, String(category?.name || "").trim()]),
);
const items = helpers.take(
topics.map((topic, index) => ({
rank: index + 1,
title: String(topic?.title || "").trim(),
replies: Math.max(0, Number(topic?.posts_count || 1) - 1),
views: Number(topic?.views || 0),
likes: Number(topic?.like_count || 0),
category:
categoryMap.get(topic?.category_id) ||
String(topic?.category_id || "").trim(),
})),
limit,
).filter((item) => item.title);
return {
ok: true,
data: {
period: normalizedPeriod,
items,
count: items.length,
},
source_url: location.href,
};
} catch (error) {
return {
ok: false,
error_code: "runtime_error",
error_message:
error instanceof Error ? error.message : "读取 linux.do 热门话题失败。",
};
}
};
@@ -0,0 +1,59 @@
async (args, helpers) => {
const query = String(args.query || "").trim();
const limit = helpers.number(args.limit, 10);
const rowSelector = "li.feed-row-wide";
await helpers.waitFor(
() =>
document.querySelectorAll(rowSelector).length > 0 ||
/搜索结果|相关好价|什么值得买/i.test(document.body?.textContent || ""),
12000,
300,
);
const rows = Array.from(document.querySelectorAll(rowSelector));
const items = helpers.take(
helpers.uniqueBy(
rows
.map((row, index) => {
const titleAnchor =
row.querySelector("h5.feed-block-title > a") || row.querySelector("h5 > a");
const rawHref =
titleAnchor?.getAttribute("href") || titleAnchor?.href || "";
const url = helpers.absoluteUrl(rawHref);
const title = (
titleAnchor?.getAttribute("title") || helpers.text(titleAnchor)
).trim();
const price = helpers.text(row.querySelector(".z-highlight"));
const mall = helpers.text(
row.querySelector(".z-feed-foot-r .feed-block-extras span") ||
row.querySelector(".z-feed-foot-r span"),
);
const commentsText = helpers.text(
row.querySelector(".feed-btn-comment"),
).replace(/[^\d]/g, "");
return {
rank: index + 1,
title,
url,
price,
mall,
comments: commentsText ? Number(commentsText) : 0,
};
})
.filter((item) => item.title && item.url),
(item) => item.url,
),
limit,
);
return {
ok: true,
data: {
query,
items,
count: items.length,
},
source_url: location.href,
};
};
@@ -0,0 +1,134 @@
async (args, helpers) => {
const symbol = String(args.symbol || "").trim().toUpperCase();
if (!symbol) {
return {
ok: false,
error_code: "invalid_args",
error_message: "symbol 不能为空。",
};
}
const normalizeText = (value) => {
const text = String(value ?? "").replace(/\s+/g, " ").trim();
return text || null;
};
const normalizeNumber = (value) => {
if (value === undefined || value === null || value === "") {
return null;
}
const text = String(value).replace(/,/g, "").replace(/%/g, "").trim();
const number = Number(text);
return Number.isFinite(number) ? number : null;
};
const buildResult = (item) => ({
ok: true,
data: {
symbol,
items: [item],
count: 1,
},
source_url: location.href,
});
try {
const chartUrl =
"https://query1.finance.yahoo.com/v8/finance/chart/" +
encodeURIComponent(symbol) +
"?interval=1d&range=1d";
const response = await fetch(chartUrl, { credentials: "include" });
if (response.ok) {
const payload = await response.json();
const chart = payload?.chart?.result?.[0];
if (chart) {
const meta = chart.meta || {};
const previousClose = meta.previousClose ?? meta.chartPreviousClose ?? null;
const price = meta.regularMarketPrice ?? null;
const change =
price != null && previousClose != null ? price - previousClose : null;
const changePercent =
change != null && previousClose
? Number(((change / previousClose) * 100).toFixed(2))
: null;
return buildResult({
symbol: meta.symbol || symbol,
name: meta.shortName || meta.longName || symbol,
price: price != null ? Number(price.toFixed(2)) : null,
change: change != null ? Number(change.toFixed(2)) : null,
changePercent,
open: chart.indicators?.quote?.[0]?.open?.[0] ?? null,
high: meta.regularMarketDayHigh ?? null,
low: meta.regularMarketDayLow ?? null,
volume: meta.regularMarketVolume ?? null,
marketCap: meta.marketCap ?? null,
});
}
}
} catch {}
await helpers.waitFor(
() =>
document.querySelector('[data-testid="qsp-price"]') ||
document.querySelector('fin-streamer[data-field="regularMarketPrice"]') ||
document.querySelector("h1"),
12000,
300,
);
const titleText = normalizeText(document.querySelector("h1")?.textContent);
const item = {
symbol,
name: titleText
? titleText.replace(/\s*\([^)]+\)\s*$/, "").trim() || symbol
: symbol,
price: normalizeNumber(
document.querySelector('[data-testid="qsp-price"]')?.textContent ||
document.querySelector('fin-streamer[data-field="regularMarketPrice"]')
?.textContent,
),
change: normalizeNumber(
document.querySelector('[data-testid="qsp-price-change"]')?.textContent ||
document.querySelector('fin-streamer[data-field="regularMarketChange"]')
?.textContent,
),
changePercent: normalizeNumber(
document.querySelector('[data-testid="qsp-price-change-percent"]')
?.textContent ||
document.querySelector(
'fin-streamer[data-field="regularMarketChangePercent"]',
)?.textContent,
),
open: normalizeNumber(
document.querySelector('[data-test="OPEN-value"]')?.textContent,
),
high: normalizeNumber(
document.querySelector('[data-test="DAYS_RANGE-value"]')
?.textContent?.split(" - ")
?.at(1),
),
low: normalizeNumber(
document.querySelector('[data-test="DAYS_RANGE-value"]')
?.textContent?.split(" - ")
?.at(0),
),
volume: normalizeNumber(
document.querySelector('[data-test="TD_VOLUME-value"]')?.textContent,
),
marketCap: normalizeText(
document.querySelector('[data-test="MARKET_CAP-value"]')?.textContent,
),
};
if (item.price != null || item.name !== symbol) {
return buildResult(item);
}
return {
ok: true,
data: {
symbol,
items: [],
count: 0,
},
source_url: location.href,
};
};
+7
View File
@@ -235,6 +235,13 @@ impl AsterAgentWrapper {
lime_agent::persist_session_recent_preferences(session_id, preferences).await
}
pub async fn persist_session_recent_access_mode(
session_id: &str,
recent_access_mode: lime_agent::SessionExecutionRuntimeAccessMode,
) -> Result<(), String> {
lime_agent::persist_session_recent_access_mode(session_id, recent_access_mode).await
}
pub async fn persist_session_recent_team_selection(
session_id: &str,
recent_team_selection: lime_agent::SessionExecutionRuntimeRecentTeamSelection,
+3
View File
@@ -1290,9 +1290,11 @@ pub fn run() {
commands::site_capability_cmd::site_recommend_adapters,
commands::site_capability_cmd::site_search_adapters,
commands::site_capability_cmd::site_get_adapter_info,
commands::site_capability_cmd::site_get_adapter_launch_readiness,
commands::site_capability_cmd::site_get_adapter_catalog_status,
commands::site_capability_cmd::site_apply_adapter_catalog_bootstrap,
commands::site_capability_cmd::site_clear_adapter_catalog_cache,
commands::site_capability_cmd::site_import_adapter_yaml_bundle,
commands::site_capability_cmd::site_run_adapter,
commands::site_capability_cmd::site_debug_run_adapter,
commands::site_capability_cmd::site_save_adapter_result,
@@ -1603,6 +1605,7 @@ pub fn run() {
commands::webview_cmd::close_chrome_profile_session,
commands::webview_cmd::get_chrome_bridge_endpoint_info,
commands::webview_cmd::get_chrome_bridge_status,
commands::webview_cmd::disconnect_browser_connector_session,
commands::webview_cmd::chrome_bridge_execute_command,
commands::webview_cmd::get_browser_backends_status,
commands::webview_cmd::get_browser_backend_policy,
@@ -99,6 +99,14 @@ pub async fn agent_runtime_update_session(
.await?;
}
if let Some(recent_access_mode) = request.recent_access_mode {
AsterAgentWrapper::persist_session_recent_access_mode(
&trimmed_session_id,
recent_access_mode,
)
.await?;
}
if let Some(recent_team_selection) = request.recent_team_selection {
AsterAgentWrapper::persist_session_recent_team_selection(
&trimmed_session_id,
@@ -71,6 +71,12 @@ pub struct AsterChatRequest {
/// 是否偏好 reasoning 变体
#[serde(default, alias = "thinkingEnabled")]
pub thinking_enabled: Option<bool>,
/// 执行权限审批策略
#[serde(default, alias = "approvalPolicy")]
pub approval_policy: Option<String>,
/// 执行沙箱策略
#[serde(default, alias = "sandboxPolicy")]
pub sandbox_policy: Option<String>,
/// 项目 ID(可选,用于注入项目上下文到 System Prompt)
#[serde(default, alias = "projectId")]
pub project_id: Option<String>,
@@ -116,6 +122,10 @@ pub struct AgentTurnConfigSnapshot {
pub model_preference: Option<String>,
#[serde(default, alias = "thinkingEnabled")]
pub thinking_enabled: Option<bool>,
#[serde(default, alias = "approvalPolicy")]
pub approval_policy: Option<String>,
#[serde(default, alias = "sandboxPolicy")]
pub sandbox_policy: Option<String>,
#[serde(default, alias = "executionStrategy")]
pub execution_strategy: Option<AsterExecutionStrategy>,
#[serde(default, alias = "webSearch")]
@@ -172,6 +182,12 @@ impl From<AgentRuntimeSubmitTurnRequest> for AsterChatRequest {
thinking_enabled: turn_config
.as_ref()
.and_then(|config| config.thinking_enabled),
approval_policy: turn_config
.as_ref()
.and_then(|config| config.approval_policy.clone()),
sandbox_policy: turn_config
.as_ref()
.and_then(|config| config.sandbox_policy.clone()),
project_id: None,
workspace_id: request.workspace_id.unwrap_or_default(),
web_search: turn_config.as_ref().and_then(|config| config.web_search),
@@ -1595,6 +1611,8 @@ pub struct AgentRuntimeUpdateSessionRequest {
pub model_name: Option<String>,
#[serde(default, alias = "executionStrategy")]
pub execution_strategy: Option<AsterExecutionStrategy>,
#[serde(default, alias = "recentAccessMode")]
pub recent_access_mode: Option<lime_agent::SessionExecutionRuntimeAccessMode>,
#[serde(default, alias = "recentPreferences")]
pub recent_preferences: Option<lime_agent::SessionExecutionRuntimePreferences>,
#[serde(default, alias = "recentTeamSelection")]
@@ -242,7 +242,7 @@ pub(super) async fn build_turn_runtime_statuses(
let decided = if request_tool_policy.requires_web_search() {
(
"已决定:先联网检索".to_string(),
"先联网检索".to_string(),
"当前任务已被明确指定为先搜索后答复,会先完成联网核实再继续生成。".to_string(),
vec![
"用户明确要求联网搜索".to_string(),
@@ -251,7 +251,7 @@ pub(super) async fn build_turn_runtime_statuses(
)
} else if news_expansion_needed {
(
"已决定:先联网扩搜".to_string(),
"先联网扩搜".to_string(),
"当前输入属于新闻或最新动态综述类请求,会先分批执行多组联网搜索,再基于结果做主题归纳与交叉核实。"
.to_string(),
vec![
@@ -261,7 +261,7 @@ pub(super) async fn build_turn_runtime_statuses(
)
} else if subagent_enabled && message_suggests_subagent(&request.message) {
(
"已决定:优先拆分为多代理".to_string(),
"优先拆分为多代理".to_string(),
"用户输入更适合并行分工处理,先按多代理路径组织执行。".to_string(),
vec![
"检测到并行/多角度需求".to_string(),
@@ -270,7 +270,7 @@ pub(super) async fn build_turn_runtime_statuses(
)
} else if task_enabled && message_suggests_task(&request.message) {
(
"已决定:升级为后台任务".to_string(),
"升级为后台任务".to_string(),
"用户输入更接近耗时或异步推进场景,优先走后台任务链路。".to_string(),
vec![
"检测到排队/持续执行诉求".to_string(),
@@ -279,7 +279,7 @@ pub(super) async fn build_turn_runtime_statuses(
)
} else if thinking_enabled && reasoning_supported {
(
"已决定:先深度思考".to_string(),
"先深度思考".to_string(),
"当前模型支持 reasoning,先做更充分的意图理解与方案判断,再决定是否调用搜索或工具。"
.to_string(),
vec![
@@ -289,7 +289,7 @@ pub(super) async fn build_turn_runtime_statuses(
)
} else if thinking_enabled {
(
"已决定:轻量理解后回答".to_string(),
"轻量理解后回答".to_string(),
"当前模型不支持显式 reasoning,先做轻量意图理解,再决定是否需要搜索或其他能力。"
.to_string(),
vec![
@@ -301,7 +301,7 @@ pub(super) async fn build_turn_runtime_statuses(
&& message_suggests_live_search(&request.message)
{
(
"已决定:先联网核实".to_string(),
"先联网核实".to_string(),
"问题包含明显时效性或实时性特征,先搜索核实再回答更稳妥。".to_string(),
vec![
"已检测到最新/实时信息需求".to_string(),
@@ -310,7 +310,7 @@ pub(super) async fn build_turn_runtime_statuses(
)
} else if message_suggests_planning(&request.message) {
(
"已决定:先规划再输出".to_string(),
"先规划再输出".to_string(),
"当前请求更像计划或方案拆解,会先整理执行路径和关键步骤。".to_string(),
vec![
"检测到计划/拆解需求".to_string(),
@@ -319,7 +319,7 @@ pub(super) async fn build_turn_runtime_statuses(
)
} else if message_suggests_content_generation(&request.message) {
(
"已决定:先生成草稿".to_string(),
"先生成草稿".to_string(),
"当前请求属于内容生成类,优先基于已有上下文生成一版草稿,信息不足时附带假设说明,而非反复追问。".to_string(),
vec![
"检测到内容生成需求".to_string(),
@@ -328,7 +328,7 @@ pub(super) async fn build_turn_runtime_statuses(
)
} else {
(
"已决定:直接回答优先".to_string(),
"直接回答优先".to_string(),
"当前请求无需默认升级为搜索或任务,先直接给出结果,必要时再调用工具。".to_string(),
vec![
"默认保持直接回答".to_string(),
@@ -115,6 +115,8 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
("gateKey", "gate_key"),
("run_title", "run_title"),
("runTitle", "run_title"),
("access_mode", "access_mode"),
("accessMode", "access_mode"),
("content_id", "content_id"),
("contentId", "content_id"),
("preferred_team_preset_id", "preferred_team_preset_id"),
@@ -173,6 +175,15 @@ pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields(
}
}
fn derive_request_access_mode(
request: &AsterChatRequest,
) -> Option<lime_agent::SessionExecutionRuntimeAccessMode> {
lime_agent::SessionExecutionRuntimeAccessMode::from_runtime_policies(
request.approval_policy.as_deref(),
request.sandbox_policy.as_deref(),
)
}
pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
request: &AsterChatRequest,
workspace_id: &str,
@@ -200,6 +211,14 @@ pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
"message_length".to_string(),
serde_json::json!(request.message.chars().count()),
);
metadata.insert(
"approval_policy".to_string(),
serde_json::json!(request.approval_policy.clone()),
);
metadata.insert(
"sandbox_policy".to_string(),
serde_json::json!(request.sandbox_policy.clone()),
);
metadata.insert(
"web_search_enabled".to_string(),
serde_json::json!(request_tool_policy.effective_web_search),
@@ -217,6 +236,14 @@ pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base(
serde_json::json!(auto_continue_metadata),
);
extend_map_with_harness_fields(&mut metadata, request.metadata.as_ref());
if !metadata.contains_key("access_mode") {
if let Some(access_mode) = derive_request_access_mode(request) {
metadata.insert(
"access_mode".to_string(),
serde_json::json!(access_mode.as_str()),
);
}
}
for (target_key, preference_keys, session_value) in [
(
"thinking_enabled",
@@ -126,6 +126,33 @@ pub(crate) fn resolve_request_web_search_preference_from_sources(
})
}
fn resolve_runtime_access_mode_from_request(
request: &AsterChatRequest,
) -> Option<lime_agent::SessionExecutionRuntimeAccessMode> {
lime_agent::SessionExecutionRuntimeAccessMode::from_runtime_policies(
request.approval_policy.as_deref(),
request.sandbox_policy.as_deref(),
)
.or_else(|| {
let access_mode =
extract_harness_string(request.metadata.as_ref(), &["access_mode", "accessMode"]);
lime_agent::SessionExecutionRuntimeAccessMode::from_access_mode_text(access_mode.as_deref())
})
}
fn backfill_runtime_access_policies(request: &mut AsterChatRequest) {
let Some(access_mode) = resolve_runtime_access_mode_from_request(request) else {
return;
};
if request.approval_policy.is_none() {
request.approval_policy = Some(access_mode.approval_policy().to_string());
}
if request.sandbox_policy.is_none() {
request.sandbox_policy = Some(access_mode.sandbox_policy().to_string());
}
}
fn should_skip_artifact_document_autopersist(
run_observation: &Arc<Mutex<ChatRunObservation>>,
final_text_output: &str,
@@ -334,6 +361,7 @@ async fn execute_aster_chat_request(
session_recent_harness_context.run_title.as_deref(),
session_recent_harness_context.content_id.as_deref(),
);
backfill_runtime_access_policies(&mut request);
// 直接使用前端传递的 session_id
// LimeSessionStore 会在 add_message 时自动创建不存在的 session
@@ -506,6 +534,8 @@ async fn execute_aster_chat_request(
.set_working_dir(Some(workspace_root.clone()))
.set_effective_user_message(request.message.clone())
.set_include_context_trace(include_context_trace)
.set_approval_policy(request.approval_policy.clone())
.set_sandbox_policy(request.sandbox_policy.clone())
.set_turn_context_metadata_from_value(request.metadata.as_ref());
// 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt
@@ -1987,6 +2017,8 @@ mod tests {
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: None,
workspace_id: "workspace-artifact".to_string(),
web_search: None,
@@ -2072,6 +2104,8 @@ mod tests {
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: None,
workspace_id: "workspace-artifact".to_string(),
web_search: None,
@@ -2138,6 +2172,8 @@ mod tests {
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: None,
workspace_id: "workspace-artifact".to_string(),
web_search: None,
@@ -2197,6 +2233,8 @@ mod tests {
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: None,
workspace_id: "workspace-social".to_string(),
web_search: None,
@@ -2240,6 +2278,45 @@ mod tests {
);
}
#[test]
fn backfill_runtime_access_policies_should_derive_from_legacy_harness_access_mode() {
let mut request = AsterChatRequest {
message: "继续执行".to_string(),
session_id: "session-access-legacy".to_string(),
event_name: "agent_stream".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: None,
workspace_id: "workspace-access".to_string(),
web_search: None,
search_mode: None,
execution_strategy: None,
auto_continue: None,
system_prompt: None,
metadata: Some(json!({
"harness": {
"access_mode": "full-access"
}
})),
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
};
backfill_runtime_access_policies(&mut request);
assert_eq!(request.approval_policy.as_deref(), Some("never"));
assert_eq!(
request.sandbox_policy.as_deref(),
Some("danger-full-access")
);
}
#[tokio::test]
async fn update_compaction_session_metrics_should_move_summary_tokens_to_current_window() {
ensure_runtime_turn_test_session_manager().await;
@@ -682,6 +682,8 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal(
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: None,
workspace_id,
web_search: None,
@@ -758,6 +760,8 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal(
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: None,
workspace_id,
web_search: None,
@@ -928,6 +928,8 @@ mod tests {
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: Some("project-1".to_string()),
workspace_id: "workspace-1".to_string(),
web_search: Some(false),
@@ -1112,6 +1114,8 @@ mod tests {
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: Some("project-1".to_string()),
workspace_id: "workspace-1".to_string(),
web_search: Some(false),
@@ -1185,6 +1189,8 @@ mod tests {
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: Some("project-1".to_string()),
workspace_id: "workspace-1".to_string(),
web_search: None,
@@ -1235,6 +1241,66 @@ mod tests {
);
}
#[test]
fn test_build_chat_run_metadata_base_derives_access_mode_from_formal_turn_context() {
let metadata = build_chat_run_metadata_base(
&AsterChatRequest {
message: "hello".to_string(),
session_id: "session-access".to_string(),
event_name: "event-access".to_string(),
images: None,
provider_config: None,
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: Some("never".to_string()),
sandbox_policy: Some("danger-full-access".to_string()),
project_id: None,
workspace_id: "workspace-1".to_string(),
web_search: Some(false),
search_mode: None,
execution_strategy: Some(AsterExecutionStrategy::React),
auto_continue: None,
system_prompt: None,
metadata: None,
turn_id: None,
queue_if_busy: None,
queued_turn_id: None,
},
"workspace-1",
AsterExecutionStrategy::React,
&RequestToolPolicy {
search_mode: RequestToolPolicyMode::Disabled,
effective_web_search: false,
required_tools: vec![],
allowed_tools: vec![],
disallowed_tools: vec![],
},
false,
None,
None,
);
assert_eq!(
metadata
.get("approval_policy")
.and_then(serde_json::Value::as_str),
Some("never")
);
assert_eq!(
metadata
.get("sandbox_policy")
.and_then(serde_json::Value::as_str),
Some("danger-full-access")
);
assert_eq!(
metadata
.get("access_mode")
.and_then(serde_json::Value::as_str),
Some("full-access")
);
}
#[test]
fn test_chat_run_observation_records_nested_artifact_protocol_paths_from_tool_result() {
let mut observation = ChatRunObservation::default();
@@ -602,6 +602,8 @@ impl Tool for LimeSiteTool {
.as_ref()
.and_then(|target| target.project_id.clone()),
save_title,
require_attached_session: None,
skill_title: None,
};
let result = Self::apply_save_target_to_run_result(
@@ -715,6 +717,8 @@ mod tests {
content_id: None,
project_id: None,
save_title: None,
require_attached_session: None,
skill_title: None,
};
let result = SiteAdapterRunResult {
ok: true,
@@ -785,6 +789,8 @@ mod tests {
content_id: None,
project_id: None,
save_title: None,
require_attached_session: None,
skill_title: None,
};
let result = SiteAdapterRunResult {
ok: true,
+3 -3
View File
@@ -24,12 +24,12 @@ pub struct CodexCliStatus {
pub error: Option<String>,
}
/// 检查 Codex CLI 状态
/// 检查 Lime CLI 状态
#[tauri::command]
pub async fn check_codex_cli_status() -> Result<CodexCliStatus, String> {
let mut status = CodexCliStatus::default();
// 1. 检查 codex 命令是否存在
// 1. 检查 Lime 命令是否存在
let version_result = Command::new("codex")
.arg("--version")
.stdout(Stdio::piped())
@@ -150,7 +150,7 @@ pub async fn get_external_tools() -> Result<Vec<ExternalTool>, String> {
tools.push(ExternalTool {
id: "codex-cli".to_string(),
name: "Codex CLI".to_string(),
description: "OpenAI Codex 命令行工具,支持 Agent 模式和工具调用".to_string(),
description: "Lime 命令行工具,支持 Agent 模式和工具调用".to_string(),
installed: codex_status.installed,
configured: codex_status.logged_in,
install_command: "npm i -g @openai/codex".to_string(),
+46 -4
View File
@@ -1,13 +1,18 @@
use crate::database::DbConnection;
use crate::services::site_adapter_import_service::{
import_imported_yaml_adapter_bundle_to_default_dir, ImportedYamlCompileOptions,
PersistImportedCatalogResult,
};
use crate::services::site_adapter_registry::{
apply_site_adapter_catalog_bootstrap, clear_site_adapter_catalog_cache,
get_site_adapter_catalog_status, SiteAdapterCatalogStatus,
};
use crate::services::site_capability_service::{
get_site_adapter, list_site_adapters, recommend_site_adapters, run_site_adapter,
run_site_adapter_with_optional_save, save_existing_site_result_to_project,
search_site_adapters, RunSiteAdapterRequest, SaveSiteAdapterResultRequest,
SavedSiteAdapterContent, SiteAdapterDefinition, SiteAdapterRecommendation,
get_site_adapter, get_site_adapter_launch_readiness, list_site_adapters,
recommend_site_adapters, run_site_adapter, run_site_adapter_with_optional_save,
save_existing_site_result_to_project, search_site_adapters, RunSiteAdapterRequest,
SaveSiteAdapterResultRequest, SavedSiteAdapterContent, SiteAdapterDefinition,
SiteAdapterLaunchReadinessRequest, SiteAdapterLaunchReadinessResult, SiteAdapterRecommendation,
SiteAdapterRunResult,
};
use serde::Deserialize;
@@ -35,6 +40,21 @@ pub struct SiteAdapterRecommendRequest {
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct SiteAdapterImportYamlBundleRequest {
pub yaml_bundle: String,
#[serde(default)]
pub catalog_version: Option<String>,
#[serde(default)]
pub source_version: Option<String>,
#[serde(default = "default_site_adapter_import_read_only")]
pub read_only: bool,
}
fn default_site_adapter_import_read_only() -> bool {
true
}
#[tauri::command]
pub fn site_list_adapters() -> Result<Vec<SiteAdapterDefinition>, String> {
Ok(list_site_adapters())
@@ -62,6 +82,14 @@ pub fn site_get_adapter_info(
get_site_adapter(&request.name).ok_or_else(|| "未找到对应的站点适配器".to_string())
}
#[tauri::command]
pub async fn site_get_adapter_launch_readiness(
db: State<'_, DbConnection>,
request: SiteAdapterLaunchReadinessRequest,
) -> Result<SiteAdapterLaunchReadinessResult, String> {
get_site_adapter_launch_readiness(db.inner(), request).await
}
#[tauri::command]
pub fn site_get_adapter_catalog_status() -> Result<SiteAdapterCatalogStatus, String> {
get_site_adapter_catalog_status()
@@ -79,6 +107,20 @@ pub fn site_clear_adapter_catalog_cache() -> Result<SiteAdapterCatalogStatus, St
clear_site_adapter_catalog_cache()
}
#[tauri::command]
pub fn site_import_adapter_yaml_bundle(
request: SiteAdapterImportYamlBundleRequest,
) -> Result<PersistImportedCatalogResult, String> {
import_imported_yaml_adapter_bundle_to_default_dir(
&request.yaml_bundle,
&ImportedYamlCompileOptions {
read_only: request.read_only,
source_version: request.source_version,
},
request.catalog_version,
)
}
#[tauri::command]
pub async fn site_run_adapter(
db: State<'_, DbConnection>,
+12 -1
View File
@@ -26,7 +26,8 @@ use lime_browser_runtime::{
EventBufferSnapshot, OpenSessionRequest,
};
use lime_server::chrome_bridge::{
self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult, ChromeBridgeStatusSnapshot,
self, ChromeBridgeCommandRequest, ChromeBridgeCommandResult, ChromeBridgeDisconnectResult,
ChromeBridgeStatusSnapshot,
};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
@@ -1173,6 +1174,16 @@ pub async fn get_chrome_bridge_status_global() -> Result<ChromeBridgeStatusSnaps
get_chrome_bridge_status().await
}
/// 主动断开 ChromeBridge 当前连接(用于连接器设置页)
#[tauri::command]
pub async fn disconnect_browser_connector_session(
profile_key: Option<String>,
) -> Result<ChromeBridgeDisconnectResult, String> {
Ok(chrome_bridge::chrome_bridge_hub()
.disconnect_connections(profile_key.as_deref())
.await)
}
/// 通过 ChromeBridge 执行命令(用于设置页测试)
#[tauri::command]
pub async fn chrome_bridge_execute_command(
+86 -3
View File
@@ -9,9 +9,12 @@ pub mod dispatcher;
#[cfg(debug_assertions)]
use axum::{
extract::State,
extract::{Query, State},
http::{request::Parts as RequestParts, HeaderValue, Method},
response::{IntoResponse, Response},
response::{
sse::{Event as SseEvent, KeepAlive, Sse},
IntoResponse, Response,
},
routing::{get, post},
Json, Router,
};
@@ -20,6 +23,8 @@ use serde::{Deserialize, Serialize};
#[cfg(debug_assertions)]
use std::sync::Arc;
#[cfg(debug_assertions)]
use std::{convert::Infallible, time::Duration};
#[cfg(debug_assertions)]
use tokio::sync::RwLock;
#[cfg(debug_assertions)]
use tower_http::cors::{AllowOrigin, CorsLayer};
@@ -34,7 +39,7 @@ use lime_services::{
provider_pool_service::ProviderPoolService, skill_service::SkillService,
};
#[cfg(debug_assertions)]
use tauri::AppHandle;
use tauri::{AppHandle, EventId, Listener};
#[cfg(debug_assertions)]
#[derive(Debug, Deserialize)]
@@ -51,6 +56,12 @@ pub struct InvokeResponse {
pub error: Option<String>,
}
#[cfg(debug_assertions)]
#[derive(Debug, Deserialize)]
pub struct EventStreamRequest {
pub event: String,
}
#[cfg(debug_assertions)]
#[derive(Clone)]
pub struct DevBridgeState {
@@ -143,6 +154,7 @@ impl DevBridgeServer {
let app = Router::new()
.route("/invoke", post(invoke_command))
.route("/events", get(stream_events))
.route("/health", get(health_check).post(health_check))
.layer(
// CORS 配置 - 允许本地开发前端访问
@@ -176,6 +188,20 @@ impl DevBridgeServer {
}
}
#[cfg(debug_assertions)]
#[derive(Clone)]
struct DevBridgeEventListenerGuard {
app_handle: AppHandle,
listener_id: EventId,
}
#[cfg(debug_assertions)]
impl Drop for DevBridgeEventListenerGuard {
fn drop(&mut self) {
self.app_handle.unlisten(self.listener_id);
}
}
#[cfg(debug_assertions)]
fn invoke_command(
State(state): State<DevBridgeState>,
@@ -198,6 +224,63 @@ fn invoke_command(
}
}
#[cfg(debug_assertions)]
async fn stream_events(
State(state): State<DevBridgeState>,
Query(req): Query<EventStreamRequest>,
) -> Response {
let event_name = req.event.trim().to_string();
if event_name.is_empty() {
return (
axum::http::StatusCode::BAD_REQUEST,
"missing event query parameter",
)
.into_response();
}
let Some(app_handle) = state.app_handle.clone() else {
return (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"dev bridge app handle unavailable",
)
.into_response();
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let listener_event_name = event_name.clone();
let listener_id = app_handle.listen_any(listener_event_name.clone(), move |event| {
let payload = event.payload();
let payload_value = serde_json::from_str::<serde_json::Value>(payload)
.unwrap_or_else(|_| serde_json::Value::String(payload.to_string()));
let serialized = serde_json::json!({
"event": listener_event_name.clone(),
"payload": payload_value,
})
.to_string();
let _ = tx.send(serialized);
});
let cleanup_handle = app_handle.clone();
let stream = async_stream::stream! {
let _listener_guard = DevBridgeEventListenerGuard {
app_handle: cleanup_handle,
listener_id,
};
while let Some(payload) = rx.recv().await {
yield Ok::<SseEvent, Infallible>(SseEvent::default().data(payload));
}
};
Sse::new(stream)
.keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keepalive"),
)
.into_response()
}
#[cfg(debug_assertions)]
async fn health_check() -> impl IntoResponse {
Json(serde_json::json!({
@@ -29,6 +29,18 @@ pub(super) async fn try_handle(
"get_chrome_bridge_status" => serde_json::to_value(
crate::commands::webview_cmd::get_chrome_bridge_status_global().await?,
)?,
"disconnect_browser_connector_session" => {
let args = args_or_default(args);
let profile_key = args
.get("profileKey")
.or_else(|| args.get("profile_key"))
.and_then(|value| value.as_str())
.map(|value| value.to_string());
serde_json::to_value(
crate::commands::webview_cmd::disconnect_browser_connector_session(profile_key)
.await?,
)?
}
"get_browser_backend_policy" => serde_json::to_value(
crate::commands::webview_cmd::get_browser_backend_policy_global().await?,
)?,
@@ -1,6 +1,9 @@
use super::super::get_db;
use super::{parse_request, DynError};
use crate::dev_bridge::DevBridgeState;
use crate::services::site_adapter_import_service::{
import_imported_yaml_adapter_bundle_to_default_dir, ImportedYamlCompileOptions,
};
use crate::services::site_adapter_registry::{
apply_site_adapter_catalog_bootstrap, clear_site_adapter_catalog_cache,
get_site_adapter_catalog_status,
@@ -48,6 +51,18 @@ pub(super) async fn try_handle(
"site_clear_adapter_catalog_cache" => {
serde_json::to_value(clear_site_adapter_catalog_cache()?)?
}
"site_import_adapter_yaml_bundle" => {
let request: crate::commands::site_capability_cmd::SiteAdapterImportYamlBundleRequest =
parse_request(args)?;
serde_json::to_value(import_imported_yaml_adapter_bundle_to_default_dir(
&request.yaml_bundle,
&ImportedYamlCompileOptions {
read_only: request.read_only,
source_version: request.source_version,
},
request.catalog_version,
)?)?
}
"site_run_adapter" | "site_debug_run_adapter" => {
let request: crate::services::site_capability_service::RunSiteAdapterRequest =
parse_request(args)?;
+1
View File
@@ -11,6 +11,7 @@
- `mod.rs` - 模块入口
- `novel_service.rs` - 小说编排服务(项目/设定/章节生成/一致性检查)
- `site_adapter_import_service.rs` - 外部适配器来源导入与 Lime 标准编译层
- `provider_pool_service.rs` - Provider 凭证池服务(多凭证轮询)
- `token_cache_service.rs` - Token 缓存服务
- `mcp_service.rs` - MCP 服务器管理
@@ -2,16 +2,12 @@
//!
//! 负责把结构化自动化任务映射到 Aster 执行链路。
use super::{AutomationJobRecord, AutomationPayload};
use super::{AutomationJobRecord, AutomationPayload, BROWSER_AUTOMATION_RETIRED_MESSAGE};
use crate::agent::AsterAgentWrapper;
use crate::app::AppState;
use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState;
use crate::commands::aster_agent_cmd::{
build_queued_turn_task, build_runtime_queue_executor, AsterChatRequest,
};
use crate::commands::browser_runtime_cmd::{
launch_browser_session_with_db, LaunchBrowserSessionRequest,
};
use crate::config::GlobalConfigManagerState;
use crate::database::DbConnection;
use crate::mcp::McpManagerState;
@@ -73,31 +69,8 @@ pub async fn execute_job(
)
.await
}
AutomationPayload::BrowserSession {
profile_id,
profile_key,
url,
environment_preset_id,
target_id,
open_window,
stream_mode,
} => {
execute_browser_session(
job,
db,
app_handle,
LaunchBrowserSessionRequest {
profile_id: Some(profile_id),
profile_key,
url,
environment_preset_id,
environment: None,
target_id,
open_window,
stream_mode,
},
)
.await
AutomationPayload::BrowserSession { .. } => {
Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string())
}
}
}
@@ -164,6 +137,8 @@ async fn execute_agent_turn(
provider_preference: None,
model_preference: None,
thinking_enabled: None,
approval_policy: None,
sandbox_policy: None,
project_id: None,
workspace_id: job.workspace_id.clone(),
web_search: Some(web_search),
@@ -208,47 +183,6 @@ async fn execute_agent_turn(
})
}
async fn execute_browser_session(
job: &AutomationJobRecord,
db: &DbConnection,
app_handle: &Option<AppHandle>,
request: LaunchBrowserSessionRequest,
) -> Result<JobExecutionResult, String> {
let app = app_handle
.as_ref()
.ok_or_else(|| "应用句柄不可用,无法执行浏览器自动化任务".to_string())?;
let app_state = app
.try_state::<AppState>()
.ok_or_else(|| "AppState 未初始化,无法执行浏览器自动化任务".to_string())?;
let app_state = app_state.inner().clone();
let response =
launch_browser_session_with_db(app.clone(), app_state, db.clone(), request).await?;
let session_id = response.session.session_id.clone();
Ok(JobExecutionResult {
output: format!("浏览器任务已启动: {} -> {}", job.name, session_id),
output_data: Some(json!({
"kind": "browser_session",
"job_id": job.id.clone(),
"job_name": job.name.clone(),
"workspace_id": job.workspace_id.clone(),
"session_id": response.session.session_id.clone(),
"profile_key": response.session.profile_key.clone(),
"environment_preset_id": response.session.environment_preset_id.clone(),
"environment_preset_name": response.session.environment_preset_name.clone(),
"target_id": response.session.target_id.clone(),
"target_title": response.session.target_title.clone(),
"target_url": response.session.target_url.clone(),
"lifecycle_state": response.session.lifecycle_state,
"control_mode": response.session.control_mode,
"remote_debugging_port": response.session.remote_debugging_port,
"ws_debugger_url": response.session.ws_debugger_url.clone(),
})),
session_id: Some(session_id),
browser_session: Some(response.session),
})
}
fn build_prompt(job: &AutomationJobRecord, prompt: &str, web_search: bool) -> String {
let mut sections = vec![
"你是一个自动化任务执行助手。".to_string(),
+204 -119
View File
@@ -17,8 +17,6 @@ use self::schedule::{
describe_schedule, next_run_for_schedule, preview_next_run, validate_schedule,
};
use crate::database::dao::agent_run::AgentRunStatus;
use crate::services::browser_environment_service::get_browser_environment_preset;
use crate::services::browser_profile_service::get_browser_profile;
use crate::services::execution_tracker_service::{ExecutionTracker, RunHandle, RunSource};
use chrono::Utc;
use lime_browser_runtime::{BrowserStreamMode, CdpSessionState};
@@ -38,7 +36,6 @@ use std::time::Duration;
use tauri::Emitter;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use url::Url;
use uuid::Uuid;
pub type AutomationJobRecord = AutomationJob;
@@ -149,6 +146,11 @@ pub struct AutomationService {
app_handle: Option<tauri::AppHandle>,
}
pub(super) const BROWSER_AUTOMATION_RETIRED_MESSAGE: &str =
"浏览器自动化任务已下线,不再允许创建或执行";
pub(super) const BROWSER_AUTOMATION_RETIRED_LAST_ERROR: &str =
"浏览器自动化任务已下线,请删除该任务";
impl AutomationService {
pub fn new(config: AutomationSettings) -> Self {
Self {
@@ -202,6 +204,17 @@ impl AutomationService {
self.status.running = true;
self.update_next_poll();
let retired_job_count = {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
retire_legacy_browser_automation_jobs(&conn)?
};
if retired_job_count > 0 {
tracing::info!(
"[Automation] 已停用 {} 条遗留浏览器自动化任务,后续不会再后台启动 Chrome",
retired_job_count
);
}
let interval_secs = self.config.poll_interval_secs.max(5);
let app_handle = self.app_handle.clone();
tokio::spawn(async move {
@@ -377,6 +390,17 @@ impl AutomationService {
.ok_or_else(|| format!("自动化任务不存在: {id}"))?
};
if is_browser_session_payload(&job.payload) {
let mut legacy_job = job.clone();
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
retire_browser_automation_job(
&conn,
&mut legacy_job,
BROWSER_AUTOMATION_RETIRED_LAST_ERROR,
)?;
return Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string());
}
let result = Self::execute_job_once(&job, db, &self.app_handle, &self.config).await?;
Ok(AutomationCycleResult {
job_count: 1,
@@ -467,6 +491,16 @@ impl AutomationService {
let started_at_str = started_at.to_rfc3339();
let is_browser_session = is_browser_session_payload(&working_job.payload);
if is_browser_session {
let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?;
retire_browser_automation_job(
&conn,
&mut working_job,
BROWSER_AUTOMATION_RETIRED_LAST_ERROR,
)?;
return Ok("error".to_string());
}
set_active_job_state(
&mut working_job,
"running",
@@ -713,17 +747,6 @@ fn normalize_optional_string(value: Option<String>) -> Option<String> {
.filter(|item| !item.is_empty())
}
fn validate_optional_http_url(value: Option<&str>, field_name: &str) -> Result<(), String> {
let Some(raw) = value.map(str::trim).filter(|item| !item.is_empty()) else {
return Ok(());
};
let parsed = Url::parse(raw).map_err(|error| format!("{field_name}无效: {error}"))?;
match parsed.scheme() {
"http" | "https" => Ok(()),
_ => Err(format!("{field_name}仅支持 http/https")),
}
}
fn validate_draft(draft: &AutomationJobDraft) -> Result<(), String> {
validate_schedule(&draft.schedule, Utc::now())?;
validate_payload(&draft.payload)?;
@@ -773,63 +796,65 @@ fn validate_payload(payload: &AutomationPayload) -> Result<(), String> {
}
}
}
AutomationPayload::BrowserSession { profile_id, .. } => {
if profile_id.trim().is_empty() {
return Err("浏览器任务必须绑定浏览器资料".to_string());
}
AutomationPayload::BrowserSession { .. } => {
return Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string());
}
}
Ok(())
}
fn validate_payload_with_conn(
conn: &Connection,
_conn: &Connection,
payload: &AutomationPayload,
) -> Result<(), String> {
match payload {
AutomationPayload::AgentTurn { .. } => Ok(()),
AutomationPayload::BrowserSession {
profile_id,
profile_key,
url,
environment_preset_id,
..
} => {
let profile_id = profile_id.trim();
let profile = get_browser_profile(conn, profile_id)?
.filter(|record| record.archived_at.is_none())
.ok_or_else(|| format!("未找到可用的浏览器资料: {profile_id}"))?;
if let Some(expected_profile_key) = profile_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if profile.profile_key != expected_profile_key {
return Err(format!(
"浏览器资料 {profile_id} 的 profile_key 与任务配置不一致: {} != {expected_profile_key}",
profile.profile_key
));
}
}
if let Some(environment_preset_id) = environment_preset_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
get_browser_environment_preset(conn, environment_preset_id)?
.filter(|record| record.archived_at.is_none())
.ok_or_else(|| {
format!("未找到可用的浏览器环境预设: {environment_preset_id}")
})?;
}
validate_optional_http_url(url.as_deref(), "浏览器启动地址")
AutomationPayload::BrowserSession { .. } => {
Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string())
}
}
}
fn retire_legacy_browser_automation_jobs(conn: &Connection) -> Result<usize, String> {
let jobs = AutomationJobDao::list(conn).map_err(|e| format!("查询自动化任务失败: {e}"))?;
let mut retired_job_count = 0usize;
for mut job in jobs {
if !is_browser_session_payload(&job.payload) {
continue;
}
retire_browser_automation_job(conn, &mut job, BROWSER_AUTOMATION_RETIRED_LAST_ERROR)?;
retired_job_count = retired_job_count.saturating_add(1);
}
Ok(retired_job_count)
}
fn retire_browser_automation_job(
conn: &Connection,
job: &mut AutomationJobRecord,
reason: &str,
) -> Result<(), String> {
let retired_at = Utc::now().to_rfc3339();
let previous_running_started_at = job.running_started_at.clone();
job.enabled = false;
job.next_run_at = None;
job.last_status = Some("error".to_string());
job.last_error = Some(reason.to_string());
if job.last_run_at.is_none() {
job.last_run_at = previous_running_started_at.clone();
}
if previous_running_started_at.is_some() {
job.last_finished_at = Some(retired_at.clone());
}
job.running_started_at = None;
job.auto_disabled_until = None;
job.updated_at = retired_at;
AutomationJobDao::update(conn, job).map_err(|e| format!("更新自动化任务失败: {e}"))
}
fn build_tracker_start_metadata(job: &AutomationJobRecord) -> Value {
let mut metadata = Map::from_iter([
("job_id".to_string(), Value::String(job.id.clone())),
@@ -1165,11 +1190,6 @@ pub(super) fn append_payload_tracking_metadata(metadata: &mut Map<String, Value>
mod tests {
use super::*;
use crate::database::schema::create_tables;
use crate::services::browser_environment_service::{
save_browser_environment_preset, SaveBrowserEnvironmentPresetInput,
};
use crate::services::browser_profile_service::{save_browser_profile, SaveBrowserProfileInput};
use lime_core::database::dao::browser_profile::BrowserProfileTransportKind;
use rusqlite::Connection;
fn setup_db() -> Connection {
@@ -1179,72 +1199,22 @@ mod tests {
}
#[test]
fn validate_payload_with_conn_should_accept_browser_session_payload() {
fn validate_payload_with_conn_should_reject_browser_session_payload() {
let conn = setup_db();
let profile = save_browser_profile(
&conn,
SaveBrowserProfileInput {
id: None,
profile_key: "shop_us".to_string(),
name: "美区店铺".to_string(),
description: None,
site_scope: None,
launch_url: Some("https://seller.example.com".to_string()),
transport_kind: BrowserProfileTransportKind::ManagedCdp,
},
)
.expect("保存浏览器资料失败");
let preset = save_browser_environment_preset(
&conn,
SaveBrowserEnvironmentPresetInput {
id: None,
name: "美区桌面".to_string(),
description: None,
proxy_server: None,
timezone_id: Some("America/Los_Angeles".to_string()),
locale: Some("en-US".to_string()),
accept_language: Some("en-US,en;q=0.9".to_string()),
geolocation_lat: None,
geolocation_lng: None,
geolocation_accuracy_m: None,
user_agent: None,
platform: None,
viewport_width: Some(1440),
viewport_height: Some(900),
device_scale_factor: Some(2.0),
},
)
.expect("保存浏览器环境预设失败");
let payload = AutomationPayload::BrowserSession {
profile_id: profile.id,
profile_id: "profile-1".to_string(),
profile_key: Some("shop_us".to_string()),
url: Some("https://seller.example.com/dashboard".to_string()),
environment_preset_id: Some(preset.id),
environment_preset_id: Some("preset-1".to_string()),
target_id: None,
open_window: false,
stream_mode: BrowserStreamMode::Events,
};
validate_payload_with_conn(&conn, &payload).expect("浏览器任务负载校验失败");
}
#[test]
fn validate_payload_with_conn_should_reject_missing_browser_profile() {
let conn = setup_db();
let payload = AutomationPayload::BrowserSession {
profile_id: "missing-profile".to_string(),
profile_key: Some("shop_us".to_string()),
url: Some("https://seller.example.com/dashboard".to_string()),
environment_preset_id: None,
target_id: None,
open_window: false,
stream_mode: BrowserStreamMode::Events,
};
let error =
validate_payload_with_conn(&conn, &payload).expect_err("缺失浏览器资料时应返回错误");
assert!(error.contains("未找到可用的浏览器资料"));
assert_eq!(
validate_payload_with_conn(&conn, &payload),
Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string())
);
}
#[test]
@@ -1306,6 +1276,103 @@ mod tests {
assert_eq!(metadata.get("session_id"), Some(&json!("session-1")));
}
#[test]
fn retire_legacy_browser_automation_jobs_should_disable_browser_session_jobs() {
let conn = setup_db();
let browser_job = AutomationJob {
id: "job-browser-1".to_string(),
name: "浏览器巡检".to_string(),
description: Some("旧浏览器自动化".to_string()),
enabled: true,
workspace_id: "workspace-1".to_string(),
execution_mode: AutomationExecutionMode::Intelligent,
schedule: TaskSchedule::Every { every_secs: 300 },
payload: json!({
"kind": "browser_session",
"profile_id": "profile-1",
"profile_key": "shop_us",
"url": "https://seller.example.com/dashboard",
"open_window": false,
"stream_mode": "events"
}),
delivery: DeliveryConfig::default(),
timeout_secs: None,
max_retries: 3,
next_run_at: Some("2026-03-16T00:05:00Z".to_string()),
last_status: Some("running".to_string()),
last_error: None,
last_run_at: Some("2026-03-16T00:00:00Z".to_string()),
last_finished_at: None,
running_started_at: Some("2026-03-16T00:00:00Z".to_string()),
consecutive_failures: 0,
last_retry_count: 0,
auto_disabled_until: None,
last_delivery: None,
created_at: "2026-03-16T00:00:00Z".to_string(),
updated_at: "2026-03-16T00:00:00Z".to_string(),
};
let agent_job = AutomationJob {
id: "job-agent-1".to_string(),
name: "日报摘要".to_string(),
description: None,
enabled: true,
workspace_id: "workspace-1".to_string(),
execution_mode: AutomationExecutionMode::Skill,
schedule: TaskSchedule::Cron {
expr: "0 9 * * *".to_string(),
tz: Some("Asia/Shanghai".to_string()),
},
payload: json!({
"kind": "agent_turn",
"prompt": "请输出日报摘要",
"web_search": false
}),
delivery: DeliveryConfig::default(),
timeout_secs: None,
max_retries: 3,
next_run_at: Some("2026-03-16T09:00:00Z".to_string()),
last_status: Some("success".to_string()),
last_error: None,
last_run_at: Some("2026-03-16T08:59:00Z".to_string()),
last_finished_at: Some("2026-03-16T09:00:05Z".to_string()),
running_started_at: None,
consecutive_failures: 0,
last_retry_count: 0,
auto_disabled_until: None,
last_delivery: None,
created_at: "2026-03-16T00:00:00Z".to_string(),
updated_at: "2026-03-16T00:00:00Z".to_string(),
};
AutomationJobDao::create(&conn, &browser_job).expect("创建浏览器任务失败");
AutomationJobDao::create(&conn, &agent_job).expect("创建 agent 任务失败");
let retired_job_count =
retire_legacy_browser_automation_jobs(&conn).expect("停用遗留浏览器任务失败");
assert_eq!(retired_job_count, 1);
let updated_browser = AutomationJobDao::get(&conn, "job-browser-1")
.expect("读取浏览器任务失败")
.expect("浏览器任务不存在");
assert!(!updated_browser.enabled);
assert_eq!(updated_browser.next_run_at, None);
assert_eq!(updated_browser.running_started_at, None);
assert_eq!(updated_browser.last_status.as_deref(), Some("error"));
assert_eq!(
updated_browser.last_error.as_deref(),
Some(BROWSER_AUTOMATION_RETIRED_LAST_ERROR)
);
let updated_agent = AutomationJobDao::get(&conn, "job-agent-1")
.expect("读取 agent 任务失败")
.expect("agent 任务不存在");
assert!(updated_agent.enabled);
assert_eq!(
updated_agent.next_run_at.as_deref(),
Some("2026-03-16T09:00:00Z")
);
}
#[test]
fn build_tracker_finish_metadata_should_include_delivery_summary() {
let job = AutomationJob {
@@ -1544,4 +1611,22 @@ mod tests {
Err("自动化任务 request_metadata 必须为对象".to_string())
);
}
#[test]
fn validate_payload_should_reject_browser_session_payload() {
let payload = AutomationPayload::BrowserSession {
profile_id: "profile-1".to_string(),
profile_key: Some("shop_us".to_string()),
url: Some("https://seller.example.com/dashboard".to_string()),
environment_preset_id: None,
target_id: None,
open_window: false,
stream_mode: BrowserStreamMode::Events,
};
assert_eq!(
validate_payload(&payload),
Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string())
);
}
}
@@ -8,6 +8,7 @@ use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tauri::{AppHandle, Manager};
const SETTINGS_SUBDIR: &str = "connectors";
@@ -26,6 +27,12 @@ const SYSTEM_CONNECTOR_DEFINITIONS: [(&str, &str, &str); 5] = [
("contacts", "通讯录", "搜索、读取和创建联系人。"),
];
const AUTH_STATUS_NOT_DETERMINED: &str = "not_determined";
const AUTH_STATUS_AUTHORIZED: &str = "authorized";
const AUTH_STATUS_DENIED: &str = "denied";
const AUTH_STATUS_ERROR: &str = "error";
const AUTH_STATUS_UNSUPPORTED: &str = "unsupported";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserConnectorAutoConfig {
#[serde(rename = "serverUrl")]
@@ -67,6 +74,10 @@ pub struct SystemConnectorSnapshot {
pub description: String,
pub enabled: bool,
pub available: bool,
pub visible: bool,
pub authorization_status: String,
pub last_error: Option<String>,
pub capabilities: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -81,10 +92,24 @@ pub struct BrowserConnectorSettingsSnapshot {
struct BrowserConnectorSettingsRecord {
enabled: bool,
install_root_dir: Option<String>,
system_connectors: HashMap<String, bool>,
system_connectors: HashMap<String, StoredSystemConnectorState>,
updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
enum StoredSystemConnectorState {
LegacyBool(bool),
Detailed(SystemConnectorStateRecord),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SystemConnectorStateRecord {
enabled: bool,
authorization_status: String,
last_error: Option<String>,
}
#[derive(Debug, Clone)]
struct ManifestInfo {
name: String,
@@ -96,7 +121,7 @@ impl Default for BrowserConnectorSettingsRecord {
Self {
enabled: true,
install_root_dir: None,
system_connectors: default_system_connector_states(),
system_connectors: default_system_connector_state_records(),
updated_at: Utc::now().to_rfc3339(),
}
}
@@ -109,6 +134,146 @@ fn default_system_connector_states() -> HashMap<String, bool> {
.collect()
}
fn default_system_connector_state_records() -> HashMap<String, StoredSystemConnectorState> {
default_system_connector_states()
.into_iter()
.map(|(id, enabled)| (id, StoredSystemConnectorState::LegacyBool(enabled)))
.collect()
}
fn default_connector_record(enabled: bool) -> SystemConnectorStateRecord {
SystemConnectorStateRecord {
enabled,
authorization_status: if cfg!(target_os = "macos") {
AUTH_STATUS_NOT_DETERMINED.to_string()
} else {
AUTH_STATUS_UNSUPPORTED.to_string()
},
last_error: None,
}
}
fn normalize_connector_record(
state: Option<&StoredSystemConnectorState>,
) -> SystemConnectorStateRecord {
match state {
Some(StoredSystemConnectorState::LegacyBool(enabled)) => default_connector_record(*enabled),
Some(StoredSystemConnectorState::Detailed(record)) => record.clone(),
None => default_connector_record(false),
}
}
fn connector_capabilities(id: &str) -> Vec<String> {
match id {
"reminders" => vec![
"list_reminders".to_string(),
"create_reminder".to_string(),
"update_reminder".to_string(),
],
"calendar" => vec![
"list_events".to_string(),
"create_event".to_string(),
"update_event".to_string(),
],
"notes" => vec![
"list_notes".to_string(),
"read_note".to_string(),
"create_note".to_string(),
],
"mail" => vec![
"list_mailboxes".to_string(),
"read_messages".to_string(),
"create_draft".to_string(),
],
"contacts" => vec![
"search_contacts".to_string(),
"read_contact".to_string(),
"create_contact".to_string(),
],
_ => Vec::new(),
}
}
#[cfg(target_os = "macos")]
fn connector_probe_script(id: &str) -> Option<&'static str> {
match id {
"reminders" => Some(r#"tell application id "com.apple.reminders" to count of lists"#),
"calendar" => Some(r#"tell application id "com.apple.iCal" to count of calendars"#),
"notes" => Some(r#"tell application id "com.apple.Notes" to count of folders"#),
"mail" => Some(r#"tell application id "com.apple.mail" to count of mailboxes"#),
"contacts" => Some(r#"tell application id "com.apple.AddressBook" to count of people"#),
_ => None,
}
}
fn truncate_connector_error(input: &str) -> String {
input
.trim()
.split('\n')
.find(|line| !line.trim().is_empty())
.unwrap_or(input)
.trim()
.to_string()
}
#[cfg(target_os = "macos")]
fn request_connector_authorization(id: &str) -> Result<SystemConnectorStateRecord, String> {
let script = connector_probe_script(id).ok_or_else(|| format!("未知的系统连接器: {id}"))?;
let output = Command::new("osascript")
.args(["-e", script])
.output()
.map_err(|error| format!("调用 osascript 失败: {error}"))?;
if output.status.success() {
return Ok(SystemConnectorStateRecord {
enabled: true,
authorization_status: AUTH_STATUS_AUTHORIZED.to_string(),
last_error: None,
});
}
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let combined = format!("{stderr}\n{stdout}");
let normalized = combined.to_ascii_lowercase();
let last_error = truncate_connector_error(&combined);
if normalized.contains("not authorized")
|| normalized.contains("not permitted")
|| normalized.contains("(-1743)")
|| normalized.contains("1743")
{
return Ok(SystemConnectorStateRecord {
enabled: false,
authorization_status: AUTH_STATUS_DENIED.to_string(),
last_error: Some(if last_error.is_empty() {
"系统已拒绝该连接器的自动化权限。".to_string()
} else {
last_error
}),
});
}
Ok(SystemConnectorStateRecord {
enabled: false,
authorization_status: AUTH_STATUS_ERROR.to_string(),
last_error: Some(if last_error.is_empty() {
"系统连接器授权失败。".to_string()
} else {
last_error
}),
})
}
#[cfg(not(target_os = "macos"))]
fn request_connector_authorization(_id: &str) -> Result<SystemConnectorStateRecord, String> {
Ok(SystemConnectorStateRecord {
enabled: false,
authorization_status: AUTH_STATUS_UNSUPPORTED.to_string(),
last_error: Some("当前平台暂不支持系统连接器。".to_string()),
})
}
fn browser_connector_settings_path() -> Result<PathBuf, String> {
Ok(lime_core::app_paths::preferred_data_dir()
.map_err(|error| format!("获取应用数据目录失败: {error}"))?
@@ -135,7 +300,10 @@ fn load_settings_record() -> Result<BrowserConnectorSettingsRecord, String> {
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);
record
.system_connectors
.entry(id)
.or_insert(StoredSystemConnectorState::LegacyBool(enabled));
}
Ok(record)
@@ -406,16 +574,28 @@ fn build_settings_snapshot(
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(),
system_connectors: if cfg!(target_os = "macos") {
SYSTEM_CONNECTOR_DEFINITIONS
.iter()
.map(|(id, label, description)| {
let connector_record =
normalize_connector_record(record.system_connectors.get(*id));
SystemConnectorSnapshot {
id: (*id).to_string(),
label: (*label).to_string(),
description: (*description).to_string(),
enabled: connector_record.enabled,
available: true,
visible: true,
authorization_status: connector_record.authorization_status,
last_error: connector_record.last_error,
capabilities: connector_capabilities(id),
}
})
.collect()
} else {
Vec::new()
},
}
}
@@ -454,7 +634,23 @@ pub fn update_system_connector_enabled(
}
let mut record = load_settings_record()?;
record.system_connectors.insert(id.to_string(), enabled);
let next_state = if enabled {
request_connector_authorization(id)?
} else {
let mut current = normalize_connector_record(record.system_connectors.get(id));
current.enabled = false;
current.last_error = None;
if !cfg!(target_os = "macos") {
current.authorization_status = AUTH_STATUS_UNSUPPORTED.to_string();
} else if current.authorization_status == AUTH_STATUS_ERROR {
current.authorization_status = AUTH_STATUS_NOT_DETERMINED.to_string();
}
current
};
record.system_connectors.insert(
id.to_string(),
StoredSystemConnectorState::Detailed(next_state),
);
record.updated_at = Utc::now().to_rfc3339();
save_settings_record(&record)?;
Ok(build_settings_snapshot(&record))
@@ -520,4 +716,37 @@ mod tests {
assert_eq!(status.installed_version.as_deref(), Some("1.0.0"));
assert_eq!(status.bundled_version, "1.1.0");
}
#[cfg(target_os = "macos")]
#[test]
fn settings_snapshot_should_expose_visible_system_connectors_on_macos() {
let record = BrowserConnectorSettingsRecord::default();
let snapshot = build_settings_snapshot(&record);
assert_eq!(
snapshot.system_connectors.len(),
SYSTEM_CONNECTOR_DEFINITIONS.len()
);
assert!(snapshot
.system_connectors
.iter()
.all(|connector| connector.visible));
assert!(snapshot
.system_connectors
.iter()
.all(|connector| connector.available));
assert!(snapshot
.system_connectors
.iter()
.all(|connector| !connector.capabilities.is_empty()));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn settings_snapshot_should_hide_system_connectors_on_non_macos() {
let record = BrowserConnectorSettingsRecord::default();
let snapshot = build_settings_snapshot(&record);
assert!(snapshot.system_connectors.is_empty());
}
}
+1
View File
@@ -36,6 +36,7 @@ pub mod runtime_evidence_pack_service;
pub mod runtime_handoff_artifact_service;
pub mod runtime_replay_case_service;
pub mod runtime_review_decision_service;
pub mod site_adapter_import_service;
pub mod site_adapter_registry;
pub mod site_capability_service;
pub mod sysinfo_service;
File diff suppressed because it is too large Load Diff
+357 -31
View File
@@ -7,6 +7,7 @@ use std::fs;
use std::path::{Path, PathBuf};
const BUNDLED_ADAPTER_RELATIVE_DIR: &str = "resources/site-adapters/bundled";
const IMPORTED_ADAPTER_RELATIVE_DIR: &str = "site-adapters/imported";
const SERVER_SYNCED_ADAPTER_RELATIVE_DIR: &str = "site-adapters/server-synced";
const BUNDLED_INDEX_FALLBACK: &str =
include_str!("../../resources/site-adapters/bundled/index.json");
@@ -35,6 +36,7 @@ impl SiteAdapterArgType {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SiteAdapterSourceKind {
Bundled,
Imported,
ServerSynced,
}
@@ -42,6 +44,7 @@ impl SiteAdapterSourceKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Bundled => "bundled",
Self::Imported => "imported",
Self::ServerSynced => "server_synced",
}
}
@@ -81,7 +84,7 @@ pub struct SiteAdapterSpec {
#[derive(Debug, Deserialize, Serialize)]
struct SiteAdapterRegistryDocument {
#[serde(default = "default_registry_version")]
#[serde(default = "default_registry_version", alias = "registryVersion")]
registry_version: u32,
#[serde(default, alias = "catalogVersion", alias = "version")]
catalog_version: Option<String>,
@@ -118,6 +121,7 @@ struct SiteAdapterArgManifest {
name: String,
description: String,
required: bool,
#[serde(alias = "argType")]
arg_type: SiteAdapterArgTypeManifest,
#[serde(default)]
example: Option<Value>,
@@ -175,7 +179,7 @@ pub struct SiteAdapterCatalogStatus {
#[derive(Debug, Deserialize)]
struct SiteAdapterCatalogBootstrapDocument {
#[serde(default = "default_registry_version")]
#[serde(default = "default_registry_version", alias = "registryVersion")]
registry_version: u32,
#[serde(default, alias = "catalogVersion", alias = "version")]
catalog_version: Option<String>,
@@ -203,7 +207,8 @@ struct SiteAdapterCatalogBootstrapEntry {
entry: SiteAdapterEntryManifest,
#[serde(default, alias = "sourceVersion")]
source_version: Option<String>,
script: String,
#[serde(default)]
script: Option<String>,
}
pub fn normalize_site_adapter_name(value: &str) -> String {
@@ -212,6 +217,9 @@ pub fn normalize_site_adapter_name(value: &str) -> String {
pub fn load_site_adapter_specs() -> Result<Vec<SiteAdapterSpec>, String> {
let mut merged = BTreeMap::new();
for spec in load_imported_site_adapters()? {
merged.insert(normalize_site_adapter_name(&spec.name), spec);
}
for spec in load_bundled_site_adapters()? {
merged.insert(normalize_site_adapter_name(&spec.name), spec);
}
@@ -230,7 +238,26 @@ pub fn find_site_adapter_spec(name: &str) -> Result<Option<SiteAdapterSpec>, Str
}
pub fn get_site_adapter_catalog_status() -> Result<SiteAdapterCatalogStatus, String> {
get_site_adapter_catalog_status_from_dir(resolve_server_synced_adapter_dir())
let server_synced = get_site_adapter_catalog_status_from_dir(
resolve_server_synced_adapter_dir(),
SiteAdapterSourceKind::ServerSynced,
)?;
if server_synced.exists {
return Ok(server_synced);
}
let imported = get_site_adapter_catalog_status_from_dir(
resolve_imported_adapter_dir(),
SiteAdapterSourceKind::Imported,
)?;
if imported.exists {
return Ok(imported);
}
Ok(empty_site_adapter_catalog_status(
SiteAdapterSourceKind::Bundled,
None,
))
}
pub fn apply_site_adapter_catalog_bootstrap(
@@ -243,7 +270,9 @@ pub fn apply_site_adapter_catalog_bootstrap(
}
pub fn clear_site_adapter_catalog_cache() -> Result<SiteAdapterCatalogStatus, String> {
clear_site_adapter_catalog_cache_at_dir(resolve_server_synced_adapter_dir())
clear_site_adapter_catalog_cache_at_dir(resolve_server_synced_adapter_dir())?;
clear_site_adapter_catalog_cache_at_dir(resolve_imported_adapter_dir())?;
get_site_adapter_catalog_status()
}
pub fn build_entry_url(
@@ -275,35 +304,27 @@ fn load_server_synced_site_adapters() -> Result<Vec<SiteAdapterSpec>, String> {
load_site_adapters_from_dir(&dir, SiteAdapterSourceKind::ServerSynced)
}
fn load_imported_site_adapters() -> Result<Vec<SiteAdapterSpec>, String> {
let Some(dir) = resolve_imported_adapter_dir() else {
return Ok(Vec::new());
};
if !dir.exists() {
return Ok(Vec::new());
}
load_site_adapters_from_dir(&dir, SiteAdapterSourceKind::Imported)
}
fn get_site_adapter_catalog_status_from_dir(
dir: Option<PathBuf>,
source_kind: SiteAdapterSourceKind,
) -> Result<SiteAdapterCatalogStatus, String> {
let directory = dir.as_ref().map(|value| value.display().to_string());
let Some(dir) = dir else {
return Ok(SiteAdapterCatalogStatus {
exists: false,
source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(),
registry_version: default_registry_version(),
directory,
catalog_version: None,
tenant_id: None,
synced_at: None,
adapter_count: 0,
});
return Ok(empty_site_adapter_catalog_status(source_kind, None));
};
let index_path = dir.join("index.json");
if !index_path.exists() {
return Ok(SiteAdapterCatalogStatus {
exists: false,
source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(),
registry_version: default_registry_version(),
directory,
catalog_version: None,
tenant_id: None,
synced_at: None,
adapter_count: 0,
});
return Ok(empty_site_adapter_catalog_status(source_kind, Some(dir)));
}
let content = fs::read_to_string(&index_path)
@@ -313,9 +334,9 @@ fn get_site_adapter_catalog_status_from_dir(
Ok(SiteAdapterCatalogStatus {
exists: true,
source_kind: SiteAdapterSourceKind::ServerSynced.as_str().to_string(),
source_kind: source_kind.as_str().to_string(),
registry_version: document.registry_version,
directory,
directory: Some(dir.display().to_string()),
catalog_version: document.catalog_version,
tenant_id: document.tenant_id,
synced_at: document.synced_at,
@@ -331,7 +352,10 @@ fn apply_site_adapter_catalog_bootstrap_to_dir(
.ok_or_else(|| "payload 中未找到 siteAdapterCatalog".to_string())?;
let document = parse_site_adapter_catalog_bootstrap_document(catalog_value)?;
write_server_synced_catalog_to_dir(dir, document)?;
get_site_adapter_catalog_status_from_dir(Some(dir.to_path_buf()))
get_site_adapter_catalog_status_from_dir(
Some(dir.to_path_buf()),
SiteAdapterSourceKind::ServerSynced,
)
}
fn clear_site_adapter_catalog_cache_at_dir(
@@ -356,7 +380,23 @@ fn clear_site_adapter_catalog_cache_at_dir(
.map_err(|error| format!("清理站点适配器缓存失败 {}: {error}", dir.display()))?;
}
get_site_adapter_catalog_status_from_dir(Some(dir))
get_site_adapter_catalog_status_from_dir(Some(dir), SiteAdapterSourceKind::ServerSynced)
}
fn empty_site_adapter_catalog_status(
source_kind: SiteAdapterSourceKind,
dir: Option<PathBuf>,
) -> SiteAdapterCatalogStatus {
SiteAdapterCatalogStatus {
exists: false,
source_kind: source_kind.as_str().to_string(),
registry_version: default_registry_version(),
directory: dir.map(|value| value.display().to_string()),
catalog_version: None,
tenant_id: None,
synced_at: None,
adapter_count: 0,
}
}
fn load_site_adapters_from_dir(
@@ -524,7 +564,7 @@ fn write_server_synced_catalog_to_dir(
return Err(format!("站点适配器重复: {}", entry.name));
}
let script = normalize_required_text(&entry.script, "script")?;
let script = resolve_server_synced_entry_script(&entry)?;
let script_file = build_server_synced_script_file(&entry.name);
let script_path = dir.join(&script_file);
if let Some(parent) = script_path.parent() {
@@ -569,6 +609,39 @@ fn write_server_synced_catalog_to_dir(
.map_err(|error| format!("写入站点适配器索引失败 {}: {error}", index_path.display()))
}
fn resolve_server_synced_entry_script(
entry: &SiteAdapterCatalogBootstrapEntry,
) -> Result<String, String> {
if let Some(script) = entry
.script
.as_ref()
.and_then(|value| normalize_optional_text(Some(value.clone())))
{
return Ok(script);
}
let bundled_manifest = find_embedded_bundled_manifest_entry(&entry.name)?.ok_or_else(|| {
format!(
"站点适配器 {} 缺少 script,且未命中 bundled 回退",
entry.name
)
})?;
load_embedded_bundled_script(&bundled_manifest.script_file).map(|value| value.to_string())
}
fn find_embedded_bundled_manifest_entry(
adapter_name: &str,
) -> Result<Option<SiteAdapterManifestEntry>, String> {
let document: SiteAdapterRegistryDocument = serde_json::from_str(BUNDLED_INDEX_FALLBACK)
.map_err(|error| format!("解析内置站点适配器索引失败: {error}"))?;
let normalized_name = normalize_site_adapter_name(adapter_name);
Ok(document
.adapters
.into_iter()
.find(|entry| normalize_site_adapter_name(&entry.name) == normalized_name))
}
fn load_embedded_bundled_script(script_file: &str) -> Result<&'static str, String> {
match script_file {
"scripts/36kr-newsflash.js" => Ok(include_str!(
@@ -583,6 +656,18 @@ fn load_embedded_bundled_script(script_file: &str) -> Result<&'static str, Strin
"scripts/github-search.js" => Ok(include_str!(
"../../resources/site-adapters/bundled/scripts/github-search.js"
)),
"scripts/linux-do-categories.js" => Ok(include_str!(
"../../resources/site-adapters/bundled/scripts/linux-do-categories.js"
)),
"scripts/linux-do-hot.js" => Ok(include_str!(
"../../resources/site-adapters/bundled/scripts/linux-do-hot.js"
)),
"scripts/smzdm-search.js" => Ok(include_str!(
"../../resources/site-adapters/bundled/scripts/smzdm-search.js"
)),
"scripts/yahoo-finance-quote.js" => Ok(include_str!(
"../../resources/site-adapters/bundled/scripts/yahoo-finance-quote.js"
)),
"scripts/zhihu-hot.js" => Ok(include_str!(
"../../resources/site-adapters/bundled/scripts/zhihu-hot.js"
)),
@@ -610,6 +695,12 @@ fn resolve_server_synced_adapter_dir() -> Option<PathBuf> {
.map(|root| root.join(SERVER_SYNCED_ADAPTER_RELATIVE_DIR))
}
pub(crate) fn resolve_imported_adapter_dir() -> Option<PathBuf> {
lime_core::app_paths::preferred_data_dir()
.ok()
.map(|root| root.join(IMPORTED_ADAPTER_RELATIVE_DIR))
}
fn resolve_packaged_resource_root() -> Option<PathBuf> {
let mut path = std::env::current_exe().ok()?;
path.pop();
@@ -784,8 +875,15 @@ fn default_registry_version() -> u32 {
#[cfg(test)]
mod tests {
use super::*;
use crate::services::site_adapter_import_service::{
compile_imported_yaml_adapter_bundle, persist_compiled_imported_adapters,
ImportedYamlCompileOptions,
};
use tempfile::tempdir;
const REAL_WORLD_IMPORTED_ADAPTER_BUNDLE_FIXTURE: &str =
include_str!("../../tests/fixtures/site-adapters/imported-real-world-bundle.yaml");
#[test]
fn should_load_bundled_registry_from_resources() {
let adapters = load_bundled_site_adapters().expect("bundled adapters should load");
@@ -801,6 +899,72 @@ mod tests {
assert!(github.script.contains("a.v-align-middle"));
}
#[test]
fn should_load_selected_bundled_market_finance_and_community_adapters_from_embedded_index() {
let adapters = load_site_adapters_from_embedded_index(SiteAdapterSourceKind::Bundled)
.expect("embedded bundled adapters should load");
let linux_do_hot = adapters
.iter()
.find(|adapter| adapter.name == "linux-do/hot")
.expect("linux-do/hot should exist");
assert_eq!(linux_do_hot.source_kind, SiteAdapterSourceKind::Bundled);
assert_eq!(linux_do_hot.source_version.as_deref(), Some("2026-03-28"));
assert!(matches!(
linux_do_hot.entry,
SiteAdapterEntrySpec::FixedUrl { ref url } if url == "https://linux.do"
));
assert_eq!(
linux_do_hot.auth_hint.as_deref(),
Some("请先在浏览器中登录 linux.do,再重试该命令。")
);
assert!(linux_do_hot.script.contains("/top.json?period="));
let linux_do_categories = adapters
.iter()
.find(|adapter| adapter.name == "linux-do/categories")
.expect("linux-do/categories should exist");
assert_eq!(
linux_do_categories.source_kind,
SiteAdapterSourceKind::Bundled
);
assert_eq!(
linux_do_categories.source_version.as_deref(),
Some("2026-03-28")
);
assert!(matches!(
linux_do_categories.entry,
SiteAdapterEntrySpec::FixedUrl { ref url } if url == "https://linux.do"
));
assert!(linux_do_categories.script.contains("/categories.json"));
let yahoo = adapters
.iter()
.find(|adapter| adapter.name == "yahoo-finance/quote")
.expect("yahoo-finance/quote should exist");
assert_eq!(yahoo.source_kind, SiteAdapterSourceKind::Bundled);
assert_eq!(yahoo.source_version.as_deref(), Some("2026-03-28"));
assert!(matches!(
yahoo.entry,
SiteAdapterEntrySpec::UrlTemplate { ref template }
if template == "https://finance.yahoo.com/quote/{{symbol|urlencode}}/"
));
assert!(yahoo.script.contains("query1.finance.yahoo.com"));
let smzdm = adapters
.iter()
.find(|adapter| adapter.name == "smzdm/search")
.expect("smzdm/search should exist");
assert_eq!(smzdm.source_kind, SiteAdapterSourceKind::Bundled);
assert_eq!(smzdm.source_version.as_deref(), Some("2026-03-28"));
assert!(matches!(
smzdm.entry,
SiteAdapterEntrySpec::UrlTemplate { ref template }
if template == "https://search.smzdm.com/?c=home&s={{query|urlencode}}&v=b"
));
assert!(smzdm.script.contains("li.feed-row-wide"));
}
#[test]
fn should_render_url_template_with_urlencode() {
let spec = SiteAdapterSpec {
@@ -876,6 +1040,114 @@ mod tests {
assert_eq!(adapters[0].source_version.as_deref(), Some("sync-1"));
}
#[test]
fn should_load_imported_adapters_from_imported_catalog() {
let temp_dir = tempdir().expect("temp dir should exist");
let dir = temp_dir.path();
fs::create_dir_all(dir.join("scripts")).expect("scripts dir should exist");
fs::write(
dir.join("index.json"),
r#"
{
"catalog_version": "imported-catalog-1",
"adapters": [
{
"name": "reddit/hot",
"domain": "www.reddit.com",
"description": "imported reddit hot",
"read_only": true,
"capabilities": ["research", "hot"],
"args": [],
"example": "reddit/hot {}",
"entry": {
"kind": "fixed_url",
"url": "https://www.reddit.com"
},
"script_file": "scripts/reddit-hot.js",
"source_version": "imported-1"
}
]
}
"#,
)
.expect("index should write");
fs::write(
dir.join("scripts/reddit-hot.js"),
"async () => ({ ok: true, data: { items: [] } })",
)
.expect("script should write");
let adapters = load_site_adapters_from_dir(dir, SiteAdapterSourceKind::Imported)
.expect("imported adapters should load");
assert_eq!(adapters.len(), 1);
assert_eq!(adapters[0].name, "reddit/hot");
assert_eq!(adapters[0].source_kind, SiteAdapterSourceKind::Imported);
assert_eq!(adapters[0].source_version.as_deref(), Some("imported-1"));
}
#[test]
fn should_load_real_world_imported_bundle_persisted_by_import_service() {
let temp_dir = tempdir().expect("temp dir should exist");
let adapters = compile_imported_yaml_adapter_bundle(
REAL_WORLD_IMPORTED_ADAPTER_BUNDLE_FIXTURE,
&ImportedYamlCompileOptions {
read_only: true,
source_version: Some("fixture-real-world".to_string()),
},
)
.expect("real world bundle should compile");
let persist_result = persist_compiled_imported_adapters(
temp_dir.path(),
&adapters,
Some("fixture-imported-catalog".to_string()),
)
.expect("real world bundle should persist");
assert_eq!(persist_result.adapter_count, 5);
let status = get_site_adapter_catalog_status_from_dir(
Some(temp_dir.path().to_path_buf()),
SiteAdapterSourceKind::Imported,
)
.expect("imported catalog status should load");
assert!(status.exists);
assert_eq!(status.source_kind, "imported");
assert_eq!(
status.catalog_version.as_deref(),
Some("fixture-imported-catalog")
);
assert_eq!(status.adapter_count, 5);
let loaded = load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::Imported)
.expect("imported adapters should load");
assert_eq!(loaded.len(), 5);
let yahoo_quote = loaded
.iter()
.find(|adapter| adapter.name == "yahoo-finance/quote")
.expect("yahoo-finance/quote should exist");
assert_eq!(yahoo_quote.source_kind, SiteAdapterSourceKind::Imported);
assert_eq!(
yahoo_quote.source_version.as_deref(),
Some("fixture-real-world")
);
assert!(matches!(
yahoo_quote.entry,
SiteAdapterEntrySpec::UrlTemplate { ref template }
if template == "https://finance.yahoo.com/quote/{{symbol|urlencode}}/"
));
assert_eq!(
yahoo_quote.auth_hint.as_deref(),
Some("该适配器依赖已有浏览器上下文,必要时请先在目标站点完成登录。")
);
let smzdm_search = loaded
.iter()
.find(|adapter| adapter.name == "smzdm/search")
.expect("smzdm/search should exist");
assert!(smzdm_search.capabilities.contains(&"search".to_string()));
}
#[test]
fn should_upgrade_legacy_server_synced_github_search_fixed_url_to_template() {
let temp_dir = tempdir().expect("temp dir should exist");
@@ -1018,6 +1290,60 @@ mod tests {
assert_eq!(adapters[0].source_version.as_deref(), Some("tenant-sync-1"));
}
#[test]
fn should_persist_server_synced_bootstrap_catalog_with_bundled_script_fallback() {
let temp_dir = tempdir().expect("temp dir should exist");
let payload = serde_json::json!({
"siteAdapterCatalog": {
"registryVersion": 1,
"catalogVersion": "tenant-sync-2",
"tenantId": "tenant-demo",
"syncedAt": "2026-03-28T10:00:00.000Z",
"adapters": [
{
"name": "github/search",
"domain": "github.com",
"description": "server synced github search",
"readOnly": true,
"capabilities": ["search"],
"args": [
{
"name": "query",
"description": "搜索关键词",
"required": true,
"argType": "string",
"example": "lime"
}
],
"example": "github/search {\"query\":\"lime\"}",
"entry": {
"kind": "url_template",
"template": "https://github.com/search?q={{query|urlencode}}&type=repositories"
},
"sourceVersion": "tenant-sync-2"
}
]
}
});
let status = apply_site_adapter_catalog_bootstrap_to_dir(temp_dir.path(), &payload)
.expect("bootstrap catalog should persist with bundled fallback");
assert!(status.exists);
assert_eq!(status.catalog_version.as_deref(), Some("tenant-sync-2"));
let script_content = fs::read_to_string(temp_dir.path().join("scripts/github-search.js"))
.expect("fallback bundled script should be written");
assert!(script_content.contains("helpers.uniqueBy"));
let adapters =
load_site_adapters_from_dir(temp_dir.path(), SiteAdapterSourceKind::ServerSynced)
.expect("persisted adapters should load");
assert_eq!(adapters.len(), 1);
assert_eq!(adapters[0].name, "github/search");
assert_eq!(adapters[0].source_version.as_deref(), Some("tenant-sync-2"));
assert_eq!(adapters[0].source_kind, SiteAdapterSourceKind::ServerSynced);
}
#[test]
fn should_clear_server_synced_catalog_cache() {
let temp_dir = tempdir().expect("temp dir should exist");
@@ -69,6 +69,36 @@ pub struct SiteAdapterRecommendation {
pub score: u32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SiteAdapterLaunchReadinessRequest {
pub adapter_name: String,
#[serde(default)]
pub profile_key: Option<String>,
#[serde(default)]
pub target_id: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SiteAdapterLaunchReadinessStatus {
Ready,
RequiresBrowserRuntime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SiteAdapterLaunchReadinessResult {
pub status: SiteAdapterLaunchReadinessStatus,
pub adapter: String,
pub domain: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_id: Option<String>,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub report_hint: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RunSiteAdapterRequest {
pub adapter_name: String,
@@ -86,6 +116,10 @@ pub struct RunSiteAdapterRequest {
pub project_id: Option<String>,
#[serde(default)]
pub save_title: Option<String>,
#[serde(default)]
pub require_attached_session: Option<bool>,
#[serde(default)]
pub skill_title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -174,6 +208,14 @@ struct SiteAdapterRecommendationCandidate {
score: u32,
}
#[derive(Debug, Clone)]
struct SiteAdapterAttachedLaunchCandidate {
profile_key: String,
target_id: String,
current_url_matches: bool,
saved_existing_session: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SiteAdapterTransportRoute {
ManagedCdp,
@@ -284,6 +326,23 @@ pub async fn recommend_site_adapters(
))
}
pub async fn get_site_adapter_launch_readiness(
db: &DbConnection,
request: SiteAdapterLaunchReadinessRequest,
) -> Result<SiteAdapterLaunchReadinessResult, String> {
let normalized_name = normalize_site_adapter_name(&request.adapter_name);
let spec = find_site_adapter_spec(&normalized_name)?
.ok_or_else(|| "未找到对应的站点适配器".to_string())?;
resolve_site_adapter_launch_readiness_for_spec(
db,
&spec,
request.profile_key.as_deref(),
request.target_id.as_deref(),
)
.await
}
pub fn build_site_result_document_title(adapter_name: &str, custom_title: Option<&str>) -> String {
let normalized_custom_title = custom_title
.map(str::trim)
@@ -586,9 +645,54 @@ pub async fn run_site_adapter(
}
};
let profile_key =
match resolve_effective_profile_key(db, request.profile_key.as_deref(), &spec.domain).await
let attached_session_readiness = if request.require_attached_session.unwrap_or(false) {
match resolve_site_adapter_launch_readiness_for_spec(
db,
&spec,
request.profile_key.as_deref(),
request.target_id.as_deref(),
)
.await
{
Ok(result) => {
if result.status != SiteAdapterLaunchReadinessStatus::Ready {
return build_error_result(
&spec,
result
.profile_key
.clone()
.unwrap_or_else(|| requested_profile_key.clone()),
None,
result.target_id.clone(),
entry_url,
"attached_session_required",
&result.message,
);
}
Some(result)
}
Err(error) => {
return build_error_result(
&spec,
requested_profile_key.clone(),
None,
None,
entry_url,
"internal_error",
&error,
);
}
}
} else {
None
};
let resolved_request_profile_key = attached_session_readiness
.as_ref()
.and_then(|result| result.profile_key.as_deref())
.or(request.profile_key.as_deref());
let profile_key =
match resolve_effective_profile_key(db, resolved_request_profile_key, &spec.domain).await {
Ok(value) => value,
Err(error) => {
return build_error_result(
@@ -617,6 +721,21 @@ pub async fn run_site_adapter(
);
}
};
if request.require_attached_session.unwrap_or(false)
&& transport_route != SiteAdapterTransportRoute::ExistingSession
{
return build_error_result(
&spec,
profile_key,
None,
attached_session_readiness
.as_ref()
.and_then(|result| result.target_id.clone()),
entry_url,
"attached_session_required",
"当前执行链路没有附着到真实浏览器会话,请先去浏览器工作台连接目标站点后重试。",
);
}
let wrapped_script = match build_wrapped_adapter_script(&spec.script, &args) {
Ok(value) => value,
@@ -633,13 +752,16 @@ pub async fn run_site_adapter(
}
};
let timeout_ms = normalize_timeout_ms(request.timeout_ms);
let resolved_target_id = attached_session_readiness
.and_then(|result| result.target_id)
.or_else(|| normalize_requested_target_id(request.target_id.as_deref()));
match transport_route {
SiteAdapterTransportRoute::ExistingSession => {
run_existing_session_adapter(
&spec,
profile_key,
request.target_id,
resolved_target_id,
entry_url,
timeout_ms,
wrapped_script,
@@ -651,7 +773,7 @@ pub async fn run_site_adapter(
db,
&spec,
profile_key,
request.target_id,
resolved_target_id,
entry_url,
timeout_ms,
wrapped_script,
@@ -759,6 +881,13 @@ fn normalize_requested_profile_key(profile_key: Option<&str>) -> Option<String>
.map(ToString::to_string)
}
fn normalize_requested_target_id(target_id: Option<&str>) -> Option<String> {
target_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn resolve_requested_profile_key(profile_key: Option<&str>) -> String {
normalize_requested_profile_key(profile_key).unwrap_or_else(|| DEFAULT_PROFILE_KEY.to_string())
}
@@ -951,6 +1080,199 @@ async fn resolve_effective_profile_key(
)
}
fn build_site_adapter_launch_readiness_result(
status: SiteAdapterLaunchReadinessStatus,
spec: &SiteAdapterSpec,
profile_key: Option<String>,
target_id: Option<String>,
message: impl Into<String>,
) -> SiteAdapterLaunchReadinessResult {
let report_hint = match status {
SiteAdapterLaunchReadinessStatus::Ready => None,
SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime => {
build_site_adapter_report_hint("attached_session_required")
}
};
SiteAdapterLaunchReadinessResult {
status,
adapter: spec.name.clone(),
domain: spec.domain.clone(),
profile_key,
target_id,
message: message.into(),
report_hint,
}
}
fn build_site_adapter_attached_session_required_message(spec: &SiteAdapterSpec) -> String {
format!(
"当前没有检测到已附着到真实浏览器的 {} 页面,请先去浏览器工作台连接浏览器并打开目标页面。",
spec.domain
)
}
fn build_site_adapter_attached_session_missing_target_message(spec: &SiteAdapterSpec) -> String {
format!(
"已检测到真实浏览器会话,但当前没有命中 {} 的目标标签页;请先打开目标页面后再回到 Claw 执行。",
spec.domain
)
}
fn build_site_adapter_attached_session_ready_message(spec: &SiteAdapterSpec) -> String {
format!(
"已检测到 {} 的真实浏览器页面,Claw 可以直接复用当前会话执行。",
spec.domain
)
}
async fn resolve_site_adapter_launch_readiness_for_spec(
db: &DbConnection,
spec: &SiteAdapterSpec,
profile_key: Option<&str>,
target_id: Option<&str>,
) -> Result<SiteAdapterLaunchReadinessResult, String> {
let normalized_profile_key = normalize_requested_profile_key(profile_key);
let normalized_target_id = normalize_requested_target_id(target_id);
let profiles = load_active_browser_profiles(db)?;
let status_snapshot = chrome_bridge::chrome_bridge_hub()
.get_status_snapshot()
.await;
if let Some(requested_profile_key) = normalized_profile_key.clone() {
let transport = load_profile_transport(db, &requested_profile_key)?;
if transport == Some(BrowserProfileTransportKind::ManagedCdp) {
return Ok(build_site_adapter_launch_readiness_result(
SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime,
spec,
Some(requested_profile_key),
normalized_target_id,
"当前资料属于 Lime 托管浏览器,不允许在 Claw 内静默接管执行;请改走浏览器工作台。",
));
}
let observer = status_snapshot
.observers
.iter()
.find(|item| item.profile_key == requested_profile_key);
if observer.is_none() {
return Ok(build_site_adapter_launch_readiness_result(
SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime,
spec,
Some(requested_profile_key),
normalized_target_id,
build_site_adapter_attached_session_required_message(spec),
));
}
if let Some(explicit_target_id) = normalized_target_id.clone() {
return Ok(build_site_adapter_launch_readiness_result(
SiteAdapterLaunchReadinessStatus::Ready,
spec,
Some(requested_profile_key),
Some(explicit_target_id),
build_site_adapter_attached_session_ready_message(spec),
));
}
let tabs = match load_existing_session_tabs(&requested_profile_key).await {
Ok(result) => result,
Err(error) => {
tracing::debug!(
"[site_capability] readiness 读取 existing_session 标签页失败: profile_key={}, error={}",
requested_profile_key,
error
);
Vec::new()
}
};
let selected_target = select_existing_session_target(&tabs, &spec.domain).map(|tab| tab.id);
let is_ready = selected_target.is_some();
return Ok(build_site_adapter_launch_readiness_result(
if is_ready {
SiteAdapterLaunchReadinessStatus::Ready
} else {
SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime
},
spec,
Some(requested_profile_key),
selected_target,
if is_ready {
build_site_adapter_attached_session_ready_message(spec)
} else {
build_site_adapter_attached_session_missing_target_message(spec)
},
));
}
let mut attached_candidates = Vec::new();
for observer in &status_snapshot.observers {
let transport = profiles
.iter()
.find(|profile| profile.profile_key == observer.profile_key)
.map(|profile| profile.transport_kind);
if transport == Some(BrowserProfileTransportKind::ManagedCdp) {
continue;
}
let tabs = match load_existing_session_tabs(&observer.profile_key).await {
Ok(result) => result,
Err(error) => {
tracing::debug!(
"[site_capability] readiness 读取自动附着标签页失败: profile_key={}, error={}",
observer.profile_key,
error
);
Vec::new()
}
};
let Some(selected_target) = select_existing_session_target(&tabs, &spec.domain) else {
continue;
};
attached_candidates.push(SiteAdapterAttachedLaunchCandidate {
profile_key: observer.profile_key.clone(),
target_id: selected_target.id,
current_url_matches: observer_matches_site_domain(observer, &spec.domain),
saved_existing_session: transport == Some(BrowserProfileTransportKind::ExistingSession),
});
}
if let Some(candidate) = attached_candidates
.into_iter()
.max_by_key(|item| (item.current_url_matches, item.saved_existing_session))
{
return Ok(build_site_adapter_launch_readiness_result(
SiteAdapterLaunchReadinessStatus::Ready,
spec,
Some(candidate.profile_key),
Some(candidate.target_id),
build_site_adapter_attached_session_ready_message(spec),
));
}
let has_attached_observer = status_snapshot.observers.iter().any(|observer| {
profiles
.iter()
.find(|profile| profile.profile_key == observer.profile_key)
.map(|profile| profile.transport_kind != BrowserProfileTransportKind::ManagedCdp)
.unwrap_or(true)
});
Ok(build_site_adapter_launch_readiness_result(
SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime,
spec,
None,
None,
if has_attached_observer {
build_site_adapter_attached_session_missing_target_message(spec)
} else {
build_site_adapter_attached_session_required_message(spec)
},
))
}
fn resolve_transport_route_from_state(
profile_transport: Option<BrowserProfileTransportKind>,
has_attached_observer: bool,
@@ -2174,6 +2496,14 @@ fn looks_like_no_matching_context_message(message: &str) -> bool {
|| normalized.contains("上下文")
}
fn looks_like_attached_session_required_message(message: &str) -> bool {
let normalized = message.to_ascii_lowercase();
normalized.contains("附着")
|| normalized.contains("真实浏览器")
|| normalized.contains("浏览器工作台")
|| normalized.contains("attached session")
}
fn normalize_site_adapter_error_code(
error_code: Option<&str>,
error_message: Option<&str>,
@@ -2193,6 +2523,14 @@ fn normalize_site_adapter_error_code(
return Some("auth_required".to_string());
}
if matches!(
normalized_code.as_deref(),
Some("attached_session_required")
) || looks_like_attached_session_required_message(normalized_message)
{
return Some("attached_session_required".to_string());
}
if matches!(
normalized_code.as_deref(),
Some("target_not_found") | Some("no_matching_context")
@@ -2225,6 +2563,10 @@ fn looks_like_navigation_timeout_error(error: &str) -> bool {
fn build_site_adapter_report_hint(error_code: &str) -> Option<String> {
match error_code {
"attached_session_required" => Some(
"Claw 不会在后台偷偷启动浏览器;请先进入浏览器工作台连接真实浏览器并打开目标站点页面,再返回 Claw 重试。"
.to_string(),
),
"auth_required" => Some(
"请先确认当前浏览器资料已经登录目标站点,再重试;如果仍失败,请附上当前页面 URL 和登录状态。"
.to_string(),
@@ -2336,6 +2678,63 @@ mod tests {
.is_some());
}
#[test]
fn should_expose_selected_bundled_market_finance_and_community_adapters() {
let linux_do_hot = get_site_adapter("linux-do/hot").expect("linux-do/hot should resolve");
assert_eq!(linux_do_hot.source_kind.as_deref(), Some("bundled"));
assert_eq!(linux_do_hot.source_version.as_deref(), Some("2026-03-28"));
assert_eq!(
linux_do_hot
.example_args
.get("period")
.and_then(Value::as_str),
Some("weekly")
);
assert_eq!(
linux_do_hot
.example_args
.get("limit")
.and_then(Value::as_i64),
Some(10)
);
let linux_do_categories =
get_site_adapter("linux-do/categories").expect("linux-do/categories should resolve");
assert_eq!(linux_do_categories.source_kind.as_deref(), Some("bundled"));
assert_eq!(
linux_do_categories.source_version.as_deref(),
Some("2026-03-28")
);
assert_eq!(
linux_do_categories
.example_args
.get("limit")
.and_then(Value::as_i64),
Some(10)
);
let yahoo =
get_site_adapter("yahoo-finance/quote").expect("yahoo-finance/quote should resolve");
assert_eq!(yahoo.source_kind.as_deref(), Some("bundled"));
assert_eq!(yahoo.source_version.as_deref(), Some("2026-03-28"));
assert_eq!(
yahoo.example_args.get("symbol").and_then(Value::as_str),
Some("AAPL")
);
let smzdm = get_site_adapter("smzdm/search").expect("smzdm/search should resolve");
assert_eq!(smzdm.source_kind.as_deref(), Some("bundled"));
assert_eq!(smzdm.source_version.as_deref(), Some("2026-03-28"));
assert_eq!(
smzdm.example_args.get("query").and_then(Value::as_str),
Some("Mac mini")
);
assert_eq!(
smzdm.example_args.get("limit").and_then(Value::as_i64),
Some(5)
);
}
#[test]
fn should_search_site_adapters_by_keyword() {
let adapters = search_site_adapters("issue");
@@ -2343,6 +2742,25 @@ mod tests {
assert_eq!(adapters[0].name, "github/issues");
}
#[test]
fn should_search_selected_bundled_adapters_by_domain_and_capability() {
let community = search_site_adapters("linux.do");
assert!(community
.iter()
.any(|adapter| adapter.name == "linux-do/hot"));
assert!(community
.iter()
.any(|adapter| adapter.name == "linux-do/categories"));
let finance = search_site_adapters("finance");
assert!(finance
.iter()
.any(|adapter| adapter.name == "yahoo-finance/quote"));
let deals = search_site_adapters("deals");
assert!(deals.iter().any(|adapter| adapter.name == "smzdm/search"));
}
#[test]
fn should_prefer_attached_existing_session_profile_for_matching_site() {
let attached_profile_keys = HashSet::from(["research_attach".to_string()]);
@@ -2716,6 +3134,81 @@ mod tests {
assert!(hint.contains("timeout_ms"));
}
#[test]
fn should_build_attached_session_required_report_hint() {
let hint = build_site_adapter_report_hint("attached_session_required")
.expect("attached_session_required 应返回提示");
assert!(hint.contains("不会在后台偷偷启动浏览器"));
}
#[tokio::test]
async fn should_report_requires_browser_runtime_when_no_attached_session_exists() {
let db = setup_test_db();
let readiness = get_site_adapter_launch_readiness(
&db,
SiteAdapterLaunchReadinessRequest {
adapter_name: "github/search".to_string(),
profile_key: None,
target_id: None,
},
)
.await
.expect("readiness should resolve");
assert_eq!(
readiness.status,
SiteAdapterLaunchReadinessStatus::RequiresBrowserRuntime
);
assert!(readiness.message.contains("浏览器工作台"));
}
#[tokio::test]
async fn should_block_managed_profile_when_attached_session_is_required() {
let db = setup_test_db();
{
let conn = lock_db(&db).expect("lock db should succeed");
BrowserProfileDao::upsert(
&conn,
&UpsertBrowserProfileInput {
id: None,
profile_key: "managed-github".to_string(),
name: "托管 GitHub".to_string(),
description: Some("托管浏览器".to_string()),
site_scope: Some("github.com".to_string()),
launch_url: Some("https://github.com".to_string()),
transport_kind: BrowserProfileTransportKind::ManagedCdp,
profile_dir: "/tmp/managed-github".to_string(),
managed_profile_dir: Some("/tmp/managed-github".to_string()),
},
)
.expect("managed profile should save");
}
let result = run_site_adapter(
&db,
RunSiteAdapterRequest {
adapter_name: "github/search".to_string(),
args: serde_json::json!({"query":"mcp"}),
profile_key: Some("managed-github".to_string()),
target_id: None,
timeout_ms: Some(5_000),
content_id: None,
project_id: None,
save_title: None,
require_attached_session: Some(true),
skill_title: Some("GitHub 仓库线索检索".to_string()),
},
)
.await;
assert!(!result.ok);
assert_eq!(
result.error_code.as_deref(),
Some("attached_session_required")
);
}
#[test]
fn should_save_existing_site_result_to_project_as_document() {
let db = setup_test_db();
@@ -2741,6 +3234,8 @@ mod tests {
content_id: None,
project_id: None,
save_title: None,
require_attached_session: None,
skill_title: None,
},
result: SiteAdapterRunResult {
ok: true,
@@ -2815,6 +3310,8 @@ mod tests {
content_id: None,
project_id: None,
save_title: None,
require_attached_session: None,
skill_title: None,
},
result: SiteAdapterRunResult {
ok: false,
@@ -2866,6 +3363,8 @@ mod tests {
content_id: None,
project_id: Some(workspace.id.clone()),
save_title: Some("自动保存的 GitHub MCP 搜索结果".to_string()),
require_attached_session: None,
skill_title: None,
};
let result = SiteAdapterRunResult {
ok: true,
@@ -2926,6 +3425,8 @@ mod tests {
content_id: None,
project_id: Some("project-1".to_string()),
save_title: None,
require_attached_session: None,
skill_title: None,
};
let result = SiteAdapterRunResult {
ok: false,
@@ -2995,6 +3496,8 @@ mod tests {
content_id: None,
project_id: None,
save_title: None,
require_attached_session: None,
skill_title: None,
},
)
.await;
@@ -3055,6 +3558,8 @@ mod tests {
content_id: Some(existing.id.clone()),
project_id: None,
save_title: Some("不会用于当前主稿".to_string()),
require_attached_session: None,
skill_title: None,
},
result: SiteAdapterRunResult {
ok: true,
@@ -3154,6 +3659,8 @@ mod tests {
content_id: Some(existing.id.clone()),
project_id: None,
save_title: Some("不应覆盖当前主稿标题".to_string()),
require_attached_session: None,
skill_title: None,
};
let result = SiteAdapterRunResult {
ok: true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "0.97.0",
"version": "0.98.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev:web-bridge",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "0.97.0",
"version": "0.98.0",
"identifier": "com.lime.app",
"build": {
"beforeDevCommand": "npm run dev",
@@ -0,0 +1,287 @@
site: zhihu
name: hot
description: 知乎热榜
domain: www.zhihu.com
args:
limit:
type: int
default: 20
description: Number of items to return
pipeline:
- navigate: https://www.zhihu.com
- evaluate: |
(async () => {
const res = await fetch('https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50', {
credentials: 'include'
});
const text = await res.text();
const d = JSON.parse(
text.replace(/("id"\s*:\s*)(\d{16,})/g, '$1"$2"')
);
return (d?.data || []).map((item) => {
const t = item.target || {};
const questionId = t.id == null ? '' : String(t.id);
return {
title: t.title,
url: 'https://www.zhihu.com/question/' + questionId,
answer_count: t.answer_count,
follower_count: t.follower_count,
heat: item.detail_text || '',
};
});
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
heat: ${{ item.heat }}
answers: ${{ item.answer_count }}
url: ${{ item.url }}
- limit: ${{ args.limit }}
columns: [rank, title, heat, answers]
---
site: zhihu
name: search
description: 知乎搜索
domain: www.zhihu.com
args:
query:
positional: true
type: str
required: true
description: Search query
limit:
type: int
default: 10
description: Number of results
pipeline:
- navigate: https://www.zhihu.com
- evaluate: |
(async () => {
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/<em>/g, '').replace(/<\/em>/g, '').trim();
const keyword = ${{ args.query | json }};
const limit = ${{ args.limit }};
const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent(keyword) + '&t=general&offset=0&limit=' + limit, {
credentials: 'include'
});
const d = await res.json();
return (d?.data || [])
.filter(item => item.type === 'search_result')
.map(item => {
const obj = item.object || {};
const q = obj.question || {};
return {
type: obj.type,
title: strip(obj.title || q.name || ''),
excerpt: strip(obj.excerpt || '').substring(0, 100),
author: obj.author?.name || '',
votes: obj.voteup_count || 0,
url: obj.type === 'answer'
? 'https://www.zhihu.com/question/' + q.id + '/answer/' + obj.id
: obj.type === 'article'
? 'https://zhuanlan.zhihu.com/p/' + obj.id
: 'https://www.zhihu.com/question/' + obj.id,
};
});
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
type: ${{ item.type }}
author: ${{ item.author }}
votes: ${{ item.votes }}
- limit: ${{ args.limit }}
columns: [rank, title, type, author, votes]
---
site: linux-do
name: hot
description: linux.do 热门话题
domain: linux.do
browser: true
args:
limit:
type: int
default: 20
description: Number of topics
period:
type: str
default: weekly
description: Time period
choices: [all, daily, weekly, monthly, yearly]
pipeline:
- navigate: https://linux.do
- evaluate: |
(async () => {
const period = ${{ args.period | json }};
const res = await fetch('/top.json?period=' + encodeURIComponent(period), { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
const topics = data?.topic_list?.topics || [];
const cats = data?.topic_list?.categories || data?.categories || [];
const catMap = Object.fromEntries(cats.map(c => [c.id, c.name]));
return topics.slice(0, ${{ args.limit }}).map(t => ({
title: t.title,
replies: (t.posts_count || 1) - 1,
views: t.views,
likes: t.like_count,
category: catMap[t.category_id] || String(t.category_id),
}));
})()
- map:
rank: ${{ index + 1 }}
title: ${{ item.title }}
replies: ${{ item.replies }}
views: ${{ item.views }}
likes: ${{ item.likes }}
category: ${{ item.category }}
- limit: ${{ args.limit }}
columns: [rank, title, replies, views, likes, category]
---
site: yahoo-finance
name: quote
description: "Yahoo Finance 股票行情"
domain: finance.yahoo.com
strategy: cookie
browser: true
args:
symbol:
positional: true
type: string
required: true
description: "Stock ticker (e.g. AAPL, MSFT, TSLA)"
columns: [symbol, name, price, change, changePercent, open, high, low, volume, marketCap]
pipeline:
- navigate: https://finance.yahoo.com/quote/${{ args.symbol | urlencode }}/
- evaluate: |
(async () => {
const sym = ${{ args.symbol | json }}.toUpperCase().trim();
try {
const chartUrl = 'https://query1.finance.yahoo.com/v8/finance/chart/' + encodeURIComponent(sym) + '?interval=1d&range=1d';
const resp = await fetch(chartUrl);
if (resp.ok) {
const d = await resp.json();
const chart = d?.chart?.result?.[0];
if (chart) {
const meta = chart.meta || {};
const prevClose = meta.previousClose || meta.chartPreviousClose;
const price = meta.regularMarketPrice;
const change = price != null && prevClose != null ? (price - prevClose) : null;
const changePct = change != null && prevClose ? ((change / prevClose) * 100) : null;
return [{
symbol: meta.symbol || sym,
name: meta.shortName || meta.longName || sym,
price: price != null ? Number(price.toFixed(2)) : null,
change: change != null ? change.toFixed(2) : null,
changePercent: changePct != null ? changePct.toFixed(2) + '%' : null,
open: chart.indicators?.quote?.[0]?.open?.[0] || null,
high: meta.regularMarketDayHigh || null,
low: meta.regularMarketDayLow || null,
volume: meta.regularMarketVolume || null,
marketCap: null,
}];
}
}
} catch(e) {}
const priceEl = document.querySelector('[data-testid="qsp-price"]');
const changeEl = document.querySelector('[data-testid="qsp-price-change"]');
const changePctEl = document.querySelector('[data-testid="qsp-price-change-percent"]');
const titleEl = document.querySelector('title');
if (priceEl) {
return [{
symbol: sym,
name: titleEl ? titleEl.textContent.split('(')[0].trim() : sym,
price: priceEl.textContent.replace(/,/g, ''),
change: changeEl ? changeEl.textContent : null,
changePercent: changePctEl ? changePctEl.textContent : null,
open: null, high: null, low: null, volume: null, marketCap: null,
}];
}
return [];
})()
- map:
symbol: ${{ item.symbol }}
name: ${{ item.name }}
price: ${{ item.price }}
change: ${{ item.change }}
changePercent: ${{ item.changePercent }}
open: ${{ item.open }}
high: ${{ item.high }}
low: ${{ item.low }}
volume: ${{ item.volume }}
marketCap: ${{ item.marketCap }}
---
site: smzdm
name: search
description: "什么值得买搜索好价"
domain: www.smzdm.com
strategy: cookie
browser: true
args:
query:
positional: true
type: string
required: true
description: Search keyword
limit:
type: int
default: 20
description: Number of results
columns: [rank, title, price, mall, comments, url]
pipeline:
- navigate: https://search.smzdm.com/?c=home&s=${{ args.query | urlencode }}&v=b
- evaluate: |
(() => {
const limit = ${{ args.limit }};
const items = document.querySelectorAll('li.feed-row-wide');
const results = [];
items.forEach((li) => {
if (results.length >= limit) return;
const titleEl = li.querySelector('h5.feed-block-title > a') || li.querySelector('h5 > a');
if (!titleEl) return;
const title = (titleEl.getAttribute('title') || titleEl.textContent || '').trim();
const url = titleEl.getAttribute('href') || titleEl.href || '';
const priceEl = li.querySelector('.z-highlight');
const price = priceEl ? priceEl.textContent.trim() : '';
let mall = '';
const mallEl = li.querySelector('.z-feed-foot-r .feed-block-extras span')
|| li.querySelector('.z-feed-foot-r span');
if (mallEl) mall = mallEl.textContent.trim();
const commentEl = li.querySelector('.feed-btn-comment');
const comments = commentEl ? parseInt(commentEl.textContent.trim()) || 0 : 0;
results.push({ rank: results.length + 1, title, price, mall, comments, url });
});
return results;
})()
- map:
rank: ${{ item.rank }}
title: ${{ item.title }}
price: ${{ item.price }}
mall: ${{ item.mall }}
comments: ${{ item.comments }}
url: ${{ item.url }}
+23 -645
View File
@@ -8,52 +8,37 @@
* _需求: 2.2, 3.2, 5.2_
*/
import React, { Suspense, lazy, useState, useEffect, useCallback } from "react";
import React, { Suspense, lazy, useState, useCallback } from "react";
import styled from "styled-components";
import { getWindowsStartupDiagnostics } from "@/lib/api/serverRuntime";
import { withI18nPatch } from "./i18n/withI18nPatch";
import { AppPageContent } from "./components/AppPageContent";
import { SplashScreen } from "./components/SplashScreen";
import { AppSidebar } from "./components/AppSidebar";
import {
ProjectType,
createProject,
ensureDefaultWorkspaceReady,
isUserProjectType,
resolveProjectRootPath,
} from "./lib/api/project";
import { useOnboardingState } from "./components/onboarding";
import { showRegistryLoadError } from "./lib/utils/connectError";
import { useDeepLink } from "./hooks/useDeepLink";
import { useRelayRegistry } from "./hooks/useRelayRegistry";
import { useSkillCatalogBootstrap } from "./hooks/useSkillCatalogBootstrap";
import { useServiceSkillCatalogBootstrap } from "./hooks/useServiceSkillCatalogBootstrap";
import { useSiteAdapterCatalogBootstrap } from "./hooks/useSiteAdapterCatalogBootstrap";
import { useAppNavigation } from "./hooks/useAppNavigation";
import { useAppShellLayout } from "./hooks/useAppShellLayout";
import { useAppStartupEffects } from "./hooks/useAppStartupEffects";
import { useGlobalTrayModelSync } from "./hooks/useGlobalTrayModelSync";
import { useOemLimeHubProviderSync } from "./hooks/useOemLimeHubProviderSync";
import { ComponentDebugProvider } from "./contexts/ComponentDebugContext";
import { SoundProvider } from "./contexts/SoundProvider";
import { ComponentDebugOverlay } from "./components/dev";
import {
AgentPageParams,
AutomationPageParams,
BrowserRuntimePageParams,
getThemeByWorkspacePage,
getThemeWorkspacePage,
isThemeWorkspacePage,
LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY,
MemoryPageParams,
OpenClawPageParams,
Page,
PageParams,
ProjectDetailPageParams,
SettingsPageParams,
StylePageParams,
ThemeWorkspacePage,
WorkspaceTheme,
} from "./types/page";
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`
@@ -73,79 +58,6 @@ const MainContent = styled.main<{ $withSidebarGap?: boolean }>`
padding-left: ${(props) => (props.$withSidebarGap ? "10px" : "0")};
`;
const PageWrapper = styled.div<{ $isActive: boolean }>`
flex: 1;
padding: 24px;
overflow: auto;
display: ${(props) => (props.$isActive ? "block" : "none")};
`;
const FullscreenWrapper = styled.div<{ $isActive: boolean }>`
flex: 1;
min-height: 0;
overflow: hidden;
display: ${(props) => (props.$isActive ? "flex" : "none")};
flex-direction: column;
position: relative;
`;
const THEME_WORKSPACE_PAGES: ThemeWorkspacePage[] = [
"workspace-general",
"workspace-social-media",
"workspace-poster",
"workspace-music",
"workspace-knowledge",
"workspace-planning",
"workspace-document",
"workspace-video",
"workspace-novel",
];
const SettingsPageV2 = lazy(() =>
import("./components/settings-v2").then((module) => ({
default: module.SettingsPageV2,
})),
);
const ToolsPage = lazy(() =>
import("./components/tools/ToolsPage").then((module) => ({
default: module.ToolsPage,
})),
);
const ResourcesPage = lazy(() =>
import("./components/resources").then((module) => ({
default: module.ResourcesPage,
})),
);
const MemoryPage = lazy(() =>
import("./components/memory").then((module) => ({
default: module.MemoryPage,
})),
);
const StylePage = lazy(() =>
import("./components/style").then((module) => ({
default: module.StylePage,
})),
);
const PluginsPage = lazy(() =>
import("./components/plugins/PluginsPage").then((module) => ({
default: module.PluginsPage,
})),
);
const ImageGenPage = lazy(() =>
import("./components/image-gen").then((module) => ({
default: module.ImageGenPage,
})),
);
const AutomationPage = lazy(() =>
import("./components/automation").then((module) => ({
default: module.AutomationPage,
})),
);
const OpenClawPage = lazy(() =>
import("./components/openclaw").then((module) => ({
default: module.OpenClawPage,
})),
);
const RecentImageInsertFloating = lazy(() =>
import("./components/image-gen/RecentImageInsertFloating").then((module) => ({
default: module.RecentImageInsertFloating,
@@ -156,36 +68,6 @@ const CreateProjectDialog = lazy(() =>
default: module.CreateProjectDialog,
})),
);
const WorkbenchPage = lazy(() =>
import("./components/workspace").then((module) => ({
default: module.WorkbenchPage,
})),
);
const BrowserRuntimeWorkspace = lazy(() =>
import("@/features/browser-runtime").then((module) => ({
default: module.BrowserRuntimeWorkspace,
})),
);
const TerminalWorkspace = lazy(() =>
import("./components/terminal").then((module) => ({
default: module.TerminalWorkspace,
})),
);
const SysinfoView = lazy(() =>
import("./components/terminal").then((module) => ({
default: module.SysinfoView,
})),
);
const FileBrowserView = lazy(() =>
import("./components/terminal").then((module) => ({
default: module.FileBrowserView,
})),
);
const WebView = lazy(() =>
import("./components/terminal").then((module) => ({
default: module.WebView,
})),
);
const OnboardingWizard = lazy(() =>
import("./components/onboarding").then((module) => ({
default: module.OnboardingWizard,
@@ -196,12 +78,6 @@ const ConnectConfirmDialog = lazy(() =>
default: module.ConnectConfirmDialog,
})),
);
const AgentChatPage = lazy(() =>
import("./components/agent/chat").then((module) => ({
default: module.AgentChatPage,
})),
);
const pageLoadingFallback = (
<div
style={{
@@ -218,26 +94,9 @@ const pageLoadingFallback = (
</div>
);
function isTauriDesktopEnvironment(): boolean {
return hasTauriInvokeCapability();
}
function isWindowsNavigatorPlatform(): boolean {
if (typeof navigator === "undefined") {
return false;
}
const platform = navigator.platform || "";
const userAgent = navigator.userAgent || "";
return /win/i.test(platform) || /windows/i.test(userAgent);
}
function AppContent() {
const [showSplash, setShowSplash] = useState(true);
const [currentPage, setCurrentPage] = useState<Page>("agent");
const [pageParams, setPageParams] = useState<PageParams>(() =>
buildHomeAgentParams(),
);
const { currentPage, pageParams, handleNavigate } = useAppNavigation();
const [agentHasMessages, setAgentHasMessages] = useState(false);
const { needsOnboarding, completeOnboarding } = useOnboardingState();
@@ -249,6 +108,7 @@ function AppContent() {
projectName: string;
} | null>(null);
useSkillCatalogBootstrap();
useServiceSkillCatalogBootstrap();
useSiteAdapterCatalogBootstrap();
useOemLimeHubProviderSync();
@@ -257,110 +117,6 @@ function AppContent() {
pageParams,
});
const resolveWorkspacePage = useCallback(
(workspaceTheme?: WorkspaceTheme): ThemeWorkspacePage => {
if (workspaceTheme) {
return getThemeWorkspacePage(workspaceTheme);
}
if (typeof window !== "undefined") {
const savedPage = localStorage.getItem(
LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY,
);
if (
savedPage &&
THEME_WORKSPACE_PAGES.includes(savedPage as ThemeWorkspacePage)
) {
return savedPage as ThemeWorkspacePage;
}
}
return getThemeWorkspacePage("general");
},
[],
);
const handleNavigate = useCallback(
(page: Page, params?: PageParams) => {
if (
page === "memory" &&
(params as { section?: string } | undefined)?.section ===
"style-library"
) {
setCurrentPage("style");
setPageParams({ section: "library" } as StylePageParams);
return;
}
if (page === "projects") {
const projectParams = params as
| {
projectId?: string;
workspaceTheme?: WorkspaceTheme;
}
| undefined;
const targetWorkspacePage = resolveWorkspacePage(
projectParams?.workspaceTheme,
);
if (typeof window !== "undefined") {
localStorage.setItem(
LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY,
targetWorkspacePage,
);
}
setCurrentPage(targetWorkspacePage);
setPageParams({
...(projectParams?.projectId
? { projectId: projectParams.projectId }
: {}),
workspaceViewMode: "project-management",
});
return;
}
if (page === "project-detail") {
const projectParams = params as ProjectDetailPageParams | undefined;
const targetWorkspacePage = resolveWorkspacePage(
projectParams?.workspaceTheme,
);
const workspaceViewMode = projectParams?.projectId
? "workspace"
: "project-management";
if (typeof window !== "undefined") {
localStorage.setItem(
LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY,
targetWorkspacePage,
);
}
setCurrentPage(targetWorkspacePage);
setPageParams({
...(projectParams?.projectId
? { projectId: projectParams.projectId }
: {}),
workspaceViewMode,
workspaceOpenProjectStyleGuide:
projectParams?.openProjectStyleGuide ?? false,
workspaceOpenProjectStyleGuideSourceEntryId:
projectParams?.openProjectStyleGuideSourceEntryId,
});
return;
}
if (isThemeWorkspacePage(page) && typeof window !== "undefined") {
localStorage.setItem(LAST_THEME_WORKSPACE_PAGE_STORAGE_KEY, page);
}
setCurrentPage(page);
setPageParams(params ? { ...params } : {});
},
[resolveWorkspacePage],
);
const _handleRequestRecommendation = useCallback(
(shortLabel: string, fullPrompt: string, currentTheme: string) => {
const themeLabels: Record<string, string> = {
@@ -455,383 +211,20 @@ function AppContent() {
const { error: registryError, refresh: _refreshRegistry } =
useRelayRegistry();
useEffect(() => {
if (registryError) {
console.warn("[App] Registry 加载失败:", registryError);
showRegistryLoadError(registryError.message);
}
}, [registryError]);
useEffect(() => {
if (!isTauriDesktopEnvironment() || !isWindowsNavigatorPlatform()) {
return;
}
void getWindowsStartupDiagnostics()
.then((diagnostics) => {
if (!diagnostics.summary_message) {
return;
}
if (diagnostics.has_blocking_issues) {
toast.error("Windows 启动自检发现阻塞问题", {
description: diagnostics.summary_message,
duration: 12000,
});
return;
}
if (diagnostics.has_warnings) {
toast.warning("Windows 环境检测提示", {
description: diagnostics.summary_message,
duration: 8000,
});
}
})
.catch((error) => {
console.warn("[App] 获取 Windows 启动诊断失败:", error);
});
}, []);
useEffect(() => {
void ensureDefaultWorkspaceReady()
.then((result) => {
if (result?.repaired) {
recordWorkspaceRepair({
workspaceId: result.workspaceId,
rootPath: result.rootPath,
source: "app_startup",
});
console.info(
"[App] 启动时检测到默认工作区目录缺失,已自动修复:",
result.rootPath,
);
}
})
.catch((error) => {
console.warn("[App] 启动时工作区健康检查失败:", error);
});
}, []);
useEffect(() => {
const mainElement = document.querySelector("main");
if (mainElement) {
mainElement.scrollTop = 0;
}
}, [currentPage]);
useAppStartupEffects({
currentPage,
registryError,
});
const { shouldShowAppSidebar, shouldAddMainContentGap } = useAppShellLayout({
currentPage,
pageParams,
agentHasMessages,
});
const handleSplashComplete = useCallback(() => {
setShowSplash(false);
}, []);
const renderThemeWorkspaces = () => {
if (!THEME_WORKSPACE_PAGES.includes(currentPage as ThemeWorkspacePage)) {
return null;
}
const page = currentPage as ThemeWorkspacePage;
const theme = getThemeByWorkspacePage(page);
return (
<div
key={page}
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<WorkbenchPage
onNavigate={handleNavigate}
projectId={(pageParams as AgentPageParams).projectId}
contentId={(pageParams as AgentPageParams).contentId}
theme={theme}
viewMode={(pageParams as AgentPageParams).workspaceViewMode}
resetAt={(pageParams as AgentPageParams).workspaceResetAt}
initialStyleGuideDialogOpen={
(pageParams as AgentPageParams).workspaceOpenProjectStyleGuide
}
initialStyleGuideSourceEntryId={
(pageParams as AgentPageParams)
.workspaceOpenProjectStyleGuideSourceEntryId
}
initialCreatePrompt={
(pageParams as AgentPageParams).workspaceCreatePrompt
}
initialCreateSource={
(pageParams as AgentPageParams).workspaceCreateSource
}
initialCreateFallbackTitle={
(pageParams as AgentPageParams).workspaceCreateFallbackTitle
}
/>
</div>
);
};
const renderCurrentPage = () => {
if (currentPage === "image-gen") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<ImageGenPage onNavigate={handleNavigate} />
</div>
);
}
if (currentPage === "automation") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<AutomationPage
onNavigate={handleNavigate}
pageParams={pageParams as AutomationPageParams}
/>
</div>
);
}
if (currentPage === "agent") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<AgentChatPage
key={`${(pageParams as AgentPageParams).projectId || ""}:${(pageParams as AgentPageParams).contentId || ""}:${(pageParams as AgentPageParams).theme || ""}:${(pageParams as AgentPageParams).lockTheme ? "1" : "0"}:${(pageParams as AgentPageParams).agentEntry || "claw"}:${(pageParams as AgentPageParams).immersiveHome ? "immersive" : "standard"}:${(pageParams as AgentPageParams).newChatAt ?? 0}`}
onNavigate={handleNavigate}
projectId={(pageParams as AgentPageParams).projectId}
contentId={(pageParams as AgentPageParams).contentId}
initialRequestMetadata={
(pageParams as AgentPageParams).initialRequestMetadata
}
initialUserPrompt={
(pageParams as AgentPageParams).initialUserPrompt
}
initialUserImages={
(pageParams as AgentPageParams).initialUserImages
}
initialCreationMode={
(pageParams as AgentPageParams).initialCreationMode
}
initialSessionName={
(pageParams as AgentPageParams).initialSessionName
}
entryBannerMessage={
(pageParams as AgentPageParams).entryBannerMessage
}
immersiveHome={(pageParams as AgentPageParams).immersiveHome}
openBrowserAssistOnMount={
(pageParams as AgentPageParams).openBrowserAssistOnMount
}
theme={(pageParams as AgentPageParams).theme}
lockTheme={(pageParams as AgentPageParams).lockTheme}
fromResources={(pageParams as AgentPageParams).fromResources}
agentEntry={(pageParams as AgentPageParams).agentEntry}
showChatPanel={
(pageParams as AgentPageParams).agentEntry !== "new-task" &&
!(pageParams as AgentPageParams).immersiveHome
}
newChatAt={(pageParams as AgentPageParams).newChatAt}
onHasMessagesChange={setAgentHasMessages}
/>
</div>
);
}
if (isThemeWorkspacePage(currentPage)) {
return renderThemeWorkspaces();
}
if (currentPage === "terminal") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<TerminalWorkspace onNavigate={handleNavigate} isActive />
</div>
);
}
if (currentPage === "sysinfo") {
return (
<FullscreenWrapper $isActive={true}>
<SysinfoView />
</FullscreenWrapper>
);
}
if (currentPage === "files") {
return (
<FullscreenWrapper $isActive={true}>
<FileBrowserView />
</FullscreenWrapper>
);
}
if (currentPage === "web") {
return (
<FullscreenWrapper $isActive={true}>
<WebView />
</FullscreenWrapper>
);
}
if (currentPage === "resources") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<ResourcesPage onNavigate={handleNavigate} />
</div>
);
}
if (currentPage === "tools") {
return (
<PageWrapper $isActive={true}>
<ToolsPage onNavigate={handleNavigate} />
</PageWrapper>
);
}
if (currentPage === "browser-runtime") {
const browserRuntimeParams = pageParams as BrowserRuntimePageParams;
return (
<PageWrapper $isActive={true}>
<BrowserRuntimeWorkspace
active={true}
onNavigate={handleNavigate}
initialProfileKey={browserRuntimeParams.initialProfileKey}
initialSessionId={browserRuntimeParams.initialSessionId}
initialTargetId={browserRuntimeParams.initialTargetId}
currentProjectId={browserRuntimeParams.projectId}
currentContentId={browserRuntimeParams.contentId}
initialAdapterName={browserRuntimeParams.initialAdapterName}
initialArgs={browserRuntimeParams.initialArgs}
initialAutoRun={browserRuntimeParams.initialAutoRun}
initialRequireAttachedSession={
browserRuntimeParams.initialRequireAttachedSession
}
initialSaveTitle={browserRuntimeParams.initialSaveTitle}
/>
</PageWrapper>
);
}
if (currentPage === "plugins") {
return (
<PageWrapper $isActive={true}>
<PluginsPage onNavigate={handleNavigate} />
</PageWrapper>
);
}
if (currentPage === "style") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<StylePage
onNavigate={handleNavigate}
pageParams={pageParams as StylePageParams}
/>
</div>
);
}
if (currentPage === "memory") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<MemoryPage
onNavigate={handleNavigate}
pageParams={pageParams as MemoryPageParams}
/>
</div>
);
}
if (currentPage === "openclaw") {
return (
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
display: "flex",
flexDirection: "column",
}}
>
<OpenClawPage
onNavigate={handleNavigate}
pageParams={pageParams as OpenClawPageParams}
isActive={true}
/>
</div>
);
}
if (currentPage === "settings") {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<SettingsPageV2
onNavigate={handleNavigate}
initialTab={(pageParams as SettingsPageParams).tab}
/>
</div>
);
}
return null;
};
const handleOnboardingComplete = useCallback(() => {
completeOnboarding();
}, [completeOnboarding]);
@@ -852,26 +245,6 @@ function AppContent() {
);
}
const currentAgentParams = pageParams as AgentPageParams;
const shouldHideSidebarForAgent =
currentPage === "agent" &&
(Boolean(currentAgentParams.fromResources) ||
Boolean(currentAgentParams.immersiveHome) ||
(agentHasMessages && Boolean(currentAgentParams.lockTheme)));
const shouldShowAppSidebar =
currentPage !== "settings" &&
currentPage !== "memory" &&
currentPage !== "image-gen" &&
currentPage !== "tools" &&
currentPage !== "plugins" &&
currentPage !== "resources" &&
!isThemeWorkspacePage(currentPage) &&
!shouldHideSidebarForAgent;
const shouldAddMainContentGap =
shouldShowAppSidebar && currentPage === "agent";
return (
<SoundProvider>
<ComponentDebugProvider>
@@ -885,7 +258,12 @@ function AppContent() {
)}
<MainContent $withSidebarGap={shouldAddMainContentGap}>
<Suspense fallback={pageLoadingFallback}>
{renderCurrentPage()}
<AppPageContent
currentPage={currentPage}
pageParams={pageParams}
onNavigate={handleNavigate}
onAgentHasMessagesChange={setAgentHasMessages}
/>
</Suspense>
</MainContent>
<Suspense fallback={null}>
+377
View File
@@ -0,0 +1,377 @@
/**
* 应用页面分发层
*
* 负责根据当前页面类型渲染对应主内容,避免主入口继续膨胀。
*/
import { lazy } from "react";
import styled from "styled-components";
import type {
AgentPageParams,
AutomationPageParams,
BrowserRuntimePageParams,
MemoryPageParams,
OpenClawPageParams,
Page,
PageParams,
SettingsPageParams,
StylePageParams,
} from "@/types/page";
import {
getThemeByWorkspacePage,
isThemeWorkspacePage,
type ThemeWorkspacePage,
} from "@/types/page";
const PageWrapper = styled.div<{ $isActive: boolean }>`
flex: 1;
padding: 24px;
overflow: auto;
display: ${(props) => (props.$isActive ? "block" : "none")};
`;
const FullscreenWrapper = styled.div<{ $isActive: boolean }>`
flex: 1;
min-height: 0;
overflow: hidden;
display: ${(props) => (props.$isActive ? "flex" : "none")};
flex-direction: column;
position: relative;
`;
const columnPageStyle = {
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
} as const;
const SettingsPageV2 = lazy(() =>
import("./settings-v2").then((module) => ({
default: module.SettingsPageV2,
})),
);
const ToolsPage = lazy(() =>
import("./tools/ToolsPage").then((module) => ({
default: module.ToolsPage,
})),
);
const ResourcesPage = lazy(() =>
import("./resources").then((module) => ({
default: module.ResourcesPage,
})),
);
const MemoryPage = lazy(() =>
import("./memory").then((module) => ({
default: module.MemoryPage,
})),
);
const StylePage = lazy(() =>
import("./style").then((module) => ({
default: module.StylePage,
})),
);
const PluginsPage = lazy(() =>
import("./plugins/PluginsPage").then((module) => ({
default: module.PluginsPage,
})),
);
const ImageGenPage = lazy(() =>
import("./image-gen").then((module) => ({
default: module.ImageGenPage,
})),
);
const AutomationPage = lazy(() =>
import("./automation").then((module) => ({
default: module.AutomationPage,
})),
);
const OpenClawPage = lazy(() =>
import("./openclaw").then((module) => ({
default: module.OpenClawPage,
})),
);
const SkillsWorkspacePage = lazy(() =>
import("./skills").then((module) => ({
default: module.SkillsWorkspacePage,
})),
);
const WorkbenchPage = lazy(() =>
import("./workspace").then((module) => ({
default: module.WorkbenchPage,
})),
);
const BrowserRuntimeWorkspace = lazy(() =>
import("@/features/browser-runtime").then((module) => ({
default: module.BrowserRuntimeWorkspace,
})),
);
const TerminalWorkspace = lazy(() =>
import("./terminal").then((module) => ({
default: module.TerminalWorkspace,
})),
);
const SysinfoView = lazy(() =>
import("./terminal").then((module) => ({
default: module.SysinfoView,
})),
);
const FileBrowserView = lazy(() =>
import("./terminal").then((module) => ({
default: module.FileBrowserView,
})),
);
const WebView = lazy(() =>
import("./terminal").then((module) => ({
default: module.WebView,
})),
);
const AgentChatPage = lazy(() =>
import("./agent/chat").then((module) => ({
default: module.AgentChatPage,
})),
);
interface AppPageContentProps {
currentPage: Page;
pageParams: PageParams;
onNavigate: (page: Page, params?: PageParams) => void;
onAgentHasMessagesChange: (hasMessages: boolean) => void;
}
function renderThemeWorkspace(
currentPage: ThemeWorkspacePage,
pageParams: PageParams,
onNavigate: (page: Page, params?: PageParams) => void,
) {
const theme = getThemeByWorkspacePage(currentPage);
const agentPageParams = pageParams as AgentPageParams;
return (
<div key={currentPage} style={columnPageStyle}>
<WorkbenchPage
onNavigate={onNavigate}
projectId={agentPageParams.projectId}
contentId={agentPageParams.contentId}
theme={theme}
viewMode={agentPageParams.workspaceViewMode}
resetAt={agentPageParams.workspaceResetAt}
initialStyleGuideDialogOpen={
agentPageParams.workspaceOpenProjectStyleGuide
}
initialStyleGuideSourceEntryId={
agentPageParams.workspaceOpenProjectStyleGuideSourceEntryId
}
initialCreatePrompt={agentPageParams.workspaceCreatePrompt}
initialCreateSource={agentPageParams.workspaceCreateSource}
initialCreateFallbackTitle={
agentPageParams.workspaceCreateFallbackTitle
}
/>
</div>
);
}
export function AppPageContent({
currentPage,
pageParams,
onNavigate,
onAgentHasMessagesChange,
}: AppPageContentProps) {
if (currentPage === "image-gen") {
return (
<div style={columnPageStyle}>
<ImageGenPage onNavigate={onNavigate} />
</div>
);
}
if (currentPage === "automation") {
return (
<div style={columnPageStyle}>
<AutomationPage
onNavigate={onNavigate}
pageParams={pageParams as AutomationPageParams}
/>
</div>
);
}
if (currentPage === "agent") {
const agentPageParams = pageParams as AgentPageParams;
return (
<div style={columnPageStyle}>
<AgentChatPage
key={`${agentPageParams.projectId || ""}:${agentPageParams.contentId || ""}:${agentPageParams.theme || ""}:${agentPageParams.lockTheme ? "1" : "0"}:${agentPageParams.agentEntry || "claw"}:${agentPageParams.immersiveHome ? "immersive" : "standard"}:${agentPageParams.newChatAt ?? 0}`}
onNavigate={onNavigate}
projectId={agentPageParams.projectId}
contentId={agentPageParams.contentId}
initialRequestMetadata={agentPageParams.initialRequestMetadata}
initialUserPrompt={agentPageParams.initialUserPrompt}
initialUserImages={agentPageParams.initialUserImages}
initialCreationMode={agentPageParams.initialCreationMode}
initialSessionName={agentPageParams.initialSessionName}
entryBannerMessage={agentPageParams.entryBannerMessage}
immersiveHome={agentPageParams.immersiveHome}
openBrowserAssistOnMount={agentPageParams.openBrowserAssistOnMount}
theme={agentPageParams.theme}
lockTheme={agentPageParams.lockTheme}
fromResources={agentPageParams.fromResources}
agentEntry={agentPageParams.agentEntry}
showChatPanel={
agentPageParams.agentEntry !== "new-task" &&
!agentPageParams.immersiveHome
}
newChatAt={agentPageParams.newChatAt}
onHasMessagesChange={onAgentHasMessagesChange}
/>
</div>
);
}
if (isThemeWorkspacePage(currentPage)) {
return renderThemeWorkspace(currentPage, pageParams, onNavigate);
}
if (currentPage === "terminal") {
return (
<div style={columnPageStyle}>
<TerminalWorkspace onNavigate={onNavigate} isActive />
</div>
);
}
if (currentPage === "sysinfo") {
return (
<FullscreenWrapper $isActive={true}>
<SysinfoView />
</FullscreenWrapper>
);
}
if (currentPage === "files") {
return (
<FullscreenWrapper $isActive={true}>
<FileBrowserView />
</FullscreenWrapper>
);
}
if (currentPage === "web") {
return (
<FullscreenWrapper $isActive={true}>
<WebView />
</FullscreenWrapper>
);
}
if (currentPage === "resources") {
return (
<div style={columnPageStyle}>
<ResourcesPage onNavigate={onNavigate} />
</div>
);
}
if (currentPage === "tools") {
return (
<PageWrapper $isActive={true}>
<ToolsPage onNavigate={onNavigate} />
</PageWrapper>
);
}
if (currentPage === "browser-runtime") {
const browserRuntimeParams = pageParams as BrowserRuntimePageParams;
return (
<PageWrapper $isActive={true}>
<BrowserRuntimeWorkspace
active={true}
onNavigate={onNavigate}
initialProfileKey={browserRuntimeParams.initialProfileKey}
initialSessionId={browserRuntimeParams.initialSessionId}
initialTargetId={browserRuntimeParams.initialTargetId}
currentProjectId={browserRuntimeParams.projectId}
currentContentId={browserRuntimeParams.contentId}
initialAdapterName={browserRuntimeParams.initialAdapterName}
initialArgs={browserRuntimeParams.initialArgs}
initialAutoRun={browserRuntimeParams.initialAutoRun}
initialRequireAttachedSession={
browserRuntimeParams.initialRequireAttachedSession
}
initialSaveTitle={browserRuntimeParams.initialSaveTitle}
/>
</PageWrapper>
);
}
if (currentPage === "plugins") {
return (
<PageWrapper $isActive={true}>
<PluginsPage onNavigate={onNavigate} />
</PageWrapper>
);
}
if (currentPage === "style") {
return (
<div style={columnPageStyle}>
<StylePage
onNavigate={onNavigate}
pageParams={pageParams as StylePageParams}
/>
</div>
);
}
if (currentPage === "memory") {
return (
<div style={columnPageStyle}>
<MemoryPage
onNavigate={onNavigate}
pageParams={pageParams as MemoryPageParams}
/>
</div>
);
}
if (currentPage === "openclaw") {
return (
<div
style={{
...columnPageStyle,
overflowY: "auto",
}}
>
<OpenClawPage
onNavigate={onNavigate}
pageParams={pageParams as OpenClawPageParams}
isActive={true}
/>
</div>
);
}
if (currentPage === "skills") {
return (
<div style={columnPageStyle}>
<SkillsWorkspacePage onNavigate={onNavigate} />
</div>
);
}
if (currentPage === "settings") {
return (
<div style={columnPageStyle}>
<SettingsPageV2
onNavigate={onNavigate}
initialTab={(pageParams as SettingsPageParams).tab}
/>
</div>
);
}
return null;
}
+15
View File
@@ -102,4 +102,19 @@ describe("AppSidebar", () => {
).not.toBeNull();
expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("false");
});
it("旧导航配置未包含技能时也应显示固定技能入口", async () => {
mockGetConfig.mockResolvedValue({
navigation: {
enabled_items: ["home-general", "claw"],
},
});
const container = mountSidebar({
agentEntry: "new-task",
} as AgentPageParams);
await flushEffects();
expect(container.textContent).toContain("技能");
});
});
+1 -1
View File
@@ -542,7 +542,7 @@ export function AppSidebar({
const filteredMainMenuItems = useMemo(() => {
return MAIN_SIDEBAR_NAV_ITEMS.filter((item) =>
enabledNavItems.includes(item.id),
item.configurable === false || enabledNavItems.includes(item.id),
);
}, [enabledNavItems]);
+6 -6
View File
@@ -23,7 +23,7 @@ interface AgentSkillsPanelProps {
skills: string[];
/** 是否正在加载 */
loading: boolean;
/** 点击"管理 Skills"按钮的回调 */
/** 点击“打开技能中心”按钮的回调 */
onManageClick: () => void;
}
@@ -33,8 +33,8 @@ interface AgentSkillsPanelProps {
* 功能:
* - 显示已加载 Skills 数量
* - 以紧凑格式显示 Skill 名称列表(用 · 分隔)
* - 提供"管理 Skills"按钮导航到 Skills 设置页面
* - 无 Skills 时显示提示文本和安装链接
* - 提供“打开技能中心”按钮导航到 Skills 主入口
* - 无 Skills 时显示提示文本和技能中心入口
* - 显示使用提示
*
* @param skills - 已加载的 Skills 名称列表
@@ -101,19 +101,19 @@ export function AgentSkillsPanel({
className="w-full"
>
<Settings2 className="h-4 w-4 mr-2" />
管理 Skills
打开技能中心
</Button>
</>
) : (
<>
{/* 无 Skills 提示 */}
<p className="text-sm text-muted-foreground">
暂无已安装的 Skills,
暂无已安装的技能,
<button
onClick={onManageClick}
className="text-primary underline hover:no-underline"
>
去安装
去技能中心
</button>
</p>
</>
@@ -35,6 +35,7 @@ const {
mockCreateServiceSkillRun,
mockGetServiceSkillRun,
mockIsTerminalServiceSkillRunStatus,
mockSiteGetAdapterLaunchReadiness,
mockToastLoading,
mockToastSuccess,
mockToastError,
@@ -217,6 +218,7 @@ const {
const mockCreateServiceSkillRun = vi.fn();
const mockGetServiceSkillRun = vi.fn();
const mockIsTerminalServiceSkillRunStatus = vi.fn();
const mockSiteGetAdapterLaunchReadiness = vi.fn();
const mockToastLoading = vi.fn();
const mockToastSuccess = vi.fn();
const mockToastError = vi.fn();
@@ -303,6 +305,7 @@ const {
mockClawSolutions,
mockUseServiceSkills: vi.fn(() => ({
skills: mockServiceSkills,
groups: [],
isLoading: false,
error: null,
refresh: vi.fn(),
@@ -314,6 +317,7 @@ const {
mockCreateServiceSkillRun,
mockGetServiceSkillRun,
mockIsTerminalServiceSkillRunStatus,
mockSiteGetAdapterLaunchReadiness,
mockToastLoading,
mockToastSuccess,
mockToastError,
@@ -428,6 +432,9 @@ vi.mock("./utils/chatToolPreferences", () => ({
task: false,
subagent: false,
})),
alignChatToolPreferencesWithExecutionStrategy: vi.fn(
(preferences: Record<string, unknown>) => preferences,
),
saveChatToolPreferences: mockSaveChatToolPreferences,
}));
@@ -464,6 +471,11 @@ vi.mock("@/lib/api/project", () => ({
listProjects: mockListProjects,
}));
vi.mock("@/lib/webview-api", () => ({
siteGetAdapterLaunchReadiness: (...args: unknown[]) =>
mockSiteGetAdapterLaunchReadiness(...args),
}));
vi.mock("sonner", () => ({
toast: {
loading: mockToastLoading,
@@ -573,6 +585,7 @@ vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({
open,
onLaunch,
onCreateAutomation,
onOpenBrowserRuntime,
}: {
skill: { id: string; title: string; runnerType?: string } | null;
open: boolean;
@@ -584,6 +597,10 @@ vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({
skill: { id: string; title: string; runnerType?: string },
slotValues: Record<string, string>,
) => void;
onOpenBrowserRuntime?: (
skill: { id: string; title: string; runnerType?: string },
slotValues: Record<string, string>,
) => void;
}) =>
open && skill ? (
<>
@@ -611,6 +628,19 @@ vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({
>
启动服务型技能
</button>
{skill.id === "github-repo-radar" && onOpenBrowserRuntime ? (
<button
type="button"
data-testid="home-shell-service-skill-open-browser-runtime"
onClick={() =>
onOpenBrowserRuntime(skill, {
repository_query: "browser assist mcp",
})
}
>
去浏览器工作台
</button>
) : null}
{skill.runnerType === "scheduled" && onCreateAutomation ? (
<button
type="button"
@@ -756,6 +786,7 @@ beforeEach(() => {
}));
mockUseServiceSkills.mockImplementation(() => ({
skills: mockServiceSkills,
groups: [],
isLoading: false,
error: null,
refresh: vi.fn(),
@@ -801,6 +832,14 @@ beforeEach(() => {
id: "service-skill-run-1",
status: "success",
});
mockSiteGetAdapterLaunchReadiness.mockResolvedValue({
status: "ready",
adapter: "github/search",
domain: "github.com",
profile_key: "attached-github",
target_id: "tab-github",
message: "已检测到 github.com 的真实浏览器页面,Claw 可以直接复用当前会话执行。",
});
mockIsTerminalServiceSkillRunStatus.mockImplementation((status: string) =>
["success", "failed", "canceled", "timeout"].includes(status),
);
@@ -1308,7 +1347,7 @@ describe("AgentChatHomeShell", () => {
},
},
initialUserPrompt:
expect.stringContaining("[服务型技能] 复制短视频脚本"),
expect.stringContaining("[技能任务] 复制短视频脚本"),
}),
);
expect(onEnterWorkspace).toHaveBeenCalledWith(
@@ -1348,7 +1387,7 @@ describe("AgentChatHomeShell", () => {
expect(launchButton).toBeTruthy();
});
it("站点型服务技能应直接导航到浏览器工作台并预填自动执行参数", async () => {
it("站点型服务技能主按钮应进入 Claw 工作区并携带初始执行载荷", async () => {
const onNavigate = vi.fn();
const onEnterWorkspace = vi.fn();
const { container } = renderShell({
@@ -1389,9 +1428,83 @@ describe("AgentChatHomeShell", () => {
content_type: "document",
}),
);
expect(onNavigate).not.toHaveBeenCalledWith(
"browser-runtime",
expect.anything(),
);
expect(onNavigate).toHaveBeenCalledWith(
"agent",
expect.objectContaining({
projectId: "project-1",
contentId: "content-service-skill-1",
theme: "general",
initialCreationMode: "guided",
initialRequestMetadata: {
artifact: {
artifact_mode: "draft",
artifact_kind: "analysis",
workbench_surface: "right_panel",
},
},
initialSiteSkillLaunch: {
adapterName: "github/search",
args: {
query: "browser assist mcp",
limit: 10,
},
autoRun: true,
profileKey: "attached-github",
targetId: "tab-github",
requireAttachedSession: true,
saveTitle: undefined,
skillTitle: "GitHub 仓库线索检索",
},
}),
);
expect(onEnterWorkspace).not.toHaveBeenCalled();
expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({
skillId: "github-repo-radar",
runnerType: "instant",
});
});
it("站点型服务技能次级按钮才应跳到浏览器工作台", async () => {
const onNavigate = vi.fn();
const onEnterWorkspace = vi.fn();
const { container } = renderShell({
onNavigate,
onEnterWorkspace,
});
await flushEffects();
const serviceSkillButton = container.querySelector(
'[data-testid="home-shell-service-skill-github-repo-radar"]',
) as HTMLButtonElement | null;
act(() => {
serviceSkillButton?.click();
});
await flushEffects();
const browserRuntimeButton = container.querySelector(
'[data-testid="home-shell-service-skill-open-browser-runtime"]',
) as HTMLButtonElement | null;
expect(browserRuntimeButton).toBeTruthy();
act(() => {
browserRuntimeButton?.click();
});
await flushEffects();
expect(onNavigate).toHaveBeenCalledWith("browser-runtime", {
projectId: "project-1",
contentId: "content-service-skill-1",
initialProfileKey: "attached-github",
initialTargetId: "tab-github",
initialAdapterName: "github/search",
initialArgs: {
query: "browser assist mcp",
@@ -1402,10 +1515,48 @@ describe("AgentChatHomeShell", () => {
initialSaveTitle: undefined,
});
expect(onEnterWorkspace).not.toHaveBeenCalled();
expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({
skillId: "github-repo-radar",
runnerType: "instant",
});
it("站点型服务技能缺少附着会话时不应进入 Claw 工作区", async () => {
const onNavigate = vi.fn();
const onEnterWorkspace = vi.fn();
mockSiteGetAdapterLaunchReadiness.mockResolvedValueOnce({
status: "requires_browser_runtime",
adapter: "github/search",
domain: "github.com",
message: "当前没有检测到已附着到真实浏览器的 github.com 页面。",
report_hint: "请先去浏览器工作台连接真实浏览器。",
});
const { container } = renderShell({
onNavigate,
onEnterWorkspace,
});
await flushEffects();
const serviceSkillButton = container.querySelector(
'[data-testid="home-shell-service-skill-github-repo-radar"]',
) as HTMLButtonElement | null;
act(() => {
serviceSkillButton?.click();
});
await flushEffects();
const launchButton = container.querySelector(
'[data-testid="home-shell-service-skill-launch"]',
) as HTMLButtonElement | null;
act(() => {
launchButton?.click();
});
await flushEffects();
expect(onNavigate).not.toHaveBeenCalledWith("agent", expect.anything());
expect(onEnterWorkspace).not.toHaveBeenCalled();
expect(mockToastError).toHaveBeenCalled();
});
it("cloud_required 服务型技能成功后应回流本地工作区", async () => {
@@ -1431,6 +1582,7 @@ describe("AgentChatHomeShell", () => {
actionLabel: "提交云端",
},
],
groups: [],
isLoading: false,
error: null,
refresh: vi.fn(),
@@ -1471,7 +1623,7 @@ describe("AgentChatHomeShell", () => {
expect(mockCreateServiceSkillRun).toHaveBeenCalledWith(
"cloud-video-dubbing",
expect.stringContaining("[服务型技能] 云端视频配音"),
expect.stringContaining("[技能任务] 云端视频配音"),
);
expect(mockCreateServiceSkillRun).toHaveBeenCalledWith(
"cloud-video-dubbing",
@@ -1592,7 +1744,7 @@ describe("AgentChatHomeShell", () => {
},
payload: expect.objectContaining({
kind: "agent_turn",
prompt: expect.stringContaining("[服务型技能] 每日趋势摘要"),
prompt: expect.stringContaining("[技能任务] 每日趋势摘要"),
content_id: "content-service-skill-1",
request_metadata: expect.objectContaining({
artifact: expect.objectContaining({
@@ -1662,7 +1814,7 @@ describe("AgentChatHomeShell", () => {
workbench_surface: "right_panel",
},
},
initialUserPrompt: expect.stringContaining("[服务型技能] 每日趋势摘要"),
initialUserPrompt: expect.stringContaining("[技能任务] 每日趋势摘要"),
}),
);
expect(mockRecordServiceSkillAutomationLink).toHaveBeenCalledWith({
+175 -30
View File
@@ -3,6 +3,7 @@ import styled from "styled-components";
import { toast } from "sonner";
import { createAutomationJob } from "@/lib/api/automation";
import { prepareClawSolution } from "@/lib/api/clawSolutions";
import { siteGetAdapterLaunchReadiness } from "@/lib/webview-api";
import {
createServiceSkillRun,
getServiceSkillRun,
@@ -19,7 +20,10 @@ import type { BrowserRuntimePageParams, Page, PageParams } from "@/types/page";
import { SettingsTabs } from "@/types/settings";
import { EmptyState } from "./components/EmptyState";
import type { CreationMode } from "./components/types";
import { saveChatToolPreferences } from "./utils/chatToolPreferences";
import {
alignChatToolPreferencesWithExecutionStrategy,
saveChatToolPreferences,
} from "./utils/chatToolPreferences";
import { isTeamRuntimeRecommendation } from "./utils/contextualRecommendations";
import { resolveClawWorkspaceProviderSelection } from "./utils/clawWorkspaceProviderSelection";
import { createChatToolPreferencesFromExecutionRuntime } from "./utils/sessionExecutionRuntime";
@@ -66,7 +70,7 @@ import { buildServiceSkillWorkspaceSeed } from "./service-skills/workspaceLaunch
import {
buildServiceSkillSiteCapabilityArgs,
buildServiceSkillSiteCapabilitySaveTitle,
isServiceSkillSiteCapabilityBound,
isServiceSkillExecutableAsSiteAdapter,
} from "./service-skills/siteCapabilityBinding";
const PageContainer = styled.div<{ $compact?: boolean }>`
@@ -129,7 +133,6 @@ const MainArea = styled.div<{ $compact?: boolean }>`
0 24px 72px -36px rgba(15, 23, 42, 0.18),
0 16px 28px -24px rgba(15, 23, 42, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.76);
backdrop-filter: blur(18px);
`;
const ChatContainer = styled.div`
@@ -372,6 +375,14 @@ export function AgentChatHomeShell({
() => createChatToolPreferencesFromExecutionRuntime(recentExecutionRuntime),
[recentExecutionRuntime],
);
const effectiveChatToolPreferences = useMemo(
() =>
alignChatToolPreferencesWithExecutionStrategy(
chatToolPreferences,
executionStrategy,
),
[chatToolPreferences, executionStrategy],
);
const {
solutions: clawSolutions,
isLoading: clawSolutionsLoading,
@@ -380,6 +391,7 @@ export function AgentChatHomeShell({
} = useClawSolutions(activeTheme === "general");
const {
skills: serviceSkills,
groups: serviceSkillGroups,
catalogMeta: serviceSkillCatalogMeta,
isLoading: serviceSkillsLoading,
error: serviceSkillsError,
@@ -422,13 +434,25 @@ export function AgentChatHomeShell({
return;
}
toast.error(`加载服务型技能失败:${serviceSkillsError}`);
toast.error(`加载技能目录失败:${serviceSkillsError}`);
}, [activeTheme, serviceSkillsError]);
useEffect(() => {
syncChatToolPreferencesSource(activeTheme, runtimeChatToolPreferences);
}, [activeTheme, runtimeChatToolPreferences, syncChatToolPreferencesSource]);
useEffect(() => {
if (chatToolPreferences.task === effectiveChatToolPreferences.task) {
return;
}
setChatToolPreferences(effectiveChatToolPreferences);
}, [
chatToolPreferences.task,
effectiveChatToolPreferences,
setChatToolPreferences,
]);
const handleRefreshSkills = useCallback(async () => {
await refreshSkills(true);
}, [refreshSkills]);
@@ -455,7 +479,7 @@ export function AgentChatHomeShell({
projectId: normalizedProjectId,
activeTheme,
creationMode,
defaultToolPreferences: chatToolPreferences,
defaultToolPreferences: effectiveChatToolPreferences,
payload,
});
@@ -481,7 +505,7 @@ export function AgentChatHomeShell({
},
[
activeTheme,
chatToolPreferences,
effectiveChatToolPreferences,
creationMode,
currentProjectId,
rememberProjectId,
@@ -504,7 +528,11 @@ export function AgentChatHomeShell({
preparation.reasonCode,
);
if (setupTab && onNavigate) {
onNavigate("settings", { tab: setupTab });
if (setupTab === SettingsTabs.Skills) {
onNavigate("skills");
} else {
onNavigate("settings", { tab: setupTab });
}
return;
}
toast.error(preparation.readinessMessage);
@@ -513,7 +541,7 @@ export function AgentChatHomeShell({
const launch = resolveClawSolutionLaunch(
preparation,
chatToolPreferences,
effectiveChatToolPreferences,
);
const targetTheme =
launch.enterWorkspacePayload.themeOverride ?? activeTheme;
@@ -566,7 +594,7 @@ export function AgentChatHomeShell({
}
},
[
chatToolPreferences,
effectiveChatToolPreferences,
currentProjectId,
handleEnterWorkspace,
input,
@@ -741,12 +769,75 @@ export function AgentChatHomeShell({
[activeTheme, createServiceSkillSeededContent, currentProjectId],
);
const prepareServiceSkillSiteWorkspacePayload = useCallback(
async (
skill: ServiceSkillHomeItem,
slotValues: ServiceSkillSlotValues,
launchReadiness: Awaited<ReturnType<typeof siteGetAdapterLaunchReadiness>>,
): Promise<HomeShellEnterWorkspacePayload> => {
if (!isServiceSkillExecutableAsSiteAdapter(skill)) {
throw new Error("当前技能未绑定站点执行能力");
}
const normalizedProjectId = normalizeProjectId(currentProjectId);
if (
skill.readinessRequirements?.requiresProject &&
!normalizedProjectId
) {
throw new Error("缺少项目工作区,请先选择项目后再启动站点技能。");
}
const binding = skill.siteCapabilityBinding;
const saveMode = binding.saveMode ?? "project_resource";
const initialArgs = buildServiceSkillSiteCapabilityArgs(
skill,
slotValues,
);
const initialSaveTitle = buildServiceSkillSiteCapabilitySaveTitle(
skill,
slotValues,
);
let nextContentId: string | undefined;
if (saveMode === "current_content" && normalizedProjectId) {
const created = await createServiceSkillSeededContent(
skill,
normalizedProjectId,
);
nextContentId = created?.id ?? undefined;
}
const seed = buildServiceSkillWorkspaceSeed(
skill,
skill.themeTarget ?? activeTheme,
);
return {
contentId: nextContentId,
themeOverride: "general",
...(seed?.requestMetadata
? { initialRequestMetadata: seed.requestMetadata }
: {}),
initialSiteSkillLaunch: {
adapterName: binding.adapterName,
args: initialArgs,
autoRun: binding.autoRun ?? true,
profileKey: launchReadiness.profile_key,
targetId: launchReadiness.target_id,
requireAttachedSession: true,
saveTitle: nextContentId ? undefined : initialSaveTitle,
skillTitle: skill.title,
},
};
},
[activeTheme, createServiceSkillSeededContent, currentProjectId],
);
const handleServiceSkillBrowserRuntimeLaunch = useCallback(
async (
skill: ServiceSkillHomeItem,
slotValues: ServiceSkillSlotValues,
): Promise<void> => {
if (!isServiceSkillSiteCapabilityBound(skill)) {
if (!isServiceSkillExecutableAsSiteAdapter(skill)) {
return;
}
@@ -765,6 +856,16 @@ export function AgentChatHomeShell({
}
const binding = skill.siteCapabilityBinding;
let launchReadiness:
| Awaited<ReturnType<typeof siteGetAdapterLaunchReadiness>>
| null = null;
try {
launchReadiness = await siteGetAdapterLaunchReadiness({
adapter_name: binding.adapterName,
});
} catch {
launchReadiness = null;
}
const saveMode = binding.saveMode ?? "project_resource";
const initialArgs = buildServiceSkillSiteCapabilityArgs(
skill,
@@ -792,6 +893,14 @@ export function AgentChatHomeShell({
const navigationParams: BrowserRuntimePageParams = {
projectId: normalizedProjectId ?? undefined,
contentId,
initialProfileKey:
launchReadiness?.status === "ready"
? launchReadiness.profile_key
: undefined,
initialTargetId:
launchReadiness?.status === "ready"
? launchReadiness.target_id
: undefined,
initialAdapterName: binding.adapterName,
initialArgs,
initialAutoRun: binding.autoRun ?? false,
@@ -817,8 +926,51 @@ export function AgentChatHomeShell({
const handleServiceSkillLaunch = useCallback(
async (skill: ServiceSkillHomeItem, slotValues: ServiceSkillSlotValues) => {
if (isServiceSkillSiteCapabilityBound(skill)) {
await handleServiceSkillBrowserRuntimeLaunch(skill, slotValues);
if (isServiceSkillExecutableAsSiteAdapter(skill)) {
let launchReadiness: Awaited<
ReturnType<typeof siteGetAdapterLaunchReadiness>
>;
try {
launchReadiness = await siteGetAdapterLaunchReadiness({
adapter_name: skill.siteCapabilityBinding.adapterName,
});
} catch (error) {
toast.error(`检测浏览器会话失败:${getErrorMessage(error)}`);
return;
}
if (launchReadiness.status !== "ready") {
toast.error(
launchReadiness.report_hint
? `${launchReadiness.message} ${launchReadiness.report_hint}`
: launchReadiness.message,
);
return;
}
let workspacePayload: HomeShellEnterWorkspacePayload;
try {
workspacePayload = await prepareServiceSkillSiteWorkspacePayload(
skill,
slotValues,
launchReadiness,
);
} catch (error) {
toast.error(`准备站点技能失败:${getErrorMessage(error)}`);
return;
}
const entered = handleEnterWorkspace(workspacePayload);
if (!entered) {
return;
}
recordServiceSkillUsage({
skillId: skill.id,
runnerType: skill.runnerType,
});
setServiceSkillDialogOpen(false);
setSelectedServiceSkill(null);
return;
}
@@ -925,7 +1077,7 @@ export function AgentChatHomeShell({
prompt,
);
} catch (error) {
toast.error(`准备服务型技能工作区失败:${getErrorMessage(error)}`);
toast.error(`准备技能工作区失败:${getErrorMessage(error)}`);
return;
}
@@ -943,10 +1095,10 @@ export function AgentChatHomeShell({
setSelectedServiceSkill(null);
},
[
handleServiceSkillBrowserRuntimeLaunch,
handleEnterWorkspace,
input,
prepareServiceSkillCloudResultWorkspacePayload,
prepareServiceSkillSiteWorkspacePayload,
prepareServiceSkillWorkspacePayload,
recordServiceSkillUsage,
],
@@ -1029,7 +1181,7 @@ export function AgentChatHomeShell({
const handleAutomationDialogSubmit = useCallback(
async (payload: AutomationJobDialogSubmit) => {
if (payload.mode !== "create") {
throw new Error("服务型技能入口当前只支持创建新的本地自动化任务");
throw new Error("当前技能入口只支持创建新的本地自动化任务");
}
setAutomationJobSaving(true);
@@ -1123,7 +1275,7 @@ export function AgentChatHomeShell({
}
const { nextToolPreferences, changed } =
enableSubagentPreference(chatToolPreferences);
enableSubagentPreference(effectiveChatToolPreferences);
if (changed) {
setChatToolPreferences(nextToolPreferences);
@@ -1136,7 +1288,7 @@ export function AgentChatHomeShell({
},
[
activeTheme,
chatToolPreferences,
effectiveChatToolPreferences,
handleEnterWorkspace,
setChatToolPreferences,
],
@@ -1172,28 +1324,21 @@ export function AgentChatHomeShell({
tab: SettingsTabs.Providers,
});
}}
webSearchEnabled={chatToolPreferences.webSearch}
webSearchEnabled={effectiveChatToolPreferences.webSearch}
onWebSearchEnabledChange={(enabled) =>
setChatToolPreferences((previous) => ({
...previous,
webSearch: enabled,
}))
}
thinkingEnabled={chatToolPreferences.thinking}
thinkingEnabled={effectiveChatToolPreferences.thinking}
onThinkingEnabledChange={(enabled) =>
setChatToolPreferences((previous) => ({
...previous,
thinking: enabled,
}))
}
taskEnabled={chatToolPreferences.task}
onTaskEnabledChange={(enabled) =>
setChatToolPreferences((previous) => ({
...previous,
task: enabled,
}))
}
subagentEnabled={chatToolPreferences.subagent}
subagentEnabled={effectiveChatToolPreferences.subagent}
onSubagentEnabledChange={(enabled) =>
setChatToolPreferences((previous) => ({
...previous,
@@ -1221,6 +1366,7 @@ export function AgentChatHomeShell({
<>
<ServiceSkillHomePanel
skills={serviceSkills}
groups={serviceSkillGroups}
catalogMeta={serviceSkillCatalogMeta}
loading={serviceSkillsLoading}
onSelect={handleServiceSkillSelect}
@@ -1242,9 +1388,7 @@ export function AgentChatHomeShell({
isSkillsLoading={skillsLoading}
onSelectServiceSkill={handleServiceSkillSelect}
onNavigateToSettings={() => {
onNavigate?.("settings", {
tab: SettingsTabs.Skills,
});
onNavigate?.("skills");
}}
onRefreshSkills={handleRefreshSkills}
onLaunchBrowserAssist={() => {
@@ -1280,6 +1424,7 @@ export function AgentChatHomeShell({
}}
onLaunch={handleServiceSkillLaunch}
onCreateAutomation={handleServiceSkillAutomationSetup}
onOpenBrowserRuntime={handleServiceSkillBrowserRuntimeLaunch}
/>
<AutomationJobDialog
open={automationDialogOpen}
+223 -61
View File
@@ -25,6 +25,7 @@ import { useCompatSubagentRuntime } from "./hooks/useCompatSubagentRuntime";
import type { TopicBranchStatus } from "./hooks/useTopicBranchBoard";
import { useSessionFiles } from "./hooks/useSessionFiles";
import { useContentSync } from "./hooks/useContentSync";
import { useDeveloperFeatureFlags } from "@/hooks/useDeveloperFeatureFlags";
import { useGlobalMediaGenerationDefaults } from "@/hooks/useGlobalMediaGenerationDefaults";
import { useTrayModelShortcuts } from "./hooks/useTrayModelShortcuts";
import { type CanvasWorkbenchLayoutMode } from "./components/CanvasWorkbenchLayout";
@@ -66,7 +67,6 @@ import {
type Character,
} from "@/lib/api/memory";
import { logAgentDebug } from "@/lib/agentDebug";
import { SettingsTabs } from "@/types/settings";
import { setActiveContentTarget } from "@/lib/activeContentTarget";
import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry";
import { useImageGen } from "@/components/image-gen/useImageGen";
@@ -81,11 +81,11 @@ import type { ThemeType, LayoutMode } from "@/components/content-creator/types";
import { normalizeProjectId } from "./utils/topicProjectResolution";
import { buildHarnessRequestMetadata } from "./utils/harnessRequestMetadata";
import { deriveHarnessSessionState } from "./utils/harnessState";
import { loadChatToolPreferences } from "./utils/chatToolPreferences";
import {
mergeArtifacts,
resolveDefaultArtifactViewMode,
} from "./utils/messageArtifacts";
alignChatToolPreferencesWithExecutionStrategy,
loadChatToolPreferences,
} from "./utils/chatToolPreferences";
import { mergeArtifacts } from "./utils/messageArtifacts";
import {
createChatToolPreferencesFromExecutionRuntime,
createSessionRecentPreferencesFromChatToolPreferences,
@@ -124,6 +124,7 @@ import {
isResumableBrowserTaskReason,
mergeMessageArtifactsIntoStore,
} from "./workspace/browserAssistArtifact";
import { ServiceSkillExecutionCard } from "./workspace/ServiceSkillExecutionCard";
import { useWorkspaceBrowserAssistRuntime } from "./workspace/useWorkspaceBrowserAssistRuntime";
import { useWorkspaceBrowserPreflightRuntime } from "./workspace/useWorkspaceBrowserPreflightRuntime";
import { useWorkspaceA2UISubmitActions } from "./workspace/useWorkspaceA2UISubmitActions";
@@ -164,6 +165,7 @@ import { useWorkspaceContextDetailActions } from "./workspace/useWorkspaceContex
import { useWorkspaceTeamSessionRuntime } from "./workspace/useWorkspaceTeamSessionRuntime";
import { useWorkspaceThemeWorkbenchDocumentPersistenceRuntime } from "./workspace/useWorkspaceThemeWorkbenchDocumentPersistenceRuntime";
import { useWorkspaceServiceSkillEntryActions } from "./workspace/useWorkspaceServiceSkillEntryActions";
import { useWorkspaceArtifactViewModeControl } from "./workspace/useWorkspaceArtifactViewModeControl";
import { resolveArtifactProtocolFilePath } from "@/lib/artifact-protocol";
import type { ArtifactDocumentV1 } from "@/lib/artifact-document";
import type { ArtifactTimelineOpenTarget } from "./utils/artifactTimelineNavigation";
@@ -190,6 +192,28 @@ import { AutomationJobDialog } from "@/components/settings-v2/system/automation/
const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist";
function resolveDefaultSelectedArtifact(
activeTheme: string,
artifacts: Artifact[],
): Artifact | null {
if (artifacts.length === 0) {
return null;
}
if (activeTheme !== "general") {
return artifacts[artifacts.length - 1] ?? null;
}
for (let index = artifacts.length - 1; index >= 0; index -= 1) {
const candidate = artifacts[index];
if (candidate.type !== "browser_assist") {
return candidate;
}
}
return null;
}
export type {
AgentChatWorkspaceProps,
WorkflowProgressSnapshot,
@@ -224,10 +248,11 @@ export function AgentChatWorkspace({
onSessionChange,
preferContentReviewInRightRail = false,
openBrowserAssistOnMount = false,
initialSiteSkillLaunch,
}: AgentChatWorkspaceProps) {
const normalizedEntryTheme = normalizeInitialTheme(initialTheme);
const shouldAutoCollapseClassicClawSidebar =
agentEntry === "claw" && !lockTheme && normalizedEntryTheme === "general";
agentEntry === "claw" && normalizedEntryTheme === "general";
const defaultTopicSidebarVisible =
showChatPanel && !shouldAutoCollapseClassicClawSidebar;
const [showSidebar, setShowSidebar] = useState(
@@ -423,6 +448,7 @@ export function AgentChatWorkspace({
const [projectMemory, setProjectMemory] = useState<ProjectMemory | null>(
null,
);
const { workspaceHarnessEnabled } = useDeveloperFeatureFlags();
const { mediaDefaults } = useGlobalMediaGenerationDefaults();
const effectiveImageWorkbenchPreference = useMemo(
() =>
@@ -562,7 +588,7 @@ export function AgentChatWorkspace({
return;
}
toast.error(`加载服务型技能失败:${serviceSkillsError}`);
toast.error(`加载技能目录失败:${serviceSkillsError}`);
}, [activeTheme, serviceSkillsError]);
const combinedSkillsLoading = skillsLoading || serviceSkillsLoading;
@@ -614,6 +640,7 @@ export function AgentChatWorkspace({
// Artifact 状态 - 用于在画布中显示
const artifacts = useAtomValue(artifactsAtom);
const selectedArtifactId = useAtomValue(selectedArtifactIdAtom);
const selectedArtifact = useAtomValue(selectedArtifactAtom);
const setArtifacts = useSetAtom(artifactsAtom);
const setSelectedArtifactId = useSetAtom(selectedArtifactIdAtom);
@@ -625,17 +652,16 @@ export function AgentChatWorkspace({
},
[setArtifacts],
);
const defaultSelectedArtifact = useMemo(
() => resolveDefaultSelectedArtifact(activeTheme, artifacts),
[activeTheme, artifacts],
);
const liveArtifact = useMemo(
() =>
selectedArtifact ||
(artifacts.length > 0 ? artifacts[artifacts.length - 1] : null),
[artifacts, selectedArtifact],
() => selectedArtifact || defaultSelectedArtifact,
[defaultSelectedArtifact, selectedArtifact],
);
// Artifact 预览状态
const [artifactViewMode, setArtifactViewMode] = useState<
"source" | "preview"
>("source");
const [artifactPreviewSize, setArtifactPreviewSize] = useState<
"mobile" | "tablet" | "desktop"
>("desktop");
@@ -661,9 +687,9 @@ export function AgentChatWorkspace({
setBrowserTaskPreflight(null);
}, [activeTheme]);
// 跳转到设置页安装技能
// 跳转到技能主页面
const handleNavigateToSkillSettings = useCallback(() => {
_onNavigate?.("settings", { tab: SettingsTabs.Skills });
_onNavigate?.("skills");
}, [_onNavigate]);
const handleOpenSavedSiteContent = useCallback(
({ projectId, contentId }: SiteSavedContentTarget) => {
@@ -1094,6 +1120,8 @@ export function AgentChatWorkspace({
setModel,
executionStrategy,
setExecutionStrategy,
accessMode,
setAccessMode,
messages = [],
setMessages: setChatMessages,
currentTurnId,
@@ -1142,6 +1170,25 @@ export function AgentChatWorkspace({
getSyncedSessionRecentPreferences,
});
activeSessionIdRef.current = sessionId;
const clawSidebarEntryResetKey = shouldAutoCollapseClassicClawSidebar
? JSON.stringify({
projectId: externalProjectId ?? null,
contentId: contentId ?? null,
sessionId: sessionId ?? null,
theme: normalizedEntryTheme,
newChatAt: newChatAt ?? null,
})
: null;
useEffect(() => {
if (!shouldAutoCollapseClassicClawSidebar) {
return;
}
autoCollapsedTopicSidebarRef.current = false;
setShowSidebar(false);
}, [clawSidebarEntryResetKey, shouldAutoCollapseClassicClawSidebar]);
const {
selectedTeam,
setSelectedTeam: handleSelectTeam,
@@ -1173,18 +1220,41 @@ export function AgentChatWorkspace({
() => createChatToolPreferencesFromExecutionRuntime(executionRuntime),
[executionRuntime],
);
const effectiveChatToolPreferences = useMemo(
() =>
alignChatToolPreferencesWithExecutionStrategy(
chatToolPreferences,
executionStrategy,
),
[chatToolPreferences, executionStrategy],
);
useEffect(() => {
syncChatToolPreferencesSource(activeTheme, runtimeChatToolPreferences);
}, [activeTheme, runtimeChatToolPreferences, syncChatToolPreferencesSource]);
useEffect(() => {
if (chatToolPreferences.task === effectiveChatToolPreferences.task) {
return;
}
setChatToolPreferences(effectiveChatToolPreferences);
}, [
chatToolPreferences.task,
effectiveChatToolPreferences,
setChatToolPreferences,
]);
useEffect(() => {
const trimmedSessionId = sessionId?.trim();
if (!trimmedSessionId || runtimeChatToolPreferences) {
return;
}
const fallbackPreferences = loadChatToolPreferences(activeTheme);
const fallbackPreferences = alignChatToolPreferencesWithExecutionStrategy(
loadChatToolPreferences(activeTheme),
executionStrategy,
);
const backfillKey = `${trimmedSessionId}:${JSON.stringify([
fallbackPreferences.webSearch,
fallbackPreferences.thinking,
@@ -1204,6 +1274,7 @@ export function AgentChatWorkspace({
});
}, [
activeTheme,
executionStrategy,
runtimeChatToolPreferences,
sessionId,
syncSessionRecentPreferences,
@@ -1218,7 +1289,7 @@ export function AgentChatWorkspace({
projectId,
sessionId,
selectedTeam,
subagentEnabled: chatToolPreferences.subagent,
subagentEnabled: effectiveChatToolPreferences.subagent,
hasRealTeamGraph,
});
const {
@@ -1275,7 +1346,7 @@ export function AgentChatWorkspace({
turns,
queuedTurnCount: queuedTurns.length,
isSending,
subagentEnabled: chatToolPreferences.subagent,
subagentEnabled: effectiveChatToolPreferences.subagent,
childSubagentSessions,
subagentParentContext,
});
@@ -1378,8 +1449,24 @@ export function AgentChatWorkspace({
);
const currentCanvasArtifact = artifactDisplayState.liveArtifact;
const displayedCanvasArtifact = artifactDisplayState.displayArtifact;
const activeArtifactViewTargetId =
displayedCanvasArtifact?.id ||
currentCanvasArtifact?.id ||
selectedArtifact?.id ||
liveArtifact?.id ||
null;
const {
artifactViewMode,
applyAutoArtifactViewMode,
handleArtifactViewModeChange,
} = useWorkspaceArtifactViewModeControl({
activeTheme,
displayedArtifact: displayedCanvasArtifact,
activeArtifactId: activeArtifactViewTargetId,
});
const {
browserAssistLaunching,
siteSkillExecutionState,
isBrowserAssistReady,
isBrowserAssistCanvasVisible,
currentBrowserAssistScopeKey,
@@ -1391,9 +1478,12 @@ export function AgentChatWorkspace({
activeTheme,
projectId,
sessionId,
contentId,
input,
initialUserPrompt,
openBrowserAssistOnMount,
initialSiteSkillLaunch,
siteSkillLaunchNonce: newChatAt,
artifacts,
messages,
currentCanvasArtifact,
@@ -1403,6 +1493,48 @@ export function AgentChatWorkspace({
upsertGeneralArtifact,
generalBrowserAssistProfileKey: GENERAL_BROWSER_ASSIST_PROFILE_KEY,
});
const handleOpenBrowserRuntimeForSiteSkillExecution = useCallback(() => {
if (!_onNavigate || !initialSiteSkillLaunch?.adapterName?.trim()) {
return;
}
_onNavigate("browser-runtime", {
projectId: projectId ?? undefined,
contentId: contentId ?? undefined,
initialProfileKey:
siteSkillExecutionState?.profileKey || initialSiteSkillLaunch.profileKey,
initialTargetId:
siteSkillExecutionState?.targetId || initialSiteSkillLaunch.targetId,
initialAdapterName: initialSiteSkillLaunch.adapterName,
initialArgs: initialSiteSkillLaunch.args,
initialAutoRun: false,
initialRequireAttachedSession: true,
initialSaveTitle: initialSiteSkillLaunch.saveTitle,
});
}, [
contentId,
initialSiteSkillLaunch,
_onNavigate,
projectId,
siteSkillExecutionState?.profileKey,
siteSkillExecutionState?.targetId,
]);
const serviceSkillExecutionCard = useMemo(
() => (
<ServiceSkillExecutionCard
state={siteSkillExecutionState}
onOpenBrowserRuntime={
siteSkillExecutionState?.phase === "blocked"
? handleOpenBrowserRuntimeForSiteSkillExecution
: undefined
}
/>
),
[
handleOpenBrowserRuntimeForSiteSkillExecution,
siteSkillExecutionState,
],
);
const compatSubagentRuntime = useCompatSubagentRuntime(sessionId);
const realSubagentTimelineItems = useMemo(
@@ -1468,40 +1600,45 @@ export function AgentChatWorkspace({
useEffect(() => {
if (activeTheme !== "general") {
setSelectedArtifactId(null);
return;
}
if (artifacts.length === 0) {
if (selectedArtifact) {
if (selectedArtifactId !== null) {
setSelectedArtifactId(null);
}
return;
}
if (artifacts.length === 0) {
if (selectedArtifactId !== null) {
setSelectedArtifactId(null);
}
return;
}
const fallbackArtifactId = defaultSelectedArtifact?.id || null;
if (!selectedArtifact) {
setSelectedArtifactId(artifacts[artifacts.length - 1]?.id || null);
if (selectedArtifactId !== fallbackArtifactId) {
setSelectedArtifactId(fallbackArtifactId);
}
return;
}
const selectedStillExists = artifacts.some(
(artifact) => artifact.id === selectedArtifact.id,
);
if (!selectedStillExists) {
setSelectedArtifactId(artifacts[artifacts.length - 1]?.id || null);
if (!selectedStillExists && selectedArtifactId !== fallbackArtifactId) {
setSelectedArtifactId(fallbackArtifactId);
}
}, [activeTheme, artifacts, selectedArtifact, setSelectedArtifactId]);
useEffect(() => {
if (activeTheme !== "general" || !displayedCanvasArtifact) {
return;
}
setArtifactViewMode(
resolveDefaultArtifactViewMode(displayedCanvasArtifact),
);
}, [activeTheme, displayedCanvasArtifact]);
}, [
activeTheme,
artifacts,
defaultSelectedArtifact,
selectedArtifact,
selectedArtifactId,
setSelectedArtifactId,
]);
const contextHarnessRuntime = useWorkspaceContextHarnessRuntime({
enabled: workspaceHarnessEnabled,
projectId,
activeTheme,
messages,
@@ -1554,12 +1691,16 @@ export function AgentChatWorkspace({
const {
a2uiSubmissionNotice,
pendingA2UIForm,
pendingA2UISource,
pendingActionRequest,
pendingLegacyQuestionnaireA2UIForm,
pendingPromotedA2UIActionRequest,
resolvePendingA2UISubmit,
} = useWorkspaceA2UIRuntime({
messages,
});
const hasPendingA2UIForm = Boolean(pendingA2UIForm);
const suppressCanvasAutoOpenForPendingA2UI = hasPendingA2UIForm;
const {
currentGate,
@@ -1595,10 +1736,10 @@ export function AgentChatWorkspace({
buildHarnessRequestMetadata({
theme: mappedTheme,
preferences: {
webSearch: chatToolPreferences.webSearch,
thinking: chatToolPreferences.thinking,
task: chatToolPreferences.task,
subagent: chatToolPreferences.subagent,
webSearch: effectiveChatToolPreferences.webSearch,
thinking: effectiveChatToolPreferences.thinking,
task: effectiveChatToolPreferences.task,
subagent: effectiveChatToolPreferences.subagent,
},
sessionMode: isThemeWorkbench ? "theme_workbench" : "default",
gateKey: isThemeWorkbench ? currentGate.key : undefined,
@@ -1617,10 +1758,10 @@ export function AgentChatWorkspace({
selectedTeamRoles: selectedTeam?.roles,
}),
[
chatToolPreferences.subagent,
chatToolPreferences.task,
chatToolPreferences.thinking,
chatToolPreferences.webSearch,
effectiveChatToolPreferences.subagent,
effectiveChatToolPreferences.task,
effectiveChatToolPreferences.thinking,
effectiveChatToolPreferences.webSearch,
contentId,
currentGate.key,
isThemeWorkbench,
@@ -1636,6 +1777,7 @@ export function AgentChatWorkspace({
],
);
const harnessInventoryRuntime = useWorkspaceHarnessInventoryRuntime({
enabled: workspaceHarnessEnabled,
chatMode,
mappedTheme,
harnessPanelVisible,
@@ -1867,7 +2009,6 @@ export function AgentChatWorkspace({
messages,
processedMessageIdsRef: processedMessageIds,
setCanvasState,
setLayoutMode,
});
const imageWorkbenchActionRuntime = useWorkspaceImageWorkbenchActionRuntime({
@@ -1898,7 +2039,7 @@ export function AgentChatWorkspace({
setInput,
mentionedCharacters,
setMentionedCharacters,
chatToolPreferences,
chatToolPreferences: effectiveChatToolPreferences,
setChatToolPreferences,
activeTheme,
mappedTheme,
@@ -1910,6 +2051,7 @@ export function AgentChatWorkspace({
runtimeStyleMessagePrompt,
projectId,
executionStrategy,
accessMode,
preferredTeamPresetId,
selectedTeam,
selectedTeamLabel,
@@ -1942,7 +2084,7 @@ export function AgentChatWorkspace({
await handleSendRef.current(
[],
webSearchPreferenceRef.current,
chatToolPreferences.thinking,
effectiveChatToolPreferences.thinking,
promptToSend,
);
},
@@ -1996,10 +2138,14 @@ export function AgentChatWorkspace({
handlePermissionResponseWithBrowserPreflight,
pendingLegacyQuestionnaireA2UIForm,
pendingPromotedA2UIActionRequest,
resolvePendingA2UISubmit,
sendMessage,
});
const handleMessageA2UISubmit = useCallback(
(formData: Parameters<typeof handleInputbarA2UISubmit>[0], _messageId: string) => {
(
formData: Parameters<typeof handleInputbarA2UISubmit>[0],
_messageId: string,
) => {
handleInputbarA2UISubmit(formData);
},
[handleInputbarA2UISubmit],
@@ -2070,6 +2216,7 @@ export function AgentChatWorkspace({
} = useWorkspaceCanvasLayoutRuntime({
activeTheme,
isThemeWorkbench,
hasPendingA2UIForm,
layoutMode,
showChatPanel,
showSidebar,
@@ -2108,7 +2255,6 @@ export function AgentChatWorkspace({
documentEditorFocusedRef,
setSelectedFileId,
setCanvasState,
setLayoutMode,
upsertNovelCanvasState,
});
@@ -2158,8 +2304,9 @@ export function AgentChatWorkspace({
syncGeneralArtifactToResource,
upsertGeneralArtifact,
setSelectedArtifactId,
setArtifactViewMode,
setArtifactViewMode: applyAutoArtifactViewMode,
setLayoutMode,
suppressCanvasAutoOpen: suppressCanvasAutoOpenForPendingA2UI,
completeStep,
setTaskFiles,
setSelectedFileId,
@@ -2240,9 +2387,10 @@ export function AgentChatWorkspace({
taskFiles,
sessionFiles,
readSessionFile,
suppressBrowserAssistCanvasAutoOpen,
upsertGeneralArtifact,
setSelectedArtifactId,
setArtifactViewMode,
setArtifactViewMode: applyAutoArtifactViewMode,
setLayoutMode,
setTaskFiles,
setSelectedFileId,
@@ -2309,7 +2457,7 @@ export function AgentChatWorkspace({
shouldSkipThemeWorkbenchAutoGuideWithoutPrompt,
themeWorkbenchEntryCheckPending,
themeWorkbenchEntryPrompt,
chatToolPreferences,
chatToolPreferences: effectiveChatToolPreferences,
setInput,
handleSend,
triggerAIGuide,
@@ -2355,6 +2503,7 @@ export function AgentChatWorkspace({
const themeWorkbenchShellRuntime = useWorkspaceThemeWorkbenchShellRuntime({
showChatPanel,
showSidebar,
hasPendingA2UIForm,
contextHarnessRuntime,
themeWorkbenchScaffoldRuntime,
themeWorkbenchSidebarRuntime,
@@ -2401,7 +2550,7 @@ export function AgentChatWorkspace({
projectId,
contentId,
input,
chatToolPreferences,
chatToolPreferences: effectiveChatToolPreferences,
onNavigate: _onNavigate,
recordServiceSkillUsage,
});
@@ -2443,6 +2592,8 @@ export function AgentChatWorkspace({
projectId: projectId ?? null,
executionStrategy,
setExecutionStrategy,
accessMode,
setAccessMode,
activeTheme,
navigationActions,
selectedTeam,
@@ -2479,7 +2630,7 @@ export function AgentChatWorkspace({
themeWorkbenchEntryPrompt,
handleRestartThemeWorkbenchEntryPrompt,
handleContinueThemeWorkbenchEntryPrompt,
generalWorkbenchEnabled: chatMode === "general",
generalWorkbenchEnabled: workspaceHarnessEnabled && chatMode === "general",
contextHarnessRuntime,
harnessState,
compatSubagentRuntime,
@@ -2489,7 +2640,7 @@ export function AgentChatWorkspace({
handleFileClick: handleWorkspaceFileClick,
shellChromeRuntime,
handleActivateTeamWorkbench,
chatToolPreferences,
chatToolPreferences: effectiveChatToolPreferences,
});
const canvasScene = useWorkspaceCanvasSceneRuntime({
@@ -2510,7 +2661,7 @@ export function AgentChatWorkspace({
displayedCanvasArtifact,
artifactDisplayState,
artifactViewMode,
setArtifactViewMode,
setArtifactViewMode: handleArtifactViewModeChange,
artifactPreviewSize,
setArtifactPreviewSize,
onSaveArtifactDocument: handleSaveArtifactDocument,
@@ -2534,7 +2685,7 @@ export function AgentChatWorkspace({
setProviderType,
model,
setModel,
documentThinkingEnabled: chatToolPreferences.thinking,
documentThinkingEnabled: effectiveChatToolPreferences.thinking,
handleDocumentThinkingEnabledChange,
handleDocumentAutoContinueRun,
handleAddImage,
@@ -2579,6 +2730,7 @@ export function AgentChatWorkspace({
handleReturnToParentSession,
entryBannerVisible,
entryBannerMessage,
serviceSkillExecutionCard,
contextWorkspaceEnabled: contextWorkspace.enabled,
input,
setInput,
@@ -2588,7 +2740,9 @@ export function AgentChatWorkspace({
setModel,
executionStrategy,
setExecutionStrategy,
chatToolPreferences,
accessMode,
setAccessMode,
chatToolPreferences: effectiveChatToolPreferences,
setChatToolPreferences,
selectedTeam,
handleSelectTeam,
@@ -2627,6 +2781,9 @@ export function AgentChatWorkspace({
sessionId,
syncStatus,
pendingA2UIForm,
pendingA2UISource,
a2uiSubmissionNotice,
handlePendingA2UISubmit: handleInputbarA2UISubmit,
handleToggleCanvas,
hideInlineStepProgress,
isContentCreationMode,
@@ -2693,6 +2850,9 @@ export function AgentChatWorkspace({
onCreateAutomation={
workspaceServiceSkillEntryActions.handleServiceSkillAutomationSetup
}
onOpenBrowserRuntime={
workspaceServiceSkillEntryActions.handleServiceSkillBrowserRuntimeLaunch
}
/>
<AutomationJobDialog
open={workspaceServiceSkillEntryActions.automationDialogOpen}
@@ -2705,7 +2865,9 @@ export function AgentChatWorkspace({
onOpenChange={
workspaceServiceSkillEntryActions.handleAutomationDialogOpenChange
}
onSubmit={workspaceServiceSkillEntryActions.handleAutomationDialogSubmit}
onSubmit={
workspaceServiceSkillEntryActions.handleAutomationDialogSubmit
}
/>
</>
);
@@ -2,6 +2,7 @@ import type { CreationMode } from "./components/types";
import type { MessageImage } from "./types";
import type { StepStatus } from "@/components/content-creator/types";
import type { Page, PageParams } from "@/types/page";
import type { AgentSiteSkillLaunchParams } from "@/types/page";
export interface WorkflowProgressSnapshot {
steps: Array<{
@@ -44,4 +45,5 @@ export interface AgentChatWorkspaceProps {
onSessionChange?: (sessionId: string | null) => void;
preferContentReviewInRightRail?: boolean;
openBrowserAssistOnMount?: boolean;
initialSiteSkillLaunch?: AgentSiteSkillLaunchParams;
}
@@ -32,7 +32,6 @@ export async function executeCodexSlashCommand(
createFreshSession,
appendAssistantMessage,
notifyInfo,
notifySuccess,
} = params;
switch (command.definition.key) {
@@ -62,12 +61,7 @@ export async function executeCodexSlashCommand(
return true;
}
const sessionName = command.userInput.trim() || undefined;
const nextSessionId = await createFreshSession(sessionName);
if (nextSessionId) {
notifySuccess(
sessionName ? `已创建新任务:${sessionName}` : "已创建新任务",
);
}
await createFreshSession(sessionName);
return true;
}
case "help":
@@ -28,7 +28,7 @@ export function buildCodexSlashHelpMessage(): string {
.join("、");
return [
"可用 Codex 命令:",
"可用 Lime 命令:",
...supported,
"",
`暂未支持:${unsupported}`,
@@ -1,8 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
A2UITaskCard,
A2UITaskLoadingCard,
} from "./A2UITaskCard";
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
import { CHAT_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets";
import {
cleanupMountedRoots,
@@ -47,7 +44,9 @@ describe("A2UITaskCard", () => {
mountedRoots,
);
expect(container.querySelector("[data-testid='agent-a2ui-task-card']")).not.toBeNull();
expect(
container.querySelector("[data-testid='agent-a2ui-task-card']"),
).not.toBeNull();
expect(container.textContent).toContain("补充信息");
expect(container.textContent).toContain("等你确认");
@@ -79,4 +78,40 @@ describe("A2UITaskCard", () => {
expect(container.textContent).toContain("正在解析结构化问题,请稍等。");
expect(container.textContent).toContain("这一步加载中...");
});
it("内嵌表面模式应压平外层卡壳但保持表单可提交", async () => {
const submitSpy = vi.fn();
const { container } = mountHarness(
A2UITaskCard,
{
response: buildCreateConfirmationA2UI(pendingConfirmation),
onSubmit: submitSpy,
preset: CHAT_A2UI_TASK_CARD_PRESET,
compact: true,
surface: "embedded",
},
mountedRoots,
);
expect(
container.querySelector<HTMLElement>(
"[data-testid='agent-a2ui-task-card']",
)?.dataset.surface,
).toBe("embedded");
expect(container.querySelector(".a2ui-container")?.className).toContain(
"space-y-2.5",
);
expect(container.querySelector(".a2ui-choice-option")).not.toBeNull();
clickButtonByText(container, "新写一篇内容");
await flushEffects();
clickButtonByText(container, "开始处理");
await flushEffects();
expect(submitSpy).toHaveBeenCalledWith(
expect.objectContaining({
create_confirmation_option: ["new_post"],
}),
);
});
});
@@ -1,4 +1,5 @@
import { A2UIRenderer } from "@/components/content-creator/a2ui/components";
import { A2UIRenderer } from "@/components/content-creator/a2ui";
import { cn } from "@/lib/utils";
import type {
A2UIFormData,
A2UIResponse,
@@ -12,6 +13,7 @@ import {
A2UITaskCardHeader,
A2UITaskCardLoadingBody,
A2UITaskCardShell,
type A2UITaskCardSurface,
} from "@/components/content-creator/a2ui/taskCardPrimitives";
export interface A2UITaskCardProps {
@@ -30,6 +32,7 @@ export interface A2UITaskCardProps {
statusLabel?: string;
footerText?: string;
preview?: boolean;
surface?: A2UITaskCardSurface;
}
interface A2UITaskLoadingCardProps {
@@ -56,6 +59,17 @@ function getCardCopy(
};
}
function getRendererClassName(
compact: boolean,
surface: A2UITaskCardSurface,
): string {
return cn(
compact ? "space-y-3" : "space-y-4",
surface === "embedded" &&
"space-y-2.5 text-[13px] leading-5 [&_.a2ui-text-h3]:text-[15px] [&_.a2ui-text-h3]:leading-6 [&_.a2ui-text-h3]:font-semibold [&_.a2ui-text-h4]:text-[13px] [&_.a2ui-text-h4]:leading-5 [&_.a2ui-text-h4]:font-medium [&_.a2ui-text-body]:text-[13px] [&_.a2ui-text-body]:leading-5 [&_.a2ui-text-body]:text-slate-700 [&_.a2ui-field-stack]:space-y-1.5 [&_.a2ui-field-label]:text-[12px] [&_.a2ui-field-label]:leading-5 [&_.a2ui-helper-text]:text-[11px] [&_.a2ui-helper-text]:leading-4 [&_.a2ui-option-list]:gap-2.5 [&_.a2ui-choice-option]:rounded-[16px] [&_.a2ui-choice-option]:px-3.5 [&_.a2ui-choice-option]:py-3 [&_.a2ui-choice-option]:text-[13px] [&_.a2ui-choice-option-title]:text-[13px] [&_.a2ui-choice-option-title]:leading-5 [&_.a2ui-option-description]:mt-1 [&_.a2ui-option-description]:text-[11px] [&_.a2ui-option-description]:leading-4 [&_.a2ui-text-input]:h-10 [&_.a2ui-text-input]:rounded-[16px] [&_.a2ui-text-input]:px-3 [&_.a2ui-text-input]:text-[13px] [&_.a2ui-textarea]:min-h-[84px] [&_.a2ui-textarea]:rounded-[16px] [&_.a2ui-textarea]:px-3 [&_.a2ui-textarea]:py-2.5 [&_.a2ui-textarea]:text-[13px] [&_.a2ui-textarea]:leading-5 [&_.a2ui-card-shell]:rounded-[16px] [&_.a2ui-card-shell]:border-slate-200/90 [&_.a2ui-card-shell]:bg-white [&_.a2ui-card-shell]:p-3 [&_.a2ui-card-shell]:shadow-none",
);
}
export function A2UITaskCard({
response,
onSubmit,
@@ -72,14 +86,21 @@ export function A2UITaskCard({
statusLabel = preset.statusLabel,
footerText,
preview = false,
surface = "default",
}: A2UITaskCardProps) {
const copy = getCardCopy(compact, preset, title, subtitle);
const rendererClassName = getRendererClassName(compact, surface);
const submitButtonClassName = cn(
"w-full",
surface === "embedded" && "h-10 rounded-[16px] px-4 text-[13px]",
);
return (
<A2UITaskCardShell
compact={compact}
className={className}
preview={preview}
surface={surface}
testId="agent-a2ui-task-card"
>
<A2UITaskCardHeader
@@ -87,10 +108,12 @@ export function A2UITaskCard({
subtitle={copy.subtitle}
compact={compact}
statusLabel={statusLabel}
surface={surface}
/>
<A2UITaskCardBody compact={compact}>
<A2UITaskCardBody compact={compact} surface={surface}>
<A2UIRenderer
key={response.id}
response={response}
onSubmit={onSubmit}
onFormStateChange={onFormStateChange}
@@ -98,8 +121,8 @@ export function A2UITaskCard({
initialFormData={initialFormData}
onFormChange={onFormChange}
submitDisabled={submitDisabled}
submitButtonClassName="w-full"
className={compact ? "space-y-3" : "space-y-4"}
submitButtonClassName={submitButtonClassName}
className={rendererClassName}
/>
</A2UITaskCardBody>
@@ -134,10 +157,7 @@ export function A2UITaskLoadingCard({
statusLabel={statusLabel}
/>
<A2UITaskCardLoadingBody
compact={compact}
text={loadingText || ""}
/>
<A2UITaskCardLoadingBody compact={compact} text={loadingText || ""} />
</A2UITaskCardShell>
);
}
@@ -10,7 +10,6 @@ import type { ChatToolPreferences } from "../utils/chatToolPreferences";
import type { CompatSubagentRuntimeStatus } from "../utils/compatSubagentRuntime";
import type { HarnessSessionState } from "../utils/harnessState";
import {
getExecutionRuntimeDisplayLabel,
getOutputSchemaRuntimeLabel,
} from "../utils/sessionExecutionRuntime";
@@ -77,7 +76,6 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
label: "联网搜索",
enabled: toolPreferences.webSearch,
},
{ key: "task", label: "后台任务", enabled: toolPreferences.task },
{ key: "subagent", label: "多代理", enabled: toolPreferences.subagent },
],
[toolPreferences],
@@ -85,10 +83,6 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
const statusItems = useMemo<StatusItem[]>(() => {
const nextItems: StatusItem[] = [];
const executionRuntimeLabel = getExecutionRuntimeDisplayLabel(
executionRuntime,
{ active: isExecutionRuntimeActive },
);
const outputSchemaLabel = getOutputSchemaRuntimeLabel(
executionRuntime?.output_schema_runtime,
);
@@ -114,19 +108,11 @@ export const AgentRuntimeStrip: React.FC<AgentRuntimeStripProps> = ({
});
}
if (executionRuntimeLabel) {
nextItems.push({
key: "execution_runtime",
label: executionRuntimeLabel,
tone: isExecutionRuntimeActive ? "secondary" : "outline",
});
}
if (outputSchemaLabel) {
nextItems.push({
key: "output_schema_runtime",
label: `结构化输出 ${outputSchemaLabel}`,
tone: "outline",
tone: isExecutionRuntimeActive ? "secondary" : "outline",
});
}
@@ -63,6 +63,8 @@ const mockToolCallItem = vi.fn(
({
toolCall,
onOpenSavedSiteContent,
grouped,
groupMarker,
}: {
toolCall: { name: string; arguments?: string; status?: string };
onOpenSavedSiteContent?: (target: {
@@ -70,10 +72,14 @@ const mockToolCallItem = vi.fn(
contentId: string;
title?: string;
}) => void;
grouped?: boolean;
groupMarker?: string;
}) => (
<div
data-testid="tool-call-item"
data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"}
data-grouped={grouped ? "yes" : "no"}
data-group-marker={groupMarker || ""}
>
{resolveMockToolText(toolCall)}
{toolCall.status === "running" ? " 进行中" : ""}
@@ -104,6 +110,8 @@ vi.mock("./ToolCallDisplay", () => ({
contentId: string;
title?: string;
}) => void;
grouped?: boolean;
groupMarker?: string;
}) => mockToolCallItem(props),
}));
@@ -273,6 +281,29 @@ function createFileArtifactItem(
};
}
function createStructuredA2UIParseResult() {
return {
parts: [
{ type: "text" as const, content: "请先确认以下选项:" },
{
type: "a2ui" as const,
content: {
id: "form-1",
root: "root",
components: [],
submitAction: {
label: "提交",
action: { name: "submit" },
},
},
},
],
hasA2UI: true,
hasWriteFile: false,
hasPending: false,
};
}
describe("AgentThreadTimeline", () => {
it("默认直接渲染内联时间线,不再显示旧摘要壳", () => {
const items: AgentThreadItem[] = [
@@ -318,7 +349,7 @@ describe("AgentThreadTimeline", () => {
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("打开了 https://mp.weixin.qq.com");
expect(container.textContent).toContain("请确认是否发布文章");
});
@@ -330,7 +361,7 @@ describe("AgentThreadTimeline", () => {
const heroJumpButton = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.includes("跳到 block hero-1"));
).find((button) => button.textContent?.includes("定位到 摘要"));
expect(heroJumpButton).not.toBeUndefined();
act(() => {
@@ -366,7 +397,7 @@ describe("AgentThreadTimeline", () => {
);
const block = container.querySelector<HTMLDetailsElement>(
'[data-testid="agent-thread-block:1:browser"]',
'[data-testid="agent-thread-block:1:process"]',
);
const focusedEntry = container.querySelector<HTMLElement>(
'[data-thread-item-id="browser-1"]',
@@ -427,7 +458,7 @@ describe("AgentThreadTimeline", () => {
'[data-testid="agent-thread-block:1:approval"]',
);
const otherGroup = container.querySelector<HTMLElement>(
'[data-testid="agent-thread-block:2:other"]',
'[data-testid="agent-thread-block:2:process"]',
);
expect(approvalGroup).not.toBeNull();
@@ -471,14 +502,56 @@ describe("AgentThreadTimeline", () => {
!value.endsWith(":details"),
);
expect(blockIds).toEqual([
"agent-thread-block:1:browser",
"agent-thread-block:2:thinking",
"agent-thread-block:3:search",
]);
expect(blockIds).toEqual(["agent-thread-block:1:process"]);
});
it("运行中的块应高亮,已完成块应降噪", () => {
it("同类多工具步骤应显示批次数量并切成轻量子行", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("browser-1", 1),
type: "tool_call",
tool_name: "browser_navigate",
arguments: { url: "https://example.com" },
},
{
...createBaseItem("browser-2", 2),
type: "tool_call",
tool_name: "browser_click",
arguments: { selector: "#publish" },
},
];
const container = renderTimeline(items, {
turn: {
status: "completed",
},
});
const block = container.querySelector<HTMLDetailsElement>(
'[data-testid="agent-thread-block:1:process"]',
);
const summary = block?.querySelector("summary");
expect(summary?.textContent).toContain("2 步");
expect(summary?.textContent).toContain("2 个工具步骤");
expect(container.querySelectorAll('[data-testid="tool-call-item"]')).toHaveLength(0);
act(() => {
summary?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const toolRows = Array.from(
container.querySelectorAll<HTMLElement>('[data-testid="tool-call-item"]'),
);
expect(toolRows).toHaveLength(2);
expect(toolRows[0]?.dataset.grouped).toBe("yes");
expect(toolRows[0]?.dataset.groupMarker).toBe("└");
expect(toolRows[1]?.dataset.grouped).toBe("yes");
expect(toolRows[1]?.dataset.groupMarker).toBe("·");
});
it("连续执行流里有运行中步骤时,应聚合为一个高亮过程块", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("browser-1", 1),
@@ -503,19 +576,11 @@ describe("AgentThreadTimeline", () => {
];
const container = renderTimeline(items, { isCurrentTurn: true });
const browserBlock = container.querySelector<HTMLElement>(
'[data-testid="agent-thread-block:1:browser"]',
);
const searchBlock = container.querySelector<HTMLElement>(
'[data-testid="agent-thread-block:2:search"]',
);
const otherBlock = container.querySelector<HTMLElement>(
'[data-testid="agent-thread-block:3:other"]',
const processBlock = container.querySelector<HTMLElement>(
'[data-testid="agent-thread-block:1:process"]',
);
expect(browserBlock?.dataset.emphasis).toBe("quiet");
expect(searchBlock?.dataset.emphasis).toBe("active");
expect(otherBlock?.dataset.emphasis).toBe("quiet");
expect(processBlock?.dataset.emphasis).toBe("active");
expect(container.textContent).toContain("Mac mini 最新价格");
});
@@ -577,26 +642,7 @@ describe("AgentThreadTimeline", () => {
});
it("思考摘要中的 A2UI 代码块应切换为结构化预览", () => {
parseAIResponseMock.mockReturnValue({
parts: [
{ type: "text", content: "请先确认以下选项:" },
{
type: "a2ui",
content: {
id: "form-1",
root: "root",
components: [],
submitAction: {
label: "提交",
action: { name: "submit" },
},
},
},
],
hasA2UI: true,
hasWriteFile: false,
hasPending: false,
});
parseAIResponseMock.mockReturnValue(createStructuredA2UIParseResult());
const items: AgentThreadItem[] = [
{
@@ -616,6 +662,93 @@ describe("AgentThreadTimeline", () => {
},
});
expect(
container.querySelector('[data-testid="timeline-a2ui-card"]'),
).not.toBeNull();
expect(container.textContent).toContain("处理中");
expect(container.textContent).toContain("请先确认以下选项:");
expect(container.textContent).not.toContain("```a2ui");
});
it("turn_summary 完成态不应显示已完成思考这类思考标签", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("summary-1", 1),
type: "turn_summary",
text: "直接回答优先\n当前请求无需默认升级为搜索或任务。",
},
];
const container = renderTimeline(items, {
turn: {
status: "completed",
},
});
expect(container.textContent).toContain("直接回答优先");
expect(container.textContent).not.toContain("已完成思考");
});
it("reasoning 中的 A2UI 代码块不应被跳过", () => {
parseAIResponseMock.mockReturnValue(createStructuredA2UIParseResult());
const items: AgentThreadItem[] = [
{
...createBaseItem("reasoning-1", 1),
status: "in_progress",
completed_at: undefined,
updated_at: at(1),
type: "reasoning",
text: "```a2ui\n{}\n```",
},
];
const container = renderTimeline(items, {
isCurrentTurn: true,
turn: {
status: "running",
},
});
expect(
container.querySelector('[data-testid="timeline-a2ui-card"]'),
).not.toBeNull();
expect(container.textContent).toContain("请先确认以下选项:");
expect(container.textContent).not.toContain("```a2ui");
});
it("已完成 reasoning 中的 A2UI 代码块展开后应显示结构化预览", () => {
parseAIResponseMock.mockReturnValue(createStructuredA2UIParseResult());
const items: AgentThreadItem[] = [
{
...createBaseItem("reasoning-1", 1),
type: "reasoning",
text: "```a2ui\n{}\n```",
},
];
const container = renderTimeline(items, {
turn: {
status: "completed",
},
});
const block = container.querySelector<HTMLDetailsElement>(
'[data-testid="agent-thread-block:1:process"]',
);
const summary = block?.querySelector("summary");
expect(block?.open).toBe(false);
expect(
container.querySelector('[data-testid="timeline-a2ui-card"]'),
).toBeNull();
act(() => {
summary?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(block?.open).toBe(true);
expect(
container.querySelector('[data-testid="timeline-a2ui-card"]'),
).not.toBeNull();
@@ -641,7 +774,7 @@ describe("AgentThreadTimeline", () => {
});
expect(
container.querySelector('[data-testid="agent-thread-block:1:thinking:details"]'),
container.querySelector('[data-testid="agent-thread-block:1:process:details"]'),
).toBeNull();
expect(container.textContent).not.toContain("思考摘要");
expect((container.textContent?.split(reasoningText).length ?? 1) - 1).toBe(1);
@@ -664,7 +797,7 @@ describe("AgentThreadTimeline", () => {
});
const block = container.querySelector<HTMLDetailsElement>(
'[data-testid="agent-thread-block:1:thinking"]',
'[data-testid="agent-thread-block:1:process"]',
);
const summary = block?.querySelector("summary");
@@ -681,6 +814,93 @@ describe("AgentThreadTimeline", () => {
expect(container.textContent).toContain("随后补齐自动续提。");
});
it("reasoning 缺少正文时应回退显示 summary", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("reasoning-1", 1),
type: "reasoning",
text: "",
summary: ["先判断任务类型", "再决定是否联网"],
},
];
const container = renderTimeline(items, {
turn: {
status: "completed",
},
});
const summary = container.querySelector("summary");
expect(summary?.textContent).toContain("先判断任务类型");
act(() => {
summary?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("先判断任务类型");
expect(container.textContent).toContain("再决定是否联网");
});
it("reasoning 同时存在 summary 与正文时应优先用 summary 做摘要", () => {
const items: AgentThreadItem[] = [
{
...createBaseItem("reasoning-1", 1),
type: "reasoning",
text: "这里是更完整的正文。",
summary: ["先判断任务类型", "再决定是否联网"],
},
];
const container = renderTimeline(items, {
turn: {
status: "completed",
},
});
const block = container.querySelector<HTMLDetailsElement>(
'[data-testid="agent-thread-block:1:process"]',
);
const summary = block?.querySelector("summary");
expect(summary?.textContent).toContain("先判断任务类型");
expect(summary?.textContent).not.toContain("这里是更完整的正文。");
expect(container.textContent).not.toContain("这里是更完整的正文。");
act(() => {
summary?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("先判断任务类型");
expect(container.textContent).toContain("再决定是否联网");
expect(container.textContent).toContain("这里是更完整的正文。");
});
it("reasoning 的 summary 与正文相同时不应重复渲染", () => {
const repeatedText = "先判断任务类型\n\n再决定是否联网";
const items: AgentThreadItem[] = [
{
...createBaseItem("reasoning-1", 1),
type: "reasoning",
text: repeatedText,
summary: ["先判断任务类型", "再决定是否联网"],
},
];
const container = renderTimeline(items, {
turn: {
status: "completed",
},
});
const summary = container.querySelector("summary");
act(() => {
summary?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect((container.textContent?.split("先判断任务类型").length ?? 1) - 1).toBe(1);
expect((container.textContent?.split("再决定是否联网").length ?? 1) - 1).toBe(1);
});
it("已完成的 request_user_input 应以只读 A2UI 卡片回显", () => {
const items: AgentThreadItem[] = [
{
@@ -4,7 +4,6 @@ import {
Bot,
ChevronDown,
Clock3,
FileText,
ListChecks,
Loader2,
ShieldAlert,
@@ -37,10 +36,8 @@ import { ActionRequestA2UIPreviewCard } from "./ActionRequestA2UIPreviewCard";
import { A2UITaskCard, A2UITaskLoadingCard } from "./A2UITaskCard";
import { ToolCallItem } from "./ToolCallDisplay";
import { DecisionPanel } from "./DecisionPanel";
import {
resolveTimelineArtifactNavigation,
type ArtifactTimelineOpenTarget,
} from "../utils/artifactTimelineNavigation";
import { AgentThreadTimelineArtifactCard } from "./AgentThreadTimelineArtifactCard";
import type { ArtifactTimelineOpenTarget } from "../utils/artifactTimelineNavigation";
interface AgentThreadTimelineProps {
turn: AgentThreadTurn;
@@ -74,6 +71,67 @@ function shortenInlineText(
return `${normalized.slice(0, maxLength - 1).trimEnd()}…`;
}
function normalizeComparableThinkingText(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
function resolveReasoningDisplayText(
item: Extract<AgentThreadItem, { type: "reasoning" }>,
): {
summaryText: string;
bodyText: string;
combinedText: string;
} {
const summaryText = (item.summary || [])
.map((line) => line.trim())
.filter(Boolean)
.join("\n\n");
const bodyText = item.text.trim();
if (!summaryText) {
return {
summaryText: "",
bodyText,
combinedText: bodyText,
};
}
if (!bodyText) {
return {
summaryText,
bodyText: "",
combinedText: summaryText,
};
}
if (
normalizeComparableThinkingText(summaryText) ===
normalizeComparableThinkingText(bodyText)
) {
return {
summaryText,
bodyText: "",
combinedText: summaryText,
};
}
return {
summaryText,
bodyText,
combinedText: `${summaryText}\n\n${bodyText}`,
};
}
function resolveThinkingDisplayText(
item: Extract<AgentThreadItem, { type: "reasoning" | "turn_summary" }>,
): string {
if (item.type === "reasoning") {
return resolveReasoningDisplayText(item).combinedText;
}
return item.text.trim() ? item.text : "";
}
function formatTimestamp(value?: string): string | null {
if (!value) {
return null;
@@ -329,7 +387,11 @@ function ThinkingItemCard({
}: {
item: Extract<AgentThreadItem, { type: "reasoning" | "turn_summary" }>;
}) {
const parsedContent = useMemo(() => parseAIResponse(item.text, false), [item.text]);
const displayText = useMemo(() => resolveThinkingDisplayText(item), [item]);
const parsedContent = useMemo(
() => parseAIResponse(displayText, false),
[displayText],
);
const hasStructuredPreview = parsedContent.hasA2UI || parsedContent.hasPending;
const content = hasStructuredPreview ? (
@@ -378,8 +440,16 @@ function ThinkingItemCard({
})}
</div>
) : (
<MarkdownRenderer content={item.text} />
<MarkdownRenderer content={displayText} />
);
const statusLabel =
item.type === "turn_summary"
? item.status === "in_progress"
? "处理中"
: null
: item.status === "in_progress"
? "思考中"
: null;
return (
<div className="py-1.5">
@@ -392,8 +462,8 @@ function ThinkingItemCard({
)}
</div>
<div className="min-w-0 flex-1">
{item.status === "in_progress" ? (
<div className="mb-1 text-xs text-slate-500">思考中</div>
{statusLabel ? (
<div className="mb-1 text-xs text-slate-500">{statusLabel}</div>
) : null}
<div className="text-sm leading-7 text-slate-800">{content}</div>
</div>
@@ -481,26 +551,7 @@ function renderThinkingItemDetails(item: AgentThreadItem) {
return <InlinePlanBlock content={item.text} isComplete={item.status !== "in_progress"} />;
}
if (item.type === "reasoning") {
return (
<div className="py-1.5">
<div className="flex items-start gap-2.5">
<div className="flex h-5 w-5 shrink-0 items-center justify-center text-slate-400">
{item.status === "in_progress" ? (
<Loader2 className="h-4 w-4 animate-spin text-sky-600" />
) : (
<Sparkles className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1 text-sm leading-7 text-slate-800">
<MarkdownRenderer content={item.text} />
</div>
</div>
</div>
);
}
if (item.type === "turn_summary") {
if (item.type === "reasoning" || item.type === "turn_summary") {
return <ThinkingItemCard item={item} />;
}
@@ -511,6 +562,198 @@ function renderThinkingItemDetails(item: AgentThreadItem) {
return null;
}
function isThinkingTimelineItem(
item: AgentThreadItem,
): item is Extract<
AgentThreadItem,
{ type: "plan" | "reasoning" | "turn_summary" | "context_compaction" }
> {
return (
item.type === "plan" ||
item.type === "reasoning" ||
item.type === "turn_summary" ||
item.type === "context_compaction"
);
}
function normalizeToolMarker(value: string | undefined): string {
return (value || "").replace(/[\s_-]+/g, "").trim().toLowerCase();
}
function isBrowserTimelineItem(item: AgentThreadItem): boolean {
if (item.type !== "tool_call") {
return false;
}
const normalized = normalizeToolMarker(item.tool_name);
return [
"browser",
"page",
"runtime",
"dom",
"cdp",
"playwright",
"navigate",
"screenshot",
"snapshot",
"click",
"hover",
"presskey",
"type",
"selectoption",
"drag",
"evaluate",
"goto",
].some((marker) => normalized.includes(marker));
}
function isToolExecutionTimelineItem(item: AgentThreadItem): boolean {
return (
item.type === "tool_call" ||
item.type === "command_execution" ||
item.type === "web_search"
);
}
function extractCompactThinkingParts(item: Extract<
AgentThreadItem,
{ type: "plan" | "reasoning" | "turn_summary" | "context_compaction" }
>) {
if (item.type === "context_compaction") {
const title =
item.stage === "completed" || item.status === "completed"
? "压了上下文"
: "正在压上下文";
const detail =
item.detail?.trim() ||
(item.stage === "completed" || item.status === "completed"
? "把前面的对话压成摘要了,后面接着做。"
: "在把前面的对话压成摘要,马上继续。");
return { title, detail };
}
if (item.type === "plan") {
return {
title: item.status === "in_progress" ? "还在排步骤" : "定了这些步骤",
detail: item.text.trim(),
};
}
if (item.type === "reasoning") {
const { summaryText, bodyText, combinedText } = resolveReasoningDisplayText(item);
const previewSource = summaryText || combinedText;
const lines = previewSource
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const [title = item.status === "in_progress" ? "思考中" : "已完成思考", ...rest] =
lines;
const detail = [rest.join("\n").trim(), bodyText].filter(Boolean).join("\n\n");
const parsed = parseAIResponse(combinedText, false);
if (parsed.hasA2UI || parsed.hasPending) {
return null;
}
return {
title,
detail,
};
}
if (item.type === "turn_summary") {
const displayText = resolveThinkingDisplayText(item);
const parsed = parseAIResponse(displayText, false);
if (parsed.hasA2UI || parsed.hasPending) {
return null;
}
const lines = displayText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const [title = item.status === "in_progress" ? "处理中" : "当前进展", ...rest] =
lines;
return {
title,
detail: rest.join("\n").trim(),
};
}
const displayText = resolveThinkingDisplayText(item);
const parsed = parseAIResponse(displayText, false);
if (parsed.hasA2UI || parsed.hasPending) {
return null;
}
const lines = displayText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const [title = item.status === "in_progress" ? "思考中" : "已完成思考", ...rest] =
lines;
return {
title,
detail: rest.join("\n").trim(),
};
}
function GroupedThinkingRow({
item,
groupMarker = "·",
}: {
item: Extract<
AgentThreadItem,
{ type: "plan" | "reasoning" | "turn_summary" | "context_compaction" }
>;
groupMarker?: string;
}) {
const compact = extractCompactThinkingParts(item);
if (!compact) {
return renderThinkingItemDetails(item);
}
const ToneIcon =
item.type === "plan"
? item.status === "in_progress"
? Loader2
: ListChecks
: item.status === "in_progress"
? Loader2
: Sparkles;
return (
<div className="flex items-start gap-2 py-1.5">
<span className="pt-0.5 font-mono text-xs text-slate-400">
{groupMarker}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<ToneIcon
className={cn(
"mt-0.5 h-3.5 w-3.5 shrink-0 text-slate-400",
item.status === "in_progress" && "animate-spin text-sky-600",
)}
/>
<div className="min-w-0 flex-1">
<div className="text-sm leading-6 text-slate-700">
{compact.title}
</div>
{compact.detail ? (
<div className="mt-0.5 text-sm leading-6 text-slate-500">
<MarkdownRenderer content={compact.detail} />
</div>
) : null}
</div>
</div>
</div>
</div>
);
}
function renderGroupItemDetails(
item: AgentThreadItem,
onFileClick?: (fileName: string, content: string) => void,
@@ -518,24 +761,14 @@ function renderGroupItemDetails(
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void,
onOpenSubagentSession?: (sessionId: string) => void,
onPermissionResponse?: (response: ConfirmResponse) => void,
options?: {
groupedToolCall?: boolean;
groupMarker?: string;
},
) {
const toolCall = toToolCallState(item);
const actionRequest = toActionRequired(item);
const timestamp = formatTimestamp(item.completed_at || item.updated_at);
const resolveArtifactSourceLabel = (source: string): string => {
switch (source) {
case "tool_result":
return "处理结果";
case "tool_start":
return "开始处理";
case "message_content":
return "消息内容";
case "artifact_snapshot":
return "快照同步";
default:
return source;
}
};
const resolveSubagentStatusLabel = (
statusLabel: string | undefined,
status: AgentThreadItem["status"],
@@ -583,74 +816,20 @@ function renderGroupItemDetails(
defaultExpanded={item.status !== "completed"}
onFileClick={onFileClick}
onOpenSavedSiteContent={onOpenSavedSiteContent}
grouped={options?.groupedToolCall}
groupMarker={options?.groupMarker}
/>
);
}
if (item.type === "file_artifact") {
const navigation = resolveTimelineArtifactNavigation(item);
const blockTargets = navigation?.blockTargets || [];
const shouldOpenFocusedBlock =
Boolean(onOpenArtifactFromTimeline) && blockTargets.length === 1;
return (
<div className="py-1.5">
<button
type="button"
className="w-full rounded-md text-left transition-colors hover:bg-slate-50"
onClick={() => {
if (onOpenArtifactFromTimeline && navigation) {
onOpenArtifactFromTimeline(
shouldOpenFocusedBlock ? blockTargets[0] : navigation.rootTarget,
);
return;
}
onFileClick?.(item.path, item.content || "");
}}
>
<div className="flex items-start gap-2.5">
<div className="flex h-5 w-5 shrink-0 items-center justify-center text-slate-400">
<FileText className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm leading-6 text-slate-900">{item.path}</div>
<Badge variant={resolveStatusBadgeVariant(item.status)}>
{resolveArtifactSourceLabel(item.source)}
</Badge>
{timestamp ? (
<span className="text-xs text-slate-400">{timestamp}</span>
) : null}
</div>
{item.content?.trim() ? (
<div className="mt-1.5 line-clamp-4 whitespace-pre-wrap text-xs leading-6 text-slate-500">
{item.content}
</div>
) : (
<div className="mt-1.5 text-xs text-slate-500">
点击在画布中打开文件
</div>
)}
</div>
</div>
</button>
{onOpenArtifactFromTimeline && blockTargets.length > 1 ? (
<div className="mt-3 flex flex-wrap gap-2">
{blockTargets.slice(0, 4).map((target) => (
<button
key={`${item.id}:${target.blockId}`}
type="button"
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 transition hover:border-sky-200 hover:bg-sky-50 hover:text-sky-700"
onClick={() => onOpenArtifactFromTimeline(target)}
>
跳到 block {target.blockId}
</button>
))}
</div>
) : null}
</div>
<AgentThreadTimelineArtifactCard
item={item}
timestamp={timestamp}
onFileClick={onFileClick}
onOpenArtifactFromTimeline={onOpenArtifactFromTimeline}
/>
);
}
@@ -735,6 +914,36 @@ function renderGroupItemDetails(
);
}
function renderTimelineItemDetails(
item: AgentThreadItem,
onFileClick?: (fileName: string, content: string) => void,
onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void,
onOpenSavedSiteContent?: (target: SiteSavedContentTarget) => void,
onOpenSubagentSession?: (sessionId: string) => void,
onPermissionResponse?: (response: ConfirmResponse) => void,
options?: {
groupedToolCall?: boolean;
groupMarker?: string;
},
) {
if (isThinkingTimelineItem(item)) {
if (options?.groupedToolCall) {
return <GroupedThinkingRow item={item} groupMarker={options.groupMarker} />;
}
return renderThinkingItemDetails(item);
}
return renderGroupItemDetails(
item,
onFileClick,
onOpenArtifactFromTimeline,
onOpenSavedSiteContent,
onOpenSubagentSession,
onPermissionResponse,
options,
);
}
function resolveCompactTechnicalSummary(block: AgentThreadOrderedBlock): string {
return `处理了 ${block.items.length} 个步骤`;
}
@@ -783,7 +992,7 @@ function resolveFocusBlockIndex(params: {
if (pendingAction?.uiKind === "browser_preflight") {
const browserIndex = findLastBlockIndex(
blocks,
(block) => block.kind === "browser",
(block) => block.items.some((item) => isBrowserTimelineItem(item)),
);
if (browserIndex >= 0) {
return browserIndex;
@@ -839,7 +1048,9 @@ function resolveExpandedBlockIndexes(params: {
if (focusBlockIndex >= 0) {
const focusBlock = blocks[focusBlockIndex];
const completedThinkingBlock =
focusBlock?.kind === "thinking" && focusBlock.status === "completed";
Boolean(focusBlock) &&
focusBlock.status === "completed" &&
focusBlock.items.every((item) => isThinkingTimelineItem(item));
const shouldExpandFocus =
focusBlock?.status !== "completed" ||
(!completedThinkingBlock && turn.status === "running") ||
@@ -875,18 +1086,10 @@ function normalizeBlockPreviewLine(
}
if (
kind === "file" &&
kind === "artifact" &&
!hasAnyPrefix(trimmed, ["看了 ", "读了 ", "写了 ", "改了 ", "动了 ", "产出了 "])
) {
return `看了 ${trimmed}`;
}
if (kind === "command" && !hasAnyPrefix(trimmed, ["执行了 ", "跑了 ", "运行了 "])) {
return `执行了 ${trimmed}`;
}
if (kind === "search" && !hasAnyPrefix(trimmed, ["搜了 ", "查了 ", "搜索了 ", "检索了 "])) {
return `搜了 ${trimmed}`;
return `产出了 ${trimmed}`;
}
if (
@@ -908,12 +1111,26 @@ function normalizeBlockPreviewLine(
}
function resolveBlockSummaryLines(block: AgentThreadOrderedBlock): string[] {
const isTurnSummaryOnlyBlock =
block.items.length > 0 &&
block.items.every((item) => item.type === "turn_summary");
const isThinkingOnlyBlock = block.items.every((item) =>
isThinkingTimelineItem(item),
);
const normalizedPreviewLines = block.previewLines
.map((line) => normalizeBlockPreviewLine(block.kind, line))
.filter((line) => line.trim().length > 0)
.map((line) => shortenInlineText(line, 92) || line);
if (block.kind === "thinking") {
if (isTurnSummaryOnlyBlock) {
if (normalizedPreviewLines.length > 0) {
return normalizedPreviewLines;
}
return [block.status === "in_progress" ? "处理中" : "当前进展"];
}
if (isThinkingOnlyBlock) {
const headline = block.status === "in_progress" ? "思考中" : "已完成思考";
if (normalizedPreviewLines.length > 0) {
return [
@@ -925,6 +1142,10 @@ function resolveBlockSummaryLines(block: AgentThreadOrderedBlock): string[] {
return [headline];
}
if (block.kind === "process" && block.items.length > 1) {
return [block.title, ...normalizedPreviewLines];
}
if (normalizedPreviewLines.length > 0) {
return normalizedPreviewLines;
}
@@ -948,6 +1169,29 @@ function resolveBlockSummaryLines(block: AgentThreadOrderedBlock): string[] {
return [block.title];
}
function resolveProcessMixLabel(block: AgentThreadOrderedBlock): string | null {
if (block.kind !== "process" || block.items.length <= 1) {
return null;
}
const toolCount = block.items.filter((item) =>
isToolExecutionTimelineItem(item),
).length;
const thinkingCount = block.items.filter((item) =>
isThinkingTimelineItem(item),
).length;
const parts: string[] = [];
if (toolCount > 0) {
parts.push(`${toolCount} 个工具步骤`);
}
if (thinkingCount > 0) {
parts.push(`${thinkingCount} 条思路`);
}
return parts.length > 0 ? parts.join(",") : null;
}
function resolveThreadInlineStatusHint(params: {
turn: AgentThreadTurn;
actionRequests?: ActionRequired[];
@@ -1085,7 +1329,9 @@ function TimelineBlockCard({
focusRequestKey?: number;
}) {
const dataTestId = `agent-thread-block:${index + 1}:${block.kind}`;
const isThinkingBlock = block.kind === "thinking";
const isThinkingOnlyBlock = block.items.every((item) =>
isThinkingTimelineItem(item),
);
const summaryLines = resolveBlockSummaryLines(block);
const headline = summaryLines[0] || block.title;
const supportingLines = summaryLines.slice(1, 3);
@@ -1093,18 +1339,21 @@ function TimelineBlockCard({
const hasFocusedItem = Boolean(
focusedItemId && block.items.some((item) => item.id === focusedItemId),
);
const shouldRenderGroupedToolRows =
block.kind === "process" && block.items.length > 1;
const detailEntries = block.items.flatMap((item) => {
const content =
block.kind === "thinking"
? renderThinkingItemDetails(item)
: renderGroupItemDetails(
item,
onFileClick,
onOpenArtifactFromTimeline,
onOpenSavedSiteContent,
onOpenSubagentSession,
onPermissionResponse,
);
const content = renderTimelineItemDetails(
item,
onFileClick,
onOpenArtifactFromTimeline,
onOpenSavedSiteContent,
onOpenSubagentSession,
onPermissionResponse,
{
groupedToolCall: shouldRenderGroupedToolRows,
groupMarker: block.items[0]?.id === item.id ? "└" : "·",
},
);
return content ? [{ id: item.id, content }] : [];
});
@@ -1128,17 +1377,16 @@ function TimelineBlockCard({
}, [focusRequestKey, hasFocusedItem]);
const singleItemContent =
block.items.length === 1 && (!isThinkingBlock || block.status !== "completed")
? (block.kind === "thinking"
? renderThinkingItemDetails(block.items[0]!)
: renderGroupItemDetails(
block.items[0]!,
onFileClick,
onOpenArtifactFromTimeline,
onOpenSavedSiteContent,
onOpenSubagentSession,
onPermissionResponse,
))
block.items.length === 1 &&
!(isThinkingOnlyBlock && block.status === "completed")
? renderTimelineItemDetails(
block.items[0]!,
onFileClick,
onOpenArtifactFromTimeline,
onOpenSavedSiteContent,
onOpenSubagentSession,
onPermissionResponse,
)
: null;
if (singleItemContent) {
@@ -1146,7 +1394,9 @@ function TimelineBlockCard({
<div
className={cn(
"py-0.5",
emphasis === "active" && !isThinkingBlock && "rounded-xl bg-sky-50/45 px-2",
emphasis === "active" &&
!isThinkingOnlyBlock &&
"rounded-xl bg-sky-50/45 px-2",
emphasis === "quiet" && "opacity-80",
)}
data-testid={dataTestId}
@@ -1168,7 +1418,14 @@ function TimelineBlockCard({
const visibleHeadline = headline;
const visibleSupportingLines =
isThinkingBlock && open ? [] : supportingLines;
isThinkingOnlyBlock && open ? [] : supportingLines;
const summaryCountLabel =
block.items.length > 1 ? block.countLabel : null;
const processMixLabel = resolveProcessMixLabel(block);
const summaryDetailHint =
hasDetailEntries && block.items.length > 1 && !open
? processMixLabel || block.rawDetailLabel
: null;
const summaryToneClassName = cn(
"text-slate-900",
block.status === "in_progress" && "text-sky-700",
@@ -1183,7 +1440,7 @@ function TimelineBlockCard({
className={cn(
"list-none rounded-md px-2 py-1.5",
hasDetailEntries ? "cursor-pointer" : "cursor-default",
emphasis === "active" && !isThinkingBlock && "bg-sky-50/45",
emphasis === "active" && !isThinkingOnlyBlock && "bg-sky-50/45",
)}
onClick={(event) => {
if (!hasDetailEntries) {
@@ -1210,6 +1467,16 @@ function TimelineBlockCard({
>
{visibleHeadline}
</span>
{summaryCountLabel ? (
<span className="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[11px] leading-none text-slate-500">
{summaryCountLabel}
</span>
) : null}
{summaryDetailHint ? (
<span className="text-xs leading-5 text-slate-400">
{summaryDetailHint}
</span>
) : null}
</div>
{visibleSupportingLines.length > 0 ? (
@@ -0,0 +1,160 @@
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 { AgentThreadTimelineArtifactCard } from "./AgentThreadTimelineArtifactCard";
import type { AgentThreadItem } from "../types";
interface MountedHarness {
container: HTMLDivElement;
root: Root;
}
const mountedRoots: MountedHarness[] = [];
beforeEach(() => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) {
break;
}
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
function createFileArtifactItem(
overrides: Partial<Extract<AgentThreadItem, { type: "file_artifact" }>> = {},
): Extract<AgentThreadItem, { type: "file_artifact" }> {
return {
id: "artifact-1",
thread_id: "thread-1",
turn_id: "turn-1",
sequence: 1,
status: "completed",
started_at: "2026-03-28T01:00:00Z",
completed_at: "2026-03-28T01:00:01Z",
updated_at: "2026-03-28T01:00:01Z",
type: "file_artifact",
path: ".lime/artifacts/thread-1/analysis-20260328.artifact.json",
source: "artifact_document_service",
content: JSON.stringify({
schemaVersion: "artifact_document.v1",
artifactId: "artifact-document:demo",
kind: "analysis",
title: "季度复盘",
status: "ready",
language: "zh-CN",
blocks: [
{
id: "hero-1",
type: "hero_summary",
summary: "本轮重点是补齐来源线索与交付节奏。",
},
{
id: "body-1",
type: "rich_text",
markdown: "这里是详细展开。",
},
],
sources: [{ id: "source-1", title: "内部周报" }],
metadata: {
currentVersionId: "artifact-document:demo:v2",
currentVersionNo: 2,
},
}),
metadata: {
artifact_id: "artifact-document:demo",
artifact_block_id: ["hero-1", "body-1"],
},
...overrides,
};
}
function renderCard(
item: Extract<AgentThreadItem, { type: "file_artifact" }>,
props?: {
timestamp?: string | null;
onFileClick?: (fileName: string, content: string) => void;
onOpenArtifactFromTimeline?: (target: {
filePath: string;
content: string;
timelineItemId: string;
blockId?: string;
artifactId?: string;
}) => void;
},
): HTMLDivElement {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<AgentThreadTimelineArtifactCard
item={item}
timestamp={props?.timestamp || "09:00"}
onFileClick={props?.onFileClick}
onOpenArtifactFromTimeline={props?.onOpenArtifactFromTimeline}
/>,
);
});
mountedRoots.push({ container, root });
return container;
}
describe("AgentThreadTimelineArtifactCard", () => {
it("结构化 artifact 文稿应收敛为可读结果卡而不是原始 JSON", () => {
const container = renderCard(createFileArtifactItem(), {
onOpenArtifactFromTimeline: vi.fn(),
});
expect(
container.querySelector('[data-testid="timeline-file-artifact-card"]'),
).not.toBeNull();
expect(container.textContent).toContain("季度复盘");
expect(container.textContent).toContain("分析");
expect(container.textContent).toContain("可阅读");
expect(container.textContent).toContain("文稿服务");
expect(container.textContent).toContain("2 个区块");
expect(container.textContent).toContain("1 条来源");
expect(container.textContent).toContain("V2");
expect(container.textContent).toContain("定位到 本轮重点是补齐来源线索与交付节奏。");
expect(container.textContent).not.toContain("artifact_document_service");
expect(container.textContent).not.toContain("schemaVersion");
expect(container.textContent).not.toContain('"artifactId"');
});
it("普通 JSON 文件也不应把原始结构直接摊在聊天区", () => {
const container = renderCard(
createFileArtifactItem({
path: ".lime/artifacts/thread-1/runtime-state.json",
source: "artifact_snapshot",
content: JSON.stringify({
queue: ["turn-1"],
retryable: true,
}),
metadata: {},
}),
);
expect(container.textContent).toContain("已同步");
expect(container.textContent).toContain("包含结构化结果,点击在画布中查看完整内容。");
expect(container.textContent).not.toContain('"queue"');
expect(container.textContent).not.toContain('"retryable"');
});
});
@@ -0,0 +1,327 @@
import { ArrowUpRight, FileStack, FileText } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
resolveArtifactDocumentCurrentVersion,
type ArtifactDocumentBlock,
type ArtifactDocumentKind,
type ArtifactDocumentStatus,
type ArtifactDocumentV1,
} from "@/lib/artifact-document";
import {
resolveArtifactProtocolDocumentPayload,
resolveArtifactProtocolPreviewText,
} from "@/lib/artifact-protocol";
import type { AgentThreadItem } from "../types";
import {
resolveTimelineArtifactNavigation,
type ArtifactTimelineOpenTarget,
} from "../utils/artifactTimelineNavigation";
interface AgentThreadTimelineArtifactCardProps {
item: Extract<AgentThreadItem, { type: "file_artifact" }>;
timestamp?: string | null;
onFileClick?: (fileName: string, content: string) => void;
onOpenArtifactFromTimeline?: (target: ArtifactTimelineOpenTarget) => void;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function normalizeText(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function resolveFileName(path: string): string {
const normalized = path.replace(/\\/g, "/").trim();
const parts = normalized.split("/");
return parts[parts.length - 1] || normalized;
}
function truncateMiddle(value: string, maxLength = 72): string {
const normalized = value.trim();
if (normalized.length <= maxLength) {
return normalized;
}
const headLength = Math.max(20, Math.ceil((maxLength - 1) * 0.58));
const tailLength = Math.max(14, maxLength - headLength - 1);
return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}`;
}
function truncateInlineText(value: string, maxLength = 160): string {
const normalized = value.trim().replace(/\s+/g, " ");
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 1).trimEnd()}…`;
}
function resolveArtifactDocumentKindLabel(
kind?: ArtifactDocumentKind,
): string | null {
switch (kind) {
case "report":
return "报告";
case "roadmap":
return "路线图";
case "prd":
return "PRD";
case "brief":
return "简报";
case "analysis":
return "分析";
case "comparison":
return "对比";
case "plan":
return "计划";
case "table_report":
return "表格报告";
default:
return kind || null;
}
}
function resolveArtifactDocumentStatusLabel(
status?: ArtifactDocumentStatus,
): string | null {
switch (status) {
case "draft":
return "草稿";
case "streaming":
return "生成中";
case "ready":
return "可阅读";
case "failed":
return "失败";
case "archived":
return "已归档";
default:
return status || null;
}
}
function resolveArtifactSourceLabel(source?: string): string | null {
switch (source) {
case "artifact_snapshot":
return "已同步";
case "artifact_document_service":
return "文稿服务";
case "tool_result":
return "处理结果";
case "tool_start":
return "开始处理";
case "message_content":
return "消息内容";
default:
return source && !source.includes("_") ? source : null;
}
}
function resolveBlockLabel(
document: ArtifactDocumentV1 | null,
blockId: string,
): string {
const block = document?.blocks.find((entry) => entry.id === blockId);
if (!block) {
return blockId;
}
const record = block as ArtifactDocumentBlock & Record<string, unknown>;
const fallbackByType: Record<string, string> = {
hero_summary: "摘要",
section_header: "章节",
rich_text: "正文",
callout: "提示",
key_points: "要点",
};
const label =
normalizeText(record.title) ||
normalizeText(record.summary) ||
normalizeText(record.description) ||
normalizeText(record.label) ||
normalizeText(record.text) ||
normalizeText(record.markdown);
return label ? truncateInlineText(label, 20) : fallbackByType[block.type] || blockId;
}
function resolveFallbackPreview(content: string | undefined): string | null {
const normalized = normalizeText(content);
if (!normalized) {
return null;
}
if (/^[[{]/.test(normalized)) {
return "包含结构化结果,点击在画布中查看完整内容。";
}
return truncateInlineText(normalized);
}
function resolveDocumentPreview(
document: ArtifactDocumentV1 | null,
displayTitle: string,
): string | null {
if (!document) {
return null;
}
const preview = normalizeText(resolveArtifactProtocolPreviewText(document));
if (!preview || preview === displayTitle) {
return "已同步到工作区,可继续在画布里阅读、编辑和定位到对应区块。";
}
return truncateInlineText(preview);
}
export function AgentThreadTimelineArtifactCard({
item,
timestamp,
onFileClick,
onOpenArtifactFromTimeline,
}: AgentThreadTimelineArtifactCardProps) {
const metadata = asRecord(item.metadata);
const navigation = resolveTimelineArtifactNavigation(item);
const blockTargets = navigation?.blockTargets || [];
const shouldOpenFocusedBlock =
Boolean(onOpenArtifactFromTimeline) && blockTargets.length === 1;
const document = resolveArtifactProtocolDocumentPayload({
content: item.content,
metadata,
});
const currentVersion = document
? resolveArtifactDocumentCurrentVersion(document)
: null;
const displayTitle =
normalizeText(document?.title) || resolveFileName(item.path);
const displayPath = truncateMiddle(item.path, 84);
const previewText =
resolveDocumentPreview(document, displayTitle) ||
resolveFallbackPreview(item.content) ||
"点击在画布中打开完整内容。";
const sourceLabel = resolveArtifactSourceLabel(item.source);
const kindLabel = resolveArtifactDocumentKindLabel(document?.kind);
const statusLabel = resolveArtifactDocumentStatusLabel(
currentVersion?.status || document?.status,
);
const blockCount = document?.blocks.length || 0;
const sourceCount = document?.sources.length || 0;
return (
<div className="py-1.5">
<button
type="button"
data-testid="timeline-file-artifact-card"
className="group w-full rounded-[18px] border border-slate-200 bg-white px-4 py-3 text-left shadow-sm shadow-slate-950/5 transition hover:border-sky-200 hover:bg-sky-50/40"
onClick={() => {
if (onOpenArtifactFromTimeline && navigation) {
onOpenArtifactFromTimeline(
shouldOpenFocusedBlock ? blockTargets[0] : navigation.rootTarget,
);
return;
}
onFileClick?.(item.path, item.content || "");
}}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 text-slate-600">
{document ? (
<FileStack className="h-[18px] w-[18px]" />
) : (
<FileText className="h-[18px] w-[18px]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1 text-sm font-medium leading-6 text-slate-900">
<span className="line-clamp-1 break-all">{displayTitle}</span>
</div>
{kindLabel ? (
<Badge
variant="outline"
className="border-sky-200 bg-sky-50 text-sky-700"
>
{kindLabel}
</Badge>
) : null}
{statusLabel ? (
<Badge
variant="outline"
className="border-emerald-200 bg-emerald-50 text-emerald-700"
>
{statusLabel}
</Badge>
) : null}
{sourceLabel ? (
<Badge
variant="outline"
className="border-slate-200 bg-slate-50 text-slate-600"
>
{sourceLabel}
</Badge>
) : null}
{timestamp ? (
<span className="text-xs text-slate-400">{timestamp}</span>
) : null}
</div>
<div
data-testid="timeline-file-artifact-preview"
className="mt-2 text-sm leading-6 text-slate-600"
>
{previewText}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
title={item.path}
className="inline-flex max-w-full rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 font-mono text-[11px] text-slate-500"
>
<span className="truncate">{displayPath}</span>
</span>
{currentVersion?.versionNo ? (
<span className="inline-flex rounded-full bg-slate-100 px-2 py-1 text-[11px] text-slate-500">
V{currentVersion.versionNo}
</span>
) : null}
{blockCount > 0 ? (
<span className="inline-flex rounded-full bg-slate-100 px-2 py-1 text-[11px] text-slate-500">
{blockCount} 个区块
</span>
) : null}
{sourceCount > 0 ? (
<span className="inline-flex rounded-full bg-slate-100 px-2 py-1 text-[11px] text-slate-500">
{sourceCount} 条来源
</span>
) : null}
<span className="inline-flex items-center gap-1 text-xs text-slate-400 transition group-hover:text-sky-700">
<span>在画布中打开</span>
<ArrowUpRight className="h-3.5 w-3.5" />
</span>
</div>
</div>
</div>
</button>
{onOpenArtifactFromTimeline && blockTargets.length > 1 ? (
<div className="mt-3 flex flex-wrap gap-2">
{blockTargets.slice(0, 4).map((target) => (
<button
key={`${item.id}:${target.blockId}`}
type="button"
className="rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 transition hover:border-sky-200 hover:bg-sky-50 hover:text-sky-700"
onClick={() => onOpenArtifactFromTimeline(target)}
>
定位到 {resolveBlockLabel(document, target.blockId || "")}
</button>
))}
</div>
) : null}
</div>
);
}
@@ -235,14 +235,12 @@ function MockArtifactDocumentPreview({
controller,
target,
onArtifactDocumentControllerChange,
artifactDocumentLayoutMode,
}: {
controller: ArtifactWorkbenchDocumentController | null;
target: CanvasWorkbenchPreviewTarget;
onArtifactDocumentControllerChange?: (
controller: ArtifactWorkbenchDocumentController | null,
) => void;
artifactDocumentLayoutMode?: "full" | "canvas-only";
}) {
React.useEffect(() => {
onArtifactDocumentControllerChange?.(
@@ -255,7 +253,7 @@ function MockArtifactDocumentPreview({
return (
<div data-testid="preview-panel">
{artifactDocumentLayoutMode}:{target.kind}:{target.title}
{target.kind}:{target.title}
</div>
);
}
@@ -589,7 +587,6 @@ describe("CanvasWorkbenchLayout", () => {
it("命中文档产物时应把文稿 inspector 收口到右侧工作台", async () => {
const controller = createMockArtifactDocumentController();
const previewOptions: Array<{
artifactDocumentLayoutMode?: "full" | "canvas-only";
onArtifactDocumentControllerChange?: (
value: ArtifactWorkbenchDocumentController | null,
) => void;
@@ -613,7 +610,6 @@ describe("CanvasWorkbenchLayout", () => {
onRevealPath: vi.fn(async () => undefined),
renderPreview: (target, options) => {
previewOptions.push({
artifactDocumentLayoutMode: options?.artifactDocumentLayoutMode,
onArtifactDocumentControllerChange:
options?.onArtifactDocumentControllerChange,
});
@@ -621,7 +617,6 @@ describe("CanvasWorkbenchLayout", () => {
<MockArtifactDocumentPreview
controller={controller}
target={target}
artifactDocumentLayoutMode={options?.artifactDocumentLayoutMode}
onArtifactDocumentControllerChange={
options?.onArtifactDocumentControllerChange
}
@@ -634,15 +629,30 @@ describe("CanvasWorkbenchLayout", () => {
expect(
container.querySelector('[data-testid="preview-panel"]')?.textContent,
).toContain("canvas-only:artifact:board-review.artifact.json");
expect(previewOptions.at(-1)?.artifactDocumentLayoutMode).toBe("canvas-only");
).toContain("artifact:board-review.artifact.json");
expect(
container.querySelector('button[aria-label="展开当前文稿检查器"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="canvas-workbench-document-inspector"]'),
).not.toBeNull();
).toBeNull();
expect(container.textContent).toContain("当前文稿");
expect(container.textContent).toContain("统一在右侧切换产物与版本");
expect(container.textContent).toContain("董事会季度复盘");
expect(container.textContent).toContain("需要优先补齐来源与版本线索。");
expect(container.textContent).toContain(
"默认先收起概览、来源、版本与编辑",
);
clickButtonByLabel(container, "展开当前文稿检查器");
await flushEffects();
expect(
container.querySelector('button[aria-label="折叠当前文稿检查器"]'),
).not.toBeNull();
expect(
container.querySelector('[data-testid="canvas-workbench-document-inspector"]'),
).not.toBeNull();
});
it("工作区文件为二进制时应展示不支持预览提示", async () => {
@@ -196,7 +196,6 @@ export interface CanvasWorkbenchLayoutProps {
target: CanvasWorkbenchPreviewTarget,
options?: {
stackedWorkbenchTrigger?: ReactNode;
artifactDocumentLayoutMode?: "full" | "canvas-only";
onArtifactDocumentControllerChange?: (
controller: ArtifactWorkbenchDocumentController | null,
) => void;
@@ -501,6 +500,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
const [stackedWorkbenchWidth, setStackedWorkbenchWidth] = useState<number | null>(
null,
);
const [documentInspectorCollapsed, setDocumentInspectorCollapsed] = useState(true);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [artifactDocumentController, setArtifactDocumentController] =
useState<ArtifactWorkbenchDocumentController | null>(null);
@@ -817,6 +817,10 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
setArtifactDocumentController(null);
}, [selectedEntry]);
useEffect(() => {
setDocumentInspectorCollapsed(true);
}, [selectedEntry?.key, artifactDocumentController?.document?.artifactId]);
const currentTarget = useMemo<CanvasWorkbenchPreviewTarget>(() => {
if (activeTab === "team" && teamView?.enabled) {
return {
@@ -1054,6 +1058,23 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
const showDocumentInspector = Boolean(
selectedEntry?.source === "artifact" && artifactDocumentController?.document,
);
const documentTitle =
artifactDocumentController?.document?.title?.trim() ||
selectedEntry?.title ||
"当前文稿";
const documentSummary =
artifactDocumentController?.document?.summary?.trim() ||
"当前选中的结构化文稿已接入右侧工作台,按需展开查看概览、来源、版本与编辑。";
const versionCount = artifactDocumentController?.versionHistory.length || 0;
const sourceCount = artifactDocumentController?.sourceLinks.length || 0;
const diffCount =
artifactDocumentController?.currentVersionDiff?.changedBlocks.length || 0;
const currentVersionLabel = artifactDocumentController?.currentVersion
? `v${artifactDocumentController.currentVersion.versionNo}`
: null;
const documentInspectorButtonLabel = documentInspectorCollapsed
? "展开当前文稿检查器"
: "折叠当前文稿检查器";
return (
<div className="space-y-4">
@@ -1121,28 +1142,65 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
</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">
<section className={cn(WORKBENCH_PANEL_CLASSNAME, "overflow-hidden bg-slate-50/80")}>
<button
type="button"
aria-label={documentInspectorButtonLabel}
aria-expanded={!documentInspectorCollapsed}
aria-controls="canvas-workbench-document-inspector-panel"
onClick={() =>
setDocumentInspectorCollapsed((current) => !current)
}
className="flex w-full items-start justify-between gap-3 border-b border-slate-200/80 bg-white/70 px-4 py-3 text-left transition-colors hover:bg-white"
>
<div className="min-w-0 flex-1">
<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 className="mt-1 flex flex-wrap items-center gap-2">
<div className="truncate text-sm font-semibold text-slate-900">
{documentTitle}
</div>
{currentVersionLabel ? (
<span className="rounded-full bg-slate-900 px-2 py-0.5 text-[10px] font-semibold text-white">
{currentVersionLabel}
</span>
) : null}
</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
当前选中的结构化文稿不再在左侧重复展开,所有上下文与编辑入口都固定在这里。
<p className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">
{documentSummary}
</p>
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-500">
<span>来源 {sourceCount}</span>
<span>版本 {versionCount}</span>
<span>差异 {diffCount}</span>
</div>
</div>
}
/>
<div className="flex items-center gap-1 pt-1 text-slate-500">
<span className="text-[11px] font-medium">
{documentInspectorCollapsed ? "展开" : "收起"}
</span>
{documentInspectorCollapsed ? (
<ChevronRight className="h-4 w-4 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 shrink-0" />
)}
</div>
</button>
{documentInspectorCollapsed ? (
<div className="px-4 py-3 text-xs leading-5 text-slate-500">
默认先收起概览、来源、版本与编辑,避免小屏进入时直接挤占画布空间;需要时再展开查看。
</div>
) : (
<ArtifactWorkbenchDocumentInspector
controller={artifactDocumentController}
testId="canvas-workbench-document-inspector"
containerClassName="min-h-0 overflow-hidden bg-slate-50/80"
tabsClassName="flex h-full min-h-0 flex-col p-4"
/>
)}
</section>
) : null}
</div>
);
@@ -1694,7 +1752,6 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
)
: renderPreview(currentTarget, {
stackedWorkbenchTrigger,
artifactDocumentLayoutMode: "canvas-only",
onArtifactDocumentControllerChange:
handleArtifactDocumentControllerChange,
})}
@@ -17,6 +17,7 @@ const {
mockProviderPoolGetOverview,
mockApiKeyProvidersGetProviders,
mockEmitProviderDataChanged,
mockWechatChannelSetRuntimeModel,
} = vi.hoisted(() => ({
mockInitAsterAgent: vi.fn(),
mockCreateAgentRuntimeSession: vi.fn(),
@@ -36,6 +37,7 @@ const {
mockProviderPoolGetOverview: vi.fn(),
mockApiKeyProvidersGetProviders: vi.fn(),
mockEmitProviderDataChanged: vi.fn(),
mockWechatChannelSetRuntimeModel: vi.fn(async () => undefined),
}));
vi.mock("@/lib/api/agentRuntime", async () => {
@@ -103,6 +105,10 @@ vi.mock("@/lib/providerDataEvents", () => ({
emitProviderDataChanged: mockEmitProviderDataChanged,
}));
vi.mock("@/lib/api/channelsRuntime", () => ({
wechatChannelSetRuntimeModel: mockWechatChannelSetRuntimeModel,
}));
import { useAsterAgentChat } from "../hooks/useAsterAgentChat";
import { ChatModelSelector } from "./ChatModelSelector";
@@ -270,6 +276,7 @@ beforeEach(() => {
mockProviderPoolGetOverview.mockResolvedValue([]);
mockApiKeyProvidersGetProviders.mockResolvedValue([]);
mockEmitProviderDataChanged.mockImplementation(() => {});
mockWechatChannelSetRuntimeModel.mockResolvedValue(undefined);
const createdAt = Math.floor(Date.now() / 1000);
mockListAgentRuntimeSessions.mockResolvedValue([
@@ -350,9 +350,15 @@ describe("EmptyState", () => {
latestCall.onSelectSkill?.(skill);
});
const sendButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("开始生成"),
);
await act(async () => {
await Promise.resolve();
});
expect(container.textContent).toContain("已挂载 canvas-design");
const sendButton = container.querySelector(
'button[aria-label="发送"]',
) as HTMLButtonElement | null;
expect(sendButton).toBeTruthy();
act(() => {
@@ -370,6 +376,50 @@ describe("EmptyState", () => {
expect(onSend).toHaveBeenCalledWith("帮我设计封面", "react", undefined);
});
it("首页技能卡应复用统一的技能数量文案", async () => {
const container = renderEmptyState({
skills: [
{
key: "writer",
name: "写作助手",
description: "desc",
directory: "writer",
installed: true,
sourceKind: "builtin",
},
],
serviceSkills: [
{
id: "trend-briefing",
title: "趋势情报",
summary: "输出趋势摘要",
category: "研究",
outputHint: "摘要",
source: "cloud_catalog",
runnerType: "instant",
defaultExecutorBinding: "browser_assist",
executionLocation: "client_default",
slotSchema: [],
version: "seed-v1",
badge: "云目录",
recentUsedAt: null,
isRecent: false,
runnerLabel: "浏览器执行",
runnerTone: "emerald",
runnerDescription: "复用登录态完成情报任务。",
actionLabel: "开始执行",
automationStatus: null,
},
],
});
await act(async () => {
await Promise.resolve();
});
expect(container.textContent).toContain("2 项技能可挂载");
});
it("点击地球按钮应切换联网搜索开关", async () => {
const onWebSearchEnabledChange = vi.fn<(enabled: boolean) => void>();
const container = renderEmptyState({
@@ -381,7 +431,7 @@ describe("EmptyState", () => {
});
const globeToggle = container.querySelector(
'button[title="开启联网搜索"]',
'button[title="联网搜索已关闭"]',
) as HTMLButtonElement | null;
expect(globeToggle).toBeTruthy();
@@ -411,9 +461,9 @@ describe("EmptyState", () => {
await Promise.resolve();
});
const sendButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("开始生成"),
);
const sendButton = container.querySelector(
'button[aria-label="发送"]',
) as HTMLButtonElement | null;
expect(sendButton).toBeTruthy();
act(() => {
@@ -449,9 +499,9 @@ describe("EmptyState", () => {
await Promise.resolve();
});
const sendButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("开始生成"),
);
const sendButton = container.querySelector(
'button[aria-label="发送"]',
) as HTMLButtonElement | null;
expect(sendButton).toBeTruthy();
act(() => {
@@ -501,9 +551,9 @@ describe("EmptyState", () => {
latestCall.onSelectSkill?.(skill);
});
const sendButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("开始生成"),
);
const sendButton = container.querySelector(
'button[aria-label="发送"]',
) as HTMLButtonElement | null;
expect(sendButton).toBeTruthy();
act(() => {
@@ -517,38 +567,40 @@ describe("EmptyState", () => {
);
});
it("通用主题工具栏应包含附件、思考、后台任务与多代理开关", async () => {
it("通用主题工具栏应包含附件、思考、Plan 与多代理开关", async () => {
const onThinkingEnabledChange = vi.fn<(enabled: boolean) => void>();
const onTaskEnabledChange = vi.fn<(enabled: boolean) => void>();
const onSubagentEnabledChange = vi.fn<(enabled: boolean) => void>();
const setExecutionStrategy = vi.fn<
(strategy: "react" | "code_orchestrated" | "auto") => void
>();
const container = renderEmptyState({
activeTheme: "general",
thinkingEnabled: false,
onThinkingEnabledChange,
taskEnabled: false,
onTaskEnabledChange,
subagentEnabled: false,
onSubagentEnabledChange,
executionStrategy: "react",
setExecutionStrategy,
});
await act(async () => {
await Promise.resolve();
});
const attachButton = container.querySelector(
'button[title="上传文件"]',
'button[title="添加图片"]',
) as HTMLButtonElement | null;
expect(attachButton).toBeTruthy();
const thinkingButton = container.querySelector(
'button[title="开启深度思考"]',
'button[title="深度思考已关闭"]',
) as HTMLButtonElement | null;
expect(thinkingButton).toBeTruthy();
const taskButton = container.querySelector(
'button[title="开启后台任务偏好"]',
const planButton = container.querySelector(
'[data-testid="inputbar-plan-toggle"]',
) as HTMLButtonElement | null;
expect(taskButton).toBeTruthy();
expect(planButton).toBeTruthy();
const subagentButton = container.querySelector(
'button[title="开启多代理偏好"]',
'button[title="多代理偏好已关闭"]',
) as HTMLButtonElement | null;
expect(subagentButton).toBeTruthy();
@@ -556,14 +608,14 @@ describe("EmptyState", () => {
thinkingButton?.click();
});
act(() => {
taskButton?.click();
planButton?.click();
});
act(() => {
subagentButton?.click();
});
expect(onThinkingEnabledChange).toHaveBeenCalledWith(true);
expect(onTaskEnabledChange).toHaveBeenCalledWith(true);
expect(setExecutionStrategy).toHaveBeenCalledWith("code_orchestrated");
expect(onSubagentEnabledChange).toHaveBeenCalledWith(true);
});
@@ -37,8 +37,6 @@ import { EmptyStateComposerPanel } from "./EmptyStateComposerPanel";
import { EmptyStateHero } from "./EmptyStateHero";
import { EmptyStateQuickActions } from "./EmptyStateQuickActions";
import {
EMPTY_STATE_BACKGROUND_ORB_LEFT_CLASSNAME,
EMPTY_STATE_BACKGROUND_ORB_RIGHT_CLASSNAME,
EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME,
EMPTY_STATE_PAGE_CONTAINER_CLASSNAME,
EMPTY_STATE_SECONDARY_ACTION_BUTTON_CLASSNAME,
@@ -47,17 +45,21 @@ import {
getEmptyStateThemeTabIconClassName,
} from "./emptyStateSurfaceTokens";
import { useActiveSkill } from "./Inputbar/hooks/useActiveSkill";
import type { SkillSelectionSourceProps } from "./Inputbar/components/skillSelectionBindings";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { WorkspaceSettings } from "@/types/workspace";
import type { MessageImage } from "../types";
import type { TeamDefinition } from "../utils/teamDefinitions";
import { isGeneralResearchTheme } from "../utils/generalAgentPrompt";
import type { AgentAccessMode } from "../hooks/agentChatStorage";
import {
getClipboardImageCandidates,
readImageAttachment,
} from "../utils/imageAttachments";
import type { ServiceSkillHomeItem } from "../service-skills/types";
import {
getActiveSkillDisplayLabel,
getSkillSelectionSummaryLabel,
} from "./Inputbar/components/skillSelectionDisplay";
// Import Assets
import capabilitySkillsPlaceholder from "@/assets/claw-home/capability-skills-placeholder.svg";
@@ -93,28 +95,6 @@ function scheduleDeferredConfigLoad(task: () => void): () => void {
};
}
const backgroundOrbDrift = keyframes`
0%, 100% {
transform: translate3d(0, 0, 0) scale(1);
opacity: 0.92;
}
50% {
transform: translate3d(18px, -14px, 0) scale(1.05);
opacity: 1;
}
`;
const backgroundOrbPulse = keyframes`
0%, 100% {
transform: translate3d(0, 0, 0) scale(1);
opacity: 0.9;
}
50% {
transform: translate3d(-16px, 18px, 0) scale(1.08);
opacity: 1;
}
`;
const contentReveal = keyframes`
from {
opacity: 0;
@@ -132,28 +112,6 @@ const PageContainer = styled.div.attrs({
isolation: isolate;
`;
const BackgroundOrbLeft = styled.div.attrs({
className: EMPTY_STATE_BACKGROUND_ORB_LEFT_CLASSNAME,
})`
animation: ${backgroundOrbDrift} 18s ease-in-out infinite;
will-change: transform, opacity;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const BackgroundOrbRight = styled.div.attrs({
className: EMPTY_STATE_BACKGROUND_ORB_RIGHT_CLASSNAME,
})`
animation: ${backgroundOrbPulse} 22s ease-in-out infinite;
will-change: transform, opacity;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const ContentWrapper = styled.div.attrs({
className: EMPTY_STATE_CONTENT_WRAPPER_CLASSNAME,
})`
@@ -164,7 +122,7 @@ const ContentWrapper = styled.div.attrs({
}
`;
interface EmptyStateProps {
interface EmptyStateProps extends SkillSelectionSourceProps {
input: string;
setInput: (value: string) => void;
onSend: (
@@ -192,13 +150,13 @@ interface EmptyStateProps {
setExecutionStrategy?: (
strategy: "react" | "code_orchestrated" | "auto",
) => void;
accessMode?: AgentAccessMode;
setAccessMode?: (mode: AgentAccessMode) => void;
onManageProviders?: () => void;
webSearchEnabled?: boolean;
onWebSearchEnabledChange?: (enabled: boolean) => void;
thinkingEnabled?: boolean;
onThinkingEnabledChange?: (enabled: boolean) => void;
taskEnabled?: boolean;
onTaskEnabledChange?: (enabled: boolean) => void;
subagentEnabled?: boolean;
onSubagentEnabledChange?: (enabled: boolean) => void;
selectedTeam?: TeamDefinition | null;
@@ -211,20 +169,6 @@ interface EmptyStateProps {
selectedText?: string;
/** 角色列表(用于 @ 引用) */
characters?: Character[];
/** 技能列表(用于 @ 引用) */
skills?: Skill[];
/** 服务型技能列表(用于 @ 引用) */
serviceSkills?: ServiceSkillHomeItem[];
/** 技能列表加载状态 */
isSkillsLoading?: boolean;
/** 选择服务型技能回调 */
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
/** 跳转到设置页安装技能 */
onNavigateToSettings?: () => void;
/** 导入本地技能 */
onImportSkill?: () => void | Promise<void>;
/** 刷新技能 */
onRefreshSkills?: () => void | Promise<void>;
/** 启动浏览器协助 */
onLaunchBrowserAssist?: () => void | Promise<void>;
/** 浏览器协助启动中 */
@@ -388,13 +332,13 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
setModel,
executionStrategy = "react",
setExecutionStrategy,
accessMode,
setAccessMode,
onManageProviders,
webSearchEnabled = false,
onWebSearchEnabledChange,
thinkingEnabled = false,
onThinkingEnabledChange,
taskEnabled = false,
onTaskEnabledChange,
subagentEnabled = false,
onSubagentEnabledChange,
selectedTeam = null,
@@ -406,9 +350,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
hasContentId = false,
selectedText = "",
characters = [],
skills = [],
serviceSkills = [],
isSkillsLoading = false,
skills,
serviceSkills,
isSkillsLoading,
onSelectServiceSkill,
onNavigateToSettings,
onImportSkill,
@@ -424,8 +368,25 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
configLoadStrategy = "immediate",
supportingSlotOverride,
}) => {
const { activeSkill, setActiveSkill, clearActiveSkill, wrapTextWithSkill } =
useActiveSkill();
const { wrapTextWithSkill, buildSkillSelection } = useActiveSkill();
const skillSelection = buildSkillSelection({
skills,
serviceSkills,
isSkillsLoading,
onSelectServiceSkill,
onNavigateToSettings,
onImportSkill,
onRefreshSkills,
});
const currentSkill = skillSelection.activeSkill;
const clearSelectedSkill = skillSelection.onClearSkill;
const skillOptionCount =
skillSelection.skills.length + skillSelection.serviceSkills.length;
const activeSkillDisplayLabel = getActiveSkillDisplayLabel(currentSkill);
const skillSummaryLabel = getSkillSelectionSummaryLabel({
activeSkill: currentSkill,
skillCount: skillOptionCount,
});
// 从配置中读取启用的主题
const [enabledThemes, setEnabledThemes] = useState<string[]>(
@@ -684,7 +645,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
imagesToSend,
);
setPendingImages([]);
clearActiveSkill();
clearSelectedSkill?.();
return;
}
@@ -705,15 +666,11 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
imagesToSend,
);
setPendingImages([]);
clearActiveSkill();
clearSelectedSkill?.();
};
const executionStrategyLabel =
executionStrategy === "auto"
? "Auto"
: executionStrategy === "code_orchestrated"
? "Plan"
: "ReAct";
const planEnabled = executionStrategy === "code_orchestrated";
const executionModeLabel = planEnabled ? "Plan 已开启" : "直接执行";
const activeCategory =
ALL_CATEGORIES.find((category) => category.id === activeTheme) ||
@@ -817,7 +774,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
},
{
key: "execution",
label: `执行 ${executionStrategyLabel}`,
label: executionModeLabel,
tone: "sky",
},
];
@@ -854,10 +811,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
});
}
if (activeSkill) {
if (activeSkillDisplayLabel) {
badges.push({
key: "skill",
label: `技能 ${activeSkill.name}`,
label: activeSkillDisplayLabel,
tone: "emerald",
});
}
@@ -865,14 +822,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
return badges.slice(0, 5);
}, [
activeCategory.label,
activeSkill,
activeTheme,
creationMode,
depth,
executionStrategyLabel,
executionModeLabel,
platform,
showCreationModeSelector,
webSearchEnabled,
activeSkillDisplayLabel,
]);
const workspaceCards = useMemo(() => {
@@ -892,11 +849,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
key: "skills",
eyebrow: "能力层",
title: "技能",
value: activeSkill
? `当前技能 ${activeSkill.name}`
: skills.length > 0
? `${skills.length} 项技能可用`
: "按需挂载能力",
value: skillSummaryLabel,
description:
"把技能当作任务能力层来用,可把固定工作流、提示链和工具调用打包进一次对话。",
icon: <Lightbulb className="h-5 w-5" />,
@@ -908,11 +861,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
key: "automation",
eyebrow: "能力层",
title: "自动化",
value: taskEnabled
? "后台任务已开启"
: executionStrategy === "auto"
? "自动执行策略"
: `${executionStrategyLabel} 执行`,
value: planEnabled ? "Plan 编排已开启" : "按当前对话直接执行",
description:
"支持把复杂任务按步骤推进,适合长链路处理、批量执行和需要持续产出的工作流。",
icon: <ListChecks className="h-5 w-5" />,
@@ -963,14 +912,11 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
return cards;
}, [
activeSkill,
browserAssistLoading,
executionStrategyLabel,
executionStrategy,
planEnabled,
onLaunchBrowserAssist,
skills.length,
skillSummaryLabel,
subagentEnabled,
taskEnabled,
]);
const workspaceFeatures = useMemo(() => {
@@ -1118,8 +1064,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
setModel={setModel}
workspaceId={projectId}
executionStrategy={executionStrategy}
executionStrategyLabel={executionStrategyLabel}
setExecutionStrategy={setExecutionStrategy}
accessMode={accessMode}
setAccessMode={setAccessMode}
onManageProviders={onManageProviders}
modelSelectorBackgroundPreload={modelSelectorBackgroundPreload}
isGeneralTheme={isGeneralTheme}
@@ -1133,16 +1080,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onEntryTaskTypeChange={setEntryTaskType}
onEntrySlotChange={handleEntrySlotChange}
characters={characters}
skills={skills}
serviceSkills={serviceSkills}
activeSkill={activeSkill}
setActiveSkill={setActiveSkill}
onSelectServiceSkill={onSelectServiceSkill}
clearActiveSkill={clearActiveSkill}
isSkillsLoading={isSkillsLoading}
onNavigateToSettings={onNavigateToSettings}
onImportSkill={onImportSkill}
onRefreshSkills={onRefreshSkills}
skillSelection={skillSelection}
showCreationModeSelector={showCreationModeSelector}
creationMode={creationMode}
onCreationModeChange={onCreationModeChange}
@@ -1160,8 +1098,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
setStylePopoverOpen={setStylePopoverOpen}
thinkingEnabled={thinkingEnabled}
onThinkingEnabledChange={onThinkingEnabledChange}
taskEnabled={taskEnabled}
onTaskEnabledChange={onTaskEnabledChange}
subagentEnabled={subagentEnabled}
onSubagentEnabledChange={onSubagentEnabledChange}
selectedTeam={selectedTeam}
@@ -1195,7 +1131,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const headerControls = onProjectChange ? (
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
<div className="inline-flex max-w-full items-center rounded-[24px] border border-white/85 bg-white/84 p-1 shadow-sm shadow-slate-950/5 backdrop-blur-sm">
<div className="inline-flex max-w-full items-center rounded-[24px] border border-slate-200/80 bg-white p-1 shadow-sm shadow-slate-950/5">
<ProjectSelector
value={projectId ?? null}
onChange={onProjectChange}
@@ -1237,8 +1173,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
return (
<PageContainer>
<BackgroundOrbLeft />
<BackgroundOrbRight />
<ContentWrapper>
<EmptyStateHero
eyebrow={
@@ -3,6 +3,14 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EmptyStateComposerPanel } from "./EmptyStateComposerPanel";
import {
createSkillSelectionProps,
type SkillSelectionProps,
} from "./Inputbar/components/skillSelectionBindings";
import {
resetStableProcessingNoticeMemoryForTest,
STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS,
} from "../hooks/useStableProcessingNotice";
vi.mock("./ChatModelSelector", () => ({
ChatModelSelector: () => <div data-testid="empty-state-model-selector" />,
@@ -62,9 +70,25 @@ afterEach(() => {
});
mounted.container.remove();
}
vi.useRealTimers();
resetStableProcessingNoticeMemoryForTest();
vi.clearAllMocks();
});
function createSkillSelection(
overrides: Partial<SkillSelectionProps> = {},
): SkillSelectionProps {
return createSkillSelectionProps({
skills: [],
onSelectSkill: vi.fn(),
onClearSkill: vi.fn(),
onNavigateToSettings: vi.fn(),
onImportSkill: vi.fn(),
onRefreshSkills: vi.fn(),
...overrides,
});
}
function renderPanel(
props?: Partial<React.ComponentProps<typeof EmptyStateComposerPanel>>,
) {
@@ -83,7 +107,6 @@ function renderPanel(
model: "gpt-4.1",
setModel: vi.fn(),
executionStrategy: "react",
executionStrategyLabel: "ReAct",
setExecutionStrategy: vi.fn(),
onManageProviders: vi.fn(),
isGeneralTheme: false,
@@ -103,14 +126,7 @@ function renderPanel(
onEntryTaskTypeChange: vi.fn(),
onEntrySlotChange: vi.fn(),
characters: [],
skills: [],
activeSkill: null,
setActiveSkill: vi.fn(),
clearActiveSkill: vi.fn(),
isSkillsLoading: false,
onNavigateToSettings: vi.fn(),
onImportSkill: vi.fn(),
onRefreshSkills: vi.fn(),
skillSelection: createSkillSelection(),
showCreationModeSelector: false,
creationMode: "guided",
onCreationModeChange: vi.fn(),
@@ -128,8 +144,6 @@ function renderPanel(
setStylePopoverOpen: vi.fn(),
thinkingEnabled: false,
onThinkingEnabledChange: vi.fn(),
taskEnabled: false,
onTaskEnabledChange: vi.fn(),
subagentEnabled: false,
onSubagentEnabledChange: vi.fn(),
webSearchEnabled: false,
@@ -177,7 +191,6 @@ function renderStatefulPanel(
model="gpt-4.1"
setModel={vi.fn()}
executionStrategy="react"
executionStrategyLabel="ReAct"
setExecutionStrategy={vi.fn()}
onManageProviders={vi.fn()}
isGeneralTheme
@@ -197,14 +210,7 @@ function renderStatefulPanel(
onEntryTaskTypeChange={vi.fn()}
onEntrySlotChange={vi.fn()}
characters={[]}
skills={[]}
activeSkill={null}
setActiveSkill={vi.fn()}
clearActiveSkill={vi.fn()}
isSkillsLoading={false}
onNavigateToSettings={vi.fn()}
onImportSkill={vi.fn()}
onRefreshSkills={vi.fn()}
skillSelection={createSkillSelection()}
showCreationModeSelector={false}
creationMode="guided"
onCreationModeChange={vi.fn()}
@@ -222,8 +228,6 @@ function renderStatefulPanel(
setStylePopoverOpen={vi.fn()}
thinkingEnabled={false}
onThinkingEnabledChange={vi.fn()}
taskEnabled={false}
onTaskEnabledChange={vi.fn()}
subagentEnabled={subagentEnabled}
onSubagentEnabledChange={setSubagentEnabled}
webSearchEnabled={false}
@@ -246,6 +250,31 @@ function renderStatefulPanel(
}
describe("EmptyStateComposerPanel", () => {
it("首页空态输入区应保留技能下拉,但与 @ 面板共用同一技能入口", () => {
const container = renderPanel({
isGeneralTheme: true,
skillSelection: createSkillSelection({
skills: [
{
key: "writer",
name: "写作助手",
description: "用于写作",
directory: "writer",
installed: true,
sourceKind: "builtin",
},
],
}),
});
expect(
container.querySelector('[data-testid="empty-state-character-mention"]'),
).toBeTruthy();
expect(
container.querySelector('[data-testid="empty-state-skill-selector"]'),
).toBeTruthy();
});
it("应将 onPaste 绑定到输入框", () => {
const onPaste = vi.fn();
const container = renderPanel({ onPaste });
@@ -272,10 +301,10 @@ describe("EmptyStateComposerPanel", () => {
onRemoveImage,
});
expect(container.querySelector('img[alt="待发送图片 1"]')).toBeTruthy();
expect(container.querySelector('img[alt="预览 1"]')).toBeTruthy();
const removeButton = container.querySelector(
'button[aria-label="移除待发送图片 1"]',
'button[aria-label="移除图片 1"]',
) as HTMLButtonElement | null;
expect(removeButton).toBeTruthy();
@@ -360,12 +389,34 @@ describe("EmptyStateComposerPanel", () => {
).toBeNull();
const toggleButton = container.querySelector(
'button[title="开启多代理偏好"]',
'button[title="多代理偏好已关闭"]',
) as HTMLButtonElement | null;
expect(toggleButton).toBeTruthy();
});
it("应通过 Plan 开关透传执行策略切换,不再渲染执行模式下拉", () => {
const setExecutionStrategy = vi.fn();
const container = renderPanel({
executionStrategy: "react",
setExecutionStrategy,
});
const planToggle = container.querySelector(
'[data-testid="inputbar-plan-toggle"]',
) as HTMLButtonElement | null;
expect(planToggle).toBeTruthy();
expect(container.textContent).not.toContain("ReAct");
expect(container.textContent).not.toContain("Auto");
act(() => {
planToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(setExecutionStrategy).toHaveBeenCalledWith("code_orchestrated");
});
it("即使已经保留 Team 方案,关闭 Team mode 后也不应显示 TeamSelector", () => {
const container = renderPanel({
isGeneralTheme: true,
@@ -383,11 +434,12 @@ describe("EmptyStateComposerPanel", () => {
expect(enableButton).toBeNull();
expect(
container.querySelector('button[title="开启多代理偏好"]'),
container.querySelector('button[title="多代理偏好已关闭"]'),
).toBeTruthy();
});
it("命中稳妥模式模型时应在首页输入区前置提示", () => {
it("命中稳妥模式模型时应短暂提示后自动收起且不再重复提醒", () => {
vi.useFakeTimers();
const container = renderPanel({
providerType: "openai",
model: "glm-4.7",
@@ -400,13 +452,34 @@ describe("EmptyStateComposerPanel", () => {
).toBeTruthy();
expect(container.textContent).toContain("稳妥模式");
expect(container.textContent).toContain("依次开始同类请求");
act(() => {
vi.advanceTimersByTime(STABLE_PROCESSING_NOTICE_AUTO_HIDE_MS + 1);
});
expect(
container.querySelector(
'[data-testid="empty-state-stable-processing-notice"]',
),
).toBeNull();
const nextContainer = renderPanel({
providerType: "openai",
model: "glm-4.7",
});
expect(
nextContainer.querySelector(
'[data-testid="empty-state-stable-processing-notice"]',
),
).toBeNull();
});
it("点击多代理图标后应自动透传 Team 配置面板打开令牌", async () => {
const container = renderStatefulPanel();
const enableButton = container.querySelector(
'button[title="开启多代理偏好"]',
'button[title="多代理偏好已关闭"]',
) as HTMLButtonElement | null;
expect(enableButton).toBeTruthy();
@@ -441,7 +514,7 @@ describe("EmptyStateComposerPanel", () => {
).toBeTruthy();
const toggleButton = container.querySelector(
'button[title="关闭多代理偏好"]',
'button[title="多代理偏好已开启"]',
) as HTMLButtonElement | null;
expect(toggleButton).toBeTruthy();
@@ -458,7 +531,7 @@ describe("EmptyStateComposerPanel", () => {
container.querySelector('[data-testid="empty-state-team-selector"]'),
).toBeNull();
expect(
container.querySelector('button[title="开启多代理偏好"]'),
container.querySelector('button[title="多代理偏好已关闭"]'),
).toBeTruthy();
});
@@ -479,10 +552,34 @@ describe("EmptyStateComposerPanel", () => {
container.querySelector('[data-testid="empty-state-team-mode-enable-button"]'),
).toBeNull();
expect(
container.querySelector('button[title="开启多代理偏好"]'),
container.querySelector('button[title="多代理偏好已关闭"]'),
).toBeTruthy();
expect(enableButton).toBeTruthy();
expect(enableButton?.textContent).toContain("启用 Team");
expect(container.textContent).toContain("当前任务更适合 Team 协作");
});
it("应渲染权限模式选择并透传切换", () => {
const setAccessMode = vi.fn();
const container = renderPanel({
accessMode: "current",
setAccessMode,
});
const select = container.querySelector(
'[data-testid="inputbar-access-mode-select"]',
) as HTMLSelectElement | null;
expect(select).toBeTruthy();
expect(select?.value).toBe("current");
act(() => {
if (select) {
select.value = "full-access";
select.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(setAccessMode).toHaveBeenCalledWith("full-access");
});
});
File diff suppressed because it is too large Load Diff
@@ -19,15 +19,6 @@ const heroReveal = keyframes`
}
`;
const orbFloat = keyframes`
0%, 100% {
transform: translate3d(0, 0, 0) scale(1);
}
50% {
transform: translate3d(16px, -12px, 0) scale(1.06);
}
`;
const cardReveal = keyframes`
from {
opacity: 0;
@@ -49,57 +40,6 @@ const HeroSection = styled.section`
}
`;
const HeroOrbLeft = styled.div`
pointer-events: none;
position: absolute;
left: -6rem;
top: -5.4rem;
height: 14rem;
width: 14rem;
border-radius: 999px;
background: rgba(167, 243, 208, 0.24);
filter: blur(48px);
animation: ${orbFloat} 16s ease-in-out infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const HeroOrbRight = styled.div`
pointer-events: none;
position: absolute;
right: -4rem;
top: -1.2rem;
height: 12rem;
width: 12rem;
border-radius: 999px;
background: rgba(186, 230, 253, 0.26);
filter: blur(42px);
animation: ${orbFloat} 19s ease-in-out infinite reverse;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const HeroOrbBottom = styled.div`
pointer-events: none;
position: absolute;
bottom: -5.4rem;
left: 33%;
height: 11rem;
width: 11rem;
border-radius: 999px;
background: rgba(253, 230, 138, 0.16);
filter: blur(44px);
animation: ${orbFloat} 17s ease-in-out infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const HeroContent = styled.div`
position: relative;
display: flex;
@@ -337,12 +277,9 @@ export function EmptyStateHero({
}: EmptyStateHeroProps) {
return (
<HeroSection>
<HeroOrbLeft />
<HeroOrbRight />
<HeroOrbBottom />
<HeroContent>
<IntroGrid>
<LeadBlock className="flex w-full min-w-0 flex-col gap-3 rounded-[28px] border border-white/80 bg-white/56 px-4 py-4 text-left shadow-sm shadow-slate-950/5 backdrop-blur-sm md:px-5 md:py-[18px]">
<LeadBlock className="flex w-full min-w-0 flex-col gap-3 rounded-[28px] border border-slate-200/80 bg-white px-4 py-4 text-left shadow-sm shadow-slate-950/5 md:px-5 md:py-[18px]">
<LeadTopRow>
<div className="inline-flex w-fit items-center rounded-full border border-emerald-200/80 bg-white/92 px-3 py-1 text-[10px] font-semibold tracking-[0.14em] text-emerald-700 shadow-sm shadow-slate-950/5">
{eyebrow}
@@ -442,13 +379,13 @@ export function EmptyStateHero({
) : null}
{features.length > 0 ? (
<FeaturePanel className="hidden rounded-[22px] border border-white/85 bg-white/72 px-4 py-3.5 shadow-sm shadow-slate-950/5 backdrop-blur-sm md:block">
<FeaturePanel className="hidden rounded-[22px] border border-slate-200/80 bg-white px-4 py-3.5 shadow-sm shadow-slate-950/5 md:block">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{features.map((feature) => (
<div
key={feature.key}
title={feature.description}
className="min-w-0 rounded-2xl border border-white/80 bg-white/74 px-3 py-2.5"
className="min-w-0 rounded-2xl border border-slate-200/80 bg-slate-50 px-3 py-2.5"
>
<div className="text-[11px] font-semibold text-slate-700">
{feature.title}
@@ -1390,14 +1390,14 @@ describe("HarnessStatusPanel", () => {
plan: {
phase: "ready",
items: [],
summaryText: "已决定:直接回答优先\n当前请求无需工具介入。",
summaryText: "直接回答优先\n当前请求无需工具介入。",
},
}),
});
expect(document.body.textContent).toContain("计划状态");
expect(document.body.textContent).toContain("已就绪");
expect(document.body.textContent).toContain("已决定:直接回答优先");
expect(document.body.textContent).toContain("直接回答优先");
expect(document.body.textContent).toContain("规划状态");
});
@@ -339,6 +339,7 @@ function createServiceSkill(
runnerDescription: "当前先进入工作区生成首版任务方案,后续再接本地自动化。",
actionLabel: "先做方案",
automationStatus: null,
groupKey: "general",
...overrides,
};
}
@@ -401,17 +402,19 @@ describe("CharacterMention", () => {
serviceSkills: [
createServiceSkill(),
createServiceSkill({
id: "carousel-post-replication",
title: "复制轮播帖",
entryHint: "拆结构并输出一版可继续改的轮播帖。",
aliases: ["轮播帖", "小红书轮播"],
id: "github-repo-radar",
title: "GitHub 仓库雷达",
summary: "围绕仓库与 Issue 快速扫描线索。",
entryHint: "补一个关键词,我先帮你扫 GitHub 仓库与讨论。",
aliases: ["仓库雷达", "GitHub 搜索"],
category: "GitHub",
runnerType: "instant",
defaultExecutorBinding: "agent_turn",
runnerLabel: "本地即时执行",
runnerTone: "emerald",
runnerDescription: "客户端起步版可直接进入工作区执行。",
defaultExecutorBinding: "browser_assist",
runnerLabel: "浏览器协助",
runnerTone: "sky",
runnerDescription: "进入真实浏览器执行只读采集。",
actionLabel: "填写参数",
promptTemplateKey: "replication",
groupKey: "github",
}),
],
});
@@ -419,9 +422,10 @@ describe("CharacterMention", () => {
await typeAtAndWait(textarea);
expect(document.body.textContent).toContain("服务技能");
expect(document.body.textContent).toContain("技能组 · 通用技能");
expect(document.body.textContent).toContain("技能组 · GitHub");
expect(document.body.textContent).toContain("每日趋势摘要");
expect(document.body.textContent).toContain("复制轮播帖");
expect(document.body.textContent).toContain("GitHub 仓库雷达");
});
it("服务技能过滤应支持命中别名", () => {
@@ -502,12 +506,12 @@ describe("CharacterMention", () => {
await typeSlashAndWait(textarea);
expect(document.body.textContent).toContain("Codex 命令");
expect(document.body.textContent).toContain("Lime 命令");
expect(document.body.textContent).toContain("/compact");
expect(document.body.textContent).toContain("/review");
});
it("slash 面板选择 Codex 命令时应回填到输入框", async () => {
it("slash 面板选择 Lime 命令时应回填到输入框", async () => {
const onChangeSpy = vi.fn<(value: string) => void>();
const container = renderHarness({
onChangeSpy,
@@ -6,9 +6,8 @@
import React, {
Suspense,
lazy,
useState,
useEffect,
useState,
useMemo,
useRef,
useCallback,
@@ -23,25 +22,23 @@ import {
filterCodexSlashCommands,
type CodexSlashCommandDefinition,
} from "../../../commands";
import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload";
import {
filterBuiltinCommands,
type BuiltinInputCommand,
} from "./builtinCommands";
const preloadCharacterMentionPanel = () => import("./CharacterMentionPanel");
const CharacterMentionPanel = lazy(async () => {
const module = await preloadCharacterMentionPanel();
return { default: module.CharacterMentionPanel };
});
import {
LazyCharacterMentionPanel,
preloadCharacterMentionPanel,
} from "./characterMentionPanelLoader";
import { partitionMentionableSkills } from "./skillQuery";
import { useIdleModulePreload } from "./useIdleModulePreload";
interface CharacterMentionProps {
/** 角色列表 */
characters: Character[];
/** 技能列表 */
skills?: Skill[];
/** 服务型技能列表 */
/** 技能目录项列表 */
serviceSkills?: ServiceSkillHomeItem[];
/** 输入框 ref */
inputRef: React.RefObject<HTMLTextAreaElement>;
@@ -53,7 +50,7 @@ interface CharacterMentionProps {
onSelectCharacter?: (character: Character) => void;
/** 选择已安装技能回调 */
onSelectSkill?: (skill: Skill) => void;
/** 选择服务型技能回调 */
/** 选择技能目录项回调 */
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
/** 选择内建命令回调 */
onSelectBuiltinCommand?: (command: BuiltinInputCommand) => void;
@@ -148,11 +145,9 @@ export function CharacterMention({
const commandRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
return scheduleIdleModulePreload(() => {
void preloadCharacterMentionPanel();
});
}, []);
useIdleModulePreload(() => {
void preloadCharacterMentionPanel();
});
const filteredBuiltinCommands = useMemo(
() => filterBuiltinCommands(mentionQuery),
@@ -177,29 +172,10 @@ export function CharacterMention({
);
}, [characters, mentionQuery]);
const installedSkills = useMemo(() => {
const installed = skills.filter((s) => s.installed);
if (!mentionQuery) return installed;
const query = mentionQuery.toLowerCase();
return installed.filter(
(s) =>
s.name.toLowerCase().includes(query) ||
s.key.toLowerCase().includes(query) ||
s.description?.toLowerCase().includes(query),
);
}, [skills, mentionQuery]);
const availableSkills = useMemo(() => {
const available = skills.filter((s) => !s.installed);
if (!mentionQuery) return available;
const query = mentionQuery.toLowerCase();
return available.filter(
(s) =>
s.name.toLowerCase().includes(query) ||
s.key.toLowerCase().includes(query) ||
s.description?.toLowerCase().includes(query),
);
}, [skills, mentionQuery]);
const { installedSkills, availableSkills } = useMemo(
() => partitionMentionableSkills(skills, mentionQuery),
[skills, mentionQuery],
);
const updateMentionState = useCallback(() => {
const textarea = inputRef.current;
@@ -398,7 +374,7 @@ export function CharacterMention({
toast.info(`技能「${skill.name}」尚未安装`, {
action: onNavigateToSettings
? {
label: "去安装",
label: "去技能中心",
onClick: onNavigateToSettings,
}
: undefined,
@@ -596,7 +572,7 @@ export function CharacterMention({
</div>
}
>
<CharacterMentionPanel
<LazyCharacterMentionPanel
mode={triggerMode}
mentionQuery={mentionQuery}
builtinCommands={filteredBuiltinCommands}
@@ -20,6 +20,69 @@ import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skill
import type { CodexSlashCommandDefinition } from "../../../commands";
import type { BuiltinInputCommand } from "./builtinCommands";
interface MentionServiceSkillGroup {
key: string;
title: string;
sort: number;
skills: ServiceSkillHomeItem[];
}
const SERVICE_SKILL_GROUP_META: Record<
string,
{ title: string; sort: number }
> = {
github: { title: "GitHub", sort: 10 },
zhihu: { title: "知乎", sort: 20 },
"linux-do": { title: "Linux.do", sort: 30 },
bilibili: { title: "Bilibili", sort: 40 },
"36kr": { title: "36Kr", sort: 50 },
smzdm: { title: "什么值得买", sort: 60 },
"yahoo-finance": { title: "Yahoo Finance", sort: 70 },
general: { title: "通用技能", sort: 90 },
};
function resolveServiceSkillGroupKey(skill: ServiceSkillHomeItem): string {
const normalized = skill.groupKey?.trim();
return normalized ? normalized : "general";
}
function resolveServiceSkillGroupTitle(groupKey: string): string {
return SERVICE_SKILL_GROUP_META[groupKey]?.title ?? groupKey;
}
function resolveServiceSkillGroupSort(groupKey: string): number {
return SERVICE_SKILL_GROUP_META[groupKey]?.sort ?? 80;
}
function groupMentionServiceSkills(
skills: ServiceSkillHomeItem[],
): MentionServiceSkillGroup[] {
const groups = new Map<string, MentionServiceSkillGroup>();
for (const skill of skills) {
const groupKey = resolveServiceSkillGroupKey(skill);
const current = groups.get(groupKey);
if (current) {
current.skills.push(skill);
continue;
}
groups.set(groupKey, {
key: groupKey,
title: resolveServiceSkillGroupTitle(groupKey),
sort: resolveServiceSkillGroupSort(groupKey),
skills: [skill],
});
}
return Array.from(groups.values()).sort((left, right) => {
if (left.sort !== right.sort) {
return left.sort - right.sort;
}
return left.title.localeCompare(right.title, "zh-CN");
});
}
interface CharacterMentionPanelProps {
mode: "mention" | "slash";
mentionQuery: string;
@@ -60,13 +123,17 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
onNavigateToSettings,
}) => {
const visibleBuiltinCommands = mode === "mention" ? builtinCommands : [];
const visibleServiceSkills = mode === "mention" ? mentionServiceSkills : [];
const visibleServiceSkillGroups = React.useMemo(
() =>
mode === "mention" ? groupMentionServiceSkills(mentionServiceSkills) : [],
[mentionServiceSkills, mode],
);
const visibleCharacters = mode === "mention" ? filteredCharacters : [];
const visibleSlashCommands = mode === "slash" ? slashCommands : [];
const hasFilteredResults =
visibleSlashCommands.length > 0 ||
visibleBuiltinCommands.length > 0 ||
visibleServiceSkills.length > 0 ||
visibleServiceSkillGroups.length > 0 ||
visibleCharacters.length > 0 ||
installedSkills.length > 0 ||
availableSkills.length > 0;
@@ -93,13 +160,13 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
onMouseDown={(e) => e.preventDefault()}
onClick={onNavigateToSettings}
>
去技能设置
去技能中心
</button>
) : null}
</div>
) : null}
{visibleSlashCommands.length > 0 ? (
<CommandGroup heading="Codex 命令">
<CommandGroup heading="Lime 命令">
{visibleSlashCommands.map((command) => (
<CommandItem
key={command.key}
@@ -143,9 +210,9 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
))}
</CommandGroup>
) : null}
{visibleServiceSkills.length > 0 ? (
<CommandGroup heading="服务技能">
{visibleServiceSkills.map((skill) => (
{visibleServiceSkillGroups.map((group) => (
<CommandGroup key={group.key} heading={`技能组 · ${group.title}`}>
{group.skills.map((skill) => (
<CommandItem
key={skill.id}
onSelect={() => onSelectServiceSkill(skill)}
@@ -153,7 +220,12 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
>
<Sparkles className="mr-2 h-4 w-4 text-emerald-600" />
<div className="flex-1">
<div className="font-medium">{skill.title}</div>
<div className="flex items-center gap-2">
<div className="font-medium">{skill.title}</div>
<span className="text-[10px] text-muted-foreground">
{group.title}
</span>
</div>
<div className="text-xs text-muted-foreground line-clamp-1">
{resolveServiceSkillEntryDescription(skill)}
</div>
@@ -161,7 +233,7 @@ export const CharacterMentionPanel: React.FC<CharacterMentionPanelProps> = ({
</CommandItem>
))}
</CommandGroup>
) : null}
))}
{visibleCharacters.length > 0 ? (
<CommandGroup heading="角色">
{visibleCharacters.map((character) => (
@@ -0,0 +1,54 @@
import React from "react";
import { ShieldCheck } from "lucide-react";
import {
MetaSelect,
MetaSelectIcon,
MetaSelectWrap,
} from "../styles";
import type { AgentAccessMode } from "../../../hooks/agentChatStorage";
interface InputbarAccessModeSelectProps {
isFullscreen?: boolean;
accessMode?: AgentAccessMode;
setAccessMode?: (mode: AgentAccessMode) => void;
}
const ACCESS_MODE_OPTIONS: Array<{
value: AgentAccessMode;
label: string;
}> = [
{ value: "read-only", label: "只读" },
{ value: "current", label: "按需确认" },
{ value: "full-access", label: "完全访问" },
];
export const InputbarAccessModeSelect: React.FC<
InputbarAccessModeSelectProps
> = ({ isFullscreen = false, accessMode = "current", setAccessMode }) => {
if (isFullscreen || !setAccessMode) {
return null;
}
return (
<MetaSelectWrap>
<MetaSelectIcon aria-hidden>
<ShieldCheck strokeWidth={1.8} />
</MetaSelectIcon>
<MetaSelect
aria-label="权限模式"
data-testid="inputbar-access-mode-select"
value={accessMode}
onChange={(event) =>
setAccessMode(event.target.value as AgentAccessMode)
}
$width="92px"
>
{ACCESS_MODE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</MetaSelect>
</MetaSelectWrap>
);
};
@@ -1,8 +1,6 @@
import React, { useState } from "react";
import type { ChatInputAdapter } from "@/components/input-kit/adapters/types";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import type {
AsterSessionExecutionRuntime,
QueuedTurnSnapshot,
@@ -17,11 +15,17 @@ import { ThemeWorkbenchStatusPanel } from "./ThemeWorkbenchStatusPanel";
import { InputbarModelExtra } from "./InputbarModelExtra";
import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice";
import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect";
import { InputbarAccessModeSelect } from "./InputbarAccessModeSelect";
import { StableProcessingNotice } from "../../StableProcessingNotice";
import { isGeneralResearchTheme } from "../../../utils/generalAgentPrompt";
import type { TeamDefinition } from "../../../utils/teamDefinitions";
import { shouldShowStableProcessingNotice } from "../../../utils/stableProcessingExperience";
import type { WorkspaceSettings } from "@/types/workspace";
import { useStableProcessingNotice } from "../../../hooks/useStableProcessingNotice";
import {
buildSkillSelectionBindings,
type SkillSelectionProps,
} from "./skillSelectionBindings";
import type { AgentAccessMode } from "../../../hooks/agentChatStorage";
import type {
ThemeWorkbenchGateState,
ThemeWorkbenchQuickAction,
@@ -35,20 +39,11 @@ interface InputbarComposerSectionProps {
themeWorkbenchQueueItems: ThemeWorkbenchWorkflowStep[];
inputAdapter: ChatInputAdapter;
characters: Character[];
skills: Skill[];
serviceSkills?: ServiceSkillHomeItem[];
isSkillsLoading?: boolean;
skillSelection: SkillSelectionProps;
textareaRef: React.RefObject<HTMLTextAreaElement>;
input: string;
activeSkill?: Skill | null;
onSelectCharacter?: (character: Character) => void;
onSelectSkill: (skill: Skill) => void;
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
onSelectBuiltinCommand: (command: BuiltinInputCommand | null) => void;
onClearSkill?: () => void;
onNavigateToSettings?: () => void;
onImportSkill?: () => void | Promise<void>;
onRefreshSkills?: () => void | Promise<void>;
selectedTeam?: TeamDefinition | null;
onSelectTeam?: (team: TeamDefinition | null) => void;
teamWorkspaceSettings?: WorkspaceSettings | null;
@@ -70,6 +65,8 @@ interface InputbarComposerSectionProps {
onManageProviders?: () => void;
executionRuntime?: AsterSessionExecutionRuntime | null;
isExecutionRuntimeActive?: boolean;
accessMode?: AgentAccessMode;
setAccessMode?: (mode: AgentAccessMode) => void;
setExecutionStrategy?: (
strategy: "react" | "code_orchestrated" | "auto",
) => void;
@@ -88,20 +85,11 @@ export const InputbarComposerSection: React.FC<
themeWorkbenchQueueItems,
inputAdapter,
characters,
skills,
serviceSkills = [],
isSkillsLoading,
skillSelection,
textareaRef,
input,
activeSkill,
onSelectCharacter,
onSelectSkill,
onSelectServiceSkill,
onSelectBuiltinCommand,
onClearSkill,
onNavigateToSettings,
onImportSkill,
onRefreshSkills,
selectedTeam,
onSelectTeam,
teamWorkspaceSettings,
@@ -122,7 +110,8 @@ export const InputbarComposerSection: React.FC<
activeTheme,
onManageProviders,
executionRuntime,
isExecutionRuntimeActive,
accessMode,
setAccessMode,
setExecutionStrategy,
topExtra,
queuedTurns,
@@ -137,26 +126,26 @@ export const InputbarComposerSection: React.FC<
const currentPendingImages =
(inputAdapter.state.attachments as MessageImage[] | undefined) ||
pendingImages;
const { mentionProps: mentionSkillProps, selectorProps: skillSelectorProps } =
buildSkillSelectionBindings(skillSelection);
const resolvedProviderType = inputAdapter.model?.providerType;
const resolvedModel = inputAdapter.model?.model;
const shouldShowStableNotice =
!isThemeWorkbenchVariant &&
shouldShowStableProcessingNotice({
providerType: resolvedProviderType,
model: resolvedModel,
});
const shouldShowStableNotice = useStableProcessingNotice({
providerType: resolvedProviderType,
model: resolvedModel,
});
const showStableNotice =
!isThemeWorkbenchVariant && shouldShowStableNotice;
const shouldShowVisionNotice =
currentPendingImages.length > 0 &&
Boolean(resolvedProviderType?.trim()) &&
Boolean(resolvedModel?.trim());
const resolvedTopExtra =
topExtra || shouldShowStableNotice || shouldShowVisionNotice ? (
topExtra || showStableNotice || shouldShowVisionNotice ? (
<>
{topExtra}
{shouldShowStableNotice ? (
{showStableNotice ? (
<StableProcessingNotice
providerType={resolvedProviderType}
model={resolvedModel}
scope={activeTools["subagent_mode"] ? "team" : "request"}
className="mx-3 mb-2"
testId="inputbar-stable-processing-notice"
@@ -206,17 +195,13 @@ export const InputbarComposerSection: React.FC<
onStop={inputAdapter.actions.stop}
/>
<CharacterMention
{...mentionSkillProps}
characters={characters}
skills={skills}
serviceSkills={serviceSkills}
inputRef={textareaRef}
value={input}
onChange={inputAdapter.actions.setText}
onSelectCharacter={onSelectCharacter}
onSelectSkill={onSelectSkill}
onSelectServiceSkill={onSelectServiceSkill}
onSelectBuiltinCommand={onSelectBuiltinCommand}
onNavigateToSettings={onNavigateToSettings}
/>
<InputbarCore
textareaRef={textareaRef}
@@ -245,7 +230,6 @@ export const InputbarComposerSection: React.FC<
: undefined
}
toolMode={isThemeWorkbenchVariant ? "attach-only" : "default"}
showTranslate={!isThemeWorkbenchVariant}
showDragHandle={!isThemeWorkbenchVariant}
visualVariant={isThemeWorkbenchVariant ? "floating" : "default"}
topExtra={resolvedTopExtra}
@@ -256,16 +240,7 @@ export const InputbarComposerSection: React.FC<
leftExtra={
<>
{showSkillSelector ? (
<SkillSelector
skills={skills}
activeSkill={activeSkill}
isLoading={isSkillsLoading}
onSelectSkill={onSelectSkill}
onClearSkill={onClearSkill}
onNavigateToSettings={onNavigateToSettings}
onImportSkill={onImportSkill}
onRefreshSkills={onRefreshSkills}
/>
<SkillSelector {...skillSelectorProps} />
) : null}
{isGeneralResearchTheme(activeTheme) ? (
activeTools["subagent_mode"] ? (
@@ -284,6 +259,12 @@ export const InputbarComposerSection: React.FC<
/>
) : null
) : null}
<InputbarExecutionStrategySelect
isFullscreen={isFullscreen}
isThemeWorkbenchVariant={isThemeWorkbenchVariant}
executionStrategy={executionStrategy}
setExecutionStrategy={setExecutionStrategy}
/>
<InputbarModelExtra
isFullscreen={isFullscreen}
isThemeWorkbenchVariant={isThemeWorkbenchVariant}
@@ -294,18 +275,14 @@ export const InputbarComposerSection: React.FC<
activeTheme={activeTheme}
onManageProviders={onManageProviders}
executionRuntime={executionRuntime}
isExecutionRuntimeActive={isExecutionRuntimeActive}
/>
<InputbarAccessModeSelect
isFullscreen={isFullscreen}
accessMode={accessMode}
setAccessMode={setAccessMode}
/>
</>
}
rightExtra={
<InputbarExecutionStrategySelect
isFullscreen={isFullscreen}
isThemeWorkbenchVariant={isThemeWorkbenchVariant}
executionStrategy={executionStrategy}
setExecutionStrategy={setExecutionStrategy}
/>
}
/>
</>
);
@@ -4,21 +4,64 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InputbarCore } from "./InputbarCore";
const {
mockGetVoiceInputConfig,
mockStartRecording,
mockStopRecording,
mockTranscribeAudio,
mockPolishVoiceText,
mockCancelRecording,
} = vi.hoisted(() => ({
mockGetVoiceInputConfig: vi.fn(async () => ({
enabled: true,
shortcut: "Alt+Space",
processor: {
polish_enabled: true,
default_instruction_id: "default",
},
output: {
mode: "type",
type_delay_ms: 0,
},
instructions: [],
sound_enabled: false,
translate_instruction_id: "",
})),
mockStartRecording: vi.fn(async () => undefined),
mockStopRecording: vi.fn(async () => ({
audio_data: [1, 2, 3, 4],
sample_rate: 16000,
duration: 1.2,
})),
mockTranscribeAudio: vi.fn(async () => ({
text: "原始识别文本",
provider: "mock",
})),
mockPolishVoiceText: vi.fn(async () => ({
text: "润色后的文本",
instruction_name: "默认润色",
})),
mockCancelRecording: vi.fn(async () => undefined),
}));
vi.mock("./InputbarTools", () => ({
InputbarTools: () => <div data-testid="inputbar-tools">tools</div>,
}));
vi.mock("@/components/ui/tooltip", () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
vi.mock("@/lib/api/asrProvider", () => ({
getVoiceInputConfig: mockGetVoiceInputConfig,
startRecording: mockStartRecording,
stopRecording: mockStopRecording,
transcribeAudio: mockTranscribeAudio,
polishVoiceText: mockPolishVoiceText,
cancelRecording: mockCancelRecording,
}));
vi.mock("@/hooks/useVoiceSound", () => ({
useVoiceSound: () => ({
playStartSound: vi.fn(),
playStopSound: vi.fn(),
}),
}));
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
@@ -43,7 +86,7 @@ afterEach(() => {
vi.clearAllMocks();
});
const renderInputbarCore = (
const renderInputbarCore = async (
props?: Partial<React.ComponentProps<typeof InputbarCore>>,
) => {
const container = document.createElement("div");
@@ -58,21 +101,23 @@ const renderInputbarCore = (
onSend={vi.fn()}
activeTools={{}}
onToolClick={vi.fn()}
showTranslate={false}
toolMode="attach-only"
visualVariant="floating"
{...props}
/>,
);
});
await act(async () => {
await Promise.resolve();
});
mountedRoots.push({ root, container });
return container;
};
describe("InputbarCore", () => {
it("主题工作台未聚焦时应使用单行紧凑态,点击展开,移出后收起", () => {
const container = renderInputbarCore();
it("主题工作台未聚焦时应使用单行紧凑态,点击展开,移出后收起", async () => {
const container = await renderInputbarCore();
const textarea = container.querySelector(
"textarea",
) as HTMLTextAreaElement | null;
@@ -93,8 +138,11 @@ describe("InputbarCore", () => {
expect(textarea?.className).not.toContain("floating-collapsed");
expect(
container.querySelector('[data-testid="inputbar-tools"]'),
container.querySelector('button[aria-label="添加图片"]'),
).toBeTruthy();
expect(
container.querySelector('[data-testid="inputbar-tools"]'),
).toBeNull();
act(() => {
inputBar?.dispatchEvent(
@@ -108,7 +156,7 @@ describe("InputbarCore", () => {
expect(textarea?.className).not.toContain("floating-collapsed");
expect(
container.querySelector('[data-testid="inputbar-tools"]'),
).toBeTruthy();
).toBeNull();
act(() => {
textarea?.blur();
@@ -126,10 +174,79 @@ describe("InputbarCore", () => {
).toBeNull();
});
it("生成中应显示稍后处理与停止按钮,并渲染待处理列表", () => {
it("点击展开按钮应切换输入框展开态", async () => {
const container = await renderInputbarCore({
visualVariant: "default",
toolMode: "default",
});
const textarea = container.querySelector(
"textarea",
) as HTMLTextAreaElement | null;
const expandButton = container.querySelector(
'button[aria-label="展开输入框"]',
) as HTMLButtonElement | null;
expect(textarea?.className).not.toContain("composer-expanded");
expect(expandButton).toBeTruthy();
await act(async () => {
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(textarea?.className).toContain("composer-expanded");
expect(
container.querySelector('button[aria-label="收起输入框"]'),
).toBeTruthy();
});
it("点击麦克风按钮应执行语音识别并把结果写回输入框", async () => {
const setText = vi.fn();
const container = await renderInputbarCore({
visualVariant: "default",
toolMode: "default",
setText,
});
await act(async () => {
await Promise.resolve();
});
const micButton = container.querySelector(
'button[aria-label="开始语音输入"]',
) as HTMLButtonElement | null;
expect(micButton).toBeTruthy();
await act(async () => {
micButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
});
expect(mockStartRecording).toHaveBeenCalledTimes(1);
const stopDictationButton = container.querySelector(
'button[aria-label="停止语音输入"]',
) as HTMLButtonElement | null;
expect(stopDictationButton).toBeTruthy();
await act(async () => {
stopDictationButton?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
await Promise.resolve();
await Promise.resolve();
});
expect(mockStopRecording).toHaveBeenCalledTimes(1);
expect(mockTranscribeAudio).toHaveBeenCalledTimes(1);
expect(mockPolishVoiceText).toHaveBeenCalledWith("原始识别文本");
expect(setText).toHaveBeenCalledWith("润色后的文本");
});
it("生成中应显示稍后处理与停止按钮,并渲染待处理列表", async () => {
const onSend = vi.fn();
const onStop = vi.fn();
const container = renderInputbarCore({
const container = await renderInputbarCore({
text: "下一条需求",
onSend,
onStop,
@@ -149,9 +266,9 @@ describe("InputbarCore", () => {
const queueButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("稍后处理"),
);
const stopButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("停止"),
);
const stopButton = container.querySelector(
'button[aria-label="停止"]',
) as HTMLButtonElement | null;
expect(queueButton).toBeTruthy();
expect(stopButton).toBeTruthy();
@@ -180,9 +297,9 @@ describe("InputbarCore", () => {
expect(onStop).toHaveBeenCalledTimes(1);
});
it("点击图片删除按钮应触发 onRemoveImage", () => {
it("点击图片删除按钮应触发 onRemoveImage", async () => {
const onRemoveImage = vi.fn();
const container = renderInputbarCore({
const container = await renderInputbarCore({
pendingImages: [
{
data: "aGVsbG8=",
@@ -3,6 +3,10 @@ import {
ActionButtonGroup,
Container,
InputBarContainer,
InputColumn,
InputIconButton,
MainRow,
MetaSlot,
StyledTextarea,
BottomBar,
LeftSection,
@@ -14,20 +18,23 @@ import {
ImagePreviewItem,
ImagePreviewImg,
ImageRemoveButton,
ToolButton,
} from "../styles";
import { InputbarTools } from "./InputbarTools";
import { ArrowUp, Square, X, Languages } from "lucide-react";
import { BaseComposer } from "@/components/input-kit";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
ArrowUp,
ChevronDown,
ChevronUp,
ImagePlus,
Loader2,
Mic,
Square,
X,
} from "lucide-react";
import { BaseComposer } from "@/components/input-kit";
import type { MessageImage } from "../../../types";
import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime";
import { QueuedTurnsPanel } from "./QueuedTurnsPanel";
import { useInputbarDictation } from "../hooks/useInputbarDictation";
const INTERACTIVE_TARGET_SELECTOR =
"button, a, input, textarea, select, option, [role='button'], [contenteditable=''], [contenteditable='true'], [contenteditable='plaintext-only']";
@@ -69,13 +76,12 @@ interface InputbarCoreProps {
placeholder?: string;
/** 工具栏模式 */
toolMode?: "default" | "attach-only";
/** 是否显示翻译按钮 */
showTranslate?: boolean;
/** 是否显示顶部拖拽条 */
showDragHandle?: boolean;
/** 视觉风格 */
visualVariant?: "default" | "floating";
activeTheme?: string;
allowEmptySend?: boolean;
queuedTurns?: QueuedTurnSnapshot[];
onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise<boolean>;
@@ -103,17 +109,33 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
topExtra,
placeholder,
toolMode = "default",
showTranslate = true,
showDragHandle = true,
visualVariant = "default",
activeTheme,
allowEmptySend = false,
queuedTurns = [],
onPromoteQueuedTurn,
onRemoveQueuedTurn,
}) => {
const [isComposerExpanded, setIsComposerExpanded] = useState(false);
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
const inputBarContainerRef = useRef<HTMLDivElement | null>(null);
const fallbackTextareaRef = useRef<HTMLTextAreaElement>(null);
const resolvedTextareaRef = externalTextareaRef ?? fallbackTextareaRef;
const isFloatingVariant = visualVariant === "floating";
const {
dictationEnabled,
dictationState,
isDictating,
isDictationBusy,
isDictationProcessing,
handleDictationToggle,
} = useInputbarDictation({
text,
setText,
textareaRef: resolvedTextareaRef,
disabled,
});
const shouldCollapseFloatingTools =
isFloatingVariant &&
toolMode === "attach-only" &&
@@ -139,6 +161,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
isFullscreen ? "flex-1 resize-none" : "",
isFloatingVariant ? "floating-composer" : "",
shouldUseCompactFloatingComposer ? "floating-collapsed" : "",
isTextareaExpanded ? "composer-expanded" : "",
]
.filter(Boolean)
.join(" ");
@@ -148,10 +171,32 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
]
.filter(Boolean)
.join(" ");
const leftSectionClassName = shouldCollapseFloatingTools ? "floating-collapsed" : "";
const mainRowClassName = [
isFloatingVariant ? "floating-composer" : "",
shouldUseCompactFloatingComposer ? "floating-collapsed" : "",
]
.filter(Boolean)
.join(" ");
const leftSectionClassName = shouldCollapseFloatingTools
? "floating-collapsed"
: "";
const rightSectionClassName = shouldUseCompactFloatingComposer
? "floating-collapsed"
: "";
const shouldRenderMetaBar =
!shouldUseCompactFloatingComposer &&
(Boolean(leftExtra) ||
Boolean(rightExtra) ||
(toolMode === "default" && !shouldCollapseFloatingTools));
const dictationButtonTitle = isDictationProcessing
? dictationState === "polishing"
? "语音润色中"
: "语音识别中"
: isDictating
? "停止语音输入"
: dictationEnabled
? "开始语音输入"
: "语音输入未启用";
const handleExpandComposer = useCallback(() => {
if (!isFloatingVariant || toolMode !== "attach-only") {
@@ -201,6 +246,13 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
[onRemoveImage],
);
const handleToggleTextareaExpanded = useCallback(() => {
setIsTextareaExpanded((previous) => !previous);
if (isFloatingVariant) {
setIsComposerExpanded(true);
}
}, [isFloatingVariant]);
return (
<BaseComposer
text={text}
@@ -213,10 +265,12 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
isFullscreen={isFullscreen}
fillHeightWhenFullscreen
hasAdditionalContent={pendingImages.length > 0}
maxAutoHeight={isFloatingVariant ? 160 : 300}
textareaRef={externalTextareaRef}
maxAutoHeight={isTextareaExpanded ? 320 : isFloatingVariant ? 160 : 120}
textareaRef={resolvedTextareaRef}
onEscape={() => onToolClick("fullscreen")}
allowSendWhileLoading
rows={isTextareaExpanded ? 6 : 1}
allowEmptySend={allowEmptySend}
placeholder={
placeholder ||
(isFullscreen
@@ -281,67 +335,118 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
onRemoveQueuedTurn={onRemoveQueuedTurn}
/>
<StyledTextarea
ref={textareaRef}
{...textareaProps}
className={textareaClassName}
/>
<BottomBar className={bottomBarClassName}>
<LeftSection className={leftSectionClassName}>
{leftExtra && (
<div className="flex items-center gap-2 mr-2">{leftExtra}</div>
)}
{!shouldCollapseFloatingTools ? (
<InputbarTools
onToolClick={onToolClick}
activeTools={activeTools}
executionStrategy={executionStrategy}
showExecutionStrategy={showExecutionStrategy}
toolMode={toolMode}
isCanvasOpen={isCanvasOpen}
activeTheme={activeTheme}
/>
) : null}
</LeftSection>
<RightSection className={rightSectionClassName}>
{rightExtra}
{showTranslate ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<ToolButton onClick={() => onToolClick("translate")}>
<Languages size={18} />
</ToolButton>
</TooltipTrigger>
<TooltipContent side="top">翻译</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
<ActionButtonGroup>
{isLoading ? (
<SecondaryActionButton
type="button"
onClick={onStop}
disabled={!onStop}
>
<Square size={14} fill="currentColor" />
<span>停止</span>
</SecondaryActionButton>
) : null}
<SendButton
<MainRow className={mainRowClassName}>
<InputIconButton
type="button"
onClick={() => onToolClick("attach")}
aria-label="添加图片"
title="添加图片"
>
<ImagePlus size={14} />
</InputIconButton>
<InputColumn>
<StyledTextarea
ref={textareaRef}
{...textareaProps}
className={textareaClassName}
/>
</InputColumn>
<ActionButtonGroup>
<InputIconButton
type="button"
onClick={handleToggleTextareaExpanded}
disabled={disabled}
className={isTextareaExpanded ? "is-active" : ""}
aria-label={isTextareaExpanded ? "收起输入框" : "展开输入框"}
title={isTextareaExpanded ? "收起输入框" : "展开输入框"}
>
{isTextareaExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronUp size={14} />
)}
</InputIconButton>
<InputIconButton
type="button"
onClick={() => void handleDictationToggle()}
disabled={disabled || isDictationProcessing}
className={
isDictationProcessing
? "is-processing"
: isDictating
? "is-recording"
: ""
}
aria-label={dictationButtonTitle}
title={dictationButtonTitle}
>
{isDictationProcessing ? (
<Loader2 size={14} className="animate-spin" />
) : isDictating ? (
<Square size={14} fill="currentColor" />
) : (
<Mic size={14} />
)}
</InputIconButton>
{isLoading ? (
<SecondaryActionButton
type="button"
onClick={onPrimaryAction}
disabled={isPrimaryDisabled}
$hasLabel={isLoading}
>
<ArrowUp size={isLoading ? 16 : 20} strokeWidth={3} />
{isLoading ? <span>稍后处理</span> : null}
<span>稍后处理</span>
</SecondaryActionButton>
) : null}
{isLoading ? (
<InputIconButton
type="button"
onClick={onStop}
disabled={!onStop}
$destructive
aria-label="停止"
title="停止"
>
<Square size={14} fill="currentColor" />
</InputIconButton>
) : null}
{!isLoading ? (
<SendButton
type="button"
onClick={onPrimaryAction}
disabled={isPrimaryDisabled || isDictationBusy}
aria-label="发送"
title="发送"
>
<ArrowUp size={16} strokeWidth={2.4} />
</SendButton>
</ActionButtonGroup>
</RightSection>
</BottomBar>
) : null}
</ActionButtonGroup>
</MainRow>
{shouldRenderMetaBar ? (
<BottomBar className={bottomBarClassName}>
<LeftSection className={leftSectionClassName}>
{leftExtra ? <MetaSlot>{leftExtra}</MetaSlot> : null}
{!shouldCollapseFloatingTools ? (
<InputbarTools
onToolClick={onToolClick}
activeTools={activeTools}
executionStrategy={executionStrategy}
showExecutionStrategy={showExecutionStrategy}
toolMode={toolMode}
isCanvasOpen={isCanvasOpen}
activeTheme={activeTheme}
/>
) : null}
</LeftSection>
{rightExtra ? (
<RightSection className={rightSectionClassName}>
<MetaSlot>{rightExtra}</MetaSlot>
</RightSection>
) : null}
</BottomBar>
) : null}
</InputBarContainer>
</Container>
);
@@ -1,11 +1,11 @@
import React from "react";
import { Code2 } from "lucide-react";
import { ListChecks } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
MetaToggleButton,
MetaToggleCheck,
MetaToggleGlyph,
MetaToggleLabel,
} from "../styles";
interface InputbarExecutionStrategySelectProps {
isFullscreen?: boolean;
@@ -18,57 +18,36 @@ interface InputbarExecutionStrategySelectProps {
export const InputbarExecutionStrategySelect: React.FC<
InputbarExecutionStrategySelectProps
> = ({
isFullscreen = false,
isThemeWorkbenchVariant = false,
executionStrategy,
setExecutionStrategy,
}) => {
if (isFullscreen || isThemeWorkbenchVariant || !setExecutionStrategy) {
> = (props) => {
const {
isFullscreen = false,
executionStrategy,
setExecutionStrategy,
} = props;
if (isFullscreen || !setExecutionStrategy) {
return null;
}
const resolvedExecutionStrategy = executionStrategy || "react";
const executionStrategyLabel =
resolvedExecutionStrategy === "auto"
? "Auto"
: resolvedExecutionStrategy === "code_orchestrated"
? "Plan"
: "ReAct";
const planEnabled = executionStrategy === "code_orchestrated";
return (
<Select
value={resolvedExecutionStrategy}
onValueChange={(value) =>
setExecutionStrategy(value as "react" | "code_orchestrated" | "auto")
<MetaToggleButton
type="button"
$checked={planEnabled}
aria-label={planEnabled ? "关闭 Plan 模式" : "开启 Plan 模式"}
aria-pressed={planEnabled}
data-testid="inputbar-plan-toggle"
title={planEnabled ? "关闭 Plan 模式" : "开启 Plan 模式"}
onClick={() =>
setExecutionStrategy(planEnabled ? "react" : "code_orchestrated")
}
>
<SelectTrigger className="h-8 text-xs bg-background border shadow-sm min-w-[116px] px-2">
<div className="flex items-center gap-1.5">
<Code2 className="w-3.5 h-3.5 text-muted-foreground" />
<span className="whitespace-nowrap">{executionStrategyLabel}</span>
</div>
</SelectTrigger>
<SelectContent side="top" className="p-1 w-[176px]">
<SelectItem value="react">
<div className="flex items-center gap-2 whitespace-nowrap">
<Code2 className="w-3.5 h-3.5" />
ReAct
</div>
</SelectItem>
<SelectItem value="code_orchestrated">
<div className="flex items-center gap-2 whitespace-nowrap">
<Code2 className="w-3.5 h-3.5" />
Plan
</div>
</SelectItem>
<SelectItem value="auto">
<div className="flex items-center gap-2 whitespace-nowrap">
<Code2 className="w-3.5 h-3.5" />
Auto
</div>
</SelectItem>
</SelectContent>
</Select>
<MetaToggleCheck $checked={planEnabled} aria-hidden />
<MetaToggleGlyph aria-hidden>
<ListChecks strokeWidth={1.8} />
</MetaToggleGlyph>
<MetaToggleLabel>Plan</MetaToggleLabel>
</MetaToggleButton>
);
};
@@ -1,11 +1,9 @@
import React from "react";
import { Badge } from "@/components/ui/badge";
import type { ModelSelectorProps } from "@/components/input-kit";
import type { AsterSessionExecutionRuntime } from "@/lib/api/agentRuntime";
import { ChatModelSelector } from "../../ChatModelSelector";
import {
getExecutionRuntimeDisplayLabel,
getOutputSchemaRuntimeLabel,
} from "../../../utils/sessionExecutionRuntime";
import { getOutputSchemaRuntimeLabel } from "../../../utils/sessionExecutionRuntime";
interface InputbarModelExtraProps {
isFullscreen?: boolean;
@@ -17,7 +15,7 @@ interface InputbarModelExtraProps {
activeTheme?: string;
onManageProviders?: () => void;
executionRuntime?: AsterSessionExecutionRuntime | null;
isExecutionRuntimeActive?: boolean;
backgroundPreload?: ModelSelectorProps["backgroundPreload"];
}
const NOOP_SET_PROVIDER_TYPE = (_type: string) => {};
@@ -33,25 +31,18 @@ export const InputbarModelExtra: React.FC<InputbarModelExtraProps> = ({
activeTheme,
onManageProviders,
executionRuntime = null,
isExecutionRuntimeActive = false,
backgroundPreload,
}) => {
if (isFullscreen || isThemeWorkbenchVariant || !providerType || !model) {
return null;
}
const executionRuntimeLabel = getExecutionRuntimeDisplayLabel(
executionRuntime,
{ active: isExecutionRuntimeActive },
);
const outputSchemaLabel = getOutputSchemaRuntimeLabel(
executionRuntime?.output_schema_runtime,
);
const executionRuntimeBadgeClass = isExecutionRuntimeActive
? "max-w-[220px] truncate border-emerald-200 bg-emerald-50 text-emerald-900"
: "max-w-[220px] truncate text-muted-foreground";
return (
<div className="flex items-center gap-2">
<div className="flex items-center flex-wrap gap-2">
<ChatModelSelector
providerType={providerType}
setProviderType={setProviderType || NOOP_SET_PROVIDER_TYPE}
@@ -61,20 +52,12 @@ export const InputbarModelExtra: React.FC<InputbarModelExtraProps> = ({
compactTrigger
popoverSide="top"
onManageProviders={onManageProviders}
backgroundPreload={backgroundPreload}
/>
{executionRuntimeLabel ? (
<Badge
variant="outline"
className={executionRuntimeBadgeClass}
title={executionRuntimeLabel}
>
{executionRuntimeLabel}
</Badge>
) : null}
{outputSchemaLabel ? (
<Badge
variant="outline"
className="max-w-[180px] truncate text-muted-foreground"
className="h-7 max-w-[180px] rounded-full border-slate-300/70 bg-white/90 px-3 text-[11px] font-medium text-slate-500 shadow-none"
title={`结构化输出 ${outputSchemaLabel}`}
>
结构化输出 {outputSchemaLabel}
@@ -132,22 +132,21 @@ describe("InputbarOverlayShell", () => {
).toBeNull();
});
it("A2UI 表单进入粘性保留期时应显示同步提示并禁用提交", () => {
it("输入栏 overlay 不再承载 A2UI 表单与提交提示", () => {
const container = renderShell({
taskFiles: [],
pendingA2UIForm: createPendingA2UIForm(),
pendingA2UIFormStale: true,
submissionNotice: {
title: "补充信息已确认",
summary: "已继续处理。",
},
isSubmissionNoticeVisible: true,
onA2UISubmit: vi.fn(),
});
const submitButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("继续处理"),
) as HTMLButtonElement | undefined;
expect(container.textContent).toContain("同步中");
expect(container.textContent).toContain(
"正在同步最新上下文,表单暂时不可提交。",
);
expect(submitButton?.disabled).toBe(true);
expect(container.textContent).not.toContain("继续处理");
expect(container.textContent).not.toContain("同步中");
expect(container.textContent).not.toContain("补充信息已确认");
});
});
@@ -5,11 +5,7 @@ import type {
A2UIFormData,
A2UIResponse,
} from "@/components/content-creator/a2ui/types";
import {
A2UISubmissionNotice,
type A2UISubmissionNoticeData,
} from "./A2UISubmissionNotice";
import { A2UIFloatingForm } from "./A2UIFloatingForm";
import type { A2UISubmissionNoticeData } from "./A2UISubmissionNotice";
import { HintRoutePopup } from "./HintRoutePopup";
import { TaskFilesPanel } from "./TaskFilesPanel";
import type { HintRouteItem } from "../hooks/useHintRoutes";
@@ -64,11 +60,11 @@ export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
onToggleTaskFiles,
onTaskFileClick,
overlayAccessory,
submissionNotice,
isSubmissionNoticeVisible,
pendingA2UIForm,
pendingA2UIFormStale = false,
onA2UISubmit,
submissionNotice: _submissionNotice,
isSubmissionNoticeVisible: _isSubmissionNoticeVisible,
pendingA2UIForm: _pendingA2UIForm,
pendingA2UIFormStale: _pendingA2UIFormStale = false,
onA2UISubmit: _onA2UISubmit,
fileInputRef,
onFileSelect,
}) => (
@@ -92,20 +88,6 @@ export const InputbarOverlayShell: React.FC<InputbarOverlayShellProps> = ({
{overlayAccessory}
</SecondaryControlsRow>
) : null}
{submissionNotice ? (
<A2UISubmissionNotice
notice={submissionNotice}
visible={isSubmissionNoticeVisible}
/>
) : null}
{pendingA2UIForm && onA2UISubmit ? (
<A2UIFloatingForm
response={pendingA2UIForm}
onSubmit={onA2UISubmit}
submitDisabled={pendingA2UIFormStale}
isStale={pendingA2UIFormStale}
/>
) : null}
<input
ref={fileInputRef}
type="file"
@@ -1,19 +1,11 @@
import React from "react";
import {
Paperclip,
Lightbulb,
Globe,
Code2,
ListChecks,
Workflow,
} from "lucide-react";
import { ToolButton } from "../styles";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Divider, ToolButton } from "../styles";
import { isGeneralResearchTheme } from "../../../utils/generalAgentPrompt";
interface InputbarToolsProps {
@@ -35,120 +27,69 @@ export const InputbarTools: React.FC<InputbarToolsProps> = ({
toolMode = "default",
activeTheme,
}) => {
const modeLabel =
executionStrategy === "auto"
? "Auto"
: executionStrategy === "code_orchestrated"
? "Plan"
: "ReAct";
const strategyEnabled =
executionStrategy !== "react" || activeTools["execution_strategy"];
executionStrategy === "code_orchestrated" ||
activeTools["execution_strategy"];
const isGeneralTheme = isGeneralResearchTheme(activeTheme);
return (
<TooltipProvider>
<div className="flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<ToolButton onClick={() => onToolClick?.("attach")}>
<Paperclip />
</ToolButton>
</TooltipTrigger>
<TooltipContent side="top">上传文件</TooltipContent>
</Tooltip>
<div className="flex items-center flex-wrap gap-2">
{toolMode === "default" ? (
<>
<ToolButton
type="button"
onClick={() => onToolClick?.("thinking")}
className={activeTools["thinking"] ? "active" : ""}
aria-pressed={activeTools["thinking"]}
title={`深度思考${activeTools["thinking"] ? "已开启" : "已关闭"}`}
>
<Lightbulb />
<span>思考</span>
</ToolButton>
{toolMode === "default" ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<ToolButton
onClick={() => onToolClick?.("thinking")}
className={activeTools["thinking"] ? "active" : ""}
>
<Lightbulb
className={activeTools["thinking"] ? "text-yellow-500" : ""}
/>
</ToolButton>
</TooltipTrigger>
<TooltipContent side="top">
深度思考 {activeTools["thinking"] ? "(已开启)" : ""}
</TooltipContent>
</Tooltip>
<ToolButton
type="button"
onClick={() => onToolClick?.("web_search")}
className={activeTools["web_search"] ? "active" : ""}
aria-pressed={activeTools["web_search"]}
title={`联网搜索${activeTools["web_search"] ? "已开启" : "已关闭"}`}
>
<Globe />
<span>搜索</span>
</ToolButton>
<Tooltip>
<TooltipTrigger asChild>
<ToolButton
onClick={() => onToolClick?.("web_search")}
className={activeTools["web_search"] ? "active" : ""}
>
<Globe
className={activeTools["web_search"] ? "text-blue-500" : ""}
/>
</ToolButton>
</TooltipTrigger>
<TooltipContent side="top">
联网搜索 {activeTools["web_search"] ? "(已开启)" : ""}
</TooltipContent>
</Tooltip>
{isGeneralTheme ? (
<>
<ToolButton
type="button"
onClick={() => onToolClick?.("subagent_mode")}
className={activeTools["subagent_mode"] ? "active" : ""}
aria-pressed={activeTools["subagent_mode"]}
title={`多代理偏好${activeTools["subagent_mode"] ? "已开启" : "已关闭"}`}
>
<Workflow />
<span>多代理</span>
</ToolButton>
</>
) : null}
{isGeneralTheme ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<ToolButton
onClick={() => onToolClick?.("task_mode")}
className={activeTools["task_mode"] ? "active" : ""}
>
<ListChecks
className={
activeTools["task_mode"] ? "text-emerald-500" : ""
}
/>
</ToolButton>
</TooltipTrigger>
<TooltipContent side="top">
后台任务 {activeTools["task_mode"] ? "(偏好已开启)" : ""}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToolButton
onClick={() => onToolClick?.("subagent_mode")}
className={activeTools["subagent_mode"] ? "active" : ""}
>
<Workflow
className={
activeTools["subagent_mode"] ? "text-fuchsia-500" : ""
}
/>
</ToolButton>
</TooltipTrigger>
<TooltipContent side="top">
多代理 {activeTools["subagent_mode"] ? "(偏好已开启)" : ""}
</TooltipContent>
</Tooltip>
</>
) : null}
{showExecutionStrategy && (
<Tooltip>
<TooltipTrigger asChild>
<ToolButton
onClick={() => onToolClick?.("execution_strategy")}
className={strategyEnabled ? "active" : ""}
>
<Code2
className={strategyEnabled ? "text-emerald-500" : ""}
/>
</ToolButton>
</TooltipTrigger>
<TooltipContent side="top">执行模式: {modeLabel}</TooltipContent>
</Tooltip>
)}
</>
) : null}
</div>
</TooltipProvider>
{showExecutionStrategy ? (
<>
<Divider />
<ToolButton
type="button"
onClick={() => onToolClick?.("execution_strategy")}
className={strategyEnabled ? "active" : ""}
aria-pressed={strategyEnabled}
title={`Plan 模式${strategyEnabled ? "已开启" : "已关闭"}`}
>
<Code2 />
<span>Plan</span>
</ToolButton>
</>
) : null}
</>
) : null}
</div>
);
};
@@ -4,6 +4,8 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SkillSelector } from "./SkillSelector";
import type { Skill } from "@/lib/api/skills";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import { SKILL_SELECTION_DISPLAY_COPY } from "./skillSelectionDisplay";
const mockToastInfo = vi.fn();
const mockPopoverState = vi.hoisted(() => ({
@@ -50,13 +52,14 @@ vi.mock("@/components/ui/popover", () => ({
}));
vi.mock("@/components/ui/command", () => {
const Command = ({
children,
shouldFilter: _shouldFilter,
...props
}: React.HTMLAttributes<HTMLDivElement> & { shouldFilter?: boolean }) => (
<div {...props}>{children}</div>
);
const Command = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { shouldFilter?: boolean }
>(({ children, shouldFilter: _shouldFilter, ...props }, ref) => (
<div ref={ref} {...props}>
{children}
</div>
));
const CommandInput = ({
value,
@@ -148,6 +151,41 @@ function createSkill(name: string, key: string, installed: boolean): Skill {
};
}
function createServiceSkill(
id: string,
title: string,
): ServiceSkillHomeItem {
return {
id,
title,
summary: `${title} 摘要`,
category: "情报研究",
outputHint: "结构化结果",
source: "cloud_catalog",
runnerType: "instant",
defaultExecutorBinding: "browser_assist",
executionLocation: "client_default",
defaultArtifactKind: "analysis",
version: "seed-v1",
themeTarget: "general",
slotSchema: [],
badge: "云目录",
recentUsedAt: null,
isRecent: false,
runnerLabel: "站点登录态采集",
runnerTone: "emerald",
runnerDescription: "复用真实登录态执行任务。",
actionLabel: "开始执行",
automationStatus: null,
groupKey: "github",
siteCapabilityBinding: {
adapterName: "github/search",
autoRun: true,
saveMode: "project_resource",
},
};
}
function renderSkillSelector(
props?: Partial<React.ComponentProps<typeof SkillSelector>>,
) {
@@ -171,14 +209,14 @@ function renderSkillSelector(
return container;
}
async function preloadSkillSelectorPanel() {
async function preloadSharedSkillPanel() {
await act(async () => {
await import("./SkillSelectorPanel");
await import("./CharacterMentionPanel");
});
}
async function openSkillSelector(container: HTMLElement) {
await preloadSkillSelectorPanel();
await preloadSharedSkillPanel();
const triggerButton = container.querySelector(
'[data-testid="skill-selector-trigger"]',
@@ -225,10 +263,16 @@ describe("SkillSelector", () => {
await openSkillSelector(container);
expect(container.textContent).toContain("不使用技能");
expect(container.textContent).toContain("已挂载 研究助手");
expect(container.textContent).toContain(
SKILL_SELECTION_DISPLAY_COPY.clearActionLabel,
);
const clearButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("不使用技能"),
(button) =>
button.textContent?.includes(
SKILL_SELECTION_DISPLAY_COPY.clearActionLabel,
),
);
expect(clearButton).toBeTruthy();
@@ -261,7 +305,7 @@ describe("SkillSelector", () => {
expect(mockToastInfo.mock.calls[0]?.[0]).toContain("尚未安装");
expect(mockToastInfo.mock.calls[0]?.[1]).toMatchObject({
action: {
label: "去安装",
label: "去技能中心",
onClick: onNavigateToSettings,
},
});
@@ -311,6 +355,33 @@ describe("SkillSelector", () => {
expect(onRefreshSkills).toHaveBeenCalledTimes(1);
});
it("应复用同一面板渲染服务技能并回调 onSelectServiceSkill", async () => {
const onSelectServiceSkill = vi.fn<
(skill: ServiceSkillHomeItem) => void
>();
const serviceSkill = createServiceSkill(
"github-repo-radar",
"GitHub 仓库线索检索",
);
const container = renderSkillSelector({
serviceSkills: [serviceSkill],
onSelectServiceSkill,
});
await openSkillSelector(container);
const skillButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("GitHub 仓库线索检索"),
);
expect(skillButton).toBeTruthy();
act(() => {
skillButton?.click();
});
expect(onSelectServiceSkill).toHaveBeenCalledWith(serviceSkill);
});
it("加载中且无技能时应显示加载状态", async () => {
const container = renderSkillSelector({
isLoading: true,
@@ -322,4 +393,16 @@ describe("SkillSelector", () => {
expect(container.textContent).toContain("技能加载中");
});
it("未选择技能时应展示统一的空态文案", async () => {
const container = renderSkillSelector({
skills: [],
});
await openSkillSelector(container);
expect(container.textContent).toContain(
SKILL_SELECTION_DISPLAY_COPY.emptySelectionLabel,
);
});
});
@@ -1,12 +1,11 @@
import React, {
Suspense,
lazy,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Zap } from "lucide-react";
import { FolderOpen, Loader2, RefreshCw, Zap } from "lucide-react";
import {
Popover,
PopoverContent,
@@ -15,20 +14,27 @@ import {
import type { Skill } from "@/lib/api/skills";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload";
const preloadSkillSelectorPanel = () => import("./SkillSelectorPanel");
const SkillSelectorPanel = lazy(async () => {
const module = await preloadSkillSelectorPanel();
return { default: module.SkillSelectorPanel };
});
import { filterMentionableServiceSkills } from "@/components/agent/chat/service-skills/entryAdapter";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
import type { BuiltinInputCommand } from "./builtinCommands";
import {
LazyCharacterMentionPanel,
preloadCharacterMentionPanel,
} from "./characterMentionPanelLoader";
import {
getActiveSkillDisplayLabel,
SKILL_SELECTION_DISPLAY_COPY,
} from "./skillSelectionDisplay";
import { partitionMentionableSkills } from "./skillQuery";
import { useIdleModulePreload } from "./useIdleModulePreload";
interface SkillSelectorProps {
skills?: Skill[];
serviceSkills?: ServiceSkillHomeItem[];
activeSkill?: Skill | null;
isLoading?: boolean;
onSelectSkill: (skill: Skill) => void;
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
onClearSkill?: () => void;
onNavigateToSettings?: () => void;
onImportSkill?: () => void | Promise<void>;
@@ -37,24 +43,162 @@ interface SkillSelectorProps {
className?: string;
}
function matchesSkillQuery(skill: Skill, query: string): boolean {
if (!query) {
return true;
}
const normalizedQuery = query.toLowerCase();
return (
skill.name.toLowerCase().includes(normalizedQuery) ||
skill.key.toLowerCase().includes(normalizedQuery) ||
skill.description?.toLowerCase().includes(normalizedQuery) === true
);
interface SkillSelectorContentProps {
activeSkill: Skill | null;
installedSkills: Skill[];
availableSkills: Skill[];
mentionServiceSkills?: ServiceSkillHomeItem[];
query: string;
refreshBusy: boolean;
hasResults: boolean;
canRefresh: boolean;
canImport: boolean;
importing: boolean;
commandRef: React.RefObject<HTMLDivElement>;
onQueryChange: (query: string) => void;
onSelectInstalledSkill: (skill: Skill) => void;
onSelectAvailableSkill: (skill: Skill) => void;
onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void;
onClearSkill?: () => void;
onNavigateToSettings?: () => void;
onRefresh?: () => void;
onImport?: () => void;
}
export const SkillSelectorContent: React.FC<SkillSelectorContentProps> = ({
activeSkill,
installedSkills,
availableSkills,
mentionServiceSkills = [],
query,
refreshBusy,
hasResults,
canRefresh,
canImport,
importing,
commandRef,
onQueryChange,
onSelectInstalledSkill,
onSelectAvailableSkill,
onSelectServiceSkill,
onClearSkill,
onNavigateToSettings,
onRefresh,
onImport,
}) => {
const activeSkillLabel = getActiveSkillDisplayLabel(activeSkill);
return (
<Suspense
fallback={
<div className="px-4 py-7 text-center text-sm text-slate-500">
加载中...
</div>
}
>
<div className="bg-white">
<div className="border-b border-slate-200/80 bg-[linear-gradient(180deg,rgb(255,255,255)_0%,rgb(248,250,252)_100%)] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-semibold tracking-[0.08em] text-slate-500">
{SKILL_SELECTION_DISPLAY_COPY.titleLabel}
</div>
<div className="mt-1 text-sm text-slate-700">
{activeSkillLabel ??
SKILL_SELECTION_DISPLAY_COPY.emptySelectionLabel}
</div>
</div>
{activeSkill && onClearSkill ? (
<button
type="button"
data-testid="skill-selector-clear"
onClick={onClearSkill}
className="rounded-full border border-slate-200 px-3 py-1.5 text-xs text-slate-600 transition hover:border-slate-300 hover:bg-white hover:text-slate-900"
>
{SKILL_SELECTION_DISPLAY_COPY.clearActionLabel}
</button>
) : null}
</div>
{refreshBusy && !hasResults ? (
<div className="mt-2 text-xs text-slate-500">
{SKILL_SELECTION_DISPLAY_COPY.loadingLabel}
</div>
) : null}
</div>
<LazyCharacterMentionPanel
mode="mention"
mentionQuery={query}
builtinCommands={[] satisfies BuiltinInputCommand[]}
slashCommands={[]}
mentionServiceSkills={mentionServiceSkills}
filteredCharacters={[]}
installedSkills={installedSkills}
availableSkills={availableSkills}
commandRef={commandRef}
onQueryChange={onQueryChange}
onSelectBuiltinCommand={() => undefined}
onSelectServiceSkill={(skill) => onSelectServiceSkill?.(skill)}
onSelectSlashCommand={() => undefined}
onSelectCharacter={() => undefined}
onSelectInstalledSkill={onSelectInstalledSkill}
onSelectAvailableSkill={onSelectAvailableSkill}
onNavigateToSettings={onNavigateToSettings}
/>
{canRefresh || canImport ? (
<div className="border-t border-slate-200/80 p-1.5">
<div className="flex items-center gap-1.5">
{canRefresh ? (
<button
type="button"
data-testid="skill-selector-refresh"
onClick={onRefresh}
disabled={refreshBusy}
className="flex flex-1 items-center justify-center gap-2 rounded-xl px-3 py-2.5 text-sm text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{refreshBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span>{refreshBusy ? "刷新中..." : "刷新技能"}</span>
</button>
) : null}
{canImport ? (
<button
type="button"
data-testid="skill-selector-import"
onClick={onImport}
disabled={importing}
className="flex flex-1 items-center justify-center gap-2 rounded-xl px-3 py-2.5 text-sm text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{importing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FolderOpen className="h-4 w-4" />
)}
<span>{importing ? "导入中..." : "导入技能"}</span>
</button>
) : null}
</div>
</div>
) : null}
{!hasResults && !canRefresh && !canImport ? (
<div className="px-4 pb-3 text-xs text-slate-400">
暂无更多技能操作。
</div>
) : null}
</div>
</Suspense>
);
};
export const SkillSelector: React.FC<SkillSelectorProps> = ({
skills = [],
serviceSkills = [],
activeSkill = null,
isLoading = false,
onSelectSkill,
onSelectServiceSkill,
onClearSkill,
onNavigateToSettings,
onImportSkill,
@@ -67,37 +211,31 @@ export const SkillSelector: React.FC<SkillSelectorProps> = ({
const [importing, setImporting] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [autoRefreshTriggered, setAutoRefreshTriggered] = useState(false);
const commandRef = useRef<HTMLDivElement>(null);
useEffect(() => {
return scheduleIdleModulePreload(() => {
void preloadSkillSelectorPanel();
});
}, []);
useIdleModulePreload(() => {
void preloadCharacterMentionPanel();
});
useEffect(() => {
React.useEffect(() => {
if (!open) {
setQuery("");
setAutoRefreshTriggered(false);
}
}, [open]);
const installedSkills = useMemo(
() =>
skills.filter(
(skill) => skill.installed && matchesSkillQuery(skill, query),
),
const { installedSkills, availableSkills } = useMemo(
() => partitionMentionableSkills(skills, query),
[query, skills],
);
const availableSkills = useMemo(
() =>
skills.filter(
(skill) => !skill.installed && matchesSkillQuery(skill, query),
),
[query, skills],
const filteredServiceSkills = useMemo(
() => filterMentionableServiceSkills(serviceSkills, query),
[query, serviceSkills],
);
const hasResults =
filteredServiceSkills.length > 0 ||
installedSkills.length > 0 ||
availableSkills.length > 0 ||
Boolean(activeSkill && onClearSkill);
@@ -118,7 +256,7 @@ export const SkillSelector: React.FC<SkillSelectorProps> = ({
}
}, [onRefreshSkills, refreshBusy]);
useEffect(() => {
React.useEffect(() => {
if (
!open ||
autoRefreshTriggered ||
@@ -150,12 +288,17 @@ export const SkillSelector: React.FC<SkillSelectorProps> = ({
setOpen(false);
};
const handleSelectServiceSkill = (skill: ServiceSkillHomeItem) => {
onSelectServiceSkill?.(skill);
setOpen(false);
};
const handleSelectAvailableSkill = (skill: Skill) => {
setOpen(false);
toast.info(`技能「${skill.name}」尚未安装`, {
action: onNavigateToSettings
? {
label: "去安装",
label: "去技能中心",
onClick: onNavigateToSettings,
}
: undefined,
@@ -207,39 +350,34 @@ export const SkillSelector: React.FC<SkillSelectorProps> = ({
sideOffset={8}
>
{open ? (
<Suspense
fallback={
<div className="px-4 py-7 text-center text-sm text-slate-500">
加载中...
</div>
<SkillSelectorContent
activeSkill={activeSkill}
installedSkills={installedSkills}
availableSkills={availableSkills}
mentionServiceSkills={filteredServiceSkills}
query={query}
refreshBusy={refreshBusy}
hasResults={hasResults}
canRefresh={canRefresh}
canImport={canImport}
importing={importing}
commandRef={commandRef}
onQueryChange={setQuery}
onSelectInstalledSkill={handleSelectInstalledSkill}
onSelectAvailableSkill={handleSelectAvailableSkill}
onSelectServiceSkill={handleSelectServiceSkill}
onClearSkill={handleClearSkill}
onNavigateToSettings={
onNavigateToSettings
? () => {
setOpen(false);
onNavigateToSettings();
}
: undefined
}
>
<SkillSelectorPanel
activeSkill={activeSkill}
installedSkills={installedSkills}
availableSkills={availableSkills}
query={query}
canRefresh={canRefresh}
refreshBusy={refreshBusy}
canImport={canImport}
importing={importing}
hasResults={hasResults}
onQueryChange={setQuery}
onRefresh={() => void handleRefresh()}
onSelectInstalledSkill={handleSelectInstalledSkill}
onSelectAvailableSkill={handleSelectAvailableSkill}
onClearSkill={handleClearSkill}
onNavigateToSettings={
onNavigateToSettings
? () => {
setOpen(false);
onNavigateToSettings();
}
: undefined
}
onImport={() => void handleImport()}
/>
</Suspense>
onRefresh={() => void handleRefresh()}
onImport={() => void handleImport()}
/>
) : null}
</PopoverContent>
</Popover>
@@ -1,228 +0,0 @@
import React from "react";
import {
Check,
FolderOpen,
Loader2,
RefreshCw,
Settings2,
X,
Zap,
} from "lucide-react";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import type { Skill } from "@/lib/api/skills";
import { cn } from "@/lib/utils";
interface SkillSelectorPanelProps {
activeSkill: Skill | null;
installedSkills: Skill[];
availableSkills: Skill[];
query: string;
canRefresh: boolean;
refreshBusy: boolean;
canImport: boolean;
importing: boolean;
hasResults: boolean;
onQueryChange: (query: string) => void;
onRefresh: () => void;
onSelectInstalledSkill: (skill: Skill) => void;
onSelectAvailableSkill: (skill: Skill) => void;
onClearSkill?: () => void;
onNavigateToSettings?: () => void;
onImport: () => void;
}
export const SkillSelectorPanel: React.FC<SkillSelectorPanelProps> = ({
activeSkill,
installedSkills,
availableSkills,
query,
canRefresh,
refreshBusy,
canImport,
importing,
hasResults,
onQueryChange,
onRefresh,
onSelectInstalledSkill,
onSelectAvailableSkill,
onClearSkill,
onNavigateToSettings,
onImport,
}) => (
<Command shouldFilter={false} className="bg-white">
<div className="border-b border-slate-200/80 bg-[linear-gradient(180deg,rgb(255,255,255)_0%,rgb(248,250,252)_100%)] px-4 py-3">
<div className="text-[11px] font-semibold tracking-[0.08em] text-slate-500">
技能能力
</div>
<div className="mt-1 text-sm text-slate-700">
{activeSkill ? `当前已启用 ${activeSkill.name}` : "为当前任务挂载额外能力"}
</div>
</div>
<div className="relative">
<CommandInput
className={cn(
"border-b-0 px-4 text-sm placeholder:text-slate-400",
canRefresh ? "pr-12" : undefined,
)}
placeholder="搜索技能或命令"
value={query}
onValueChange={onQueryChange}
/>
{canRefresh ? (
<button
type="button"
data-testid="skill-selector-refresh"
onClick={onRefresh}
disabled={refreshBusy}
className="absolute right-3 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={refreshBusy ? "技能加载中" : "刷新技能"}
title={refreshBusy ? "技能加载中" : "刷新技能"}
>
{refreshBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
) : null}
</div>
<CommandList>
{activeSkill && onClearSkill ? (
<CommandGroup heading="当前已选">
<CommandItem
value="__clear_skill__"
onSelect={onClearSkill}
className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50"
>
<X className="mr-2 h-4 w-4 text-slate-400" />
<div className="flex-1">
<div className="font-medium text-slate-900">不使用技能</div>
<div className="text-xs text-slate-500">
当前已选:{activeSkill.name}
</div>
</div>
</CommandItem>
</CommandGroup>
) : null}
{installedSkills.length > 0 ? (
<CommandGroup heading="可用技能">
{installedSkills.map((skill) => {
const selected = activeSkill?.key === skill.key;
return (
<CommandItem
key={skill.directory}
value={`${skill.name} ${skill.key} ${skill.description || ""}`}
onSelect={() => onSelectInstalledSkill(skill)}
className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50"
>
<Zap
className={cn(
"mr-2 h-4 w-4",
selected ? "text-emerald-600" : "text-slate-400",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-slate-900">
{skill.name}
</span>
<span className="text-[11px] text-slate-400">
/{skill.key}
</span>
</div>
{skill.description ? (
<div className="line-clamp-1 text-xs text-slate-500">
{skill.description}
</div>
) : null}
</div>
{selected ? (
<Check className="ml-2 h-4 w-4 text-emerald-600" />
) : null}
</CommandItem>
);
})}
</CommandGroup>
) : null}
{availableSkills.length > 0 ? (
<CommandGroup heading="未安装技能">
{availableSkills.map((skill) => (
<CommandItem
key={skill.directory}
value={`${skill.name} ${skill.key} ${skill.description || ""}`}
onSelect={() => onSelectAvailableSkill(skill)}
className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 opacity-80 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50"
>
<Settings2 className="mr-2 h-4 w-4 text-slate-400" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-slate-900">
{skill.name}
</span>
<span className="text-[11px] text-slate-400">
/{skill.key}
</span>
</div>
{skill.description ? (
<div className="line-clamp-1 text-xs text-slate-500">
{skill.description}
</div>
) : null}
</div>
</CommandItem>
))}
</CommandGroup>
) : null}
{!hasResults ? (
<div className="px-4 py-7 text-center text-sm text-slate-500">
{refreshBusy ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-slate-400" />
<div>技能加载中...</div>
</div>
) : (
<>
<div>暂无可用技能</div>
{onNavigateToSettings ? (
<button
type="button"
className="mt-2 text-slate-900 hover:underline"
onClick={onNavigateToSettings}
>
去技能设置
</button>
) : null}
</>
)}
</div>
) : null}
</CommandList>
{canImport ? (
<div className="border-t border-slate-200/80 p-1.5">
<button
type="button"
data-testid="skill-selector-import"
onClick={onImport}
disabled={importing}
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left text-sm text-slate-600 transition hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{importing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FolderOpen className="h-4 w-4" />
)}
<span>{importing ? "导入中..." : "导入本地技能"}</span>
</button>
</div>
) : null}
</Command>
);

Some files were not shown because too many files have changed in this diff Show More