mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v0.98.0
This commit is contained in:
@@ -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
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -143,6 +143,13 @@ npm run test:contracts
|
||||
|
||||
**不是鼓励走新路,而是先封住老路。**
|
||||
|
||||
这同样适用于已经删除的旧 UI 壳或旧组件路径:
|
||||
|
||||
- 删除旧文件后,仍应在治理目录册里补 import / 文本守卫,防止后续 AI 或人工把旧路径重新接回主链
|
||||
- 如果已经把重复 UI 的扁平 props 收口为共享契约,也应补对应文本 / 正则守卫,防止父层透传和子层接口一起长回旧面
|
||||
- 如果共享契约还依赖单独的构造器或归一化 helper,应继续限制只有事实源边界能调用它,不要让运行时代码到处重新拼装
|
||||
- 如果多个页面或面板展示的是同一份状态,也要把状态文案收敛到共享 helper,不要让首页、下拉面板、状态徽标各自重新命名
|
||||
|
||||
### 6. 主链路和旁路一起治理
|
||||
|
||||
如果只迁:
|
||||
|
||||
@@ -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) - 性能分析与火焰图
|
||||
|
||||
@@ -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. 进入同一工作区中的两个话题
|
||||
|
||||
@@ -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 导出 / 审核主链,除了契约检查,还应至少补:
|
||||
|
||||
@@ -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 执行。
|
||||
@@ -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 来源 的“站点适配器库”,不是它的“后台浏览器自动化模式”。
|
||||
@@ -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`)
|
||||
- 桌面端主动断开链路(默认开启)
|
||||
|
||||
## 兼容说明
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "0.97.0",
|
||||
"version": "0.98.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Generated
+31
-31
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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("• 检测到计划需求"));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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,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(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/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
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("技能");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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("规划状态");
|
||||
});
|
||||
|
||||
|
||||
+17
-13
@@ -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>
|
||||
);
|
||||
};
|
||||
+36
-59
@@ -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>
|
||||
);
|
||||
|
||||
+30
-51
@@ -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}
|
||||
|
||||
+9
-10
@@ -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
Reference in New Issue
Block a user